技術文章

PDFium Delphi 檢視器:渲染快取與平滑縮放策略

在一個天真的 PDF 檢視器中按住縮放按鈕,然後觀察 CPU 圖表。單按一次自動重複的縮放控制項會在一秒內觸發十幾次或更多的縮放步驟,如果每個步驟都啟動可見頁面的全品質重新渲染,渲染堆積的速度會比完成的速度更快。獨立點陣化頁面很好,也許 A4 掃描需要 180 毫秒,但您現在對使用者已經越過的工作執行了十幾次 180 毫秒的渲染。檢視器鎖死,一個核心固定在 100%,而當畫面跟上時,使用者已經停在四次渲染前的縮放級別。解決之道不是更快的點陣化器。而是一個能立即傳回完成頁面的快取,以及一個願意在工作過時那一刻放棄工作的渲染迴圈

PDFium Component 為您提供這兩者的組件,並置身於策略之外。您會獲得呼叫者擁有的點陣圖、一個接受取消 token 的漸進式渲染器、在調整大小時重新計算縮放比例的適應模式,以及一個用於點陣化過大以致無法完整處理之頁面的圖塊呼叫。它刻意不提供的就是快取本身,因為正確的驅逐策略取決於您的視埠、您平台的記憶體上限,以及您的使用者如何滾動。這個決定要由您來做對,而做錯的後果正是凍結和洩漏

毫秒和 MB 都花到哪裡去了

在設計任何東西之前先將成本量化。在 96 DPI 下的 A4 頁面大約是 794 乘 1123 像素,作為 32 位元點陣圖大約是 3.5 MB。縮放至 200%,這將會翻兩番(增加四倍)。在 400% 的高 DPI 顯示器上,您正在分配和填寫一個 50 到 60 MB 的單頁點陣圖,而連續滾動的檢視器同時保持好幾頁存活。點陣化成本追蹤輸出像素,因此縮放比例每增加一倍,大約就會讓渲染時間和記憶體都增加四倍

從該算術直接得出兩個結果。其鍵值忽略縮放級別的快取是毫無價值的,因為它需要加速的手勢(即縮放)每次都會產生新的點陣圖。而無上限的快取將會讓 32 位元處理程序正好在人們最用力縮放的檔案上耗盡位址空間:密集的權狀掃描、工程圖、大幅地圖。快取必須正確設定鍵值並嚴格設加上限,這兩者都不是可有可無的

什麼應該放在快取鍵中

僅當塑造其像素的每個輸入仍然相符時,快取的點陣圖才可安全重複使用。這意味著頁碼、有效縮放比例(或等效的輸出像素尺寸)、旋轉、螢幕 DPI,以及產生時生效的渲染選項。帶有 reAnnotations 渲染的頁面與沒有它們的相同頁面是不同的影像,而透過 reGrayscale 的灰階處理又不同了。從鍵中放棄這其中的任何一個,錯誤都是可預見的:在審查者刪除評論後依然殘留的註解疊加,或是使用者將視窗從筆記型電腦面板拖曳到外部 4K 螢幕、而在過時的點陣圖下改變 DPI 的那一刻就變得模糊的頁面

function TPageCache.Acquire(Pdf: TPdf; PageNo: Integer; ZoomPct: Single;
  Rotation: TRotation; Opts: TRenderOptions): TBitmap;
var
  Key: string;
begin
  Key := Format('%d|%.0f|%d|%d|%d',
    [PageNo, ZoomPct, Ord(Rotation), Screen.PixelsPerInch, OptionsMask(Opts)]);
  if FBitmaps.TryGetValue(Key, Result) then
    Exit;

  Pdf.PageNumber := PageNo;
  Result := Pdf.RenderPage(0, 0, OutputWidth(PageNo, ZoomPct),
    OutputHeight(PageNo, ZoomPct), Rotation, Opts);
  FBitmaps.Add(Key, Result);   // the cache now owns this bitmap
end;

命中時會在幾微秒內傳回,這正是重點。更困難的問題是從快取中掉出的點陣圖會發生什麼事,而結果證明這是一個關於誰擁有它們的問題

誰負責釋放點陣圖

函式形式的 RenderPage 傳回一個呼叫者擁有的 TBitmap。在單次匯出中,該擁有權是明顯且容易遵守的。在快取內,它成為 Delphi PDF 檢視器中最常見的單一洩漏原因,因為現在字典保存了對每個點陣圖的唯一參考,而單純的 TDictionary 僅在它們是受控類型(managed types)時才會為您釋放鍵和值。TBitmap 則不然。未呼叫 Free 就驅逐一個項目,像素會保持分配狀態,而沒有任何指標指向它們

這之所以會漏掉的原因在於時機。十分鐘的冒煙測試永遠不會縮放足夠多的不同頁面以致引起注意;這個洩漏只會在有人滾動和縮放長檔案幾個小時後才顯現出來,那時處理程序持有數百個孤兒頁面點陣圖,機器開始分頁。這就是為什麼驅逐屬於快取的第一個版本,而不是後來的版本。以預估的位元組數為快取設加上限,計算方式為寬度乘高度乘四,驅逐位在視埠和預先擷取視窗之外的最近最少使用(LRU)頁面,並在移除每個點陣圖時釋放它。對於真正短暫的繪製,渲染到呼叫者提供的 TBitmap 或直接渲染到 HDC 的多載(overloads),讓您可以完全跳過擁有權舞蹈。預覽列印是明顯的例子,因為您對每張紙只渲染一次,快取它沒有任何好處

漸進式渲染與誠實的取消

