Vue + Android WebView 实现大文件 PDF 预览完整解决方案

Vue + Android WebView 实现大文件 PDF 预览完整解决方案

一、问题背景

在企业级 PAD 应用开发中,我遇到了一个棘手的问题:100MB+ 的大 PDF 文件在 Android WebView 中预览时,出现严重的性能问题

1.1 具体表现

问题表现
缩放卡顿双指缩放时明显卡顿,甚至卡死
字体模糊放大后字体不清晰,影响阅读
内存溢出大文件加载时容易 OOM 崩溃
渲染异常部分页面白屏或渲染不完整

关键发现:同样的 Vue2 项目,在桌面端浏览器访问完全正常,问题只出现在 Android WebView 环境。

1.2 尝试过的方案

我尝试了市面上几乎所有主流的 Vue PDF 预览插件,均存在不同程度的问题:

插件名称存在的问题
vue-pdf• 部分字体丢失或显示异常
• WebView 中大文件缩放严重卡顿或卡死
• 需要手动实现缩放、翻页等功能
vue-pdf-signature• 部分字体渲染模糊
• WebView 中大文件缩放严重卡顿或卡死
• 需要手动实现缩放、翻页等功能
vue-pdf-app• WebView 中大文件缩放严重卡顿或卡死
• 放大后字体不清晰
• 功能完善(缩放、翻页、搜索等)
@vue-office/pdf• WebView 中大文件缩放严重卡顿或卡死
• 需要手动实现缩放、翻页等功能
pdfjs-dist• 高版本(v4+)在 WebView/移动端浏览器不兼容,样式错乱或报错
• 低版本放大后字体不清晰
• 自带 viewer.html,功能完善(缩放、翻页、搜索等)

💡 关键发现:以上插件在桌面端浏览器中表现正常,问题主要出现在 Android WebView 环境。推测可能与 WebView 的硬件加速、Canvas 渲染性能、内存限制等因素有关。


二、最终解决方案

经过大量测试,我采用了 “PDF.js 多版本 + Android PDF 组件” 的混合方案:

┌─────────────────────────────────────────────────────────────┐
│                      PDF 预览策略                            │
├─────────────────────────────────────────────────────────────┤
│  桌面端浏览器  →  PDF.js v5(最新特性)                         │
│  移动端浏览器  →  PDF.js v3(兼容性好)                         │
│  Android App   →  android-pdf-viewer 组件(性能最优)          │
└─────────────────────────────────────────────────────────────┘

2.1 为什么需要 PDF.js 多版本?

  • PDF.js v5+:使用了现代 JavaScript 特性,在部分 Android WebView 和移动端浏览器中会出现样式错乱或报错
  • PDF.js v3:兼容性更好,适合移动端环境

2.2 为什么需要 Android PDF 组件?

即使使用 PDF.js v3,大文件在 WebView 中仍有性能瓶颈。使用 android-pdf-viewer PDF 组件:

  • 直接调用 Android 原生渲染能力
  • 支持流畅的缩放和翻页
  • 字体渲染清晰
  • 内存占用更低

三、技术架构

3.1 整体架构图

┌──────────────────────────────────────────────────────────────────┐
│                          Vue2 前端                                │
│  ┌────────────────────────────────────────────────────────────┐  │
│  │                    PdfJsViewer 组件                         │  │
│  │  ┌─────────────┐  ┌─────────────┐  ┌──────────────────┐    │  │
│  │  │ PDF.js v5   │  │ PDF.js v3   │  │ Android Bridge   │    │  │
│  │  │   (桌面端)   │  │   (移动端)   │  │ 	(原生预览)      │    │  │
│  │  └─────────────┘  └─────────────┘  └──────────────────┘    │  │
│  └────────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────────┘
                              │
                              │ JavaScript Bridge
                              ▼
┌──────────────────────────────────────────────────────────────────┐
│                       Android WebView                            │
│  ┌────────────────────────────────────────────────────────────┐  │
│  │                     WebActivity                            │  │
│  │  ┌───────────────────────┐  ┌───────────────────────────┐  │  │
│  │  │ @JavascriptInterface  │  │ android-pdf-viewer 插件    │  │  │
│  │  │ previewPdfByUrl       │  │ (全屏弹窗预览)              │  │  │
│  │  └───────────────────────┘  └───────────────────────────┘  │  │
│  └────────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────────┘

