在 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 把区间加宽插入的行数,在其上方的插入则把整条记录下移
为什么 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 内置格式单独报告出来,与指向工作簿自定义 NumberFormats 池的 NumberFormatIndex 区分开。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> 子元素的顺序
你手里的 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 的形态存在
这些都不必盲信。每次保存之后工作簿暴露四个只读属性:LastSaveUsedStreaming、LastSavePeakRowXmlChars(最长单行的字符数)、LastSaveBufferedWorksheetXmlChars(驻留内存的工作表 XML 总量,流式时为零)和 LastSavePeakAuxiliaryXmlChars(最长的共享字符串或透视条目)。对 .xlsx、.xltx 和 .xltm 输出以及二进制 .xlsb 写入方,LastSaveUsedStreaming 为 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 电子表格组件页面