技术文章

在 Delphi 中进行可取消的渐进式 PDF 渲染 (PDFium)

大多数 PDF 页面只需几毫秒即可光栅化,您甚至无需去考虑它。但当用户打开一张 A1 的工程图纸,或者是一个塞满了数万个矢量笔触的页面,再或者是一个挤满了透明度组和柔和蒙版的海报时,绘制它的单次调用可能就需要两到三秒钟。如果这个调用运行在 UI 线程上,窗口就会停止重绘,标题栏变灰,操作系统则会提示你终止应用程序。这项工作是合理的。该页面的确需要这么长的时间。其缺陷在于,这种渲染是一次不可分割的阻塞调用,它无法中途停顿,也没有停止的途径

本文的主旨仅仅是关于这两个问题中的其中一个:如何在不冻结 UI 的情况下取消耗时的单页渲染。用户可能点击了下一页、进行了缩放,或者关闭了文档,而飞行中的渲染此时就变成了浪费的工作,应该在下一个时机结束,而不是运行到完成。通过缓存已光栅化的内容来平滑滚动和缩放是另一个具有不同设计的关注点,在文末链接的相关文章中有介绍。这里的唯一问题是,如何让一次渐进式渲染快速、干净地响应取消请求

PDFium 已提供的渐进式渲染 API

PDFium 预见到了冻结这半个问题。与单次调用的 FPDF_RenderPageBitmap 并行,它公开了一个渐进式的变体,将页面拆分为多个工作块。你只需调用一次 FPDF_RenderPageBitmap_Start,针对目标位图来设置渲染,然后重复调用 FPDF_RenderPage_Continue。每次的 Continue 会光栅化一个有界的切片并返回一个状态。FPDF_RENDER_TOBECONTINUED 表示还有更多工作要做,FPDF_RENDER_DONE 表示页面已完成,而 FPDF_RENDER_FAILED 意味着由于错误而停止。当循环结束时,您调用 FPDF_RenderPage_Close 来释放每页的渐进式状态。因为在不同切片之间控制权会返回给你的代码,所以你可以分发消息、更新进度指示器,或者检查是否还需要这项工作

PDFium 提供的用于决定何时让出控制权的机制是一个名为 IFSDK_PAUSE 的回调结构体。你将它传递给 Start 和每一个 Continue。在每个工作块之后,PDFium 会调用其 NeedToPauseNow 函数指针,如果该函数返回一个非零值,则当前的 Continue 将提前停止,并通过 FPDF_RENDER_TOBECONTINUED 交回控制权。该结构体还携带一个 version 字段,必须设置为 1,以及一个自由形式的 user 指针,PDFium 从未接触过该指针并且原封不动地将其传递。这个未被触及的指针就是接下来设计的整个关键

将暂停用作取消

NeedToPauseNow 的最初意图是时间切片。当你的帧预算用尽时返回非零值,若想继续渲染则返回零,此时 PDFium 就会暂停,以便你能在恢复同一渲染前去执行其他操作。PDFium 组件在不同动词中复用了同一个信号。与回答“我应该暂停好让你随后恢复吗”不同,此回调要回答的是“这项工作已经被取消了吗”。这两种含义能完美地映射在一起,这是因为当循环捕捉到该标志时所发生的事情。一次真正的暂停期待稍后的 Continue;而取消则不然。一旦调用循环观察到令牌已被取消,它就会关闭渲染上下文并且永不再次调用 Continue,因此被 PDFium 解读为“停止这个分块”的非零返回,实际上也就变成了“永远停止”

取消是通过 IPdfCancellationToken 接口表达的,当程序的其他部分要求停止渲染时,其 IsCancelled 属性将从 false 翻转为 true。在这个 Pascal 接口与 PDFium 的 C 回调之间的桥梁是一个单指针。令牌的接口引用被写入 IFSDK_PAUSE.user,静态的 cdecl 回调将其读出并进行查询。这是允许 C 库回调 Pascal 代码时的典型问题:由于 PDFium 存储和调用的是一个对 Pascal 对象或 Self 一无所知的裸函数指针,回调必须是一个使用 C 调用约定的普通函数,而不能是对象方法

type
  TPdfProgressivePause = record
    Pause: IFSDK_PAUSE;            // PDFium reads this; .user holds the token
    Token: IPdfCancellationToken; // strong ref keeps the token alive
  end;

function ProgressivePauseCallback(pThis: PIFSDK_PAUSE): FPDF_BOOL; cdecl;
var
  Token: IPdfCancellationToken;
