大多数 PDF 页面在几毫秒内就能光栅化,通常不会引起关注,但当用户打开一张 A1 的工程图纸、一个包含数万个向量笔触的页面,或一个挤满透明度组与柔和蒙版的海报时,单次绘制调用可能会持续两三秒。如果这次调用发生在 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 交回控制权。该结构还携带必须设为 1 的 version 字段,以及一个自由形式的 user 指针,PDFium 不会触碰该指针,只会透传,因此它成为后续设计的关键
将暂停信号复用为取消
NeedToPauseNow 的原始用途是时间切片:当你的帧预算用尽时返回非零值,返回 0 则继续渲染。PDFium 因而会暂停,以便你先处理其他任务再恢复同一次渲染。PDFium 组件把这个信号复用为取消语义。它不再回答“是否暂停后可恢复”,而是回答“这次渲染是否已取消”。两者可直接映射,因为循环响应该标志的行为不同:真正的暂停会在后续仍然通过 Continue 继续,而取消不会。循环一旦察觉令牌已取消,就会关闭渲染上下文并不再调用 Continue,PDFium 对同一非零返回的理解从“停止这个片段”变成了“彻底停止”
取消通过 IPdfCancellationToken 表达,其 IsCancelled 会在其他流程要求中断时从 false 变为 true。Pascal 接口和 PDFium 的 C 回调之间靠一个指针桥接。令牌接口引用写入 IFSDK_PAUSE.user,静态 cdecl 回调读取并查询属性。这正是 C 库回调 Pascal 时的典型边界:PDFium 只持有一个裸函数指针,不知道 Pascal 对象或 Self,因此回调必须是普通函数而不是对象方法
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 与接口类型之间回转是生命周期问题的常见来源。Delphi 的 IInterface 使用引用计数,只有编译器看到接口变量赋值时计数才会变化。如果仅把令牌作为裸指针放在 IFSDK_PAUSE.user,它会绕开引用计数器。若该令牌唯一外部引用在循环运行时已离开作用域,对象可能提前释放,下一片段就会解引用悬空指针
因此该描述符使用两部分记录:Pause 供 PDFium 读取,Token 提供编译器可见引用并在记录生命周期内保持令牌存活。该记录通常是栈上的局部变量,随着循环持续生效并在例程退出时销毁。user 中的裸指针与 Token 中的受控引用都指向同一对象,一个用于供 PDFium 读取,一个用于防止对象被回收
var
Pause: TPdfProgressivePause;
EffectiveToken: IPdfCancellationToken;
begin
// 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。三者都必须执行关闭路径,而取消路径最容易被漏掉,因为“检测到取消就退出”的写法会很容易跳过清理。遗漏会导致每次中止页后累积状态泄漏
稳妥写法是把循环和结果分类放在 try 中,在对应 finally 无条件调用 FPDF_RenderPage_Close。目标位图也在同一块中销毁,既便中途 Exit 离开,清理仍可执行,不会被绕过
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,返回 TPdfProgressiveStatus 的三种值之一:prsDone、prsCancelled 或 prsFailed。这些值与 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:用 PdfNoCancellationToken 代替,它的 IsCancelled 永远返回 false。这样回调和循环每次都能直接查询,不再需要 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 库持有的回调仍可安全读取。把完整驱动循环包在 try 中,并在 finally 释放底层上下文。该模板同样适用于任何需要回调并在 C 代码持有指针时由 Pascal 管理生命周期的 PDFium 操作
可取消渲染只解决了响应性的一半。另一半是避免重复重绘已渲染页面,借助缓存位图让缩放和滚动保持流畅,这在 关于渲染缓存和缩放性能的文章里有具体展开。对于如何把可取消渲染整合到一个具备导航、文本选择和搜索功能的完整查看器,请参考 使用 PDFium 组件构建功能丰富的 PDF 查看器。这些能力都属于 Delphi 与 Lazarus 的 PDFium Component,其中还包括本文提到之外的读取、渲染和表单 API