3.2 技术栈

层级技术版本
前端框架Vue2^2.6.11
PDF 渲染 (桌面)PDF.js5.4.449
PDF 渲染 (移动)PDF.js3.11.174
Android 原生android-pdf-viewer3.2.0-beta.3
工具库Hutool5.8.16

四、前端实现

4.1 PDF.js 部署

将两个版本的 PDF.js 放到 Vue 项目的 public 目录:

public/
├── pdfjs-v3/
│   ├── build/
│   │   ├── pdf.js
│   │   ├── pdf.worker.js
│   │   └── ...
│   └── web/
│       ├── viewer.html
│       ├── viewer.js
│       ├── viewer.css
│       ├── locale/
│       ├── images/
│       └── ...
├── pdfjs-v5/
│   ├── build/
│   │   ├── pdf.mjs
│   │   ├── pdf.worker.mjs
│   │   └── ...
│   └── web/
│       ├── viewer.html
│       ├── viewer.mjs
│       ├── viewer.css
│       ├── locale/
│       ├── images/
│       └── ...

📝 说明:直接将下载的 PDF.js 发布包解压到 public 目录即可。

下载地址

  • PDF.js: https://github.com/mozilla/pdf.js/releases

4.2 Vue PDF 预览组件

文件路径src/components/PdfJsViewer/index.vue

<template>
    <div class="pdfjs-viewer-container" :style="containerStyle">
        <!-- Loading 状态 -->
        <div v-if="loading" class="pdfjs-loading">
            <div class="loading-spinner"></div>
            <span class="loading-text">{{ loadingText }}</span>
        </div>

        <!-- 错误状态 -->
        <div v-else-if="error" class="pdfjs-error">
            <i class="el-icon-warning-outline"></i>
            <span class="error-text">{{ error }}</span>
            <el-button type="primary" @click="loadPdf">重新加载</el-button>
        </div>

        <template v-else-if="pdfBlobUrl">
            <!-- Android PDF 组件预览按钮 -->
            <span v-if="isAndroidApp" class="native-preview-btn" @click="openWithNativeViewer">
                查看原图
            </span>

            <!-- PDF 预览 iframe -->
            <iframe
                ref="pdfIframe"
                :src="viewerUrl"
                class="pdfjs-iframe"
                frameborder="0"
                allowfullscreen
            ></iframe>
        </template>

    </div>
</template>

<script>
import request from '@/utils/request'
import {isNotNull} from "@/utils/common";
import defaultSettings from "@/settings";
import {getToken} from "@/utils/auth";
import {md5} from "@/utils/secret";

