技術文章

HotXLS 的列塊儲存格儲存與串流 XLSX 儲存

在 SpreadsheetML 裡,帶貨幣格式的一欄只需要一個元素:<col min="2" max="2" style="3"/>。ECMA-376 Part 1 只在三個地方附加儲存格樣式:欄(§18.3.1.13)、列(§18.3.1.73)與儲存格(§18.3.1.4)。把格式收窄到 B2:D500000,SpreadsheetML 就沒有任何單一元素能表達這個形狀:欄樣式會超出第 500000 列,列樣式會蓋住每一欄,於是寫入端只能把它發成純樣式儲存格——1,499,997 個

記憶體內的試算表模型在更早一步就撞上同一堵牆。如果「格式化」意味著對每個儲存格物件設屬性,在工作表標頭上點一下,就要配置一百萬個物件來帶一個小整數;只想設個數字格式的 Delphi 行程,會在一份磁碟上只有幾 KB 的活頁簿上用光位址空間。真正的設計問題是:編輯期間格式住在哪裡、何時才變成逐儲存格資料

HotXLS 如何不建立儲存格就格式化一個範圍

HotXLS 把範圍格式保存為區間。TXLSXRange 上的每個樣式 setter——SetNumberFormat、SetNumberFormatIndex、SetFontIndex、SetFillIndex、SetBorderIndex、SetAlignmentIndex——都會在工作表的 StyleOverlays 清單加一筆記錄:四個座標、該呼叫擁有的樣式部件,以及那些部件的池索引。矩形內已存在的儲存格立刻拿到新部件;空座標保持為空,不為它們配置任何東西

var
  Workbook: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  Edge: TXLSXBorder;
  BorderIdx, Row: Integer;
  Stats: TXLSXCellStorageStatistics;
begin
  Workbook := TXLSXWorkbook.Create;
  try
    Sheet := Workbook.Sheets.Add('Ledger');
    Sheet.WriteStringRowValues(1, 2, ['Net', 'Tax', 'Gross']);
    for Row := 2 to 1001 do
      Sheet.WriteFloatRowValues(Row, 2, [Row * 1.5, Row * 0.3, Row * 1.8]);

    // B..D 欄涵蓋全部 1,048,576 列:每次呼叫一個區間
    Sheet.RCRange[1, 2, XlsxMaxRow, 4].SetNumberFormat('#,##0.00 "EUR"');

    // 邊框只建一次,之後按索引套用
    Edge := TXLSXBorder.Create;
    try
      Edge.SetAll(xlsxBorderThin);
      BorderIdx := Workbook.Borders.LookupOrAdd(Edge); // 從 1 起算
    finally
      Edge.Free;
    end;
    Sheet.RCRange[1, 2, XlsxMaxRow, 4].SetBorderIndex(BorderIdx);

    Stats := Sheet.Cells.GetStorageStatistics;
    // 預期 3,003:三個標題儲存格加 3,000 個值
    WriteLn('Cells: ', Stats.CellCount);
    if Workbook.SaveAs('ledger.xlsx') <> 1 then
      raise Exception.Create('Save failed');
  finally
    Workbook.Free;
  end;
end;

那兩次格式化呼叫涵蓋 3,145,728 個座標,卻沒讓計數增加分毫。區間刻意從第 1 列開始:整欄區間會存成一個 <col> 元素,而且第 1 列的文字標題反正不受數字格式影響。RCRange、Range['A1:C5']、EntireColumn 與 EntireRow 回傳的範圍由工作表擁有,所以範例從不釋放它們

TXLSXFormatParts 是讓多個區間不打架的機制。它的成員是 xfpFont、xfpFill、xfpBorder、xfpNumberFormat、xfpAlignment、xfpProtection 與 xfpCellControl,每個 TXLSXRange setter 恰好認領其中一個。解析某個儲存格時,覆蓋它的區間按加入順序套用,每個只寫自己的部件,而儲存格本身的明確格式壓過全部。設數字格式的欄區間與設填滿的列區間,都能完好抵達 B5

一筆記錄要帶多個部件時,Sheet.StyleOverlays.Add(Row1, Col1, Row2, Col2, Parts, State) 吃一個填好池索引的 TXLSXCellStyleState。Add 會把座標夾進 1,048,576 × 16,384 的格線、對調顛倒的角,Parts 為空時回傳 nil、什麼都不記。結構編輯讓記錄保持同步:在已格式化區塊內 InsertRows 會把區間加寬插入的列數,在其上方插入則把整筆記錄向下移

HotXLS 的區間樣式疊加圖解:欄位格式化範圍存成單一記錄並聲明其格式組成部分,而不是一百萬個空白儲存格物件(Delphi)
欄、列或矩形格式只以區間疊加記錄存在一次,只主張它擁有的部分;有效樣式要等到範圍內的儲存格真正被碰到,才懶洋洋地組裝

