技术文章

PDFium Delphi 查看器:渲染缓存与流畅缩放

在一个天真的 PDF 查看器里按住放大按钮,看看 CPU 曲线。一个自动重复的缩放控件按一下就会每秒发出十几步缩放,而如果每一步都触发一次对可见页面的全质量重渲染,这些渲染堆积的速度会超过它们完成的速度。页面单独栅格化是没问题的,一张 A4 扫描件也许 180 毫秒,可你现在是在为用户早已越过的工作跑十几次 180 毫秒的渲染。查看器卡死,一个核心钉在 100%,等屏幕追上来时,用户已经停在四次渲染之前的那个缩放级别上了。解药不是更快的栅格化器,而是一个能瞬间返回成品页面的缓存,加上一个愿意在工作过期的那一刻立刻抛弃它的渲染循环

PDFium Component 把这两样的零件都交给你,而不插手策略。你拿到的是归调用方所有的位图、一个接受取消令牌的渐进式渲染器、能在窗口尺寸变化时重算缩放的适配模式,以及一个供大到无法整页栅格化的页面使用的分块调用。它有意不提供的正是缓存本身,因为正确的淘汰策略取决于你的视口、你所在平台的内存上限,以及你的用户怎么滚动。这个决定要由你来做对,而做错的后果恰恰就是卡死和泄漏

毫秒和兆字节都花到哪去了

在设计任何东西之前,先给代价定个数。一张 A4 页面在 96 DPI 下大约是 794 乘 1123 像素,作为 32 位位图约 3.5 MB。缩放到 200%,这个数字翻两番。在一块高 DPI 显示器上缩放到 400%,你为单页位图分配并填充的是 50 到 60 MB,而一个连续滚动的查看器会同时留着好几页。栅格化代价跟着输出像素走,所以缩放每翻一倍,渲染时间和内存大致同时翻两番

这个算术直接推出两个后果。一个键里不含缩放级别的缓存毫无价值,因为它最需要加速的那个手势——缩放——每次都会产出一张新位图。而一个不设上限的缓存,恰恰会在人们缩放得最狠的那些文档上把 32 位进程的地址空间耗光:密密麻麻的地契扫描件、工程图纸、大幅面地图。缓存的键必须建对,容量必须封死,两者都不是可选项

缓存键里该放什么

只有当塑造了那些像素的每一项输入仍然匹配时,复用一张缓存位图才是安全的。这意味着页码、有效缩放(或者等价地说,输出像素尺寸)、旋转、显示器 DPI,以及它被产出时生效的那些渲染选项。带着 reAnnotations 渲染出来的页面,和不带它的同一页是两张不同的图像,而经过 reGrayscale 的灰度一趟又是另一张。从键里漏掉其中任何一项,缺陷都是可预料的:评审者删掉批注之后仍然赖着不走的注解叠加层,或者一个用户把窗口从笔记本屏幕拖到外接 4K 显示器、DPI 在一张过期位图底下变了、页面就立刻糊掉

Delphi 查看器中的 PDFium 渲染缓存查找:缓存键由页码、缩放、旋转、显示器 DPI 和渲染选项组合而成,命中时以微秒级返回位图,淘汰时释放它丢弃的每一张位图
缓存键覆盖了塑造像素的每一项输入,而淘汰会释放它丢弃的那些位图
function TPageCache.Acquire(Pdf: TPdf; PageNo: Integer; ZoomPct: Single;
  Rotation: TRotation; Opts: TRenderOptions): TBitmap;
var
  Key: string;
begin
  Key := Format('%d|%.0f|%d|%d|%d',
    [PageNo, ZoomPct, Ord(Rotation), Screen.PixelsPerInch, OptionsMask(Opts)]);
  if FBitmaps.TryGetValue(Key, Result) then
    Exit;

  Pdf.PageNumber := PageNo;
  Result := Pdf.RenderPage(0, 0, OutputWidth(PageNo, ZoomPct),
    OutputHeight(PageNo, ZoomPct), Rotation, Opts);
  FBitmaps.Add(Key, Result);   // 这张位图从此归缓存所有
