HotPDF 能從你自行實作的任何隨機存取來源載入 PDF,而 THPDFCoalescingRandomAccessSource 會包裝這個來源,把剖析器分散的小型讀取,轉換成一組有界限、附帶快取的區塊範圍,並搭配非同步預讀。對於透過 HTTP 範圍請求提供的文件,這就是幾百次來回請求,與幾十次來回請求之間的差別
剖析器本身完全不需要改動。你仍然呼叫 LoadFromRandomAccessSource,回傳的仍是同一種文件物件,頁面 API 也照常運作。改變的只是底層的流量
為什麼同一份 PDF 在本機瞬間載入,透過網路卻慢如牛步?
因為 PDF 剖析器讀取檔案的方式不是順序讀,而是導航式讀取。它會先定位到檔案結尾尋找 startxref,跳回交互參照表,解析 trailer 字典,跟隨參照找到 Catalog,再找到頁面樹的根節點,然後是某個頁面節點,接著是它的資源字典。每一個步驟都是從不同偏移量讀取幾十個位元組
在本機檔案上,這種存取模式幾乎是免費的:作業系統早已把周圍的 4 KiB 頁快取起來,第二次讀取的成本只是一次記憶體複製。透過網路傳輸就沒有這種局部性了。每一次讀取都是一個帶有自身延遲的請求,300 次循序請求、每次 40 毫秒,加起來就是十二秒幾乎全部耗在等待上。修正方法不是少讀一點;剖析器需要的就是它要求的那些內容。真正的修正方法,是讓每一次實際的實體讀取,涵蓋更多下一次邏輯讀取會用到的內容
合併機制改變了什麼
合併來源會把每一次讀取向上湊整到一個區塊,並快取該區塊。BlockSize 預設為 262,144 位元組,MaxCacheBytes 預設為 2,097,152,因此預設會有八個區塊常駐記憶體,並依最近最少使用(LRU)順序、對照一個硬性位元組預算來淘汰。剖析器對 trailer 鍵值的 40 位元組讀取,會一併帶入周圍 256 KiB 的內容,接下來那個鄰近區域內的十幾次讀取,也就是交互參照與 catalog 資料所在的位置,就會直接從記憶體提供
你自己的來源可以維持簡單。實作 GetSize 與 ReadAt,如果你的傳輸機制支援即時中止就覆寫 ReadAtCancellable,其餘快取、合併與預讀就交給包裝層處理
type
THttpRangeSource = class(THPDFRandomAccessSource)
private
FClient: TMyHttpClient;
FUrl: string;
FSize: Int64;
public
function GetSize: Int64; override;
function ReadAt(Offset: Int64; var Buffer; Count: Longint): Longint; override;
function ReadAtCancellable(Offset: Int64; var Buffer; Count: Longint;
CancellationToken: THPDFCancellationToken): Longint; override;
end;
var
Raw: THttpRangeSource;
Cached: THPDFCoalescingRandomAccessSource;
Pdf: THotPDF;
begin
Raw := THttpRangeSource.Create('https://files.example.com/contract.pdf');
// OwnsSource=True:包裝層會連同自身一起釋放 Raw
Cached := THPDFCoalescingRandomAccessSource.Create(Raw, True, 262144, 8388608);
Pdf := THotPDF.Create(nil);
try
Cached.AsyncPrefetchEnabled := True;
Cached.AdaptiveReadAheadEnabled := True;
Cached.MaxReadAheadBlocks := 8;
if Pdf.LoadFromRandomAccessSource(Cached, True) = 1 then
RenderFirstPage(Pdf);
finally
Pdf.Free;
end;
end;
該預讀多遠?
自適應預讀為每一份文件分別回答這個問題,不必逼你自己猜。啟用 AdaptiveReadAheadEnabled 後,隨著持續向前讀取累積,視窗會依序成長為 1、2、4、8 個區塊,且絕不會超過 MaxReadAheadBlocks 或已設定的快取容量。一旦某次讀取的位置與前一次結束的位置對不太上,視窗就會立刻收縮,預讀也隨之暫停
SequentialReadToleranceBytes,預設 4,096,定義了什麼叫「差不多對得上」。落在前一次讀取結尾這個距離內的讀取,仍會被視為循序讀取,這一點很重要,因為 PDF 剖析器在走訪內容串流時,並不會產生完全連續的偏移量;它會在這裡跳過一個長度欄位,在那裡跳過一個內嵌字典。容忍度設得太低,正常的向前掃描就會被歸類成隨機存取,預讀永遠不會啟動。設得太高,真正的隨機存取又會被誤判成循序存取,於是你會抓下沒人需要的大量資料。預設值是針對內容串流的走訪模式校準的,統計資訊會告訴你你的傳輸機制是否不符合這個假設
這種不對稱是刻意設計的:成長是漸進的,收縮則是立即的。在隨機存取的工作負載上過度預讀,會在計量式傳輸上耗費真實的頻寬與真實的金錢,因此系統偏好犯代價較低的錯誤,而不是代價較高的錯誤
真正能中止傳輸的取消機制
基底類別宣告了 ReadAtCancellable,而合併來源會全程遵循它。當前景讀取要求的範圍,不是某個進行中的預讀正在提供的內容時,該預讀就會被取消,而不是任由它跑完,這樣使用者的頁面請求就不必排在推測性流量後面等待。THPDFRandomAccessSource 的預設實作會退回使用單純的 ReadAt,這代表這項功能對每種傳輸機制而言都是可選的:支援中止請求的 HTTP 用戶端能取得真正的取消能力,較簡單的來源則照常運作、不受影響
把它與貫穿你的 UI 的取消權杖結合起來,使用者關閉文件時就會真的停止網路流量,而不是等它自然跑完。以請求佇列進行背景渲染 中描述的排隊機制,底層用的正是同一套權杖模型,因此單一權杖就能涵蓋從畫面到通訊端的整條路徑
判讀範圍快取統計資訊
GetStatistics 會填入一筆 THPDFRangeCacheStatistics 記錄,把你的傳輸機制實際做了什麼、與快取本身做了什麼區分開來。SourceReadCount 與 SourceBytesRead 是實體流量。CacheHitCount 與 CacheMissCount 是邏輯流量。SequentialReadCount 與 RandomReadCount 顯示存取模式如何被分類,CurrentReadAheadBlocks 與 PeakReadAheadBlocks 顯示視窗開了多大,PrefetchRequestCount、PrefetchCompletedCount、PrefetchCancelledCount 與 SuppressedPrefetchCount 則顯示這些推測是否有回報
var
S: THPDFRangeCacheStatistics;
begin
Cached.GetStatistics(S);
Log(Format('physical %d reads / %d bytes, hits %d, misses %d',
[S.SourceReadCount, S.SourceBytesRead, S.CacheHitCount, S.CacheMissCount]));
Log(Format('pattern: %d sequential, %d random, peak window %d blocks',
[S.SequentialReadCount, S.RandomReadCount, S.PeakReadAheadBlocks]));
Log(Format('prefetch: %d issued, %d completed, %d cancelled, %d suppressed',
[S.PrefetchRequestCount, S.PrefetchCompletedCount,
S.PrefetchCancelledCount, S.SuppressedPrefetchCount]));
end;
有三種讀數會告訴你該調整什麼。取消預讀次數多、隨機讀取比例又高,代表文件被以無序方式存取,這時應調低 MaxReadAheadBlocks,別再為了被丟棄的頻寬付出代價。未命中次數多、而預讀視窗高峰卻始終停在 1,代表容忍度正在拒絕一個實質上算是循序的存取模式,這時應調高 SequentialReadToleranceBytes。而讀取的位元組數遠超過檔案大小,則代表快取正在抖動,這時應優先調高 MaxCacheBytes,而不是先動其他設定
線性化檔案改變了整套計算方式
如果你能掌控產生端,將文件線性化改變的是問題本身,而不只是最佳化。線性化的 PDF 會把第一頁的物件與提示表放在檔案最前端,讓檢視器不必看完整份檔案,就能從一開頭的一百萬位元組內渲染出第一頁。HotPDF 透過 GetProgressiveLinearizedLoadInfo 與 ReadProgressiveLinearizedFirstPageSection 直接開放這條路徑,寫入端的作法則在 產生附帶提示表的線性化 PDF 中說明
這兩種技術可以搭配使用。合併機制能讓任何文件在慢速連線上都變得可容忍;線性化則能讓你自己產生的文件第一頁快速抵達。至於存放在本機磁碟上、卻大到裝不進記憶體的檔案,直接檔案 API 工作流程 中描述的記憶體對映與延遲串流路徑通常才是更好的工具,因為這裡一開始就沒有需要攤銷的來回延遲
HotPDF 是一套原生 VCL PDF 元件,適用於 Delphi 與 C++Builder,剖析器不需要外部 DLL,並提供完整原始碼。隨機存取來源 API、合併包裝層與漸進式載入進入點,皆記載於 HotPDF Delphi PDF 元件頁面