技術文章

Delphi PDF 檢視器磁碟快取與每監視器 DPI

每個嵌入式 PDF 檢視器通常都會伴隨著兩個抱怨:大型文件在每次啟動時都需要從頭重新轉譯,以及一旦將視窗拖曳到高 DPI 監視器上,文字就會變得模糊。losLab PDF 函式庫在它的 TPDFlibViewer 控制項中解決了這兩個問題:DiskCacheFolder 屬性啟用了以文件指紋為索引的跨工作階段磁碟頁面快取,而 ScreenDPI 屬性在 GetDpiForWindowWM_DPICHANGED 處理常式的支援下,為控制項提供了真正的每監視器 DPI 感知能力。

這兩個問題都值得認真對待,因為使用者會立即注意到它們。一份昨天需要花費 8 秒才能捲動的 400 頁掃描合約,今天依然需要 8 秒,即使該檔案沒有發生任何變更。此外,在混合 DPI 的桌面上,以主監視器 96 DPI 轉譯的檢視器在該監視器上看起來很正常,但在旁邊的 4K 螢幕上看起來卻明顯模糊。本篇文章將逐步探討 TPDFlibViewer 如何解決這兩個問題,更實用的是,還會說明為什麼會採用這樣的設計。如果您是第一次接觸這個控制項,請先閱讀互動式 PDF 檢視器控制項概觀,然後再來了解效能層。

如何在 Delphi 中跨工作階段快取轉譯的 PDF 頁面?

TPDFlibViewer.DiskCacheFolder 指派一個資料夾,控制項就會將轉譯的頁面點陣圖儲存到磁碟中,這樣最近檢視過的文件就能立即重新開啟,而不需要重新轉譯。在磁碟上的版面配置為 <資料夾>\PDFlibPas-PageCache\<文件指紋>\p<頁碼>_z<縮放比例值>.bin:每個文件一個子資料夾,每個頁面和縮放比例組合一個檔案。其他所有一切都是自動的,不需要呼叫任何快取 API,也不需要清除任何內容。

uses
  PDFlibViewer;

procedure TMainForm.FormCreate(Sender: TObject);
begin
  FViewer := TPDFlibViewer.Create(Self);
  FViewer.Parent := Self;
  FViewer.Align := alClient;
  // One line turns on the cross-session page cache
  FViewer.DiskCacheFolder := 'C:\ProgramData\MyApp\PageCache';
  FViewer.LoadFromFile('C:\Contracts\master-agreement.pdf', '');
end;

DiskCacheHits 計數器會報告目前工作階段中磁碟快取節省了多少次頁面轉譯,這使得效能提升是可以量化的,而不僅僅是傳聞。在您關閉並重新開啟的文件中,每個捲動到檢視中而不需要轉譯的頁面都會增加該計數。

為什麼快取索引是文件指紋而不是版本號碼

文件指紋是對字串 path|size|last write time(路徑|大小|最後寫入時間)進行 64 位元 FNV-1a 雜湊運算,格式化為 16 位元十六進位數字。這三個屬性的組合就是完整的快取失效策略:當編輯檔案時,其大小或寫入時間會發生變化,雜湊值隨之改變,檢視器便會直接開啟不同的快取子資料夾。過期的資料夾永遠不會再被存取,最終會透過 LRU 淘汰機制被修剪。沒有需要維護的版本標籤協定,沒有會產生漂移的戳記比較邏輯,也沒有任何舊點陣圖會被提供給修改後文件的風險。

這與建置系統使用的「內容身分作為快取索引」想法相同,它之所以有價值,是因為它消除了各種失敗模式。如果快取僅使用文件的純名稱來儲存檔案,就必須明確偵測編輯行為,而每次明確的檢查都可能在極端情況下出錯:例如從備份還原的檔案具有舊的時間戳記、保留大小的儲存操作、在一部機器上區分大小寫而在另一部機器上不區分的路徑比較等。將這三個訊號雜湊到資料夾名稱中,從結構上使過期變得不可能,而不需要程序化的檢查。

為什麼磁碟快取索引故意排除旋轉角度

快取檔案名稱僅編碼頁碼和縮放比例,其他什麼都不包含 —— 檢視旋轉角度被故意排除在外。磁碟快取始終儲存未旋轉的轉譯位元組;當在檢視旋轉的情況下載入已快取的頁面時,像素會在解碼後於記憶體中重新調整方向。如果旋轉是磁碟索引的一部分,使用者在切換四個檢視角度時,就會將同一頁寫入磁碟四次,這在沒有額外資訊的情況下會使快取增長四倍,因為與完整的轉譯相比,旋轉只是一個便宜的像素重排操作。

記憶體中的點陣圖快取確實使用複合索引 Round(Zoom * 1000) * 4 + Rotation div 90,因為旋轉後的點陣圖正是繪製處理常式所需要的。背景預先存取執行緒遵循相同的原則:它始終轉譯未旋轉的頁面並報告純縮放比例索引,而主執行緒在處理結果時會套用旋轉 —— 這與平行頁面轉譯與執行緒安全中討論的「主執行緒擁有快取」規則相同。在記憶體層之下的所有層級中排除旋轉,意味著每個頁面和縮放比例只需一個標準的轉譯,從而節省了在昂貴的磁碟儲存上的空間。

