技术文章

Delphi 中的 PDF 查看器磁盘缓存与每显示器 DPI 优化

每个嵌入式 PDF 查看器都面临着两个抱怨:每次启动时大文档都需要从头开始重新渲染,以及一旦窗口被拖动到高 DPI 显示器上文本就会变得模糊;losLab PDF Library 在其 TPDFlibViewer 控件中解决了这两个问题:DiskCacheFolder 属性启用了以文档指纹为键的跨会话磁盘页面缓存,而 ScreenDPI 属性(在 GetDpiForWindowWM_DPICHANGED 句柄的支持下)赋予了控件真正的每显示器(Per-Monitor) DPI 感知能力

这两个问题都值得认真对待,因为用户会立即注意到它们;一份 400 页的扫描版合同,昨天花了解析 8 秒才变得可滚动,今天再次打开仍需要 8 秒,即使文件中的任何内容都没有改变;而且在混合 DPI 的桌面上,在主显示器 96 DPI 下渲染的查看器在主屏上看起来很好,但在旁边的 4K 面板上明显呈现模糊;本文将介绍 TPDFlibViewer 如何解决这其中的每一个问题,更实用的是,介绍为什么设计做此决断;如果您对控件本身还不熟悉,请先从交互式 PDF 查看器控件概述开始,然后再回过头来看性能层

在 Delphi 中如何跨会话缓存渲染好的 PDF 页面?

将一个文件夹指定给 TPDFlibViewer.DiskCacheFolder,控件就会把渲染好的页面位图持久化到磁盘上,因此最近查看的文档会立即重新打开,而不需要重新渲染;磁盘上的布局是 <Folder>\PDFlibPas-PageCache\<document fingerprint>\p<page>_z<zoomkey>.bin:每个文档一个子文件夹,每个页面和缩放的组合一个文件;其他一切都是自动的;没有需要调用的缓存 API,也没有需要刷新的内容

uses
  PDFlibViewer;

procedure TMainForm.FormCreate(Sender: TObject);
begin
  FViewer := TPDFlibViewer.Create(Self);
  FViewer.Parent := Self;
  FViewer.Align := alClient;
  // One line turns on the cross-session page cache
  FViewer.DiskCacheFolder := 'C:\ProgramData\MyApp\PageCache';
  FViewer.LoadFromFile('C:\Contracts\master-agreement.pdf', '');
end;

DiskCacheHits 计数器报告了磁盘缓存在当前会话中省去了多少次页面渲染,这使该优化变得可衡量,而不是凭空闲聊;在您关闭并重新打开的文档上,每个无需渲染扫描便滚动进入视图的页面都会递增它

为什么缓存键是指纹,而不是版本号?

文档指纹是针对 path|size|last write time 字符串计算出的 64 位 FNV-1a 哈希,格式化为十六位十六进制数字;该三元组是整个失效策略的全部:当文件被编辑时,其大小或写入时间发生变化,哈希随之变化,查看器只需打开不同的缓存子文件夹;陈旧的文件夹将永远不会再被查询,并最终落入 LRU 修剪中;没有需要维护的版本标签协议,没有可能漂移的时间戳比较逻辑,也没有办法对修改过的文档提供旧位图

这与构建系统使用的“内容标识作为缓存键”是同一个想法,并且它消除了失效模式的隐患;在文档普通名称下存储文件的缓存必须显式检测逆变,而每次显式检查都是等待边缘情况发生的 bug:从带有旧时间戳的备份中恢复的文件、保留了大小的保存操作、在一台机器上区分大小写而在另一台机器上不区分大小写的路径;将这三个信号哈希到文件夹名称中,可以让陈旧在结构上变得不可能,而不是在程序上进行检查

为什么磁盘缓存键中特意排除了解析旋转?

缓存文件名编码了页码和缩放,除此之外别无他物 —— 视图旋转角度被特意排除了;磁盘缓存始终存储未旋转的渲染字节;当在视图旋转时加载缓存页面,解码后像素在内存中被重新定向;如果旋转是在轨磁盘键的一部分,用户循环遍历四个视图角度会将同一个页面写入磁盘四次,四倍的缓存增长换来的是零额外信息,因为较之于完整的渲染扫描,旋转不过是廉价的像素重新排列(Pixel shuffle)