export default {
    name: 'PdfJsViewer',
    props: {
        // PDF 文件路径(用于 API 请求)
        url: {
            type: String,
            required: true
        },
        // 容器高度
        height: {
            type: String,
            default: '100%'
        },
        // 容器宽度
        width: {
            type: String,
            default: '100%'
        },
        // 加载提示文字
        loadingText: {
            type: String,
            default: '正在加载PDF文件...'
        }
    },
    data() {
        return {
            loading: false,
            error: null,
            pdfBlobUrl: null
        }
    },
    computed: {
        containerStyle() {
            return {
                height: this.height,
                width: this.width
            }
        },
        // 检测是否为移动端
        isMobile() {
            const userAgent = navigator.userAgent.toLowerCase()
            const mobileKeywords = ['android', 'iphone', 'ipad', 'ipod', 'windows phone', 'mobile']
            return mobileKeywords.some(keyword => userAgent.includes(keyword))
        },
        // 检测是否为 Android App
        isAndroidApp() {
            const userAgent = navigator.userAgent.toLowerCase()
            return userAgent.includes('android') && window.android
        },
        viewerUrl() {
            if (!this.pdfBlobUrl) return ''
            // 根据设备类型选择不同的 pdfjs 版本
            // 移动端使用 pdfjs-v3,桌面端使用 pdfjs-v5
            const pdfjsPath = this.isMobile ? '/pdfjs-v3/web/viewer.html' : '/pdfjs-v5/web/viewer.html'
            // 将 Blob URL 作为参数传给 viewer.html
            return `${defaultSettings.publicPath}${pdfjsPath}?file=${encodeURIComponent(this.pdfBlobUrl)}#zoom=page-width`
        }
    },
    watch: {
        url: {
            immediate: true,
            handler(newUrl) {
                if (isNotNull(newUrl)) {
                    this.loadPdf()
                } else {
                    // 清理之前的 PDF 数据
                    this.revokePdfData()
                }
            }
        }
    },
    methods: {
        /**
         * 获取 PDF 文件流
         */
        async fetchPdfFile(previewUrl) {
            return request({
                url: previewUrl,
                method: 'get',
                responseType: 'arraybuffer'
            })
        },

        /**
         * 加载 PDF 文件
         */
        async loadPdf() {
            // 清理之前的 PDF 数据
            this.revokePdfData()

            this.loading = true
            this.error = null

            try {
                // 获取文件流
                const response = await this.fetchPdfFile(this.url)

                // 检查响应数据
                if (!response || response.byteLength === 0) {
                    this.error = '获取到的文件内容为空'
                    this.$emit('load-error', new Error(this.error))
                    return
                }

                // 创建 Blob 对象
                const blob = new Blob([response], { type: 'application/pdf' })

                // 创建 Blob URL
                this.pdfBlobUrl = URL.createObjectURL(blob)

                this.$emit('load-success')

                // 启动字体问题修复机制
                this.fixFontIssue()
            } catch (err) {
                console.error('PDF 加载失败:', err)
                this.error = err.message || '加载PDF文件失败,请重试'
                this.$emit('load-error', err)
            } finally {
                this.loading = false
            }
        },

        /**
         * 修复移动端PDF字体显示问题
         */
        fixFontIssue() {
            // 只在移动端执行
            if (!this.isMobile) return

            // 最大重试次数
            const MAX_RETRY_COUNT = 3;

            const attemptFix = (retryCount = 0) => {
                if (retryCount > MAX_RETRY_COUNT) return

                setTimeout(() => {
                    try {
                        const iframe = this.$refs.pdfIframe
                        if (iframe && iframe.contentWindow && iframe.contentWindow.PDFViewerApplication) {
                            const app = iframe.contentWindow.PDFViewerApplication

                            // 检查PDF是否已加载
                            if (app.pdfDocument && app.pdfViewer.pagesPromise) {
                                // 等待页面渲染完成后再进行修复
                                app.pdfViewer.pagesPromise.then(() => {
                                    // 切换缩放模式触发重新渲染
                                    app.pdfViewer.currentScaleValue = "page-fit"
                                    setTimeout(() => {
                                        app.pdfViewer.currentScaleValue = "page-width"
                                    }, 50)
                                })
                            } else if (retryCount < MAX_RETRY_COUNT) {
                                // 如果PDF还未完全加载,稍后重试
                                attemptFix(retryCount + 1)
                            }
                        } else if (retryCount < MAX_RETRY_COUNT) {
                            // 如果iframe还未准备好,稍后重试
                            attemptFix(retryCount + 1)
                        }
                    } catch (e) {
                        alert("字体修复尝试失败")
                        console.warn("字体修复尝试失败:", e)
                        if (retryCount < MAX_RETRY_COUNT) {
                            attemptFix(retryCount + 1)
                        }
                    }
                }, 500 * (retryCount + 1)) // 递增延迟时间
            }

            attemptFix()
        },

        /**
         * 使用 Android PDF 预览插件打开 PDF
         */
        async openWithNativeViewer() {
            if (!this.pdfBlobUrl) {
                this.$message.warning('PDF 文件未加载完成')
                return
            }

            if (!this.isAndroidApp) {
                this.$message.warning('当前环境不支持 Android PDF 预览插件')
                return
            }

            try {
                // 调用Android PDF预览插件方法
                const success = this.previewPdfByNative(this.url)

                if (!success) {
                    this.$message.error({message: "打开 Android PDF 预览插件失败,请重试", offset: 80})
                }
            } catch (err) {
                console.error('打开 Android PDF 预览插件失败:', err)
                this.$message.error({message: "打开 Android PDF 预览插件失败,请重试", offset: 80})
            }
        },

        /**
         * 使用 Android PDF 预览插件
         * @param {String} url - PDF 文件 URL
         * @returns {Boolean} 是否成功调用
         */
        previewPdfByNative(url) {
            if (!this.isAndroidApp || !window.android.previewPdfByUrl) {
                this.$message.warning({message: "'当前环境不支持 Android PDF 预览插件", offset: 80})
                return false
            }
            try {
                const fileName = md5(url) + ".pdf";
                window.android.previewPdfByUrl(
                    url,
                    fileName,
                    JSON.stringify({
                        Authorization: getToken()
                    })
                )
                return true
            } catch (err) {
                console.error('调用 Android PDF 预览插件失败:', err)
                return false
            }
        },

        /**
         * 释放 PDF 数据
         */
        revokePdfData() {
            if (this.pdfBlobUrl) {
                // 组件销毁时释放 Blob URL
                URL.revokeObjectURL(this.pdfBlobUrl)
                this.pdfBlobUrl = null
            }
        }
    },
    beforeDestroy() {
        // 组件销毁时释放 PDF 数据
        this.revokePdfData()
    }
}
</script>

