早晨曙光 發表於 2025-12-30 09:23:04

Vue + Android WebView实现大文件PDF预览完整解决方案(附详细代码)

<div id="navCategory"><h5 class="catalogue">目录</h5><ul class="first_class_ul"><li><a href="#_label0">一、问题背景</a></li><ul class="second_class_ul"><li><a href="#_lab2_0_0">1.1 具体表现</a></li><li><a href="#_lab2_0_1">1.2 尝试过的方案</a></li></ul><li><a href="#_label1">二、最终解决方案</a></li><ul class="second_class_ul"><li><a href="#_lab2_1_2">2.1 为什么需要 PDF.js 多版本?</a></li><li><a href="#_lab2_1_3">2.2 为什么需要 Android PDF 组件?</a></li></ul><li><a href="#_label2">三、技术架构</a></li><ul class="second_class_ul"><li><a href="#_lab2_2_4">3.1 整体架构图</a></li><li><a href="#_lab2_2_5">3.2 技术栈</a></li></ul><li><a href="#_label3">四、前端实现</a></li><ul class="second_class_ul"><li><a href="#_lab2_3_6">4.1 PDF.js 部署</a></li><li><a href="#_lab2_3_7">4.2 Vue PDF 预览组件</a></li><ul class="third_class_ul"><li><a href="#_label3_3_7_0">4.3 组件使用方式</a></li></ul></ul><li><a href="#_label4">五、Android 端实现</a></li><ul class="second_class_ul"><li><a href="#_lab2_4_8">5.1 添加权限</a></li><ul class="third_class_ul"></ul><li><a href="#_lab2_4_9">5.2 添加依赖</a></li><ul class="third_class_ul"></ul><li><a href="#_lab2_4_10">5.3 PDF 预览弹窗布局</a></li><ul class="third_class_ul"></ul><li><a href="#_lab2_4_11">5.4 WebActivity 核心代码</a></li><ul class="third_class_ul"></ul></ul><li><a href="#_label5">六、前端调用 Android PDF 组件方法</a></li><ul class="second_class_ul"><li><a href="#_lab2_5_12">6.1 调用方式一:通过 URL 预览(推荐)</a></li><ul class="third_class_ul"></ul><li><a href="#_lab2_5_13">6.2 调用方式二:通过 Base64 数据预览(不推荐)</a></li><ul class="third_class_ul"></ul></ul><li><a href="#_label6">七、缓存策略优化</a></li><ul class="second_class_ul"></ul><li><a href="#_label7">八、实现效果</a></li><ul class="second_class_ul"><li><a href="#_lab2_7_14">8.1 桌面端浏览器 PDF 预览(PDF.js v5)</a></li><ul class="third_class_ul"></ul><li><a href="#_lab2_7_15">8.2 Android WebView PDF 预览(PDF.js v3)</a></li><ul class="third_class_ul"></ul><li><a href="#_lab2_7_16">8.3 Android PDF 组件预览(android-pdf-viewer)</a></li><ul class="third_class_ul"></ul><li><a href="#_lab2_7_17">8.4 使用体验对比</a></li><ul class="third_class_ul"></ul></ul><li><a href="#_label8">九、总结</a></li><ul class="second_class_ul"></ul><li><a href="#_label9">十、参考资源</a></li><ul class="second_class_ul"></ul></ul></div><p class="maodian"><a name="_label0"></a></p><h2>一、问题背景</h2>
<p>在企业级 PAD 应用开发中,我遇到了一个棘手的问题:<strong>100MB+ 的大 PDF 文件在 Android WebView 中预览时,出现严重的性能问题</strong>。</p>
<p class="maodian"><a name="_lab2_0_0"></a></p><h3>1.1 具体表现</h3>
<table><thead><tr><th>问题</th><th>表现</th></tr></thead><tbody><tr><td>缩放卡顿</td><td>双指缩放时明显卡顿,甚至卡死</td></tr><tr><td>字体模糊</td><td>放大后字体不清晰,影响阅读</td></tr><tr><td>内存溢出</td><td>大文件加载时容易 OOM 崩溃</td></tr><tr><td>渲染异常</td><td>部分页面白屏或渲染不完整</td></tr></tbody></table>
<p><strong>关键发现</strong>:同样的 Vue2 项目,在桌面端浏览器访问完全正常,问题只出现在 Android WebView 环境。</p>
<p class="maodian"><a name="_lab2_0_1"></a></p><h3>1.2 尝试过的方案</h3>
<p>我尝试了市面上几乎所有主流的 Vue PDF 预览插件,均存在不同程度的问题:</p>
<table><thead><tr><th>插件名称</th><th>存在的问题</th></tr></thead><tbody><tr><td><code>vue-pdf</code></td><td>&bull; 部分字体丢失或显示异常<br />&bull; WebView 中大文件缩放严重卡顿或卡死<br />&bull; 需要手动实现缩放、翻页等功能</td></tr><tr><td><code>vue-pdf-signature</code></td><td>&bull; 部分字体渲染模糊<br />&bull; WebView 中大文件缩放严重卡顿或卡死<br />&bull; 需要手动实现缩放、翻页等功能</td></tr><tr><td><code>vue-pdf-app</code></td><td>&bull; WebView 中大文件缩放严重卡顿或卡死<br />&bull; 放大后字体不清晰<br />&bull; 功能完善(缩放、翻页、搜索等)</td></tr><tr><td><code>@vue-office/pdf</code></td><td>&bull; WebView 中大文件缩放严重卡顿或卡死<br />&bull; 需要手动实现缩放、翻页等功能</td></tr><tr><td><code>pdfjs-dist</code></td><td>&bull; 高版本(v4+)在 WebView/移动端浏览器不兼容,样式错乱或报错<br />&bull; 低版本放大后字体不清晰<br />&bull; 自带 viewer.html,功能完善(缩放、翻页、搜索等)</td></tr></tbody></table>
<blockquote><p>💡 <strong>关键发现</strong>:以上插件在桌面端浏览器中表现正常,问题主要出现在 Android WebView 环境。推测可能与 WebView 的硬件加速、Canvas 渲染性能、内存限制等因素有关。</p></blockquote>
<p class="maodian"><a name="_label1"></a></p><h2>二、最终解决方案</h2>
<p>经过大量测试,我采用了 <strong>&ldquo;PDF.js 多版本 + Android PDF 组件&rdquo;</strong> 的混合方案:</p>
<div class="jb51code"><pre class="brush:plain;">┌─────────────────────────────────────────────────────────────┐
│                      PDF 预览策略                            │
├─────────────────────────────────────────────────────────────┤
│桌面端浏览器→PDF.js v5(最新特性)                         │
│移动端浏览器→PDF.js v3(兼容性好)                         │
│Android App   →android-pdf-viewer 组件(性能最优)          │
└─────────────────────────────────────────────────────────────┘
</pre></div>
<p class="maodian"><a name="_lab2_1_2"></a></p><h3>2.1 为什么需要 PDF.js 多版本?</h3>
<ul><li><strong>PDF.js v5+</strong>:使用了现代 JavaScript 特性,在部分 Android WebView 和移动端浏览器中会出现样式错乱或报错</li><li><strong>PDF.js v3</strong>:兼容性更好,适合移动端环境</li></ul>
<p class="maodian"><a name="_lab2_1_3"></a></p><h3>2.2 为什么需要 Android PDF 组件?</h3>
<p>即使使用 PDF.js v3,大文件在 WebView 中仍有性能瓶颈。使用 <code>android-pdf-viewer</code> PDF 组件:</p>
<ul><li>直接调用 Android 原生渲染能力</li><li>支持流畅的缩放和翻页</li><li>字体渲染清晰</li><li>内存占用更低</li></ul>
<p class="maodian"><a name="_label2"></a></p><h2>三、技术架构</h2>
<p class="maodian"><a name="_lab2_2_4"></a></p><h3>3.1 整体架构图</h3>
<div class="jb51code"><pre class="brush:js;">┌──────────────────────────────────────────────────────────────────┐
│                        Vue2 前端                              │
│┌────────────────────────────────────────────────────────────┐│
││                  PdfJsViewer 组件                         ││
││┌─────────────┐┌─────────────┐┌──────────────────┐    ││
│││ PDF.js v5   ││ PDF.js v3   ││ Android Bridge   │    ││
│││   (桌面端)   ││   (移动端)   ││         (原生预览)      │    ││
││└─────────────┘└─────────────┘└──────────────────┘    ││
│└────────────────────────────────────────────────────────────┘│
└──────────────────────────────────────────────────────────────────┘
                              │
                              │ JavaScript Bridge
                              ▼