end;

命中时这个函数以微秒级返回,而这正是全部意义所在。更难的问题是那些掉出缓存的位图会怎么样,而这归根结底是一个关于谁拥有它们的问题

谁来释放这张位图

RenderPage 的函数形式返回一个归调用方所有的 TBitmap。在一次性的导出里,这份所有权显而易见也容易遵守。放进缓存里,它就成了 Delphi PDF 查看器中最常见的那种泄漏,因为字典现在持有每张位图的唯一引用,而一个普通的 TDictionary 只有在键值是托管类型时才替你释放它们。TBitmap 不是。淘汰一个条目却不调用 Free,那些像素就一直分配着,而没有任何东西指向它们

这件事之所以能溜过去,是因为时间尺度。一次十分钟的冒烟测试永远缩放不到足够多的不同页面去察觉它;泄漏只在有人对着一份长文档滚动缩放了几个小时之后才现身,那时进程已经攥着几百张无主的页面位图,机器开始换页。所以淘汰属于缓存的第一个版本,而不是后来的某个版本。按估算字节数给缓存封顶——用宽乘高乘四来算——按最近最少使用淘汰那些落在视口和预取窗口之外的页面,并在移除每一张位图时把它释放掉。对于真正一次性的绘制,那些渲染进调用方提供的 TBitmap 或者直接渲染到一个 HDC 上的重载,能让你彻底跳过这套所有权舞蹈。打印预览就是显而易见的例子,因为每一张纸你只渲染一次,缓存它什么也换不来

渐进式渲染与诚实的取消

普通的 RenderPage 重载会一直阻塞到页面完成,而这恰恰是用户还在拨动缩放控件时你最不想要的行为。为此你要伸手去拿 RenderPageProgressive。它接受一个 IPdfCancellationToken,返回 prsDoneprsCancelledprsFailed 之一。绊倒人的行为细节是:取消并不是瞬时的。令牌是在渲染内部的分块边界上轮询的,所以你在一个分块中途发出的取消信号,要等那个分块结束才会生效。在一个复杂页面上,从请求到停下之间的延迟能达到几十毫秒。要围着这个间隙做设计,而不是指望它不存在:新的缩放值一到就立刻取消上一个令牌,但别假设旧的渲染在你一开口的那一刻就停了

Delphi 中 PDFium 渐进式渲染的时间线:每个新的缩放请求取消上一个令牌,取消落在分块边界上,被取代的渲染返回 prsCancelled,最后一次尝试返回 prsDone
每个新的缩放请求都会取消上一次渲染的令牌,而取消落在一个分块边界上
procedure TViewerForm.RequestRender(TargetZoom: Single);
var
  Status: TPdfProgressiveStatus;
begin
  if FTokenSource <> nil then
    FTokenSource.Cancel;           // 放弃上一次仍在途中的渲染
  FTokenSource := TPdfCancellationTokenSource.New;  // 来自 FPdfAsync 单元

  Status := Pdf.RenderPageProgressive(FBackBuffer, 0, 0,
    FBackBuffer.Width, FBackBuffer.Height, FTokenSource.Token,
    ro0, [reAnnotations]);

  case Status of
    prsDone:      PresentBackBuffer;
    prsCancelled: ;                // 已被更新的请求取代:静默丢弃
    prsFailed:    ShowRenderFailure;
  end;
end;

在交互过程中,prsCancelled 是常态结果,而不是例外。一次缩放手势启动的渲染大多会在完成之前被取代,所以把取消当作例行公事,静默丢弃结果。一个把每一次取消都记成警告的渲染队列,会把真正要紧的那一次失败埋在几千行噪音底下。为了不让屏幕在真正的渲染跑着时看起来像死了一样,给渐进式路径配一个廉价的替身:把上一张缓存位图缩放到新的缩放级别并立刻呈现出来。它会有一两百毫秒显得发软,但读起来像是瞬时的,而且它替全质量渲染争取到了完成或者被下一个手势取消所需要的时间