<style scoped>
.pdfjs-viewer-container {
    position: relative;
    overflow: hidden;
    background-color: #525659;
}

.pdfjs-iframe {
    width: 100%;
    height: 100%;
    border: none;
}

.pdfjs-loading {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    background-color: #f5f7fa;
    color: #606266;
}

.loading-spinner {
    width: 40px;
    height: 40px;
    border: 3px solid rgba(64, 158, 255, 0.3);
    border-top-color: #409eff;
    border-radius: 50%;
    animation: spin 1s linear infinite;
}

@keyframes spin {
    to {
        transform: rotate(360deg);
    }
}

.loading-text {
    margin-top: 16px;
    font-size: 14px;
}

.pdfjs-error {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    background-color: #525659;
    color: #fff;
}

.pdfjs-error .el-icon-warning-outline {
    font-size: 48px;
    color: #e6a23c;
    margin-bottom: 16px;
}

.error-text {
    font-size: 14px;
    margin-bottom: 16px;
    text-align: center;
    padding: 0 20px;
}

/* Android PDF 预览插件按钮样式 */
.native-preview-btn {
    position: absolute;
    right: 50px;
    height: 32px;
    line-height: 33px;
    z-index: 1000;
    text-align: center;
    font-size: 14px;
    cursor: pointer;

    :active {
        background: none;
    }
}
</style>

4.3 组件使用方式

<template>
    <div>
        <pdf-js-viewer
            :url="pdfUrl"
            height="100vh"
            @load-success="onLoadSuccess"
            @load-error="onLoadError"
        />
    </div>
</template>

<script>
import PdfJsViewer from '@/components/PdfJsViewer'

export default {
    components: { PdfJsViewer },
    data() {
        return {
            pdfUrl: 'https://example.com/document.pdf'
        }
    },
    methods: {
        onLoadSuccess() {
            console.log('PDF 加载成功')
        },
        onLoadError(err) {
            console.error('PDF 加载失败:', err)
        }
    }
}
</script>


五、Android 端实现

5.1 添加权限

AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    
    <!-- 网络权限,用于下载 PDF 文件 -->
    <uses-permission android:name="android.permission.INTERNET" />
    
    <!-- ... -->
</manifest>

5.2 添加依赖

app/build.gradle

dependencies {
    // ... 其他依赖
    
    // PDF 预览插件
    implementation 'com.github.mhiew:android-pdf-viewer:3.2.0-beta.3'
    
    // 工具库(用于 HTTP 请求和文件操作)
    implementation 'cn.hutool:hutool-all:5.8.16'
}

注意:该库托管在 JitPack,需要在项目根 build.gradle 中添加:

allprojects {
    repositories {
        // ...
        maven { url 'https://jitpack.io' }
    }
}

5.3 PDF 预览弹窗布局

res/layout/dialog_pdf_preview.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/white">

    <!-- 标题栏 -->
    <RelativeLayout
        android:id="@+id/titleBar"
        android:layout_width="match_parent"
        android:layout_height="48dp"
        android:layout_alignParentTop="true"
        android:background="#F5F5F5"
        android:paddingHorizontal="16dp">

        <TextView
            android:id="@+id/tvTitle"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_centerVertical="true"
            android:text="PDF 预览"
            android:textColor="#333333"
            android:textSize="16sp"
            android:textStyle="bold" />

        <TextView
            android:id="@+id/tvPageInfo"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_centerInParent="true"
            android:textColor="#666666"
            android:textSize="14sp" />

        <ImageView
            android:id="@+id/ivClose"
            android:layout_width="36dp"
            android:layout_height="36dp"
            android:layout_alignParentEnd="true"
            android:layout_centerVertical="true"
            android:background="?attr/selectableItemBackgroundBorderless"
            android:contentDescription="关闭"
            android:padding="8dp"
            android:src="@android:drawable/ic_menu_close_clear_cancel" />

    </RelativeLayout>

    <!-- PDF 预览区域 -->
    <com.github.barteksc.pdfviewer.PDFView
        android:id="@+id/pdfView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_below="@id/titleBar" />

