技术文章

使用 HotPDF 在 Delphi 中实现后台 PDF 渲染队列

HotPDF 的 THPDFBackgroundRenderer 类是 TThread 的派生类,它在线程中将已加载的 PDF 页面渲染为位图,因此 Delphi 查看器可以在页面仍于后台进行栅格化时继续滚动和重绘。THPDFBackgroundRenderer.RequestPage 会为该线程排队一个页面索引,CancelAll 会丢弃仍在等待的内容,而 GetCachedBitmap 会返回已完成的位图,该位图由调用方拥有并且必须释放。如果仅在 UI 线程中以打印分辨率滚动一份两百页的扫描合同,每次翻页都会让窗口停顿,直到 GDI 完成绘制,这正是 THPDFBackgroundRenderer 要消除的卡顿

为什么要在后台线程中渲染 PDF 页面

后台线程之所以值得增加复杂度,是因为 HotPDF 的页面渲染器是真正的内容流解释器,而不是一个在别人察觉之前就返回的廉价位图复制操作:它会遍历 PDF 操作符、维护图形状态栈,并通过 GDI 栅格化路径、图像和字形,这与将已加载的 PDF 页面渲染为 TBitmap使用的是同一个引擎。如果在滚动或绘制处理程序中同步执行这项工作,消息循环就会一直停止泵送,直到调用返回,而这正是冻结窗口的实际含义。在渲染调用中加入 Application.ProcessMessages 也无法解决问题:它只能让消息队列排空,但渲染本身仍然占用调用线程,所以窗口会更快地重绘过时内容,而真正的工作并没有移到别处。要让查看器在真正缓慢的渲染期间保持响应,唯一方式是把渲染放到其他地方执行,这也是 THPDFBackgroundRenderer 被实现为 TThread 子类而不是回调或计时器的原因

为滚动查看器设置请求队列

THPDFBackgroundRenderer.Create 接收已加载的 THotPDF 实例和一个在该渲染器整个生命周期内保持不变的 DPI,因此通过同一个实例排队的每个页面都会以同一分辨率渲染;支持缩放的查看器在缩放级别改变时需要创建新的渲染器,而不是设置新的 DPI 属性。RequestPage 会将页面索引追加到内部队列并立即返回:它本身不执行渲染,也不会触碰 UI 线程。Execute 是继承自 TThread 的入口点,HotPDF 在调用 Start 后会运行它;该入口点每次从队列前端取出一个索引,通过文档的页面缓存渲染页面,并按页面存储副本,以便稍后由 GetCachedBitmap 返回

type
  TViewerForm = class(TForm)
    RenderPollTimer: TTimer;
    procedure RenderPollTimerTimer(Sender: TObject);
  private
    FDoc: THotPDF;
    FRenderer: THPDFBackgroundRenderer;
    FPendingPage: Integer;
    procedure RequestPageWindow(CenterPage: Integer);
  end;

procedure TViewerForm.RequestPageWindow(CenterPage: Integer);
var
  I: Integer;
begin
  if FRenderer <> nil then
  begin
    FRenderer.CancelAll;
    FRenderer.Free;
  end;
  FRenderer := THPDFBackgroundRenderer.Create(FDoc, 150);
  for I := CenterPage - 1 to CenterPage + 1 do
    if (I >= 0) and (I < FDoc.LoadedPageCount) then
      FRenderer.RequestPage(I);
  FPendingPage := CenterPage;
  FRenderer.Start;
end;

procedure TViewerForm.RenderPollTimerTimer(Sender: TObject);
var
  Bmp: TBitmap;
begin
  if FRenderer = nil then Exit;
  Bmp := FRenderer.GetCachedBitmap(FPendingPage);
  if Bmp <> nil then
  begin
    PageImage.Picture.Bitmap.Assign(Bmp);
    Bmp.Free;
  end;
end;

GetCachedBitmap 在该页面副本准备好之前会返回 nil,因此像上面这样使用计时器轮询就足够了;不需要连接单独的就绪事件,HotPDF 通过简单的 nil 检查解决了这个问题,而不是引入更大的通知 API。下一节将介绍 CancelAll 和 Free 调用实际执行的操作,因为当页面开始乱序渲染,或滚动速度快于队列排空速度时,这两者都很重要

针对单个页面的一次调用快捷方式

对于不直接操作 THPDFBackgroundRenderer、只需准确启动一个页面的常见情况,THotPDF.RenderLoadedPageToBitmapAsync 很有用:它在内部创建渲染器,调用一次 RequestPage,启动线程,然后将 TThread 引用返回给调用方;调用方拥有该引用并负责释放它。结果通过 THotPDF.GetLoadedCachedRenderedBitmap 获取,而不是通过渲染器自己的 GetCachedBitmap 获取,因为 GetLoadedCachedRenderedBitmap 会读取按页面索引和 DPI 设置键的文档共享缓存,这也是 RenderLoadedPageToBitmapCached 和内置预取器使用的同一个缓存。某个页面如果已经由查看器的其他部分以该 DPI 渲染过,就可以立即返回,甚至早于刚刚启动的后台线程被操作系统调度

// A simpler alternative to the queue above, for one page at a time.
procedure TViewerForm.RequestSinglePage(PageIndex: Integer);
begin
  if FAsyncWorker <> nil then
    FAsyncWorker.Free; // waits if a prior page is still rendering
  FAsyncWorker := Pdf.RenderLoadedPageToBitmapAsync(PageIndex, 150);
  FPendingPage := PageIndex;
end;

procedure TViewerForm.AsyncPollTimerTimer(Sender: TObject);
var
  Bmp: TBitmap;