被缩放悄悄关掉的适配模式

查看器的 FitMode 属性设为 pfmFitPagepfmFitWidth 时,会在每次尺寸变化时重算缩放,好让页面随窗口变化始终保持贴合。麻烦在于直接给 Zoom 赋值会把 FitMode 重置回 pfmNone。作为默认行为这是对的:一个特意键入了 150% 的用户,不希望下一次窗口缩放就把它扔掉。但它会让任何一个把放大按钮接成 Zoom := Zoom * 1.25 的人吃惊,然后想不明白为什么适合宽度在第一次点击之后就不响应了。如果你的工具栏同时提供显式缩放和适配模式,你就得自己记住用户上一次选的适配方式,并在他们再次按下适配按钮时重新赋值。组件不会去恢复一个刚被缩放赋值清掉的模式,它也不该恢复

一份你辩护得了的内存预算

一份写得下来的预算,才是你能在代码评审上为之辩护的预算,所以从一个具体场景出发。假设连续滚动会保留可见页面外加上下各一页预取,同时还有一条缩略图栏。在一块 96 DPI 显示器上以 100% 显示时,这三张全尺寸位图每张约 3.5 MB,不算什么。在一块 4K 显示器上以 300% 显示时,同样这三张位图每张大约 30 MB,而这还是在缓存一张历史页面都没留下来之前。增长来自手势,不是来自文档

Delphi 查看器的 PDFium 位图内存算术:缩放每翻一倍页面内存翻两番,连续滚动同时留着三页,封顶的 LRU 预算守住缓存,超大图纸交给 RenderTile
缩放每翻一倍,位图内存就翻两番,所以缓存需要一个硬上限,超大页面则需要分块

对一个 32 位 Delphi 进程来说,一份稳妥的默认值是在 LRU 淘汰下设 256 MB 位图预算。在 64 位上你可以按物理内存来放大,但无论如何都要留一个硬顶,因为你要防的失败并不是自己的进程崩掉。而是整台机器疯狂读写页面文件,同时你的查看器技术上还在跑,用户却纳闷为什么别的东西都慢了下来。硬上限的失败是可预料的;不设上限的缓存失败时会把整个桌面一起拖下水。缩略图值得单独对待:每张只在它那个很小的目标尺寸上渲染一次,放进一个 LRU 逻辑永远不碰的独立池子里。靠把一张 60 MB 的整页位图缩下去来重新生成一个 120 像素的缩略图,是产出一张邮票最浪费的方式

有些单页能击垮任何预算。一张 E 号工程图纸或一张大地图在 400% 下整页渲染,是几百兆字节的一次分配,没有哪种淘汰策略能让它变得可以接受。那里的答案是别再渲染整页。RenderTile 只栅格化名义上被缩放到 PageWidthPageHeight 的页面中位于像素偏移 (Left, Top) 处的那块区域,于是你只渲染可见矩形加上四周一块的余量以保证平移流畅,并把分块偏移连同缩放一起折进缓存键里。整份文档要保持分块尺寸固定。固定分块意味着 DPI 一变,整张网格就干净利落地失效;而可变分块会让你到处追那些在略微不同的比例下渲染出来、区域之间露出的接缝

有两个相邻的功能会悄悄给这一切加码。灰度或反色这类颜色滤镜是在渲染之后跑的,每次都会再产出一张全尺寸位图,把任何用到它们的视图的单页占用翻倍;那份代价是Delphi PDF 查看器的低视力颜色滤镜一文的主题。而一个在文本转语音时高亮词语的查看器,每念一个词就会让渲染好的视图失效,所以高亮重绘与语速之间的相互作用比乍看起来更要紧,这在逐词 TTS 高亮里讲过

那些渲染重载、渐进式状态码,以及查看器组件本身,都记录在 PDFium Component 的产品页上