技術文章

Delphi 與 HotXLS 處理大型 Excel 活頁簿的效能

當一份三十萬列的匯出耗盡它的記憶體預算時,列數通常成了代罪羔羊。列數通常是無辜的。大型活頁簿昂貴的部分,是那些做為副作用而被建立的東西:一個因為格式化是在迴圈內加入、而每個儲存格多長出一筆的樣式集區;在儲存時組裝成單一巨大字串的工作表 XML;一百萬個逐一儲存的相同公式主體。HotXLS,losLab 供 XLS 與 XLSX 檔案使用的原生 Delphi 程式庫,為這些成本各提供了一支專屬的操作桿。沒有任何一支預設啟用,因為每一支都改變了某個取捨,所以知道哪支操作桿對應哪個症狀,才是真正的效能技能

大型活頁簿的記憶體花在哪

有兩個截然不同的記憶體情境要推理。在產生期間,記憶體內的儲存格模型隨你觸碰的每個儲存格而長大:值、格式與公式全都變成物件或集區項目。在儲存期間,預設的 XLSX 路徑還會在把每個工作表的 XML 壓進 zip 容器之前,先把它繪製成一個寬字串,所以峰值用量是模型加上最大工作表的序列化形式。一份在建置迴圈中活下來、卻在 SaveAs 內死掉的工作,撞上的是第二個情境而不是第一個,而為其中一個做的修復對另一個毫無作用

Delphi HotXLS 大型活頁簿作業的兩種記憶體型態:產生迴圈建出的記憶體內儲存格模型,加上預設儲存時最大工作表的序列化 XML 字串——StreamingWrite 可消除此項
建置迴圈與儲存呼叫分屬兩種記憶體體質,StreamingWrite 只壓平儲存時的尖峰,建置路徑的記憶體得靠樣式池與回呼槓桿

檔案大小遵循一條相關的規則:儲存格只是其中一個貢獻者,與樣式、共用字串、公式、圖片和註解並列。一趟用 ForEachCell 加上每個工作表集合計數的稽核,能在你最佳化錯對象之前,先告訴你哪個資源實際主宰了一份有問題的檔案。一個測量上的細微之處:XLSX 端的 Sheet.Cells.Count 回報的是稀疏存放區中已實例化的儲存格數,而不是已用範圍的面積。一份資料佔據 1000 乘 50 矩形、且半數儲存格為空的工作表,計數大約是 25,000,而不是 50,000。當你拿客戶的「超大」檔案與你的測試夾具比較時,這個區別很重要,因為在稀疏的財務版面中,已用範圍面積與實際儲存格數可以差上一個數量級

StreamingWrite 修復儲存路徑,而非建置路徑

TXLSXWorkbook.StreamingWrite := True 設好,SaveAs 就會切換成一個串流序列化器,把工作表 XML 直接寫進 zip 串流,消除每個工作表的字串中介產物。它為了行為相容性而預設為 False,而開啟它是一行的改動:

Book := TXLSXWorkbook.Create;
try
  Sheet := Book.Sheets.Add('Bulk');
  for R := 1 to 100000 do
  begin
    Sheet.Cells[R, 1].Value := R;
    Sheet.Cells[R, 2].Value := 'Row ' + IntToStr(R);
    Sheet.Cells[R, 3].Value := R * 1.5;
  end;
  Book.StreamingWrite := True;   // 工作表 XML 串流進 zip 容器
  Book.SaveAs('bulk.xlsx');
finally
  Book.Free;
end;

要精確掌握這買到了什麼:迴圈建置的儲存格模型占用的記憶體與之前一模一樣。StreamingWrite 壓平了儲存時的尖峰,也就是一份能完成的批次工作與一份在 95% 之處失敗的工作之間的差別。如果建置迴圈本身就耗盡記憶體,你需要的操作桿是接下來這兩支

樣式集區:加入一次,重用索引

HotXLS 中的 XLSX 格式化是以集區為基礎的:Book.Fonts.Add(...)Fills.AddSolid(...)Borders.Add(...) 回傳一個從 0 起算的集區索引供儲存格參照。在迴圈內以相同參數呼叫 Fonts.Add 會被去重,所以它浪費的是時間而非空間。Alignments.Add 的行為不同:它每次呼叫都回傳一個全新物件,所以逐儲存格的對齊建立會讓集區隨列數線性成長。一個習慣涵蓋兩種情況:在迴圈「外」把每個集區索引解析一次,在迴圈「內」只指派索引

HotXLS Delphi 樣式池使用比較:每列都建立新的 Alignments.Add 物件讓池呈線性成長;提到迴圈外只解析一次的 Fonts.Add 索引由每個儲存格重用,0 起算索引再加一
在迴圈外把每個字型、填色、框線與對齊索引解析一次,迴圈內指派該 0 起算池索引加一的值
// 把集區查詢提升到熱門迴圈之外
HeaderFont := Book.Fonts.Add('Calibri', 11, True, False);   // 以 0 為基準的集區索引
for C := 1 to 24 do
  Sheet.Cells[1, C].FontIndex := HeaderFont + 1;            // 儲存格儲存 1 為基準;0 = 預設

那個 + 1 不是筆誤,而忘了它正是這裡製造症狀的典型錯誤:集區發出的是從 0 起算的索引,而儲存格端的屬性把 0 當作「預設」,所以每個集區索引在指派時都必須加一。漏掉而弄錯,你的標題就會悄悄以活頁簿的預設字型繪製,這個缺陷直到品牌審查才會有人發現

用列回呼取代逐儲存格的 Variant 往返

