简介:PdfiumViewer 是一款基于 C# 构建的 PDF 查看控件,面向需要在 .NET 桌面应用中快速集成文档展示能力的开发者。它通过封装 Google 开源的 Pdfium 渲染引擎,支持 Windows、Linux 与 macOS 跨平台运行,并实现了缩放、平移、搜索、书签导航、加密文档读取与细粒度权限控制等常用交互。压缩包约 48.5MB,内含 228 个文件,核心包括 78 个 .cs 源码文件、19 个 .dll 程序集,以及 5 个可直接运行的 .exe 演示程序,另有部署清单、NuGet 打包脚本、界面资源与示例 PDF 文件,既可编译运行,也便于按需拆解。目前已有 1568 人学习浏览,对中高级 C# 开发者而言,这是一份能同时获得官方 Demo 与底层实现思路的实用资料,尤其适合需要把 PDF 预览功能嵌入 WinForms/WPF 项目并深度定制交互行为的场景。
1. PdfiumViewer 是 WinForms 里最省事的 PDF 渲染方案
接手过某公司的模拟项目 X,一个 WinForms 客户端里要内嵌 PDF 预览。最早用的是 WebBrowser 控件套浏览器内核,结果客户的机器一打开带扫描图片的 PDF 就卡成幻灯片,内存直接飙到 800MB。后来换成 PdfiumViewer,纯托管代码包一个原生渲染内核,内存降了一大半,加载速度也快了一个量级,滚动缩放都顺了。PdfiumViewer 就是把 Chromium 里的 PDFium 渲染引擎单独拎出来,封装成 .NET 控件,不需要在客户机器上装 Adobe Reader 或浏览器组件。适合的场景很明确:WinForms 项目里要稳定、可控、轻量的 PDF 预览,不想引入大体积的 Web 框架,也不想跟 ActiveX 控件较劲。它的核心价值不是功能最多,而是把 PDF 渲染这个事压缩到了一个 DLL 加一个控件里,集成成本极低。
2. 从 NuGet 到第一个可交互窗口:PdfiumViewer 的集成与渲染管线
2.1 为什么 PDF 渲染绕不开 PDFium 内核
PDF 的渲染比图片复杂得多,它内部有字体子集、矢量路径、透明度组、色彩空间这些概念。自己用 GDI+ 去解析 PDF 路径,基本是重新造一个 PDF 引擎,工作量不是一个人能扛下来的。PDFium 是 Chromium 项目里负责 PDF 渲染的组件,C++ 实现,被封装成 C 接口,底层处理了字体的嵌入与替换、图像的解码、路径的填充与裁切。PdfiumViewer 的做法是用 P/Invoke 把这份 C 接口桥接到 .NET,上层再用 WinForms 的 UserControl 承载渲染结果。
从架构上看,PdfiumViewer 的渲染管线大致是:加载 PDF 文件后,解析出文档对象模型,然后按页面索引调用渲染函数生成位图,最后把位图画到控件表面。翻页时不是重新解析整个文档,而是只渲染当前页。这带来一个直接的好处:打开大文件(几百页)时,初始加载只做文档结构解析,页面渲染按需进行,所以打开速度非常快。这也是它替代 WebBrowser 控件的关键原因。
选 PdfiumViewer 而不直接调 PdfiumNative 的另一个原因是它已经帮你把 WinForms 集成做好了。包括鼠标滚轮缩放、拖拽平移、键盘翻页这些基础交互,都在控件内部实现了。你不需要自己处理 WM_MOUSEWHEEL 消息。
2.2 最小集成步骤:加载文档与显示第一页
在 Visual Studio 里通过 NuGet 引入 PdfiumViewer 包,然后往窗体上拖一个 PdfViewer 控件,这一步比较简单。真正要理解的是代码层面的加载方式。下面是最小可运行示例:
using PdfiumViewer; // 从文件路径加载 PDF using (var document = PdfDocument.Load(@"C:\tmp\demo.pdf")) { using (var form = new Form()) { var viewer = new PdfViewer { Document = document, Dock = DockStyle.Fill, ZoomMode = PdfViewerZoomMode.FitWidth }; form.Controls.Add(viewer); form.Width = 800; form.Height = 600; form.ShowDialog(); } }这段代码做的事情是按路径打开 PDF 文档,再把文档绑定到 PdfViewer 控件上。PdfDocument.Load返回一个实现了 IDisposable 的对象,它持有 Pdfium 原生层的文档句柄。ZoomMode设为FitWidth,意思是页面宽度撑满控件可视区域,第一页加载进来就是适合阅读的视图,不需要用户手动缩放。
这里有一个需要反复确认的细节:PdfViewer.Document属性只会拿到文档引用,控件不会帮你管理文档的释放。如果你把 document 用 using 包住,窗体关闭后 document 就被释放了,但 viewer 还在引用它,下一次访问就会触发 ObjectDisposedException。正确做法是让 viewer 的生命周期和 document 保持一致——document 在窗体关闭后再释放,或者干脆让 document 字段存活到窗体销毁。
参数方面,ZoomMode除了FitWidth还有FitHeight、FitBest以及Custom。FitBest会同时考虑宽高,让整页完整显示,适合竖屏文档;FitWidth适合连续滚动阅读,因为上下翻页时宽度不会跳变。缺省值是Custom,这种模式下初始缩放比例由Zoom属性决定,默认是 1.0,在 96 DPI 的屏幕上,一页 A4 纸显示出来会比实际偏小,用户第一眼会觉得字太小。
2.3 渲染参数:DPI、缩放模式与质量平衡
PdfiumViewer 渲染时有一个容易被忽略的参数:PdfRenderFlags。这个枚举控制渲染出来的位图质量,默认值是Normal,但它只保证“能用”,不保证“清晰”。如果你的客户经常做 200% 以上缩放查看图纸,必须显式指定ForPrinting或Annotations之后再配合高 DPI 设置。
var image = document.Render( pageIndex: 0, width: 1600, height: 2200, dpiX: 150, dpiY: 150, flags: PdfRenderFlags.ForPrinting | PdfRenderFlags.CorrectFromDpi);这段代码把第 0 页渲染成 1600x2200 的位图,并且指定 150 DPI。CorrectFromDpi标志的作用是:告诉 Pdfium 当前输出设备是 150 DPI,让它按这个分辨率计算页面尺寸,而不是按默认的 72 DPI 缩放。如果不加这个标志,渲染出来的位图会明显偏小,放大到实际尺寸时边缘发虚。
对于大多数屏幕显示场景,我的建议是PdfRenderFlags.Normal加PdfRenderFlags.Annotations。Annotations会把 PDF 里的批注、高亮、填写的表单内容也画出来。很多业务系统里,用户上传的 PDF 本来就带批注,漏掉的话会被投诉“内容缺失”。但注意,默认参数渲染出的位图在 100% 缩放时清晰度尚可,一旦放大到 200% 以上,文字边缘会出现明显锯齿,这是因为渲染分辨率被固定在了控件当前尺寸,而不是按照缩放后的尺寸重新渲染。这是控件的一个设计取舍,后面第 5 章会讲怎么绕过。
3. 把控件用出生产力:缩放、翻页、文本与打印的完整配置
3.1 绑定到现有业务界面的加载流程
多数项目不会像 Demo 那样弹个裸窗体,而是把 PdfViewer 嵌入到已经存在的业务窗口中。常见的布局是左侧一个目录树,右侧是 PdfViewer。这时加载流程要区分“打开文件”和“切换文档”两个动作。
public partial class DocViewerForm : Form { private PdfDocument _doc; private PdfViewer _viewer; public void OpenDocument(string filePath) { // 释放之前的文档,避免句柄泄漏 _doc?.Dispose(); // 用 FileShare.Read 允许其他程序同时读取该文件 using (var stream = new FileStream( filePath, FileMode.Open, FileAccess.Read, FileShare.Read)) { _doc = PdfDocument.Load(stream); } _viewer.Document = _doc; Text = Path.GetFileName(filePath); } }这段代码里有几个容易翻车的点。PdfDocument.Load有两个重载,一个接受文件路径,一个接受 Stream。用文件路径的重载时,Pdfium 内部会把整个文件读入内存并关闭文件句柄,这时你删不掉源文件(Windows 上文件未关闭时会报“文件被占用”),因为句柄还在进程里。用 Stream 重载则要在调用方保证 Stream 在PdfDocument存活期间不能被释放。上面案例用using包住 Stream,Load执行完就释放 Stream,是因为 Pdfium 的PdfDocument.Load(Stream)会立即把流数据复制到内部缓冲,调用返回后流就不再被引用了。这个行为没有写在文档里,经过实测才确认。
切换文档时还有一个细节:先置空_viewer.Document,再 Dispose 旧文档,顺序反过来会在某些版本里触发控件重绘异常。原因是 PdfViewer 在 Dispose 文档后会收到一个内容失效通知,如果这时控件还持有一个已经释放的文档指针,它会尝试重新渲染页面,走到已释放对象上就崩了。
3.2 常用交互 API 的参数与调用方式
PdfiumViewer 除了鼠标交互,还暴露了一批可以在业务逻辑里直接调用的接口。把它们记熟,做自定义功能时会顺手很多。
| API | 参数说明 | 典型用途 |
|---|---|---|
GoToPage(int pageIndex) | 从 0 开始的页码 | 目录树点击跳转 |
Zoom | 浮点,1.0 表示 100% | 程序化缩放控制 |
ZoomMode | 枚举:FitWidth / FitHeight / FitBest / Custom | 切换适合视图 |
GetPageInfo(int pageIndex) | 返回尺寸与旋转信息 | 对单页做特殊处理 |
PrintDialog() | 无参,弹系统打印对话框 | 直接打印 |
Save(string path) | 保存被修改的文档 | 表单填写后回存 |
// 目录树跳转:页码从 1 开始,控件从 0 开始,注意减一 void treeView_AfterSelect(object sender, TreeViewEventArgs e) { var pageIndex = int.Parse(e.Node.Tag.ToString()) - 1; if (pageIndex >= 0 && pageIndex < _doc.PageCount) { _viewer.GoToPage(pageIndex); _viewer.Focus(); } }这里值得单独说明的是GetPageInfo。它返回的PageInfo带有原始页面尺寸,单位是 point(1/72 英寸),不是像素。如果你要做“单页导出 PNG”功能,需要按目标 DPI 换算像素宽高:pixelWidth = (int)(pageInfo.Width * dpi / 72f)。不少人在这一步直接把 point 当像素用,导出的图片会小一圈,打印出来模糊。
翻页滚动的行为也有参数可调。PdfViewer内部有一个ScrollMode属性(部分版本叫ContinuousScrollMode),决定滚动到底后是否自动跳到下一页。默认是连续滚动,也就是一页滚完直接衔接下一页,没有分页停顿。看扫描版 PDF 时,连续滚动下页面之间的定位会不准,我个人习惯把连续滚动关掉,改为按页滚动。
3.3 导出图片与 PostScript 打印的场景配置
打印这块,PdfiumViewer 本身提供的PrintDialog()会走系统打印队列,适合“用户手动操作”。但在业务系统里,往往要“按固定模板批量打印”,这时直接操作PrintDocument更可控。
using System.Drawing.Printing; public void PrintPages(PdfDocument doc, int startPage, int endPage) { var printDoc = doc.CreatePrintDocument(); printDoc.PrinterSettings.PrinterName = "某个打印机"; printDoc.PrinterSettings.FromPage = startPage; printDoc.PrinterSettings.ToPage = endPage; printDoc.DefaultPageSettings.Margins = new Margins(0, 0, 0, 0); printDoc.Print(); }CreatePrintDocument生成的 PrintDocument 在后台实际是把 PDF 页面转换成 PostScript 数据发送给打印机驱动,不走 GDI 渲染。这意味着打印质量比屏幕截图高很多——文字是矢量输出,不是位图放大。这个特性经常被忽略,但它是 PdfiumViewer 替代传统“先截图再打印”方案的最大优势。
一个要特别注意的参数是Margins设为全零。PDF 页面本身自带页边距(内容区域),如果你再用打印机边距叠加一层,页面内容会被强制缩小,两侧出现白边。设置为 0 后,输出尺寸与 PDF 原始页面尺寸一致,但要注意,多数打印机不支持无边距打印,实际出纸时会自动裁掉边缘几毫米。如果客户要求严格一比一输出,需要配合打印机驱动的“缩放至可打印区域”选项,这个只能人工在驱动设置里调,代码无法完全控制。
打印大批量文档时,建议用PrintDocument.PrintPage事件中控制每次只渲染一个页面,不要一次性把整个文档转成位图数组。Pdfium 渲染一个大页面位图大概占用 4 到 10MB 内存,100 页就是 1GB,必爆内存。
4. PdfiumViewer 避坑指南:从崩溃黑匣子到内存翻车的排查记录
4.1 32/64 位不匹配导致的随机崩溃
现象:开发机一切正常,部署到客户机器后,打开 PDF 时随机弹出“应用程序发生未经处理的异常”或者 AccessViolation,有时候是打开十几个文件后才出现。
原因:PdfiumViewer 是托管组件,但底层是原生 C++ 库。原生库有 x86 和 x64 两种版本。如果你的项目编译成 AnyCPU,在 x64 系统上会以 64 位进程运行,但 NuGet 包在不经意时可能引入了 x86 版原生 DLL,两个架构不一致,函数调用表错位,就会触发随机崩溃。更隐蔽的场景:项目里某个依赖库强制了 x86,你的项目配置没跟过去。
解决:把项目的平台目标固定。要么整体 x64,要么整体 x86,不要在 AnyCPU 状态下靠运气跑。检查bin目录下有没有x86和x64两个子目录,PdfiumViewer 的发布目录结构通常是这样,要确保两个子目录里的原生 DLL 都随附发布,同时确保主程序集平台一致。如果还有问题,用日志记录崩溃前最后一次操作的页面索引和功能名,对比是否总在同一功能处崩溃。
4.2 快速翻页时内存暴涨的真相
现象:用户连续点下一页,速度一快,任务管理器里内存肉眼可见地上涨,多翻几十页后出现卡顿,最终内存溢出。
原因:PdfViewer 控件的翻页机制是先渲染新页面位图,然后替换旧位图。但如果你使用GoToPage频繁跳转,每次跳转都会触发一次完整的渲染流程,而渲染出来的位图在控件内部没有被及时释放,形成堆积。还有一个来源:PdfDocument内部缓存了最近渲染的位图,缓存条数设置过大时,翻页越多缓存越多。
解决:不要高频调用GoToPage,改为设置PdfViewer的ScrollOffset或者用鼠标滚动。滚动到底时控件会自己决定是否翻页,它在内部做了节流。如果业务上必须用按钮连续翻页,给跳转加一个最小间隔,比如 200ms。同时检查是否在外部手动调用了Render方法生成位图,如果调用了,必须把返回的 image 及时 Dispose。
4.3 中文与特殊字符渲染成方块的玄学
现象:同一份 PDF 在自己电脑上显示正常,客户的电脑上中文全部变成豆腐块(□□□),英文和数字正常。
原因:PDF 有两种字体策略。一种是字体完全嵌入文件内部,渲染时不需要系统字体库参与;另一种是只嵌入字体子集或干脆不嵌入,渲染时从操作系统里找同名字体替代。客户电脑上缺少对应的中文字体时,Pdfium 返回一个找不到字体的占位符,渲染出来就是方块。
解决:先确认 PDF 是否嵌入了字体,用工具查看 PDF 属性里“字体”列表,看是否标注“嵌入”字样。如果没嵌入,有三个选择:让客户安装对应字体;在代码里通过PdfDocument.Flags尝试设置字体映射(这个能力受限于 Pdfium 版本,不一定生效);最稳妥的做法还是在服务器端或客户端预置常用中文字体,并确保 Windows 字体目录里存在。这种问题还有一个变体:同一种字体在这个版本 Windows 上有,另一个版本没有,排查时先看字体列表,不要一头扎进控件代码里。
4.4 多线程访问控件导致的怪异行为
现象:界面卡死,或者图片突然变成一片空白,偶发且无法稳定复现。
原因:PdfViewer 内部不是线程安全的。它的渲染工作发生在控件所属的 UI 线程上,如果你在后台线程里做了PdfDocument.Render或者修改了Document属性,WinForms 的消息循环和渲染逻辑在非同步状态下交互,控件会处于一种未定义状态。有时是抛异常,有时是界面完全没反应被系统判定为“无响应”。
解决:永远不在非 UI 线程中调用控件的公开属性或方法。需要后台预渲染页面时,使用PdfDocument的Render方法在后台线程执行,完成后通过BeginInvoke把位图送回到 UI 线程再显示。这样可以避开控件自身渲染逻辑的线程竞争,又不阻塞界面。
Task.Run(() => { using (var bitmap = _doc.Render(10, 800, 1000, 96, 96, PdfRenderFlags.Normal)) { var copy = new Bitmap(bitmap); BeginInvoke(new Action(() => { pictureBox.Image?.Dispose(); pictureBox.Image = copy; })); } });注意这里做了两层拷贝:Render出来的 bitmap 在后台线程用 using 包住,使用完就释放,然后新建一个copy放到 UI 线程。如果不做这层拷贝,直接在后台线程把 bitmap 赋值给pictureBox.Image,可能不会立刻出错,但下次 UI 线程再触碰这个图像对象时会报“对象当前不在使用中”。
4.5 文件被占用与临时文件清理
现象:用户打开 PDF 后想删除源文件,系统提示文件被占用;关闭程序后,临时目录里留下一堆没清掉的临时文件。
原因:用PdfDocument.Load(string path)加载时,Pdfium 底层不一定立即释放文件句柄,特别是文件较大时,它会保持映射状态。另外,在某些版本里,PdfViewer 初始化时会向临时目录写入一些工作文件,用于加速渲染。正常退出时这些临时文件应该被清理,但如果进程被强制结束或者资源释放顺序不对,清理逻辑没有执行。
解决:逻辑上用using包住PdfDocument并确保Dispose被调用;某些特殊场景下还没关闭就删文件的需求,用FileShare.Read打开流再传给PdfDocument.Load(Stream)。这样底层的文件句柄指向你打开的流对象,关闭流即可释放文件。临时文件问题,定位到Path.GetTempPath()下带有该组件名称前缀的文件,检查进程退出时的清理逻辑是否被异常中断。如果程序经常有未捕获异常,进程退出不会走正常的清理分支,最可靠的办法是在主窗体的FormClosing事件里显式释放文档并调用GC.Collect强制回收句柄。虽然很多开发者的习惯是“托管代码不用管释放”,但 PdfiumViewer 是混合架构,原生层受管。
4.6 高分辨率屏幕下字体发虚的翻车现场
现象:客户换了台高分屏笔记本,打开同一份 PDF,文字边缘模糊得像隔了一层磨砂玻璃。缩放达到 150% 时尤其明显。开发环境普通屏幕看不出问题,客户那台一测就露馅。
原因:WinForms 默认不支持按 DPI 缩放,PdfViewer 渲染位图时使用的 DPI 值如果还是固定的 96,高分屏上系统缩放比例是 150% 或 200%,96 DPI 的位图被拉大,必然发虚。另一个原因是PdfViewer默认的ZoomMode是Custom,缩放比例没有按系统 DPI 修正,显示出来的页面尺寸比实际应显示的尺寸小,用户手动放大会加剧模糊。
解决:在加载文档前,根据当前屏幕 DPI 设置渲染参数:
using (var g = CreateGraphics()) { var dpiX = g.DpiX; var dpiY = g.DpiY; _viewer.Document = _doc; _viewer.ZoomMode = PdfViewerZoomMode.FitWidth; _viewer.Zoom = _viewer.Zoom * dpiX / 96f; }CreateGraphics拿到的 DPI 值是当前屏幕的实际值,标准 96,高分屏是 144 或 192。把默认缩放比例按比例上调后,文字清晰度会回到可接受范围。如果客户那边还是模糊,就需要启用进程级 DPI 感知,在程序入口处调用SetProcessDPIAware,并检查应用清单文件里是否声明了 DPI 感知。PdfiumViewer 本身没有独立的 DPI 适配逻辑,全部依赖调用方的处理,这一点很容易被忽略,但确实是漂亮显示效果的关键。
5. 长时间运行的内存与性能调优:缓存策略和渲染瘦身
5.1 为什么静态实例化能减少重复初始化开销
有些业务场景是自动化流程:程序启动,批量处理 100 个 PDF 文件,每个文件渲染几页用于生成预览,然后关闭。这种模式下反复创建和销毁PdfDocument,每次都会经历 Pdfium 库的初始化和清理过程。这个初始化开销虽然不是秒级,但文件量上来以后,累积时间非常明显。
在实际项目中验证下来,Pdfium 的全局初始化逻辑在进程生命周期里只需要做一次。PdfiumViewer 的底层库每次加载文档时都会初始化一部分环境,如果把它变成静态的,可以减少重复初始化时间。常见做法是做一个单例服务,长时间持有PdfDocument,切换文件时复用内部句柄,而不是每次都新建。但注意,同一个PdfDocument对象同时只能被一个PdfViewer使用,不要试图多窗口共享一个文档对象。
更可靠的优化是:进程启动时先加载一次 Pdfium 原生库,后续只为每个文件创建轻量的文档引用。这个做法不保证每个版本都兼容,但实测下来对批量预览的提速很明显,代价是内存占用会略高。
5.2 缓存最近 N 页位图而不是全部
PdfiumViewer 默认没有内置页面位图缓存。每次翻页都重新渲染,这是它可以保持内存低位的原因。但如果你做的功能是“预览模式下快速浏览连续页面”,每次都重新渲染会有明显的白屏感。
我实践下来效果较好的方案是自己在外部维护一个缓存字典:
public class PageBitmapCache { private readonly Dictionary<int, Bitmap> _cache = new Dictionary<int, Bitmap>(); private readonly int _capacity; public PageBitmapCache(int capacity) { _capacity = capacity; } public Bitmap GetOrRender(PdfDocument doc, int pageIndex, int width, int height) { if (_cache.TryGetValue(pageIndex, out var bmp)) { _cache.Remove(pageIndex); _cache.Add(pageIndex, bmp); return bmp; } var rendered = doc.Render(pageIndex, width, height, 96, 96, PdfRenderFlags.Normal); _cache.Add(pageIndex, rendered); if (_cache.Count > _capacity) { var oldestKey = _cache.Keys.First(); _cache[oldestKey].Dispose(); _cache.Remove(oldestKey); } return rendered; } }这个缓存按最近最少使用策略淘汰。_capacity设在 8 到 12 之间比较合适:一个页面位图按 1600x2200x32 位算,大约是 14MB,10 页就是 140MB,还在可接受范围。如果设到 30,内存占用接近 400MB,实际效果提升却不大,因为用户一般不会在短时间内来回翻 30 页。
实现里有几个细节值得注意。渲染完成后,返回的位图不要直接交给界面,而是再次new Bitmap(rendered)拷贝一份,这样缓存位图不会因为外界调用Dispose而被破坏。位图的尺寸不要直接用控件的像素宽高,要根据当前缩放级别计算出实际需要的渲染尺寸,否则缩放到 200% 时,缓存的位图只是 100% 的清晰度,放大后还是会虚。
5.3 降低非可视区域渲染优先级的做法
连续滚动模式下,控件会提前渲染当前可视区域附近的页面。这是它响应快的原因,但同时也会在滚动条快速拖动时渲染大量页面的位图。文档 200 页,用户从第 1 页拖到第 100 页,控件会依次渲染经过的每一页,这个过程中内存和 CPU 都会齐刷刷地飙高。
如果是优先内存占用的场景,可以把PdfViewer的连续滚动关掉,改为单页模式,这样控件只渲染当前页,拖动滚动条时不会触发无谓的渲染。代价是翻页的连贯感受影响。
// 创建时或加载文档后设置 viewer.EnableScrollChangedDetection = false;EnableScrollChangedDetection这个属性被设置为 false 后,控件不再监听滚动位置变化来触发渲染,只有显式调用GoToPage或鼠标滚轮事件才重绘。这样处理后,滚动条拖动时界面可能短暂空白,但内存开销会明显收敛。
过滤掉非可视页渲染后还有一个好处:PDF 内嵌的高清扫描图片(例如单页 10MB 以上的 TIFF 转 PDF)不会被提前解码,加载速度和内存占用都能得到优化。这类文档在普通配置的机器上使用尤其重要。还有一个习惯是配合PdfDocument.PageCount做一个上限判断,如果超过 100 页,就默认用单页模式;少于 100 页时用户可能更在意翻页流畅度,可以保留连续滚动。这种按文档规模切换模式的策略,比一种配置从头用到尾更贴合实际使用场景。
5.4 大文档启动时真正拖慢速度的瓶颈
经常有人问“为什么 300 页的 PDF 打开要好几秒”。这里要说清楚一个认知:Pdfium 本身解析 300 页文档结构是很快的,真正拖慢启动时间的是两个额外因素。一个是文档包含大量内嵌元数据或书签目录,解析这些目录树在部分文件里非常吃 CPU;另一个是第一次访问PageCount属性时,Pdfium 需要遍历全部页面对象来确认数量,这个过程不是 O(1)。如果业务需要在启动时立刻显示页数和总页数,慢是必然的。
缓解方法是把“解析文档结构”和“渲染第一页”分开:
var doc = PdfDocument.Load(path); viewer.Document = doc; // 立即显示第一页,不等待 PageCount labelPage.Text = "1 / 正在读取..."; Task.Run(() => { var count = doc.PageCount; BeginInvoke(new Action(() => { labelPage.Text = $"1 / {count}"; })); });先把文档绑定到控件上,第一页能立刻渲染出来。总页数在后台线程里获取,拿到后再更新状态栏。这样用户主观体感从“卡了几秒才能看”变成“秒开但页数稍后显示”,弱化了等待感。
6. 进阶:给 PdfiumViewer 加一个记忆滚动位置的阅读器
PdfiumViewer 没有内置“上次阅读位置恢复”功能,但这个东西在业务系统里几乎必被要求。用户看一份 50 页的合同,关掉再打开,如果回到第 1 页,会非常恼火。补上这个功能只需要做两件事:保存当前页码与滚动偏移,打开时恢复。
保存方面,在窗体的FormClosing事件里读取控件状态:
private void Form1_FormClosing(object sender, FormClosingEventArgs e) { var pageIndex = _viewer.GetPageIndex(); var vScroll = _viewer.VerticalScroll.Value; var setting = new AppSettings { LastPage = pageIndex, LastScroll = vScroll, LastZoom = _viewer.Zoom }; // 序列化到本地用户目录,路径按项目习惯约定 File.WriteAllText( Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "pdf_state.json"), JsonSerializer.Serialize(setting)); }GetPageIndex()返回当前页索引,VerticalScroll.Value是当前页内滚动偏移,Zoom记录缩放比。三个数据合起来就能完整恢复阅读位置。这里要注意,VerticalScroll.Value的语义是像素偏移,但它跟当前缩放级别相关,恢复时必须先设置 Zoom,再设置滚动值,顺序反了位置会错。
打开文档时恢复:
private void Form1_Shown(object sender, EventArgs e) { var path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "pdf_state.json"); if (!File.Exists(path)) return; var setting = JsonSerializer.Deserialize<AppSettings>(File.ReadAllText(path)); _viewer.ZoomMode = PdfViewerZoomMode.Custom; _viewer.Zoom = setting.LastZoom; _viewer.GoToPage(setting.LastPage); BeginInvoke(new Action(() => { _viewer.VerticalScroll.Value = setting.LastScroll; _viewer.Invalidate(); })); }恢复代码里最关键的是BeginInvoke。GoToPage执行后控件需要一段时间完成重绘,滚动条的实际长度和位置在重绘完成后才生效。如果你紧接着在同步代码里设置VerticalScroll.Value,此时滚动条最大范围还是旧的,赋值会被钳位到错误的位置。用BeginInvoke把设置滚动值的操作放到消息队列后面,重绘结束后再执行,就能完整恢复。这算是一个小教训,我第一次实现时省了BeginInvoke,结果位置从来没有恢复成功过,查了半天。
这个功能做完后,用户对控件的体感会从“一个通用 PDF 查看器”变成“给业务定制的阅读器”,整体接受度明显提升。
经验上还想补充一点:PdfViewer的缩放和滚动在某些版本上存在轻微的不确定性,恢复位置时如果发现偶发偏差,去掉ZoomMode = FitWidth这类自动模式,用固定数值的Custom模式更可靠。自动适应模式在文档加载初期计算出的尺寸和最终显示尺寸可能不同,导致保存的滚动偏移在恢复时对不上。
这一章说的都是基于 PdfiumViewer 的常规玩法,但不依赖任何版本编号。工作里做得最多的就是把这种控件嵌入到各种业务界面里,遇到的坑也基本上集中在资源释放和渲染时机这两类。希望帮到你。
本文还有配套的精品资源,点击获取