简介:面向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 三条路线对比:内嵌浏览器、转图片、转 PDF
Unity 项目里要显示 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 最终技术栈
最终这套模块的技术栈如下:
| 环节 | 技术选型 |
|---|---|
| 格式转换 | LibreOffice(soffice --headless --convert-to pdf) |
| PDF 渲染 | PDFium(Google 开源 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 字节流到屏幕上的一个 Texture2D
4.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 的位图拷贝成 Texture2D
PDF 文档打开后,逐页渲染的核心逻辑如下:
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_TEXT(LCD 优化的文字渲染),前者保证批注可见,后者缓解文字彩色边缘问题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第三参数是密码,如果传入 null,Marshal 会当作空字符串处理;但如果你传了 string 类型,默认按 ANSI 编组,遇到中文密码就会乱码。我一般在调用前把密码转成 UTF-8 字节再手动传 IntPtr,避免平台编码不一致。
另一个跨平台的关键点:动态库加载路径。Windows 上是pdfium.dll,Android 上是libpdfium.so,Unity 中要把它们放在Assets/Plugins/x86_64(Windows)和Assets/Plugins/Android/arm64-v8a对应位置。Android 上构建时注意目标架构,只选 arm64-v8a 就够了,armeabi-v7a 如果需要兼容老设备再加大体积。
4.4 异步渲染管线:不阻塞 UI 的实现
当文件是多页 PPT 时,逐页异步渲染的价值立刻显现。核心思路是维护一个待渲染队列,后台线程处理像素拷贝,主线程再生成纹理:
public class PdfViewerController : MonoBehaviour { private Queue<int> pendingPages = new Queue<int>(); private ConcurrentQueue<RenderResult> renderResults = new ConcurrentQueue<RenderResult>(); 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, Action<bool> 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,希望帮到你。
本文还有配套的精品资源,点击获取