┌──────────────────────────────────────────────────────────────────┐
│                     Android WebView                            │
│┌────────────────────────────────────────────────────────────┐│
││                     WebActivity                            ││
││┌───────────────────────┐┌───────────────────────────┐││
│││ @JavascriptInterface││ android-pdf-viewer 插件    │││
│││ previewPdfByUrl       ││ (全屏弹窗预览)            │││
││└───────────────────────┘└───────────────────────────┘││
│└────────────────────────────────────────────────────────────┘│
└──────────────────────────────────────────────────────────────────┘
</pre></div>
<p class="maodian"><a name="_lab2_2_5"></a></p><h3>3.2 技术栈</h3>
<table><thead><tr><th>层级</th><th>技术</th><th>版本</th></tr></thead><tbody><tr><td>前端框架</td><td>Vue2</td><td>^2.6.11</td></tr><tr><td>PDF 渲染 (桌面)</td><td>PDF.js</td><td>5.4.449</td></tr><tr><td>PDF 渲染 (移动)</td><td>PDF.js</td><td>3.11.174</td></tr><tr><td>Android 原生</td><td>android-pdf-viewer</td><td>3.2.0-beta.3</td></tr><tr><td>工具库</td><td>Hutool</td><td>5.8.16</td></tr></tbody></table>
<p class="maodian"><a name="_label3"></a></p><h2>四、前端实现</h2>
<p class="maodian"><a name="_lab2_3_6"></a></p><h3>4.1 PDF.js 部署</h3>
<p>将两个版本的 PDF.js 放到 Vue 项目的 <code>public</code> 目录:</p>
<div class="jb51code"><pre class="brush:js;">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/
│       └── ...
</pre></div>
<blockquote><p>📝 <strong>说明</strong>:直接将下载的 PDF.js 发布包解压到 public 目录即可。</p></blockquote>
<p><strong>下载地址</strong>:</p>
<ul><li>PDF.js: https://github.com/mozilla/pdf.js/releases</li></ul>
<p class="maodian"><a name="_lab2_3_7"></a></p><h3>4.2 Vue PDF 预览组件</h3>
<p><strong>文件路径</strong>:<code>src/components/PdfJsViewer/index.vue</code></p>
<div class="jb51code"><pre class="brush:xhtml;">&lt;template&gt;
    &lt;div class="pdfjs-viewer-container" :style="containerStyle"&gt;
      &lt;!-- Loading 状态 --&gt;
      &lt;div v-if="loading" class="pdfjs-loading"&gt;
            &lt;div class="loading-spinner"&gt;&lt;/div&gt;
            &lt;span class="loading-text"&gt;{{ loadingText }}&lt;/span&gt;
      &lt;/div&gt;

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

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

            &lt;!-- PDF 预览 iframe --&gt;
            &lt;iframe
                ref="pdfIframe"
                :src="viewerUrl"
                class="pdfjs-iframe"
                frameborder="0"
                allowfullscreen
            &gt;&lt;/iframe&gt;
      &lt;/template&gt;

    &lt;/div&gt;