</RelativeLayout>

5.4 WebActivity 核心代码

package com.qms.android;

import androidx.appcompat.app.AlertDialog;
import android.app.Activity;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;
import android.webkit.JavascriptInterface;
import android.webkit.WebView;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
import android.widget.TextView;
import android.util.Base64;

import com.github.barteksc.pdfviewer.PDFView;

import cn.hutool.core.io.FileUtil;
import cn.hutool.crypto.SecureUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;

import org.json.JSONException;
import org.json.JSONObject;

import java.io.File;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;

public class WebActivity extends Activity {

    private WebView mWebView;

    // ... 其他代码省略 ...

    /**
     * 初始化 WebView,添加 JavaScript 接口
     */
    public void initWebView() {
        // ... WebView 配置代码 ...
        
        // 添加 JavaScript 接口,供前端调用
        mWebView.addJavascriptInterface(this, "android");
    }

    // ==================== PDF 预览相关方法 ====================

    /**
     * JavaScript 接口:通过 URL 预览 PDF
     * @param url PDF 文件的下载地址
     * @param fileName 文件名(用于缓存)
     * @param headersJson 请求头信息的 JSON 字符串
     */
    @JavascriptInterface
    public void previewPdfByUrl(String url, String fileName, String headersJson) {
        runOnUiThread(() -> {
            if (TextUtils.isEmpty(url)) {
                ToastUtils.showLong(this, "PDF 地址为空");
                return;
            }
            downloadPdfAndPreview(url, fileName, headersJson);
        });
    }

    /**
     * JavaScript 接口:通过 Base64 数据预览 PDF
     * @param base64Data PDF 文件的 Base64 编码数据
     */
    @JavascriptInterface
    public void previewPdfByData(String base64Data) {
        runOnUiThread(() -> {
            if (TextUtils.isEmpty(base64Data)) {
                ToastUtils.showLong(this, "PDF 数据为空");
                return;
            }
            saveBase64PdfAndPreview(base64Data);
        });
    }

    /**
     * 显示加载提示弹窗
     */
    private AlertDialog showLoadingDialog(String message) {
        LinearLayout layout = new LinearLayout(this);
        layout.setOrientation(LinearLayout.HORIZONTAL);
        layout.setPadding(48, 32, 48, 32);
        layout.setGravity(android.view.Gravity.CENTER_VERTICAL);

        ProgressBar progressBar = new ProgressBar(this);
        layout.addView(progressBar);

        TextView textView = new TextView(this);
        textView.setText(message);
        textView.setTextSize(16);
        textView.setPadding(32, 0, 0, 0);
        layout.addView(textView);

        AlertDialog dialog = new AlertDialog.Builder(this)
                .setView(layout)
                .setCancelable(false)
                .create();
        dialog.show();
        return dialog;
    }

    /**
     * 下载 PDF 文件并预览
     */
    private void downloadPdfAndPreview(String url, String fileName, String headersJson) {
        Map<String, String> headers = parseHeaders(headersJson);

        if (TextUtils.isEmpty(fileName)) {
            // 使用 URL 的 MD5 作为缓存文件名
            fileName = SecureUtil.md5(url) + ".pdf";
        }

        // 下载到缓存目录
        String cachePath = getCacheDir().getAbsolutePath() + File.separator + "pdf_preview";
        File pdfFile = new File(cachePath, fileName);
        
        if (pdfFile.exists()) {
            // 缓存已存在,直接显示
            showPdfDialog(pdfFile);
            return;
        }

        // 显示加载提示
        AlertDialog loadingDialog = showLoadingDialog("正在加载文件,请稍候...");

        // 在子线程中执行下载
        new Thread(() -> {
            try {
                HttpRequest request = HttpRequest.get(url);
                // 添加请求头
                if (!headers.isEmpty()) {
                    for (Map.Entry<String, String> entry : headers.entrySet()) {
                        request.header(entry.getKey(), entry.getValue());
                    }
                }

                HttpResponse response = request.execute();
                runOnUiThread(loadingDialog::dismiss);

                if (response.isOk()) {
                    FileUtil.writeBytes(response.bodyBytes(), pdfFile);
                    runOnUiThread(() -> showPdfDialog(pdfFile));
                } else {
                    runOnUiThread(() -> ToastUtils.showLong(this, "PDF 下载失败"));
                }
            } catch (Exception e) {
                e.printStackTrace();
                runOnUiThread(() -> {
                    loadingDialog.dismiss();
                    ToastUtils.showLong(this, "PDF 下载失败: " + e.getMessage());
                });
            }
        }).start();
    }