内存中的位图缓存确实使用了复合键 Round(Zoom * 1000) * 4 + Rotation div 90,因为在内存中,旋转后的位图正是绘制处理程序所需的;后台预取线程遵循相同的纪律:它始终渲染未旋转的页面并报告普通的缩放键,主线程在输出结果时应用旋转 —— 这正是并行页面渲染和线程安全中讨论的“主线程拥有缓存”规则;将旋转排除在内存层底下的任何东西之外,意味着每个页面和缩放只有一份规范渲染,放在昂贵的地方进行存储

LRU 修剪如何保持缓存文件夹受控?

缓存最多保留十个文档文件夹,并在新文档到达时修剪最久未使用的文件夹;最久未使用通过每个文档文件夹中的 last-used.marker 文件进行追踪,其时间戳在缓存写入和缓存读取时都会被触及,因此您仅仅重新阅读的文档就会保持“温热”,而不会被重新写入;属于当前打开的文档的文件夹是免于删除的,因此修剪绝不会从活动视图下抽走页面

缓存中的每个磁盘操作 —— 指纹识别、文件夹创建、标记触及、页面读写、修剪 —— 都会自己吞掉其自身的异常;这是一种刻意的姿态,而不是粗心大意:缓存是一个尽力而为的加速器,满的磁盘、只读文件夹或杀毒软件的锁定,必须将查看器降级为“像以前一样渲染”,而绝不能是“显示页面失败”;缓存 IO 错误能产生的最坏结果仅仅是未命中缓存

procedure TMainForm.FormClose(Sender: TObject; var Action: TCloseAction);
begin
  // How many render passes did the disk cache save this session?
  Log(Format('Disk cache hits: %d', [FViewer.DiskCacheHits]));
end;

在 Delphi 中如何让 PDF 查看器具备 DPI 感知能力?

TPDFlibViewer 将每一个与 DPI 相关的计算都路由通过单个字段,而这种单一变更点的设计正是使每显示器支持易于处理的原因;布局、预取和渲染代码都读取一个 FScreenDPI 值;在 100% 缩放时,一个 PDF 点被映射为 ScreenDPI / 72 个像素;公共的 ScreenDPI 属性 Setter 清空位图缓存、重建布局、更新滚动范围并使窗口失效,因此更改 DPI 只是单次赋值,每个使用者都会自动跟随;当您是针对打印机而不是显示器进行渲染时,相同的像素数学计算也很重要,这一主题在打印预览和设备上下文渲染中有所涵盖

当窗口移动到 4K 显示器上时会发生什么?

当每显示器感知(Per-Monitor Aware)的窗口跨入具有不同缩放因子的显示器时,Windows 会发送 WM_DPICHANGEDTPDFlibViewer 直接处理它;处理程序从 wParam 的低位字中读取新的 DPI,通过 SetScreenDPI 赋值(它会丢弃现在已是错误的位图缓存并重新排布页面),然后接受 Windows 在 lParam 中通过 SetWindowPos 传递的建议窗口矩形,这使逻辑客户端大小在移动过程中保持稳定;用户看到的是文档在新比例下清晰地重新渲染,而不是被操作系统拉伸的位图

// Normally you never touch ScreenDPI: CreateWnd detects the host monitor
// and WM_DPICHANGED tracks moves. Override it only for special targets,
// e.g. rendering the layout as if for a 150% display:
FViewer.ScreenDPI := 144;  // clears the bitmap cache and rebuilds layout

发布前值得了解的边界限制

在您的设计评审中,有三个实际的限制应该有一席之地;首先,只有在进程选择加入时才会收到 WM_DPICHANGED:您的应用程序清单必须声明每显示器 DPI 感知(在当前的 Windows 上为 per-monitor v2),在 Delphi IDE 中即为 Application 选项下的 DPI awareness 设置;系统级 DPI 感知(System DPI Aware)的进程永远不会收到该消息,控件之后会在窗口创建时检测到的 DPI 下进行渲染;其次,磁盘缓存以磁盘空间换取速度 —— 横跨多个缩放级别的十个文档价值的页面位图是真实存在的存储,因此请将 DiskCacheFolder 指向您放心增长的位置,而不是漫游配置文件;第三,缓存文件夹需要运行用户的写入权限,没有它,查看器仍可以静默地工作,但命中率为零 —— 如果您怀疑缓存没有起作用,请在测试中检查 DiskCacheHits

比起大多数渲染优化,这两项功能加在一起,更能改善文档密集型应用程序的感知质量:您的用户重新访问的文档会立即打开,并且视图在落入的任何显示器上都保持清晰;这两者均作为 TPDFlibViewer 的普通属性在 losLab PDF Library 中提供,因此采用它们只是一个下午的接线,而不是渲染引擎的研发项目