為什麼 SetBorders 會建立儲存格,SetBorderIndex 不會?

TXLSXRange.SetBorders(Kind, Style, Color) 必須逐一檢視每個儲存格,因為答案取決於位置。xlsxEdgeOutline 時頂列得上邊、左欄得左邊、內部無事;xlsxEdgeInside 大致相反。這個呼叫還要把新邊合併進儲存格既有的邊框,所以先 Outline 再 Inside 會織出完整格線。這種逐儲存格的合成走 Cells[R, C],儲存格不存在時就建一個。在 10 × 40 的報表區塊上,這恰恰是對的。在 EntireColumn(3) 配 xlsxEdgeAll 上,是 1,048,576 個新儲存格物件

所以分工很簡單。比一個螢幕的資料更高的東西,邊框建一次、按索引套用,如同第一個範例:TXLSXBorder.SetAll,然後 Workbook.Borders.LookupOrAdd 取得從 1 起算的索引,然後 SetBorderIndex。SetBorders 留給外框與內線不同的區塊,例如上面的 10 × 40 報表區塊

讀回格式而不白做工

同一個陷阱也逮得到只讀的程式碼。Cells[R, C] 是查無即建的存取器:問它 B500000 的數字格式,你得到正確答案,外加一個新的、帶樣式的空儲存格落在 B500000,並且從此會被保存與寫出。對一整欄跑這種迴圈稽核格式,等於把區間原本要避免的那一百萬個物件重建回來,一次一個無辜的讀取

TXLSXWorksheet.GetCellVisibleFormat 解析座標的生效樣式而不新增儲存格。它合併工作表預設、列與欄區間、矩形區間、表格樣式,以及儲存格上的任何明確格式。帶 out ABuiltinNumberFormat 參數的多載,把 14(短日期)這類 Excel 內建格式與 NumberFormatIndex 分開回報——後者指向活頁簿自訂的 NumberFormats 池。Cells.HasCell(R, C) 回答實體儲存格到底存不存在

var
  Sheet: TXLSXWorksheet;
  Before, After: TXLSXCellStorageStatistics;
  State: TXLSXCellStyleState;
  Row, Builtin, Unformatted, Physical: Integer;
begin
  Sheet := Workbook.Sheets[1];  // Sheets 從 1 起算
  Before := Sheet.Cells.GetStorageStatistics;
  Unformatted := 0;
  Physical := 0;
  for Row := 1 to 200000 do
  begin
    // 生效樣式,解析時不往工作表加儲存格
    State := Sheet.GetCellVisibleFormat(Row, 2, Builtin);
    if (State.NumberFormatIndex = 0) and (Builtin = 0) then
      Inc(Unformatted);
    if Sheet.Cells.HasCell(Row, 2) then
      Inc(Physical);
    // Sheet.Cells[Row, 2].NumberFormatIndex 回答同一個問題,
    // 但每走訪一列就留下一個新儲存格物件
  end;
  After := Sheet.Cells.GetStorageStatistics;
  // 迴圈前後兩個計數應完全相同
  WriteLn(Format('cells %d -> %d, styled blanks %d -> %d',
    [Before.CellCount, After.CellCount,
     Before.StyledBlankCellCount, After.StyledBlankCellCount]));
end;

這種檢查對著你的真實工作負載跑一次就好。StyledBlankCellCount 數的是有樣式而沒有值、公式或富文字的儲存格。若它在理應唯讀的一趟裡攀升,某個輔助常式正為了放樣式而建儲存格,典型手法是呼叫 Cells[R, C] 來判斷儲存格需不需要格式

256 列區塊對你的程式碼意味著什麼

實體儲存格住在 256 列的區塊裡。區塊內,每個有內容的列把儲存格按欄排序存在清單;沒有儲存格的列只是一個空槽;整整 256 列都沒有儲存格的區段連區塊都沒有。資料落在第 3、7、512 列的稀疏表因此只佔兩個區塊。儲存以列為主序走訪同一結構——這正是 SpreadsheetML 對 <row> 元素與其 <c> 子元素要求的順序

列區塊儲存圖解:HotXLS 工作表儲存格存在固定 256 列區塊中、控制代碼穩定,並以列為主序序列化進 XLSX deflate 串流
儲存格住在固定的 256 列區塊裡,只配置佔用的列,並按位元組離開行程的同一順序序列化;公開控制代碼熬得過內部重組,區塊版面對呼叫端始終不可見

你握住的 TXLSXCell 參照在周圍儲存格增刪時仍是同一個物件;區塊重整的是自己的簿記,不是你的控制代碼。WriteFloatRowValues、WriteStringRowValues 這類批次寫入器在全新工作表上走更快的路,把列主序的值追加進一個緊湊存放。第一次經由 Cells[R, C] 的逐格存取會把那個存放一次性轉成儲存格物件。GetStorageStatistics 透過 PackedStorageActive 與 PackedCellCount 顯示你處於哪個狀態,旁邊還有 RowBlockCount 與 PopulatedRowCount