begin
  Result := 0;
  if (pThis = nil) or (pThis^.user = nil) then
    Exit;
  Token := IPdfCancellationToken(pThis^.user);
  if Token.IsCancelled then
    Result := 1; // non-zero: PDFium stops this chunk
end;

回调通过将 pThis^.user 转换回接口类型并读取 IsCancelled 来恢复令牌。它不进行分配、锁定或阻塞操作,这一点至关重要,因为 PDFium 会在每一个分块之后于渲染线程上调用它,而此处进行的任何工作都会增加渲染本身的成本。对 nil 结构体或 nil user 字段的防护措施,意味着该函数即使安装在从未得到过真实令牌的渲染上也是安全的

让令牌在循环间存活

通过原始 Pointer 转换接口指针并转回的过程正是生命周期 Bug 滋生的地方。在 Delphi 中,IInterface 是带引用计数的,而且只有当编译器看到正在赋值一个接口类型的变量时计数才会移动。如果仅把令牌作为裸指针储存于 IFSDK_PAUSE.user 内部,那就会把它完全对引用计数器隐藏。如果对此令牌唯一其余的引用在 Continue 循环仍在运行时超出了作用域,对象就会在回调下方被释放,下一个工作块就会解引用一个悬空指针

这就是为什么该描述符是一个容纳两个而不是一个元素的记录(record)。Pause 字段是 PDFium 读取的结构。Token 字段是编译器会计数的真正接口类型引用,并且它存在的唯一原因就是在该记录存在的时间内将令牌固定在内存中。由于该记录是渲染例程堆栈上的一个局部变量,因此它在整个循环期间保持有效,并且仅在例程退出时才会被拆除。user 里的裸指针和 Token 里的计数引用命名的是同一个对象;一个是 PDFium 可以读取的内容,另一个则是防止对象被回收的原因

var
  Pause: TPdfProgressivePause;
  EffectiveToken: IPdfCancellationToken;
begin
  // ... choose EffectiveToken ...

  // Strong ref first, then publish the same object to PDFium via .user.
  Pause.Token := EffectiveToken;
  Pause.Pause.version := 1;
  Pause.Pause.NeedToPauseNow := ProgressivePauseCallback;
  Pause.Pause.user := Pointer(EffectiveToken);

无论循环如何结束都能关闭渲染上下文

每次对 FPDF_RenderPageBitmap_Start 的调用都会分配 PDFium 关联到该页面的渐进式状态,且这种状态只由 FPDF_RenderPage_Close 释放。从驱动循环中有三种退出方式。页面完成且最终状态为 FPDF_RENDER_DONE。令牌跳闸且循环因取消报告而提早退出。发生某项故障且状态为 FPDF_RENDER_FAILED。所有这三种情况均必须调用 Close,取消路径是最易出错的,由于“看到取消,立即跳出”的自然形态往往会在奔向出口的途中略过清理工作。如果没有执行 Close 就会泄漏每页的状态,而且允许用户取消一个接一个渲染的查看器就会将这些泄漏在每个被中止的页面上累积起来

一种鲁棒的形式将循环和结果分类放在 try 内,将 FPDF_RenderPage_Close 置于对应的 finally 内。目标位图在同一个代码块内被销毁。由于取消操作可以通过早期的 Exit 离开循环而 finally 仍然会运行,因此这是唯一一处能释放渐进式状态的地方,而且它不可被绕过

Status := FPDF_RenderPageBitmap_Start(PdfBmp, FPage, Left, Top,
  Width, Height, Ord(Rotation), EncodeRenderOptions(Options), Pause.Pause);
try
  while Status = FPDF_RENDER_TOBECONTINUED do
  begin
    if EffectiveToken.IsCancelled then
    begin
      Result := prsCancelled;
      Exit;
    end;
    Status := FPDF_RenderPage_Continue(FPage, Pause.Pause);
  end;

  if EffectiveToken.IsCancelled then
    Result := prsCancelled
  else if Status = FPDF_RENDER_DONE then
    Result := prsDone
  else
    Result := prsFailed;
finally
  // Frees the progressive state Start allocated; mandatory on every path.
  FPDF_RenderPage_Close(FPage);
  FPDFBitmap_Destroy(PdfBmp);
end;

在每次 Continue 之前循环也会检查令牌,此外也依赖它内部的回调。回调缩短当前的工作块;而循环检查会阻止下一个块开始。两者结合将取消操作生效的时间限定在大约一个工作块的时长之内

三种结果,以及取消后位图中包含了什么