begin
  Bmp := Pdf.GetLoadedCachedRenderedBitmap(FPendingPage, 150);
  if Bmp <> nil then
  begin
    PageImage.Picture.Bitmap.Assign(Bmp);
    Bmp.Free;
  end;
end;

已经排队的页面可以取消吗

CancelAll 只会移除仍在队列中的任务;对于 HotPDF 已经从队列前端取出并交给渲染调用的页面,它会继续执行直到完成,因为 THPDFBackgroundRenderer 没有中断已在进行中的工作的机制。在实践中这是合理的取舍——单个页面渲染通常不够耗时,不值得为了抢占增加复杂度——但快速滚动时每个滚动事件都触发 CancelAll,仍然需要为每次取消发生时正处于渲染中的那一个页面付出代价。官方参考说明得很直接:已经开始运行的渲染可能会在线程终止前完成

Execute 还有一个容易忽略的行为:它一旦发现队列为空就会退出,不会空闲等待更多工作到来。因此,THPDFBackgroundRenderer 实例是一次性批处理工作线程,而不是持久的后台服务——排入几个页面,调用 Start,最后一个排队页面渲染完成后,底层操作系统线程就会自行结束。在 Execute 已经排空队列后,再对同一实例调用 RequestPage 不会重新启动它,这正是上面的 RequestPageWindow 每次调用都替换渲染器实例,而不是尝试持续向一个长期存在的对象中填充任务的原因

在 Delphi 中从后台线程操作 TBitmap 安全吗

只要同一个位图实例始终只由一个线程操作,在 HotPDF 的设计中从后台线程操作 TBitmap 是安全的,而 THPDFBackgroundRenderer 会强制执行这一边界,不会把责任留给调用方。Execute 会在文档自身的渲染锁中渲染每个页面,这个临界区也是每次 RenderLoadedPageToBitmapCached 调用和内置的 PrefetchLoadedPages 预取器共同使用的临界区,因此给定页面的实际 GDI 绘制始终只在一个线程上发生,不会与该文档的另一次渲染重叠。生成的位图是由工作线程拥有的对象,THPDFBackgroundRenderer 不会直接将它发布给调用方

GetCachedBitmap 会改为分配一个全新的 TBitmap,并在渲染器自己的独立锁中对它调用 Assign,因此复制始终发生在 Execute 被阻塞、无法在其下替换缓存槽的期间——调用线程拿到的是像素数据,而不是原始句柄。这种分离也是不应编写自定义渲染线程、绕过 THPDFBackgroundRenderer 或 PrefetchLoadedPages 直接调用 HotPDF 渲染函数的原因:让两个渲染过程竞相访问同一已加载文档的共享缓存和对象图,正是 HotPDF 内部锁机制要防止的场景,而后台渲染器类无需重新实现锁机制就能直接提供这种保护

这与 HotPDF 内置的页面预取有什么不同

PrefetchLoadedPages 和 THPDFBackgroundRenderer 解决的是相关但不同的问题:PrefetchLoadedPages 接收一个页面范围后,会在线程中自动将整个邻近范围渲染到共享文档缓存,不需要调用方创建或管理队列对象。THPDFBackgroundRenderer 则用这种自动化换取控制权——调用方可以准确决定哪些页面索引重要以及顺序,并且可以取消仍在队列中的页面,而不会影响内置预取器正在其他位置预热的范围。两者都通过同一个渲染锁,因此查看器可以在普通的后续几个页面场景中运行 PrefetchLoadedPages,只在出现该模式之外的情况时使用 THPDFBackgroundRenderer,例如缩略图条直接跳转到用户刚刚点击的页面

begin
  // PrefetchLoadedPages takes a 1-based "start-end" range string, while
  // RequestPage below stays 0-based like every other loaded-page index.
  Pdf.PrefetchLoadedPages(Format('%d-%d', [CenterPage + 1, CenterPage + 5]), 150);

  // Reach for THPDFBackgroundRenderer only for a page outside that
  // window, such as a thumbnail the user just clicked.
  FRenderer := THPDFBackgroundRenderer.Create(Pdf, 150);
  FRenderer.RequestPage(ClickedThumbnailPage);
  FRenderer.Start;
end;

有两个生命周期细节值得带入生产代码。RenderLoadedPageToBitmapCached 背后的文档级缓存受 RenderCacheCapacity 限制,默认容纳八个页面,填满后会淘汰最近最少使用的条目,但 THPDFBackgroundRenderer 实例自己的结果列表没有这样的上限——它会为通过该实例请求过的每个不同页面索引保留一个位图,直到实例自身被释放,因此在高 DPI 下让一个渲染器贯穿整个滚动会话运行,很容易为滚过的每个页面累积一张完整分辨率的位图。HotPDF 也不会像在文档加载或销毁前取消自己的预取器那样,自动取消调用方创建的渲染器,因为 THPDFBackgroundRenderer 实例从不会注册到它所指向的 THotPDF 对象上——因此,重新加载或释放文档之前,调用代码必须取消并释放针对该文档创建的每个渲染器,这与 HotPDF 在内部应用于 PrefetchLoadedPages 的顺序要求相同

THPDFBackgroundRenderer 是 HotPDF 已加载文档外观层的一部分,位于MVC 查看器架构之后;当正在滚动的文档本身过大、不能在一开始就随意加载时,它也很适合与大型 PDF 的 Direct File API 工作流搭配使用。本文介绍的后台渲染、请求队列和渲染缓存,都是面向 Delphi 和 C++Builder 的标准 HotPDF Component 的组成部分