&lt;/template&gt;

&lt;script&gt;
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 =&gt; userAgent.includes(keyword))
      },
      // 检测是否为 Android App
      isAndroidApp() {
            const userAgent = navigator.userAgent.toLowerCase()
            return userAgent.includes('android') &amp;&amp; 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(, { 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) =&gt; {
                if (retryCount &gt; MAX_RETRY_COUNT) return

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

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

&lt;style scoped&gt;
.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;
    }
}
&lt;/style&gt;
</pre></div>
<p class="maodian"><a name="_label3_3_7_0"></a></p><h4>4.3 组件使用方式</h4>
<div class="jb51code"><pre class="brush:js;">&lt;template&gt;
    &lt;div&gt;
      &lt;pdf-js-viewer
            :url="pdfUrl"
            height="100vh"
            @load-success="onLoadSuccess"
            @load-error="onLoadError"
      /&gt;
    &lt;/div&gt;
&lt;/template&gt;

&lt;script&gt;
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)
      }
    }
}
&lt;/script&gt;

</pre></div>
<p class="maodian"><a name="_label4"></a></p><h2>五、Android 端实现</h2>
<p class="maodian"><a name="_lab2_4_8"></a></p><h3>5.1 添加权限</h3>
<p><strong>AndroidManifest.xml</strong>:</p>
<div class="jb51code"><pre class="brush:xml;">&lt;manifest xmlns:android="http://schemas.android.com/apk/res/android"&gt;
   
    &lt;!-- 网络权限,用于下载 PDF 文件 --&gt;
    &lt;uses-permission android:name="android.permission.INTERNET" /&gt;
   
    &lt;!-- ... --&gt;
