大多數 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 的原始意圖是時間切片(time-slicing)。當您的影格預算耗盡時返回非零值,返回零則繼續渲染,PDFium 就會暫停,以便您在恢復相同渲染之前可以執行其他操作。PDFium 元件重複使用相同的訊號來表示另一個動作。該回呼不是回答「我是否應該暫停並讓您恢復」,而是回答「這項工作是否已取消」。這兩者能乾淨地互相映射,是因為迴圈在看到該旗標時所執行的操作。真正的暫停預期會有後續的 Continue;而取消則不然。一旦呼叫迴圈觀察到 token 已被取消,它就會關閉渲染上下文且不再呼叫 Continue,因此 PDFium 讀作「停止這個區塊」的相同非零返回值,實際上就變成了「永遠停止」
取消是透過介面 IPdfCancellationToken 表達的,當程式的其他部分要求停止渲染時,其 IsCancelled 屬性會從 false 翻轉為 true。這個 Pascal 介面與 PDFium 的 C 回呼之間的橋樑是一個單一指標。token 的介面參考被寫入 IFSDK_PAUSE.user,然後由靜態的 cdecl 回呼將其讀出並進行查詢。這是讓 C 函式庫回呼 Pascal 的經典問題:回呼必須是帶有 C 呼叫約定的純函式,而不是方法,因為 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 轉換回介面類型來復原 token 並讀取 IsCancelled。它裡面沒有任何配置、鎖定或封鎖的操作,這一點很重要,因為 PDFium 會在每個區塊之後於渲染執行緒上呼叫它,且在這裡執行的任何工作都會增加渲染本身的成本。針對 nil 結構或 nil user 欄位的防護措施,意味著即使在從未被賦予真正 token 的渲染上,安裝相同的函式也是安全的
在迴圈中保持 token 存活
透過原始的 Pointer 將介面指標轉換來回,正是生命週期錯誤誕生的地方。Delphi 中的 IInterface 是採用參考計數的,而只有當編譯器看到介面類型的變數被賦值時,計數才會變動。僅將 token 作為裸指標儲存在 IFSDK_PAUSE.user 內部,將使其完全避開參考計數器。如果該 token 的唯一其他參考在 Continue 迴圈仍在執行時超出了作用域,該物件將在回呼底層被釋放,下一個區塊將會解參考一個懸空指標(dangling pointer)
這就是為什麼該描述元是包含兩個東西而不是一個東西的記錄(record)。Pause 欄位是 PDFium 讀取的結構。Token 欄位是編譯器會計數的真實介面類型參考,它的存在完全是為了在記錄存活期間將 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。token 被觸發,迴圈提早退出並報告取消。發生某些失敗,狀態為 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 之前都會檢查 token,同時也依賴其內部的回呼。回呼縮短了目前的區塊;迴圈的檢查會阻止下一個區塊開始。它們共同將取消生效所需的時間限制在大約一個區塊的持續時間
三個結果,以及取消後點陣圖包含的內容
公開的進入點是 TPdf.RenderPageProgressive,它返回一個 TPdfProgressiveStatus,其值為 prsDone、prsCancelled 或 prsFailed 之一。這些值以 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 token 和無分支的回呼路徑
取消是選擇性加入的。如果呼叫者只是想要漸進式渲染以獲得訊息處理的好處,而無意中止,應該能夠在 token 處傳遞 nil。支援此做法的原始方式是將「是否提供了 token」的檢查散佈在回呼和迴圈中,這意味著在每個區塊都有一個分支,且回呼必須處理真實 token 的存在與缺失兩種情況
實作時透過在呼叫者未傳遞任何東西時替換成一個單例來避免這種情況。nil token 被替換為 PdfNoCancellationToken,這是一個 IsCancelled 始終為 false 的介面。從那一刻起,回呼和迴圈在每種情況下都有一個可查詢的 token,因此兩者都不需要 nil 檢查,也不需要特殊路徑。永不取消的 token 始終回答 false,回呼始終返回零,且渲染將如同不可取消的渲染一樣執行到底。可選行為被建模為永遠不會觸發的 token,而不是 token 的缺失,這保持了熱路徑(hot path)的一致性
// 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 中釋放原生上下文。同樣的範本可延續至任何漸進式或受回呼驅動的 PDFium 操作,這些操作中,Pascal 程式碼必須維持生命週期的控制,而 C 則持有一個指標
取消只是回應迅速的檢視器的一半。另一半是不去重新渲染您已經繪製的頁面,並透過提供快取的點陣圖來保持縮放和捲動的平滑,這在我們關於渲染快取和縮放效能的文章中有所說明。關於可取消的渲染如何與導覽、選取和搜尋一起融入完整的檢視器中,請參閱使用 PDFium VCL 元件建置功能豐富的 PDF 檢視器。此處描述的漸進式渲染作為 Delphi 和 Lazarus 的 PDFium 元件 的一部分提供,以及本部落格其他地方涵蓋的載入、渲染和表單 API