每一次 Sheet.Cells[R, C].Value := X 都涉及一次儲存格的查詢或建立,加上一次 Variant 指派。在幾十萬個儲存格的規模上,這種每次存取的額外負擔在效能剖析中就變得可量測。HotXLS 在兩個外觀層上都提供了批次回呼 API(讀取用 ForEachCellForEachRow,寫入用 WriteCellsWriteRows),把迭代搬進引擎內部,一次交給你的程式碼一整列:

procedure TLedgerExport.FillRow(Sender: TObject;
  SheetIndex, Row, FirstCol, LastCol: Integer;
  var Values: Variant; var Skip: Boolean; var Cancel: Boolean);
begin
  if Row > FCount then
  begin
    Cancel := True;     // 停止整個寫入
    Exit;
  end;
  Values := VarArrayOf([FRows[Row - 1].Account,
                        FRows[Row - 1].PostedOn,
                        FRows[Row - 1].Amount]);
end;

// 一次引擎呼叫,而不是幾十萬次屬性存取
Sheet.WriteRows(1, 1, FCount, 3, FillRow);

回呼的 Skip 旗標會讓某列保持未動而不中止,而 Cancel 則提早結束操作,這在來源是一個你邊讀邊發現長度的讀取器時很有用。把建置用的 WriteRows 與儲存用的 StreamingWrite 配對起來,產生路徑就不再有殘留的逐儲存格熱點

XLS 外觀層上的讀取端操作桿

大型舊式 .xls 檔案有它們自己的工具組。_DisableGraphics := TrueOpen 之前設定,會完全略過繪圖圖層的解析,這對於載入帶有多年累積形狀與內嵌圖片的工作簿能加速。限制是硬性的:繪圖圖層於是不存在於模型中,所以儲存這樣的工作簿會寫出一份沒有其繪圖的檔案。請把這個旗標保留給唯讀的分析工作。SetTempDir 重新導向 BIFF 寫入器的暫存檔,這在預設暫存位置有配額或位於慢速儲存的伺服器上很重要。UseSharedFormulas 把重複的公式主體分組成共用公式記錄,縮小那些某個公式欄位重複六萬列的檔案

對 XLS 資料的讀取迴圈有一個索引陷阱值得標記,因為它在防禦性處理時會讓工作量加倍、在漏掉時則毀損結果:UsedRange 以從 0 起算的方式回報它的 FirstRowLastRowFirstColLastCol 邊界,而 Cells.Item[Row, Col] 是從 1 起算。一個走過已用範圍的掃描必須在存取儲存格時把每個座標加一,如 Cells.Item[Row + 1, Col + 1],否則它讀到的是一個對角偏移一格的格線,悄悄丟掉最後一列與最後一欄、並納入一個幻影般的第一列第一欄。ForEachCell 回呼完全避開了這個不一致,這也是它更適合全工作表掃描的另一個理由

在載入檔案之前先探測

最便宜的大型活頁簿操作,是你避開的那個。兩個外觀層的 GetSheetNames 會在載入儲存格資料的情況下列出檔案的工作表。XLSX 實作只讀 zip 內的活頁簿資訊清單,並明確讓活頁簿執行個體保持未填入,而 XLS 外觀層則在第一個子串流邊界停止掃描。這讓它成為「這次匯入工作該以哪個工作表為目標」這類預檢的正確選擇,而 CanReadEncrypted 則在一場注定失敗的 Open 嘗試之前回答「這是不是一個加密容器」

Delphi 中未知 Excel 檔案的 HotXLS 預檢流程:GetSheetNames 列出工作表而不載入儲存格資料,回傳碼小於等於 0 時清空清單並表示失敗,CanReadEncrypted 在註定失敗的 Open 之前標記加密容器,之後才執行完整載入
GetSheetNames 與 CanReadEncrypted 在任何儲存格資料被剖析之前,就回答該瞄準哪張工作表、容器讀不讀得開
Names := TStringList.Create;
Book := TXLSXWorkbook.Create;
try
  if Book.GetSheetNames('big-unknown.xlsx', Names) <= 0 then
    raise Exception.Create('cannot enumerate sheets');   // 失敗會清除清單
  // 挑選目標工作表,再決定是否值得完整 Open
finally
  Book.Free;
  Names.Free;
end;

注意回傳碼慣例:這些探測函式以小於或等於零的值表示失敗並清空輸出清單,所以請測試 <= 0,而不是拿來與某個特定的成功值比較

依工作的規模選擇做法

對於會循序產生許多大型檔案的無人值守管線,還有兩個習慣能補全全貌。活頁簿物件不是可供共用的執行緒安全物件,但沒有什麼能阻止每個工作者執行緒一個獨立活頁簿,這能把批次轉換乾淨地並行化。而當輸出送到 HTTP 而非磁碟時,TStream 儲存多載與 StreamingWrite 結合起來,讓一個大型回應永不具現化為暫存檔。一條營運註腳適用:串流儲存從目前位置寫入而不倒帶,所以在把串流交給回應框架之前請設好 Position := 0串流寫入與批次工作一文展開了那個伺服器端模式,而資料庫匯出一文則顯示了這些操作桿在資料集驅動報表中的位置

最後,為每個報表家族保留一份最壞情況的夾具,並在 CI 中計時。文件產生的效能回歸很少會自己宣布。一個在迴圈內加入的樣式,或一個被完整 Open 取代的探測,在功能上什麼也沒改變,而夜間批次就是多花了四十分鐘。在一份具代表性的五十萬儲存格夾具上的計時測試,會把那種漂移變成一次紅燈建置,而不是一次營運事故

評估建置、帶有大量產生範例的展示專案,以及完整的 API 參考,都可在HotXLS Delphi Component 頁取得