
简介面向Unity开发者的文档查看功能实现方案解决Android平台无法直接显示Word、Excel、PDF、PPT等格式的难题。资源完整覆盖第三方库集成、文件读取与格式转换、WebView渲染、性能优化及用户交互设计等关键环节适合教育类、文档管理类应用开发者参考。压缩包内共2000个文件包含C#脚本、DLL库、Unity场景与预制体、文档示例docx/xlsx/pdf/pptx以及大量info、md、txt说明文件整体结构清晰便于按模块研读与复用包体约172.43MB。内容涵盖通过Spire系列处理Word/Excel、PDF转图像序列、Aspose.Slides转换PPT等具体做法并针对Android环境下的JNI调用与内存优化给出思路。已有917人学习下载是快速落地Unity全类型文档预览功能的高价值参考资料。1. 用 Unity 做文档展示从“PPT 放不出来”到画质不糊的落地过程前阵子做一个展厅大屏项目需求是“在 Unity 里把 Word、Excel、PPT、PDF 全都能打开”。一开始以为可以找个现成库直接解析 Office 格式结果翻遍了方案发现Unity 引擎本身对 Office 文档没有任何内置支持而这几类文件格式又完全不同。真正跑通后我的结论是别想着在 Unity 里“解析” Office 文件而是用“格式转换 渲染”的思路来落地。本文会带你完整走一遍这个方案的选型、目录设计、核心代码实现和二次开发方向让读者看完以后能直接照着复现一套自己的文档展示模块。2. 技术选型为什么最终走了“转 PDF 再渲染”这条路2.1 三条路线对比内嵌浏览器、转图片、转 PDFUnity 项目里要显示 Office 文档常见的有三条路线我先说结论大部分跨平台场景走“Office 文件转 PDF → 渲染 PDF”这条最稳。第一条路线是用系统内嵌浏览器控件比如 Windows 的 WebView2打开 Office Online 的链接。这个方案优点是代码极少缺点也很明显依赖网络、依赖微软服务器、离线环境直接翻车而且加密的 Office 文件比如公司内部带权限的文档根本传不上去。第二条路线是把 Office 文件转成图片然后作为 Sprite 显示。这个方案在小项目里好用但大文件比如 100 页 PPT会瞬间吃掉大量内存而且图片放大后模糊交互比如选择 Excel 单元格完全做不了。只适合“能看就行”的场景。第三条路线就是本文采用的在服务端用 LibreOffice 无头模式把 Word、PPT、Excel 统一转成 PDF然后在 Unity 里用 PDFium 渲染每一页把渲染结果转换成 Texture2D 显示。这样做的好处是格式统一、逻辑一致、离线可用、占用内存可控缺点是需要一个转换环境但你不需要在最终用户的机器上装 Office。2.2 最终技术栈最终这套模块的技术栈如下环节技术选型格式转换LibreOfficesoffice --headless --convert-to pdfPDF 渲染PDFiumGoogle 开源 PDF 渲染引擎Unity 版本2021.3 LTS 及以上跨平台方案Windows / Android 各编译一份 PDFium 动态库文件加载UnityWebRequest 本地文件流读取交互控制翻页、缩放、拖拽等基于 RectTransform 实现PDFium 的选择是一个关键点。Unity 里也有第三方 PDF 插件但它们底层十有八九包的就是 PDFium自己编译一次能省掉很多授权问题和更新等待时间。PDFium 支持 C API 接口Unity 里用 P/Invoke 调用C# 侧只处理页面的位图数据。选这套方案的另外一个原因是 Excel 文件转 PDF 后单元格内容会变成排好版的页面不需要在 Unity 里做复杂的表格交互PPT 转 PDF 后动画会丢失但静态展示场景下影响很小。如果你的项目需要 PPT 动画那就得上系统浏览器方案这是一个取舍问题。2.3 转换环境为什么放在服务端而不是客户端最开始我尝试过在客户端直接调用命令行转换但这个思路只在 Windows 平台勉强可用Android 和 iOS 上既没有 LibreOffice也没有对应的权限模型。后来调整为开发机或局域网服务器跑一个转换服务Unity 客户端通过 HTTP 接口传文件路径服务端返回 PDF 字节流。这样架构的额外好处是多人协作时比如不同展厅的多个 Unity 客户端可以共用同一个转换服务不需要每台设备都安装转换工具而且转换耗时的任务放在后台线程执行不阻塞 UI。但要注意如果项目是单机离线场景比如军工、电力巡检这种不能联网的环境可以在打包前把 PDF 文件准备好Unity 直接读 PDF 即可转换服务就不参与了。这套模块里我把“文件入口”抽象了一层就是同时支持“传 PDF 路径直接显示”和“传 Office 路径先转换再显示”两种模式。这个设计是路线 3 能灵活落地的基础。3. 工程结构模块分层和核心脚本的职责划分3.1 目录结构设计整个文档展示模块在 Assets 下的目录结构如下Assets/ ├── DocumentViewer/ │ ├── Runtime/ │ │ ├── ConvertService/ // 远程转换服务封装 │ │ │ ├── IConvertClient.cs │ │ │ ├── LocalCommandClient.cs │ │ │ └── HttpConvertClient.cs │ │ ├── RenderCore/ │ │ │ ├── PdfiumNative.cs // PDFium C API 封装 │ │ │ ├── PdfDocument.cs // PDF 文档打开/关闭/页数 │ │ │ ├── PdfPageRenderer.cs // 单页渲染成 Texture2D │ │ │ └── PdfViewerController.cs // 对外主控制器 │ │ ├── UI/ │ │ │ ├── DocumentViewerUI.cs // 翻页按钮、页码显示 │ │ │ └── DragZoomHandler.cs // 拖拽和缩放手势 │ │ └── Utilities/ │ │ └── FilePathResolver.cs // 路径拼接和校验 │ └── Samples/ │ ├── Scenes/ │ │ └── DocumentViewerDemo.unity │ └── Scripts/ │ └── DemoDragDrop.cs // 把文件拖进窗口触发显示这个结构遵循了“转换层 / 渲染层 / UI 层”三分离的原则。转换层不依赖渲染层也就是说以后如果不想用 PDFium可以直接把 RenderCore 替换成别的渲染库而不会牵连 UI 代码。3.2 对外控制器的核心接口在写 PdfViewerController 的时候我参考了平时惯用的“控制器模式”把打开文件、翻页、跳页、放大缩小的操作统一封装到一个类里UI 层只调用这个控制器的接口不直接碰 PDFium 的 API。对外暴露的关键接口如下public class PdfViewerController : MonoBehaviour { public int PageCount { get; private set; } public int CurrentPage { get; private set; } public bool OpenPdf(byte[] pdfData, string password ) public bool OpenOfficeFile(string filePath, IConvertClient convertClient) public Texture2D GetPageTexture(int pageIndex, int dpi 96) public void NextPage() public void PrevPage() public void JumpToPage(int pageIndex) public void ZoomIn(float factor) public void ZoomOut(float factor) public void ResetZoom() public void Close() }主要接口的说明OpenPdf直接接收 PDF 的字节数组所以无论是本地文件流还是 HTTP 接口拉取结果都能统一处理OpenOfficeFile需要传入一个转换客户端实例内部先完成转换再对接 OpenPdfGetPageTexture返回的 Texture2D 要由调用方负责销毁避免内存碎片积累CurrentPage提供的是从 1 开始的页码与 UI 显示一致3.3 文件输入的两种模式由于实际项目里存在两种加载场景我把文件解析做成了可插拔。如果是运行期从磁盘加载直接用FilePathResolver校验路径后读取字节如果是编辑期测试我在 Demo 脚本里实现了 OnDrop 回调把文件从系统资源管理器拖到 GameView 窗口就能显示。这个 Demo 代码是给项目成员试用的方便他们快速验证一个文档能不能正常渲染。public class DemoDragDrop : MonoBehaviour { public PdfViewerController viewer; private IConvertClient convertClient; void Start() { // 顺便演示一下 HTTP 转换客户端的组装 convertClient new HttpConvertClient(http://127.0.0.1:5010/convert); } void OnDrop(string filePath) { string ext Path.GetExtension(filePath).ToLowerInvariant(); // 如果是 PDF 就直接打开 if (ext .pdf) { byte[] data File.ReadAllBytes(filePath); viewer.OpenPdf(data); return; } // 如果是 Office 文档就走转换管线 if (ext is .doc or .docx or .xls or .xlsx or .ppt or .pptx) { viewer.OpenOfficeFile(filePath, convertClient); } } }拖拽关闭剪贴板事件是在 UI 层的 EventSystem 里配置的这里不用额外处理。参数说明第一个参数 viewer 是场景里的控制器对象第二个参数是转换服务地址端口 5010 是本地转换服务默认监听端口。这样设计的好处是将来如果换成局域网转换服务器只需要修改HttpConvertClient的构造参数就行主流程代码不需要动。4. 核心实现拆解从 PDF 字节流到屏幕上的一个 Texture2D4.1 打开 PDF 文档解析页数、初始化和资源管理PDFium 渲染的核心逻辑都在PdfDocument和PdfPageRenderer这两个类里。先看打开文档的部分public class PdfDocument : IDisposable { private IntPtr docPtr IntPtr.Zero; public int PageCount { get; private set; } public bool Load(byte[] pdfData) { // PDFium 要求数据在整个生命周期内保持有效 // 所以这里需要把字节数组固定住不能交给 GC 回收 unsafe { fixed (byte* ptr pdfData) { var doc PdfiumNative.FPDF_LoadMemDocument( (IntPtr)ptr, pdfData.Length, null); if (doc IntPtr.Zero) { uint err PdfiumNative.FPDF_GetLastError(); Debug.LogError($打开 PDF 失败错误码{err}); return false; } docPtr doc; PageCount PdfiumNative.FPDF_GetPageCount(doc); return PageCount 0; } } } public void Dispose() { if (docPtr ! IntPtr.Zero) { PdfiumNative.FPDF_CloseDocument(docPtr); docPtr IntPtr.Zero; } } }逻辑说明FPDF_LoadMemDocument是 PDFium 的 C 接口参数分别是内存地址、数据长度和密码传 null 表示无密码。返回的句柄 doc 指向整个 PDF 文档对象。FPDF_GetPageCount获取文档总页数供翻页条使用。这个实现里最需要注意的就是fixed块PDFium 的 LoadMemDocument 并不会拷贝数据而是直接引用你传进去的托管数组内存。如果 C# 侧把byte[]释放了后续渲染时会读到野指针导致崩溃。我通常在类内部用一个字段保存pdfData的引用保证实例存活期间数据不会被 GC 回收。4.2 渲染单页把 PDFium 的位图拷贝成 Texture2DPDF 文档打开后逐页渲染的核心逻辑如下public class PdfPageRenderer { public Texture2D RenderPage(PdfDocument doc, int pageIndex, int dpi) { // 打开指定页面返回页面句柄 IntPtr pagePtr PdfiumNative.FPDF_LoadPage(doc.DocPtr, pageIndex); if (pagePtr IntPtr.Zero) { Debug.LogError($加载第 {pageIndex} 页失败); return null; } // 获取页面尺寸的单位是点point需要换算成像素 double widthPt PdfiumNative.FPDF_GetPageWidthF(pagePtr); double heightPt PdfiumNative.FPDF_GetPageHeightF(pagePtr); // dpi 缩放默认 96 对应 1:1高 DPI 设置用于高清屏 float scale dpi / 72f; int widthPx (int)(widthPt * scale); int heightPx (int)(heightPt * scale); // 创建位图对象 IntPtr bitmapPtr PdfiumNative.FPDFBitmap_Create( widthPx, heightPx, alpha: 0); // 设置白底避免默认透明背景导致文字发虚 PdfiumNative.FPDFBitmap_FillRect(bitmapPtr, 0, 0, widthPx, heightPx, 0xFFFFFFFF); // 把页面内容渲染到位图上 int renderFlags PdfiumNative.FPDF_ANNOT | PdfiumNative.FPDF_LCD_TEXT; PdfiumNative.FPDF_RenderPageBitmap(bitmapPtr, pagePtr, 0, 0, widthPx, heightPx, 0, renderFlags); // 拿到位图数据的扫描线首地址复制像素到 Texture2D IntPtr bufferPtr PdfiumNative.FPDFBitmap_GetBuffer(bitmapPtr); var texture new Texture2D(widthPx, heightPx, TextureFormat.BGRA32, false); // 拷贝像素数据 int stride PdfiumNative.FPDFBitmap_GetStride(bitmapPtr); byte[] pixelData new byte[stride * heightPx]; Marshal.Copy(bufferPtr, pixelData, 0, pixelData.Length); texture.LoadRawTextureData(pixelData); texture.Apply(); // 释放 PDFium 位图对象和页面对象 PdfiumNative.FPDFBitmap_Destroy(bitmapPtr); PdfiumNative.FPDF_ClosePage(pagePtr); return texture; } }这段代码里有几个参数要特别留意dpi参数直接决定输出的清晰度。在 4K 大屏上如果直接按 72 DPI 渲染文字边缘会明显发虚我一般根据屏幕实际缩放比传入 144 或者 192FPDF_RenderPageBitmap的最后一个参数是渲染标志这里用了FPDF_ANNOT渲染注释和FPDF_LCD_TEXTLCD 优化的文字渲染前者保证批注可见后者缓解文字彩色边缘问题TextureFormat.BGRA32是必须的PDFium 输出的位图内存格式是 BGRA 序而不是 Unity 常用的 RGBA在实际项目中我还遇到过一个问题FPDF_RenderPageBitmap是同步阻塞的大文件单页渲染可能耗时几百毫秒如果 UI 线程直接调用会卡顿。处理方案是把渲染移到后台线程然后把得到 Texture2D 通过主线程回调贴到 RawImage 上但 Unity 中只有主线程能创建 Texture2D所以需要在子线程渲染像素数据后在 Update 中检查一个队列来创建纹理。这个技巧在 4.4 小节用代码说明。4.3 底层的 P/Invoke 封装与跨平台路径问题PDFium 的 C API 数量不少但不需要全部封装我日常用的就这几个核心函数。封装的时候有几个细节值得注意写成如下代码public static class PdfiumNative { private const string DLL_NAME pdfium; [DllImport(DLL_NAME)] public static extern IntPtr FPDF_LoadMemDocument(IntPtr data_buf, int size, string password); [DllImport(DLL_NAME)] public static extern int FPDF_GetPageCount(IntPtr doc); [DllImport(DLL_NAME)] public static extern IntPtr FPDF_LoadPage(IntPtr doc, int page_index); [DllImport(DLL_NAME)] public static extern double FPDF_GetPageWidthF(IntPtr page); [DllImport(DLL_NAME)] public static extern double FPDF_GetPageHeightF(IntPtr page); [DllImport(DLL_NAME)] public static extern IntPtr FPDFBitmap_Create(int width, int height, int alpha); [DllImport(DLL_NAME)] public static extern void FPDFBitmap_FillRect(IntPtr bitmap, int left, int top, int width, int height, int color); [DllImport(DLL_NAME)] public static extern int FPDF_RenderPageBitmap(IntPtr bitmap, IntPtr page, int start_x, int start_y, int dest_width, int dest_height, int rotate, int flags); [DllImport(DLL_NAME)] public static extern IntPtr FPDFBitmap_GetBuffer(IntPtr bitmap); [DllImport(DLL_NAME)] public static extern int FPDFBitmap_GetStride(IntPtr bitmap); [DllImport(DLL_NAME)] public static extern void FPDFBitmap_Destroy(IntPtr bitmap); [DllImport(DLL_NAME)] public static extern void FPDF_ClosePage(IntPtr page); [DllImport(DLL_NAME)] public static extern void FPDF_CloseDocument(IntPtr doc); [DllImport(DLL_NAME)] public static extern uint FPDF_GetLastError(); }这个封装里最容易翻车的是字符串参数。PDFium 的FPDF_LoadMemDocument第三参数是密码如果传入 nullMarshal 会当作空字符串处理但如果你传了 string 类型默认按 ANSI 编组遇到中文密码就会乱码。我一般在调用前把密码转成 UTF-8 字节再手动传 IntPtr避免平台编码不一致。另一个跨平台的关键点动态库加载路径。Windows 上是pdfium.dllAndroid 上是libpdfium.soUnity 中要把它们放在Assets/Plugins/x86_64Windows和Assets/Plugins/Android/arm64-v8a对应位置。Android 上构建时注意目标架构只选 arm64-v8a 就够了armeabi-v7a 如果需要兼容老设备再加大体积。4.4 异步渲染管线不阻塞 UI 的实现当文件是多页 PPT 时逐页异步渲染的价值立刻显现。核心思路是维护一个待渲染队列后台线程处理像素拷贝主线程再生成纹理public class PdfViewerController : MonoBehaviour { private Queueint pendingPages new Queueint(); private ConcurrentQueueRenderResult renderResults new ConcurrentQueueRenderResult(); private class RenderResult { public int PageIndex; public byte[] PixelData; public int Width; public int Height; } private void Update() { // 主线程统一处理渲染结果生成纹理只能在主线程做 while (renderResults.TryDequeue(out var result)) { var tex new Texture2D(result.Width, result.Height, TextureFormat.BGRA32, false); tex.LoadRawTextureData(result.PixelData); tex.Apply(); pageTextures[result.PageIndex] tex; } } private void EnqueueRender(int pageIndex, int dpi) { ThreadPool.QueueUserWorkItem(_ { // 子线程里只能做 PDFium 渲染和像素拷贝 // 把 RGBA 数据放进结果队列 }); } }这里最核心的分工是PDFium 的FPDF_RenderPageBitmap不涉及图形 API可以在任意线程执行但Texture2D.LoadRawTextureData内部会调用图形设备接口必须回到主线程。把这两步拆开就能在滚动浏览大量页面时保持 UI 不掉帧。参数说明dpi 如果按 96 传一个 A4 页面的像素尺寸大概是 794×1123约 350 万像素一个字节的 BGRA 数据接近 3.5MB。如果一次渲染 10 页瞬时内存 35MB还能接受但如果按 192 DPI 渲染单页数据直接膨胀到 14MB再乘以 10 页就是 140MB这时候就必须做缓存清理策略。5. 避坑指南这份模块开发中我踩过的五个深坑5.1 PDFium 渲染出来的文字发虚不是 DPI 的锅现象用默认 72 DPI 渲染 PDF 后放到 4K 大屏上文字边缘有明显锯齿和彩色噪点。刚开始以为是 DPI 不够粗暴地把 DPI 调高到 192但文字还是不够锐利而且内存暴涨。原因PDFium 默认的渲染标志里没有开 LCD 优化文字反锯齿算法用的是旧式灰度抗锯齿在小字号下表现很差。另一个原因是没有设置FPDF_LCD_TEXT标志。解决渲染标志改成FPDF_ANNOT | FPDF_LCD_TEXT同时把页面 DPI 和屏幕缩放比进行映射而不是拍脑袋固定一个数字。5.2 打开带密码的 PDF 导致崩溃而不是报错现象试了一个客户发来的加密 PDF直接调用FPDF_LoadMemDocument传入 null 作为密码参数结果是整个 Unity Editor 闪退连错误日志都没打出来。原因PDFium 文档里明确要求密码必须是非 null 的 UTF-8 字符串如果传 null底层解析函数会对空指针做取值操作在 Windows 上直接触发访问冲突。解决先查FPDF_GetLastError错误码 4 表示密码错误对于不确定是否有密码的文件先尝试空密码打开失败后再弹出密码输入框并在调用前把输入框内容转成 UTF-8 字节。5.3 大文件多次切换时内存只涨不降现象连续打开十几个 PDF 文件后内存从 200MB 一路涨到 1.5GB看起来像是泄漏。查了FPDF_CloseDocument也正常调用了没有报错。原因Unity 的Texture2D虽然设置了 false 不生成 mipmap但大量纹理创建后没有马上触发垃圾回收。更关键的是 PDFium 内部对页面渲染有缓存FPDF_CloseDocument并不会立即释放所有内部缓存块频繁开关文档会累计内存碎片。解决在 Close 后手动调用Resources.UnloadUnusedAssets()并且对pageTextures字典里的旧纹理逐个Destroy。另外在Dispose里强制调用一次 GC 并把 PDFium 的文档句柄置零。从那以后我每次打开新文件前都强制走一遍这个清理流程。5.4 Android 上无法加载 pdfium.so 的 StartUp 崩溃现象打出来的 Android 包在真机上点击打开 PDF 时直接闪退Logcat 里提示找不到FPDF_LoadMemDocument。原因Unity 默认只打进 ARMv7 的 lib如果只装了 arm64 的设备执行时找不到对应 ABI 的 so 文件另外 PDFium 的 so 文件如果是在老版本 NDK 下编译的可能在 Android 高版本上有符号链接问题。解决把Assets/Plugins/Android下的架构目录只保留 arm64-v8a如果你的目标是平板同时在 Player Settings 里把 IL2CPP 的 Target Architectures 设为 ARM64。如果还报错检查 so 文件是否被 Android 的压缩机制破坏了需要在 Import Settings 里勾选 Load on Startup 并关闭压缩。5.5 LibreOffice 转换时中文变成乱码或豆腐块现象Word 文档在 Windows 上显示正常但转出来的 PDF 里中文全部变成方框。原因部署转换服务的 Linux 服务器上没有安装中文字体。LibreOffice 在 Linux 上的字体列表是受限的找不到字体时就退化成字体兜底最终输出无字形。解决在服务器上安装至少一套中文字体apt-get install -y fonts-noto-cjk然后重新生成字体缓存fc-cache -fv这个坑在踩过一次之后我养成了一个习惯每次部署转换服务都先创建一个带中文的测试 Word 文件跑一遍转换确认中文正常后再接入业务。6. 二次开发方向扩展新格式、接入网络文件和性能调优6.1 如何支持 WPS 格式.et/.dps/.wps如果项目面向国内市场大概率会遇到 WPS 格式。好消息是 LibreOffice 本身对 WPS 格式有部分支持可以直接把扩展名加进转换白名单if (ext is .et or .dps or .wps) { // LibreOffice 识别不了这几种扩展名需要先重命名 string renamed Path.ChangeExtension(filePath, .xlsx); File.Copy(filePath, renamed, overwrite: true); viewer.OpenOfficeFile(renamed, convertClient); }但注意WPS 格式的兼容性不如 Office 格式稳定特别是带加密和宏的旧版 WPS 文件转换后可能出现排版错乱。我一般会在转换服务里做一次文件类型探测用file命令读取真实格式而不是轻信扩展名。6.2 接入网络文件从 URL 加载文档展厅项目经常需要从后端拉取最新的 PPT 版本所以文件入口要做好网络层支持。实现思路是在OpenOfficeFile之前增加一层通用下载器public IEnumerator LoadFromRemote(string url, Actionbool onFinished) { using var request UnityWebRequest.Get(url); yield return request.SendWebRequest(); if (request.result ! UnityWebRequest.Result.Success) { Debug.LogError(request.error); onFinished?.Invoke(false); yield break; } byte[] fileData request.downloadHandler.data; // 先落盘到持久化目录再交给转换服务 string localPath Path.Combine(Application.persistentDataPath, FileNameFromUrl(url)); File.WriteAllBytes(localPath, fileData); onFinished?.Invoke(true); }这里有一个关键设计文件必须落盘不能直接把内存字节流转给 LibreOffice。原因是转换服务命令行调用的入口是文件路径如果走内存方式就要写二次开发接口复杂度和稳定性都会下降。落盘后用统一入口继续走已有的转换管线改动量最少。6.3 性能优化页面缓存、延迟加载和缩略图如果文档页数超过 50 页一次性全部渲染会非常卡。建议的优化策略是只渲染三页当前页、上一页、下一页其余页用延迟加载。代码逻辑public void JumpToPage(int pageIndex) { CurrentPage pageIndex; // 只有三个页面槽位翻页时按需替换 RenderPageIfNeeded(pageIndex - 1); RenderPageIfNeeded(pageIndex); RenderPageIfNeeded(pageIndex 1); // 距离当前页超过 3 页的直接清理掉纹理 ClearPagesOutsideRange(pageIndex - 3, pageIndex 3); }参数说明pageIndex从 1 开始渲染时内部会转成从 0 开始的下标。ClearPagesOutsideRange里调用的就是 5.3 小节里说的清理逻辑防止纹理堆积。缩略图模式是另一个提升体验的功能在侧栏显示每页的缩小版本。实现时不要在 UI 线程里逐页渲染而是用一个协程每帧只处理一页避免阻塞翻页操作。6.4 这套模块的边界、局限和最后的建议做完这套模块我最大的感受是边界感很重要。它不能播放 PPT 的动画、不能编辑 Excel、不能显示 PDF 表单域——因为底层是 PDFium 的静态渲染。如果你确实需要这些能力那就要换内嵌浏览器方案或者换 WebGL 预览方案那时候就得回头评估网络依赖和安全性了。但反过来如果你的业务是“把一堆文档稳定地显示出来”这套方案的稳定性和可控性显著高于浏览器方案内存可预测、离线可用、渲染质量可控。代码维护也简单因为 PDFium 的 API 极其稳定从 2018 年到 2025 年几乎没变过不用担心升级断档。最后分享一个习惯我每次接新的文档展示需求首先问“是静态展示还是动态交互”。如果需要动画和编辑我会果断推掉 PDFium 方案如果只是“放出来看得清”我会守住这个方案不动摇。把边界划清楚项目上线后能少很多玄学 bug希望帮到你。本文还有配套的精品资源点击获取