&lt;/manifest&gt;
</pre></div>
<p class="maodian"><a name="_lab2_4_9"></a></p><h3>5.2 添加依赖</h3>
<p><strong>app/build.gradle</strong>:</p>
<div class="jb51code"><pre class="brush:js;">dependencies {
    // ... 其他依赖
   
    // PDF 预览插件
    implementation 'com.github.mhiew:android-pdf-viewer:3.2.0-beta.3'
   
    // 工具库(用于 HTTP 请求和文件操作)
    implementation 'cn.hutool:hutool-all:5.8.16'
}
</pre></div>
<p><strong>注意</strong>:该库托管在 JitPack,需要在项目根 <code>build.gradle</code> 中添加:</p>
<div class="jb51code"><pre class="brush:js;">allprojects {
    repositories {
      // ...
      maven { url 'https://jitpack.io' }
    }
}
</pre></div>
<p class="maodian"><a name="_lab2_4_10"></a></p><h3>5.3 PDF 预览弹窗布局</h3>
<p><strong>res/layout/dialog_pdf_preview.xml</strong>:</p>
<div class="jb51code"><pre class="brush:xml;">&lt;?xml version="1.0" encoding="utf-8"?&gt;
&lt;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"&gt;

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

      &lt;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" /&gt;

      &lt;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" /&gt;

      &lt;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" /&gt;

    &lt;/RelativeLayout&gt;

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