    /**
     * 解析请求头信息
     */
    private Map<String, String> parseHeaders(String headersJson) {
        Map<String, String> headers = new HashMap<>();
        if (TextUtils.isEmpty(headersJson)) return headers;
        
        try {
            JSONObject jsonObject = new JSONObject(headersJson);
            Iterator<String> keys = jsonObject.keys();
            while (keys.hasNext()) {
                String key = keys.next();
                headers.put(key, jsonObject.optString(key));
            }
        } catch (JSONException e) {
            e.printStackTrace();
        }
        return headers;
    }

    /**
     * 保存 Base64 格式的 PDF 数据并预览
     */
    private void saveBase64PdfAndPreview(String base64Data) {
        AlertDialog loadingDialog = showLoadingDialog("正在加载文件,请稍候...");

        try {
            // 使用 Base64 数据的 MD5 作为缓存文件名
            String cacheFileName = SecureUtil.md5(base64Data) + ".pdf";
            String cachePath = getCacheDir().getAbsolutePath() + File.separator + "pdf_preview";
            File pdfFile = new File(cachePath, cacheFileName);
            
            if (!pdfFile.exists()) {
                byte[] bytes = Base64.decode(base64Data, Base64.DEFAULT);
                FileUtil.writeBytes(bytes, pdfFile);
            }

            loadingDialog.dismiss();
            showPdfDialog(pdfFile);
        } catch (Exception e) {
            loadingDialog.dismiss();
            ToastUtils.showLong(this, "PDF 数据处理失败");
        }
    }

    /**
     * 显示 PDF 预览对话框
     */
    private void showPdfDialog(File pdfFile) {
        if (pdfFile == null || !pdfFile.exists()) {
            ToastUtils.showLong(this, "PDF 文件不存在");
            return;
        }

        View view = LayoutInflater.from(this).inflate(R.layout.dialog_pdf_preview, null, false);
        PDFView pdfView = view.findViewById(R.id.pdfView);
        ImageView ivClose = view.findViewById(R.id.ivClose);
        TextView tvPageInfo = view.findViewById(R.id.tvPageInfo);

        AlertDialog dialog = new AlertDialog.Builder(this)
                .setView(view)
                .setCancelable(true)
                .create();

        ivClose.setOnClickListener(v -> dialog.dismiss());
        dialog.show();

        // 设置弹窗全屏显示
        Window window = dialog.getWindow();
        if (window != null) {
            window.setLayout(WindowManager.LayoutParams.MATCH_PARENT, 
                           WindowManager.LayoutParams.MATCH_PARENT);
            window.setBackgroundDrawableResource(android.R.color.white);
        }

        // 延迟加载 PDF,确保 View 已完成布局
        pdfView.post(() -> loadPdf(pdfView, pdfFile, tvPageInfo));
    }

    /**
     * 加载并显示 PDF 内容
     */
    private void loadPdf(PDFView pdfView, File pdfFile, TextView tvPageInfo) {
        pdfView.fromFile(pdfFile)
                .enableSwipe(true)           // 启用滑动翻页
                .swipeHorizontal(false)      // 垂直滑动
                .enableDoubletap(true)       // 启用双击缩放
                .defaultPage(0)              // 默认显示第一页
                .enableAnnotationRendering(false)
                .enableAntialiasing(true)    // 启用抗锯齿
                .spacing(4)                  // 页面间距
                .onPageChange((page, pageCount) -> {
                    tvPageInfo.setText(String.format("%d / %d", page + 1, pageCount));
                })
                .onLoad(nbPages -> {
                    tvPageInfo.setText(String.format("1 / %d", nbPages));
                    // 设置缩放范围
                    pdfView.setMinZoom(0.5f);
                    pdfView.setMaxZoom(10.0f);
                    pdfView.setMidZoom(2f);
                })
                .onError(t -> {
                    ToastUtils.showLong(this, "PDF 加载失败: " + t.getMessage());
                })
                .load();
    }
}

