當一份三十萬列的匯出耗盡它的記憶體預算時,列數通常成了代罪羔羊。列數通常是無辜的。大型活頁簿昂貴的部分,是那些做為副作用而被建立的東西:一個因為格式化是在迴圈內加入、而每個儲存格多長出一筆的樣式集區;在儲存時組裝成單一巨大字串的工作表 XML;一百萬個逐一儲存的相同公式主體。HotXLS,losLab 供 XLS 與 XLSX 檔案使用的原生 Delphi 程式庫,為這些成本各提供了一支專屬的操作桿。沒有任何一支預設啟用,因為每一支都改變了某個取捨,所以知道哪支操作桿對應哪個症狀,才是真正的效能技能
大型活頁簿的記憶體花在哪
有兩個截然不同的記憶體情境要推理。在產生期間,記憶體內的儲存格模型隨你觸碰的每個儲存格而長大:值、格式與公式全都變成物件或集區項目。在儲存期間,預設的 XLSX 路徑還會在把每個工作表的 XML 壓進 zip 容器之前,先把它繪製成一個寬字串,所以峰值用量是模型加上最大工作表的序列化形式。一份在建置迴圈中活下來、卻在 SaveAs 內死掉的工作,撞上的是第二個情境而不是第一個,而為其中一個做的修復對另一個毫無作用
檔案大小遵循一條相關的規則:儲存格只是其中一個貢獻者,與樣式、共用字串、公式、圖片和註解並列。一趟用 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 的行為不同:它每次呼叫都回傳一個全新物件,所以逐儲存格的對齊建立會讓集區隨列數線性成長。一個習慣涵蓋兩種情況:在迴圈「外」把每個集區索引解析一次,在迴圈「內」只指派索引
// 把集區查詢提升到熱門迴圈之外
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(讀取用 ForEachCell 與 ForEachRow,寫入用 WriteCells 與 WriteRows),把迭代搬進引擎內部,一次交給你的程式碼一整列:
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 := True 在 Open 之前設定,會完全略過繪圖圖層的解析,這對於載入帶有多年累積形狀與內嵌圖片的工作簿能加速。限制是硬性的:繪圖圖層於是不存在於模型中,所以儲存這樣的工作簿會寫出一份沒有其繪圖的檔案。請把這個旗標保留給唯讀的分析工作。SetTempDir 重新導向 BIFF 寫入器的暫存檔,這在預設暫存位置有配額或位於慢速儲存的伺服器上很重要。UseSharedFormulas 把重複的公式主體分組成共用公式記錄,縮小那些某個公式欄位重複六萬列的檔案
對 XLS 資料的讀取迴圈有一個索引陷阱值得標記,因為它在防禦性處理時會讓工作量加倍、在漏掉時則毀損結果:UsedRange 以從 0 起算的方式回報它的 FirstRow、LastRow、FirstCol 與 LastCol 邊界,而 Cells.Item[Row, Col] 是從 1 起算。一個走過已用範圍的掃描必須在存取儲存格時把每個座標加一,如 Cells.Item[Row + 1, Col + 1],否則它讀到的是一個對角偏移一格的格線,悄悄丟掉最後一列與最後一欄、並納入一個幻影般的第一列第一欄。ForEachCell 回呼完全避開了這個不一致,這也是它更適合全工作表掃描的另一個理由
在載入檔案之前先探測
最便宜的大型活頁簿操作,是你避開的那個。兩個外觀層的 GetSheetNames 會在載入儲存格資料的情況下列出檔案的工作表。XLSX 實作只讀 zip 內的活頁簿資訊清單,並明確讓活頁簿執行個體保持未填入,而 XLS 外觀層則在第一個子串流邊界停止掃描。這讓它成為「這次匯入工作該以哪個工作表為目標」這類預檢的正確選擇,而 CanReadEncrypted 則在一場注定失敗的 Open 嘗試之前回答「這是不是一個加密容器」
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 頁取得