技術文章

使用 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 是 HotPDF 繼承的 TThread 進入點,在呼叫 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 呼叫實際上在做什麼,因為當頁面開始以不同順序算繪,或捲動速度快過佇列排空速度時,兩者都很重要

單一頁面的單次呼叫捷徑

THotPDF.RenderLoadedPageToBitmapAsync 適用於恰好啟動單一頁面算繪、又不想直接接觸 THPDFBackgroundRenderer 的常見情境:它會在內部建立算繪器、呼叫一次 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 被阻擋、無法在其下方替換該快取項目的期間完成 — 呼叫執行緒取得的是像素資料,而不是原始控制代碼。這種分離也是不應自行製作自訂算繪執行緒、直接呼叫 HotPDF 算繪函式而不經過 THPDFBackgroundRenderer 或 PrefetchLoadedPages 的原因:兩次算繪若同時競爭同一份已載入文件的共用快取與物件圖形,正是 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的一部分