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.js 5.4.449
PDF 渲染 (移动) PDF.js 3.11.174
Android 原生 android-pdf-viewer 3.2.0-beta.3
工具库 Hutool 5.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 目录即可。

下载地址:

4.2 Vue PDF 预览组件

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

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 组件使用方式

vue 复制代码
<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:

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:

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 中添加:

gradle 复制代码
allprojects {
    repositories {
        // ...
        maven { url 'https://jitpack.io' }
    }
}

5.3 PDF 预览弹窗布局

res/layout/dialog_pdf_preview.xml:

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 核心代码

java 复制代码
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 和请求头。

javascript 复制代码
// 检测是否为 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)场景。

javascript 复制代码
// 获取 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.js Android 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预览、大文件、性能优化

相关推荐
千里马学框架2 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
A黄俊辉A2 天前
uniapp webview中实现 app和内嵌的H5双向通信
vue.js·json
美狐美颜SDK开放平台3 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone3 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc3 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
honkun63 天前
vue 表格组件 vxe-table 配置 ajax 请求自动加载数据与表单查询
vue.js·vxe-table
ttyyttemo3 天前
Kotlin 协程中的 Job 结构化并发与取消
android
kybs19913 天前
全球灾害数据分析可视化 毕业设计-附源码66794
vue.js·spring boot·mysql·安全·django·c#·asp.net
雪芽蓝域zzs3 天前
第26章:ECharts 自定义主题 + 页面一键切换主题(深色 / 浅色两套)
vue.js·echars
雪芽蓝域zzs3 天前
第22章:ECharts 图表联动(多图表交互联动)
vue.js·echars