LRU 淘汰機制如何維持快取資料夾的容量界限?

快取最多保留十個文件資料夾,並在有新文件加入時淘汰最少使用的資料夾。使用頻率是透過每個文件資料夾中的 last-used.marker 檔案來追蹤,無論在寫入或讀取快取時都會更新該檔案的時間戳記,因此您僅僅重新閱讀的文件會保持常用狀態,而不需要被重新寫入。屬於目前開啟文件的資料夾是免除被刪除的,因此淘汰機制絕不會在目前檢視下將頁面刪除。

快取中的每個磁碟操作 —— 生成指紋、建立資料夾、更新標記、讀取和寫入頁面、進行修剪淘汰 —— 都會自動吞掉它自己的例外。這是一種刻意的做法,而不是馬虎:快取只是一個盡力而為的加速器,磁碟已滿、唯讀資料夾或防毒軟體鎖定都必須只會使檢視器降級到「像以前一樣進行轉譯」,而絕不能變成「無法顯示頁面」。快取 I/O 錯誤能產生的最壞結果頂多就是快取未命中。

procedure TMainForm.FormClose(Sender: TObject; var Action: TCloseAction);
begin
  // How many render passes did the disk cache save this session?
  Log(Format('Disk cache hits: %d', [FViewer.DiskCacheHits]));
end;

如何在 Delphi 中讓 PDF 檢視器具備 DPI 感知能力?

TPDFlibViewer 將每個與 DPI 相關的計算都路由到單一欄位中,這種單一變更點設計使得每監視器支援變得易於處理。配置、預先存取和轉譯程式碼都讀取同一個 FScreenDPI 值;在 100% 縮放時,一個 PDF 點會對應到 ScreenDPI / 72 像素。公開的 ScreenDPI 屬性設定器會清除點陣圖快取、重建版面配置、更新捲動範圍並讓視窗失效,因此變更 DPI 只需進行一次指派,所有使用者程式碼都會自動跟隨。當您為印表機而不是監視器轉譯時,相同的像素數學計算也同樣重要,此主題已在列印預覽與裝置內容轉譯中介紹。

偵測是在 CreateWnd 覆寫中進行,而不是在建構函式中,因為 GetDpiForWindow 需要有效的視窗控制代碼,而建構函式在控制代碼存在之前很早就已執行。在 CreateWnd 觸發之前,控制項會保持舊有的預設值 96,以確保版面數學計算保持合理。GetDpiForWindow 本身是 Windows 10 1607+ API,因此控制項會使用 GetProcAddress 動態繫結它,並在舊系統上退回到 GetDeviceCaps(LOGPIXELSY) —— 該單元仍然會在所有地方載入,舊版 Windows 只會報告系統 DPI。

當視窗移動到 4K 監視器時會發生什麼事?

當具備每監視器感知能力的視窗跨越到具有不同縮放比例的監視器時,Windows 會傳送 WM_DPICHANGED 訊息,而 TPDFlibViewer 會直接處理它。處理常式會從 wParam 的低位元組中讀取新的 DPI,並透過 SetScreenDPI 進行指派 —— 這會丟棄現在已錯誤的點陣圖快取並重新排版頁面 —— 然後接受 Windows 在 lParam 中透過 SetWindowPos 傳遞的建議視窗矩形,這可以在視窗移動時保持邏輯用戶端大小的穩定。使用者會看到文件在新比例下清晰地重新轉譯,而不是看到由作業系統拉伸的點陣圖。

// Normally you never touch ScreenDPI: CreateWnd detects the host monitor
// and WM_DPICHANGED tracks moves. Override it only for special targets,
// e.g. rendering the layout as if for a 150% display:
FViewer.ScreenDPI := 144;  // clears the bitmap cache and rebuilds layout

在您出貨前值得了解的界限

在您的設計審查中,有三個實際的限制值得一提。第一,只有在處理程序選擇加入時,才會收到 WM_DPICHANGED 訊息:您的應用程式資訊清單必須宣告每監視器 DPI 感知(目前 Windows 上的 per-monitor v2),這在 Delphi IDE 中是「應用程式選項」下的 DPI 感知設定。具備系統 DPI 感知能力的處理程序永遠不會收到此訊息,且控制項隨後會以它在建立視窗時偵測到的 DPI 進行轉譯。第二,磁碟快取是用磁碟空間換取速度 —— 十個文件在多個縮放比例下的頁面點陣圖是實實在在的儲存空間,因此請將 DiskCacheFolder 指向您能接受其增長的位置,而不是漫遊設定檔。第三,快取資料夾需要執行使用者的寫入權限,如果沒有該權限,檢視器仍能運作,但會靜默進行且快取命中數為零 —— 如果您懷疑快取沒有發发挥作用,請在測試中檢查 DiskCacheHits

與大多數轉譯優化相比,這兩個功能更能顯著提升重度使用文件應用程式的感官品質:使用者重新造訪的文件可以立即開啟,且檢視畫面在任何監視器上都保持清晰。這兩者都是作為 TPDFlibViewer 的普通屬性隨附在 losLab PDF 函式庫中,因此採用它們只需花費一個下午進行程式碼連接,而不是需要耗時的轉譯引擎專案。