公共入口点是 TPdf.RenderPageProgressive,它返回的是 prsDoneprsCancelled,或 prsFailed 之一的 TPdfProgressiveStatus。这些值是以 Pascal 的风格对应 PDFium 的 FPDF_RENDER_* 常量,但将取消情况作为一个一等结果并入,而不是作为一个错误

这里迷惑人的地方在于返回 prsCancelled 后目标位图中有什么。它不是空白的。PDFium 在一个接一个的工作块中向同一个位图进行渐进式渲染,因此当取消操作停止了循环时,位图中就保存有截至那一刻所绘制的任意内容,这是一幅部分完成的图像:一部分带状区域已经完成,其余的依然显示着填充色。这一部分结果是否有用取决于调用方。如果某个查看器因为用户导航到其他地方而打算丢弃位图,那它就可以直接忽略它。而希望展示低成本预览的查看器可以保留它。你绝不能做的假设是 prsCancelled 意味着位图为空或未定义;它代表一幅未完成渲染的真实快照

var
  Bmp: TBitmap;
  Token: IPdfCancellationToken;
  Status: TPdfProgressiveStatus;
begin
  Bmp := TBitmap.Create;
  try
    // Token starts un-cancelled; flip Token.IsCancelled from elsewhere
    // (a UI action, a navigation event) to abort the render in flight.
    Status := Pdf.RenderPageProgressive(Bmp, 0, 0, PageW, PageH, Token);
    case Status of
      prsDone:      Image1.Picture.Assign(Bmp);  // fully rendered
      prsCancelled: ;                            // partial bitmap, usually discarded
      prsFailed:    ShowMessage('Render failed');
    end;
  finally
    Bmp.Free;
  end;
end;

nil 令牌与无分支的回调路径

取消功能是选择性的。如果调用者仅为了能进行消息分发的益处而想要渐进式渲染,并无中止渲染意图,那它就应当可以为令牌传递 nil。要支持这一点的幼稚做法是,把“如果提供了一个令牌”这类的检查分散在回调以及循环中,这意味着对每一次代码块都要有一个分支判断,而且还需要处理拥有真实令牌及不具有令牌这两种情况

具体实现的做法是,在调用者传递 nil 时用一个单例进行替换以避免前述的情况。nil 令牌被替换为 PdfNoCancellationToken,这是一个 IsCancelled 永远为 false 的接口。由此开始,回调以及循环在任何情况下都有令牌可供查询,这样就无需进行 nil 检查,也没有任何一种情况需要专门的执行路径。这种永不取消的令牌在任何时候简单地都回答为 false,回调永远都会返回 0,而且渲染过程就能像不可取消的渲染过程一样百分百顺利完成执行。可选的功能被模拟为从未触发过的一个令牌,而非某种令牌的缺失,从而也就使热路径保持了一致

// nil -> never-cancel singleton, so the callback path is identical
// whether or not the caller opted into cancellation.
if AToken <> nil then
  EffectiveToken := AToken
else
  EffectiveToken := PdfNoCancellationToken;

所呈现出来的结构虽然小巧,却值得再次重申,因为它是可复用的部分。一个支持回调的 C 语言库,为你提供了将状态传入该回调的唯一途径,即那个不透明的 user 指针。在这个指针后面放置一个计数的 Pascal 接口引用,然后在结构旁边保持第二个真正的引用处于活动状态,这样就能防止对象在函数调用过程中被回收,接着再在一个静态的 cdecl 函数内部把该接口重新读出来。用一个 try 把整个驱动循环包裹起来,并且在 finally 里面把这个原生上下文进行释放。对于所有的,或者是通过回调驱动的那些要求 Pascal 代码在 C 语言持有指针时控制其生命周期的 PDFium 操作,都可以使用这一相同的模板

取消操作只是具有良好响应性查看器功能的一半而已。另外一半则在于不要对您以前曾绘制过的网页再次进行重新渲染,并借助提供那些被缓存下来的位图让缩放与滚动变得更加顺滑,这些内容都被我们涵盖在了关于渲染缓存及缩放性能的文章之中。至于该可取消式的渲染怎样同一套完整的含有导航、文本选择乃至搜索功能的查看器进行契合,可以查阅借助 PDFium VCL 组件构建一套功能丰富的 PDF 查看器的内容。前述的可渐进式渲染被作为了可用于 Delphi 还有 Lazarus 环境的 PDFium Component 库的一部分连同在该博客中别处提及过的各类用来进行读取与渲染以及表单操作 API 工具被一起加以了推出