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 目录即可。
下载地址:
- 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.js | Android PDF 组件 |
|---|---|---|
| 缩放流畅度 | 大文件卡顿明显,甚至卡死 | 丝滑流畅 |
| 字体清晰度 | 放大后模糊 | 始终清晰 |
| 内存占用 | 较高,易 OOM | 较低,稳定 |
| 首次加载 | 快(直接渲染) | 需下载后预览 |
📝 以上为实际使用中的主观体验对比,非精确测量数据。
九、总结
本文介绍了在 Vue2 + Android WebView 环境下预览大文件 PDF 的完整解决方案:
- PDF.js 多版本策略:桌面端用 v5,移动端用 v3,解决兼容性问题
- Android PDF 组件:使用
android-pdf-viewer实现高性能预览 - JS Bridge 通信:通过
@JavascriptInterface实现前端与原生的交互 - MD5 缓存策略:避免重复下载,提升用户体验
这套方案已在生产环境稳定运行,成功解决了大文件 PDF 在 PAD 上的预览问题。
十、参考资源
标签:Vue2、Android、WebView、PDF预览、大文件、性能优化

1万+

被折叠的 条评论
为什么被折叠?