樣式池在儲存時修剪。寫出 styles.xml 之前,HotXLS 重建字型、填滿、框線、數字格式、對齊與保護池,只留下仍在使用的項目,被使用者改了一小時樣式的活頁簿不會把每個棄用的填滿一起出貨。釘在絕對 FormatIndex 上的儲存格保住自己的槽位

逐列串流儲存,以及能看見它的數字

TXLSXWorkbook.StreamingWrite 預設為 True。開著時,SaveAs 逐列產生工作表 <row>、直接寫進所屬 ZIP 項目的 deflate 串流,任何時刻記憶體裡最大的工作表 XML 片段就是單獨一列。關掉時,每張工作表的 XML 先組成完整字串再壓縮,峰值記憶體跟著最大的那張表走。矩形區間變成的那些純樣式儲存格——開頭那個 B2:D500000 案例——也在那個逐列迴圈裡產生,所以它們增加檔案大小、卻從不以一整塊 XML 坐在記憶體裡

共用字串與樞紐分析快取記錄遵循同一模式。每個 <si> 或 <r> 項目各自產生、各自寫出,所以寬大的分析活頁簿上,比任何單張工作表還大的共用字串表不必以一整塊 XML 存在

StreamingWrite 圖解比較:HotXLS 逐列 deflate 串流,對上先在記憶體組好整個工作表 XML 再壓縮(Delphi)
開著 StreamingWrite,每列直接流進套件的 deflate 串流,峰值記憶體只跟一列走;關掉旗標則先把整張工作表 XML 組好,峰值記憶體隨整張表增長

這些都不必憑信心接受。每次儲存之後,活頁簿暴露四個唯讀屬性:LastSaveUsedStreaming、LastSavePeakRowXmlChars(最長單列的字元數)、LastSaveBufferedWorksheetXmlChars(記憶體中持有的工作表 XML 總量,串流時為零)與 LastSavePeakAuxiliaryXmlChars(最長的共用字串或樞紐項目)。LastSaveUsedStreaming 對 .xlsx、.xltx、.xltm 輸出與二進位 .xlsb 寫入器為 True;OpenDocument 輸出走自己的寫入器、回報 False

procedure ReportSave(Workbook: TXLSXWorkbook; const FileName: string;
  Streaming: Boolean);
begin
  Workbook.StreamingWrite := Streaming;
  if Workbook.SaveAs(FileName) <> 1 then
    raise Exception.CreateFmt('Save failed: %s', [FileName]);
  WriteLn(Format('streaming=%s peakRow=%d buffered=%d peakAux=%d',
    [BoolToStr(Workbook.LastSaveUsedStreaming, True),
     Workbook.LastSavePeakRowXmlChars,
     Workbook.LastSaveBufferedWorksheetXmlChars,
     Workbook.LastSavePeakAuxiliaryXmlChars]));
end;

// 同一份活頁簿、兩種模式;在你的分析器裡觀察行程記憶體
ReportSave(Workbook, 'ledger-stream.xlsx', True);
ReportSave(Workbook, 'ledger-buffered.xlsx', False);

在自己的資料上,緩衝那行會顯示出與整張工作表 XML 同一數量級的 buffered 數字,串流那行則是零,外加不超過最寬那一列的 peakRow。讓 StreamingWrite 保持開啟。這個旗標只決定工作表 XML 是否在壓縮前整份組裝,LastSaveBufferedWorksheetXmlChars 回報那次組裝的字元大小

其餘記憶體去了哪裡

儲存格與樣式都緊湊之後,大型活頁簿上下一批最大的消耗者是共用字串表,以及檔案帶著的任何衛星部件:樞紐分析快取、繪圖,以及物件模型沒有建模之部件的保留 XML。那些各有自己的槓桿,沒有任何單一設定能罩住全部

如果瓶頸在開檔而非儲存,先從選擇性載入下手:僅中繼資料與選擇性工作表載入 的逐步解說,涵蓋不為碰不到的工作表付錢的讀法。超大檔案的讀取路徑吞吐量,見 平行 XLSX 剖析與記憶體配置器 的筆記;永遠不需要物件模型的純輸出工作,伺服器批次工作的串流寫入 比這裡任何調校都更合身

HotXLS 從原生 Delphi 與 C++Builder 程式碼讀寫 XLS 與 XLSX,不必 Excel 也不必 OLE 自動化,所以上面每個數字都出自你自己的行程、也能在那裡驗證。支援的格式與 RAD Studio 版本列於 HotXLS Delphi 試算表元件頁面