純粹的 RenderPage 多載會阻塞直到頁面完成,這正是當使用者仍在移動縮放控制項時您不想要的行為。為此,您應選擇 RenderPageProgressive。它接受一個 IPdfCancellationToken 並傳回 prsDoneprsCancelledprsFailed 之一。讓使用者措手不及的行為細節是取消不是瞬間發生的。Token 是在渲染內的區塊邊界處進行輪詢,所以您在區塊中間發出訊號的 token 只有在該區塊完成時才會生效。在複雜的頁面上,從要求到停止之間的延遲可達數十毫秒。請圍繞該差距進行設計,而不是希望它消失:當新的縮放值到達的瞬間取消先前的 token,但不要假設舊的渲染會在您要求它的瞬間停止

procedure TViewerForm.RequestRender(TargetZoom: Single);
var
  Status: TPdfProgressiveStatus;
begin
  if FTokenSource <> nil then
    FTokenSource.Cancel;           // abandon the previous in-flight render
  FTokenSource := TPdfCancellationTokenSource.New;  // FPdfAsync unit

  Status := Pdf.RenderPageProgressive(FBackBuffer, 0, 0,
    FBackBuffer.Width, FBackBuffer.Height, FTokenSource.Token,
    ro0, [reAnnotations]);

  case Status of
    prsDone:      PresentBackBuffer;
    prsCancelled: ;                // superseded by a newer request: drop silently
    prsFailed:    ShowRenderFailure;
  end;
end;

在互動期間,prsCancelled 是正常的結果,不是例外的。縮放手勢啟動的大多數渲染都會在它們完成前被取代,因此請將取消視為例行公事並默默丟棄結果。如果渲染佇列將每次取消記錄為警告,它會將真正重要的那一次失敗掩埋在數千行雜訊之下。為了讓畫面在真正的渲染執行時不至於看起來死寂,請將漸進式路徑與廉價的替身配對:將先前快取的點陣圖縮放至新的縮放級別並立即呈現。它在一兩百毫秒內看起來會有些模糊,但它給人的感覺是即時的,並為全品質渲染爭取到了完成或被下一個手勢取消所需的時間

縮放悄悄關閉的適應模式

檢視器的 FitMode 屬性,若設為 pfmFitPagepfmFitWidth,會在每次調整大小時重新計算縮放比例,讓頁面隨著視窗改變保持適應。問題在於直接指派 Zoom 會將 FitMode 重設回 pfmNone。作為預設值,這是正確的:一個刻意輸入 150% 的使用者並不希望下次視窗調整大小時把它丟棄。但這會讓任何將放大按鈕連接為 Zoom := Zoom * 1.25,然後想不通為什麼適應寬度在第一次點擊後就停止回應的人感到驚訝。如果您的工具列同時提供明確的縮放和適應模式,您必須自己記住使用者最後的適應選擇,並在他們再次按下適應按鈕時重新指派它。元件不會還原剛剛被縮放指派清除的模式,它本來就不應該這麼做

一個您可以捍衛的記憶體預算

一個您可以寫下來的預算是您在程式碼審查中可以據理力爭的預算,因此請從具體情境開始。假設連續滾動保留了可見頁面加上其上下各一個預先擷取的頁面,以及一列縮圖。在 96 DPI 顯示器上放大 100% 時,那三個全尺寸點陣圖每個約為 3.5 MB,這不算什麼。在 4K 顯示器上放大 300% 時,同樣的三個點陣圖每個約為 30 MB,而這還是快取尚未保留單個歷史頁面之前。成長在於手勢,而不在於檔案

32 位元 Delphi 處理程序在 LRU 驅逐下,一個合理的預設值是 256 MB 的點陣圖預算。在 64 位元上,您可以根據實體 RAM 進行縮放,但無論如何都要保持硬性上限,因為您要防範的失敗不是處理程序當機。而是整台機器在其分頁檔中輾轉反側,而您的檢視器在技術上仍保持執行,使用者納悶為何其他一切都變慢了。硬性上限會可預測地失敗;無上限的快取則會拖垮桌面。縮圖值得擁有自己的處理方式:將每個縮圖以其小目標尺寸渲染一次,並將其保存在 LRU 邏輯永遠不會觸碰的獨立池中。透過縮小 60 MB 的全頁點陣圖來重新產生 120 像素的縮圖,是產生郵票最浪費可能的方式

有些單一頁面會擊敗任何預算。一張 E 尺寸的工程圖或大幅地圖在 400% 完整渲染時是數百 MB 的分配,而沒有任何驅逐策略能讓這變得可以接受。這裡的答案是停止渲染整個頁面。RenderTile 僅點陣化頁面內概念上縮放至 PageWidthPageHeight、位於像素偏移量 (Left, Top) 處的區域,因此您只渲染可見矩形加上其周圍的一塊圖塊邊界以實現平滑平移,並將圖塊偏移量與縮放比例一起折疊到快取鍵中。在整個檔案中保持圖塊尺寸固定。固定的圖塊意味著 DPI 變更會乾淨地使整個網格無效,而可變的圖塊會讓您追逐在稍微不同比例下渲染的區域之間的可見接縫

兩個相鄰的功能悄悄地增加了這一切。像是灰階或反轉的色彩濾鏡過程在渲染後執行,每次產生第二個全尺寸點陣圖,使任何使用它們的檢視器的每頁記憶體佔用量加倍;該成本是 Delphi PDF 檢視器的低視力色彩濾波的主題。而一個在文字轉語音期間反白單字的檢視器會在每個說出的單字上使渲染視圖無效,因此反白重繪和語速之間的互動比乍看之下更重要,正如逐字 TTS 反白所涵蓋的

渲染多載、漸進式狀態代碼以及檢視器元件本身,都記錄在 PDFium Component 的產品頁面上