六、前端调用 Android PDF 组件方法

6.1 调用方式一:通过 URL 预览(推荐)

推荐使用此方式,由 Android 端负责下载和渲染,前端只需传递 URL 和请求头。

// 检测是否为 Android App 环境
const isAndroidApp = navigator.userAgent.toLowerCase().includes('android') && window.android

if (isAndroidApp && window.android.previewPdfByUrl) {
    window.android.previewPdfByUrl(
        'https://api.example.com/file/xxx',  // 下载 URL
      	'xxx.pdf',  						 // 文件名称(缓存使用)
        JSON.stringify({                	 // 请求头
            Authorization: 'Bearer xxx'
        })
    )
}

6.2 调用方式二:通过 Base64 数据预览(不推荐)

⚠️ 不推荐使用此方式

原因:前端使用 btoa() 将大文件转换为 Base64 时会导致浏览器卡死,100MB+ 的文件基本无法处理。

此接口仅适用于小文件(< 5MB)场景。

// 获取 PDF 的 ArrayBuffer
const response = await fetch(pdfUrl)
const arrayBuffer = await response.arrayBuffer()

// ⚠️ 大文件会导致浏览器卡死!
const base64Data = btoa(
    new Uint8Array(arrayBuffer).reduce((data, byte) => data + String.fromCharCode(byte), '')
)

// 调用 Android 原生方法
if (window.android?.previewPdfByData) {
    window.android.previewPdfByData(base64Data)
}

七、缓存策略优化

为了避免重复下载相同的 PDF 文件,我使用 MD5 哈希 作为缓存文件名:

场景缓存键说明
URL 方式(推荐)MD5(url) + ".pdf"相同 URL 只下载一次
Base64 方式MD5(base64Data) + ".pdf"仅适用于小文件

八、实现效果

8.1 桌面端浏览器 PDF 预览(PDF.js v5)

在这里插入图片描述

在这里插入图片描述

效果说明

  • 桌面端使用 PDF.js v5,功能完善
  • 支持缩放、翻页、搜索等功能
  • 缩放流畅、放大后字体依然清晰

8.2 Android WebView PDF 预览(PDF.js v3)

在这里插入图片描述

效果说明

  • Android WebView 使用 PDF.js v3 以保证兼容性
  • 支持手势缩放、翻页、搜索等功能
  • 大文件缩放时可能存在卡顿、放大后字体不清晰
  • 右上角提供「查看原图」按钮,点击后以弹窗方式打开 Android PDF 组件预览

8.3 Android PDF 组件预览(android-pdf-viewer)

在这里插入图片描述

在这里插入图片描述

效果说明

  • 全屏弹窗预览,沉浸式体验
  • 顶部显示页码信息(如 1 / 1)
  • 右上角关闭按钮,操作便捷
  • 支持双指缩放
  • 放大后字体渲染清晰,缩放流畅无卡顿

8.4 使用体验对比

指标WebView + PDF.jsAndroid PDF 组件
缩放流畅度大文件卡顿明显,甚至卡死丝滑流畅
字体清晰度放大后模糊始终清晰
内存占用较高,易 OOM较低,稳定
首次加载快(直接渲染)需下载后预览

📝 以上为实际使用中的主观体验对比,非精确测量数据。


九、总结

本文介绍了在 Vue2 + Android WebView 环境下预览大文件 PDF 的完整解决方案:

  1. PDF.js 多版本策略:桌面端用 v5,移动端用 v3,解决兼容性问题
  2. Android PDF 组件:使用 android-pdf-viewer 实现高性能预览
  3. JS Bridge 通信:通过 @JavascriptInterface 实现前端与原生的交互
  4. MD5 缓存策略:避免重复下载,提升用户体验

这套方案已在生产环境稳定运行,成功解决了大文件 PDF 在 PAD 上的预览问题。


十、参考资源


标签:Vue2、Android、WebView、PDF预览、大文件、性能优化

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值