&lt;/RelativeLayout&gt;
</pre></div>
<p class="maodian"><a name="_lab2_4_11"></a></p><h3>5.4 WebActivity 核心代码</h3>
<div class="jb51code"><pre class="brush:js;">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(() -&gt; {
            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(() -&gt; {
            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&lt;String, String&gt; 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(() -&gt; {
            try {
                HttpRequest request = HttpRequest.get(url);
                // 添加请求头
                if (!headers.isEmpty()) {
                  for (Map.Entry&lt;String, String&gt; 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(() -&gt; showPdfDialog(pdfFile));
                } else {
                  runOnUiThread(() -&gt; ToastUtils.showLong(this, "PDF 下载失败"));
                }
            } catch (Exception e) {
                e.printStackTrace();
                runOnUiThread(() -&gt; {
                  loadingDialog.dismiss();
                  ToastUtils.showLong(this, "PDF 下载失败: " + e.getMessage());
                });
            }
      }).start();
    }

    /**
   * 解析请求头信息
   */
    private Map&lt;String, String&gt; parseHeaders(String headersJson) {
      Map&lt;String, String&gt; headers = new HashMap&lt;&gt;();
      if (TextUtils.isEmpty(headersJson)) return headers;
      
      try {
            JSONObject jsonObject = new JSONObject(headersJson);
            Iterator&lt;String&gt; 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 -&gt; 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(() -&gt; 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) -&gt; {
                  tvPageInfo.setText(String.format("%d / %d", page + 1, pageCount));
                })
                .onLoad(nbPages -&gt; {
                  tvPageInfo.setText(String.format("1 / %d", nbPages));
                  // 设置缩放范围
                  pdfView.setMinZoom(0.5f);
                  pdfView.setMaxZoom(10.0f);
                  pdfView.setMidZoom(2f);
                })
                .onError(t -&gt; {
                  ToastUtils.showLong(this, "PDF 加载失败: " + t.getMessage());
                })
                .load();
    }
}
</pre></div>
<p class="maodian"><a name="_label5"></a></p><h2>六、前端调用 Android PDF 组件方法</h2>
<p class="maodian"><a name="_lab2_5_12"></a></p><h3>6.1 调用方式一:通过 URL 预览(推荐)</h3>
<p><strong>推荐使用此方式</strong>,由 Android 端负责下载和渲染,前端只需传递 URL 和请求头。</p>
<div class="jb51code"><pre class="brush:js;">// 检测是否为 Android App 环境
const isAndroidApp = navigator.userAgent.toLowerCase().includes('android') &amp;&amp; window.android

if (isAndroidApp &amp;&amp; window.android.previewPdfByUrl) {
    window.android.previewPdfByUrl(
      'https://api.example.com/file/xxx',// 下载 URL
              'xxx.pdf',                                               // 文件名称(缓存使用)
      JSON.stringify({                       // 请求头
            Authorization: 'Bearer xxx'
      })
    )
}
</pre></div>
<p class="maodian"><a name="_lab2_5_13"></a></p><h3>6.2 调用方式二:通过 Base64 数据预览(不推荐)</h3>
<blockquote><p>⚠️ <strong>不推荐使用此方式</strong></p>
<p>原因:前端使用 <code>btoa()</code> 将大文件转换为 Base64 时会导致浏览器卡死,100MB+ 的文件基本无法处理。</p>
<p>此接口仅适用于小文件(&lt; 5MB)场景。</p></blockquote>
<div class="jb51code"><pre class="brush:js;">// 获取 PDF 的 ArrayBuffer
const response = await fetch(pdfUrl)
const arrayBuffer = await response.arrayBuffer()

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

// 调用 Android 原生方法
if (window.android?.previewPdfByData) {
    window.android.previewPdfByData(base64Data)
}
</pre></div>
<p class="maodian"><a name="_label6"></a></p><h2>七、缓存策略优化</h2>
<p>为了避免重复下载相同的 PDF 文件,我使用 <strong>MD5 哈希</strong> 作为缓存文件名:</p>
<table><thead><tr><th>场景</th><th>缓存键</th><th>说明</th></tr></thead><tbody><tr><td>URL 方式(推荐)</td><td><code>MD5(url) + &quot;.pdf&quot;</code></td><td>相同 URL 只下载一次</td></tr><tr><td>Base64 方式</td><td><code>MD5(base64Data) + &quot;.pdf&quot;</code></td><td>仅适用于小文件</td></tr></tbody></table>
<p class="maodian"><a name="_label7"></a></p><h2>八、实现效果</h2>
<p class="maodian"><a name="_lab2_7_14"></a></p><h3>8.1 桌面端浏览器 PDF 预览(PDF.js v5)</h3>
<p style="text-align:center"><img alt="" src="https://img.jbzj.com/file_images/article/202512/2025123009112492.png" /></p>
<p style="text-align:center"><img alt="" src="https://img.jbzj.com/file_images/article/202512/2025123009112477.png" /></p>
<p><strong>效果说明</strong>:</p>
<ul><li>桌面端使用 PDF.js v5,功能完善</li><li>支持缩放、翻页、搜索等功能</li><li>缩放流畅、放大后字体依然清晰</li></ul>
<p class="maodian"><a name="_lab2_7_15"></a></p><h3>8.2 Android WebView PDF 预览(PDF.js v3)</h3>
<p style="text-align:center"><img alt="" src="https://img.jbzj.com/file_images/article/202512/2025123009112496.png" /></p>
<p><strong>效果说明</strong>:</p>
<ul><li>Android WebView 使用 PDF.js v3 以保证兼容性</li><li>支持手势缩放、翻页、搜索等功能</li><li>大文件缩放时可能存在卡顿、放大后字体不清晰</li><li>右上角提供「查看原图」按钮,点击后以弹窗方式打开 Android PDF 组件预览</li></ul>
<p class="maodian"><a name="_lab2_7_16"></a></p><h3>8.3 Android PDF 组件预览(android-pdf-viewer)</h3>
<p style="text-align:center"><img alt="" src="https://img.jbzj.com/file_images/article/202512/2025123009112530.png" /></p>
<p style="text-align:center"><img alt="" src="https://img.jbzj.com/file_images/article/202512/2025123009112582.png" /></p>
<p><strong>效果说明</strong>:</p>
<ul><li>全屏弹窗预览,沉浸式体验</li><li>顶部显示页码信息(如 1 / 1)</li><li>右上角关闭按钮,操作便捷</li><li>支持双指缩放</li><li><strong>放大后字体渲染清晰,缩放流畅无卡顿</strong></li></ul>
<p class="maodian"><a name="_lab2_7_17"></a></p><h3>8.4 使用体验对比</h3>
<table><thead><tr><th>指标</th><th>WebView + PDF.js</th><th>Android PDF 组件</th></tr></thead><tbody><tr><td>缩放流畅度</td><td>大文件卡顿明显,甚至卡死</td><td>丝滑流畅</td></tr><tr><td>字体清晰度</td><td>放大后模糊</td><td>始终清晰</td></tr><tr><td>内存占用</td><td>较高,易 OOM</td><td>较低,稳定</td></tr></tbody></table>
<blockquote><p>📝 以上为实际使用中的主观体验对比,非精确测量数据。</p></blockquote>
<p class="maodian"><a name="_label8"></a></p><h2>九、总结</h2>
<p>本文介绍了在 Vue + Android WebView 环境下预览大文件 PDF 的完整解决方案:</p>
<ol><li><strong>PDF.js 多版本策略</strong>:桌面端用 v5,移动端用 v3,解决兼容性问题</li><li><strong>Android PDF 组件</strong>:使用 <code>android-pdf-viewer</code> 实现高性能预览</li><li><strong>JS Bridge 通信</strong>:通过 <code>@JavascriptInterface</code> 实现前端与原生的交互</li><li><strong>MD5 缓存策略</strong>:避免重复下载,提升用户体验</li></ol>
<p>这套方案已在生产环境稳定运行,成功解决了大文件 PDF 在 PAD 上的预览问题。</p>
<p class="maodian"><a name="_label9"></a></p><h2>十、参考资源</h2>
<ul><li><a href="https://mozilla.github.io/pdf.js/" rel="external nofollow" target="_blank">PDF.js 官方文档</a></li><li><a href="https://github.com/mhiew/AndroidPdfViewer" rel="external nofollow" target="_blank">android-pdf-viewer GitHub</a></li><li><a href="https://hutool.cn/" rel="external nofollow" target="_blank">Hutool 工具库</a></li></ul>
頁: [1]
查看完整版本: Vue + Android WebView实现大文件PDF预览完整解决方案(附详细代码)