☰
ExpressSpreadSheet源码编译与公式引擎避坑
2026/11/4 7:32:03 网站建设 项目流程

简介:面向Delphi与C++ Builder开发者的DevExpress ExpressSpreadSheet v1.38源代码包,适合希望深入理解电子表格组件内部实现、开展二次开发或优化现有应用的中高级程序员。压缩包采用rar格式,共382个文件、约2.81MB,以pas源码单元、dpk与bpk/bpr工程包文件、cpp源文件、dfm窗体、res资源及h头文件为主,并附带示例工程与演示数据,方便按模块查阅、编译与对照调试。源码内容覆盖电子表格引擎、数据绑定、公式解析、样式与格式设置、GDI+图形渲染、XP主题适配以及共享核心库等部分,可用于研究组件API、架构设计与性能取舍;配套示例展示了自定义函数、功能演示、股票行情与动态效果等场景。已有447人学习,适合对照源码扩展单元格计算、导入导出和界面渲染能力,也能为排查主题一致性、定制格式与复用核心模块提供参考。目录中源码与示例并置,便于从调用入口追踪到实现细节,对学习组件分层设计、工程组织与资源管理同样有帮助。

1. 为什么还要翻出这套 ExpressSpreadSheet 源码:从表格卡顿到能改底层

ExpressSpreadSheet v1.38 for Delphi/BCB 的源代码包,第一次拿到手里时我并没有急着编译,而是先翻了三类文件:运行期包、设计期包和公式引擎单元。原因很简单,老项目里表格控件一旦出问题,运行时包版本、DCU 搜索路径和源码补丁三者会互相打架,表现出来就是“昨天还能跑,今天打开就报找不到单元”。这套源码适合两类人:还在维护 Delphi 或 C++Builder 桌面项目、需要直接改表格底层行为的从业者;以及想弄清电子表格公式重算、单元格样式继承怎么落地的新手。它解决的不是“显示一个表格”这种表面需求,而是把表格当黑匣子时没法定位的重绘、导出和打印问题。表格控件用久了会发现,真正难改的往往不是界面,而是重算顺序和坐标边界,这两处一旦翻车,日志里只剩一句“显示不对”。

2. 源码包拆解与编译落地:先分清运行期包、设计期包和搜索路径

2.1 目录结构判读:哪几个文件夹决定能不能编译

拿到源码包后先别双击工程文件。我一般先把压缩包解到一层短路径下,比如D:\work\ess138,路径里不要带中文和空格。Delphi 和 BCB 的编译器对长路径、空格路径的容忍度并不一致,BCB 的 make 行为尤其容易在带空格的路径上出问题。解压后先看根目录,常见结构是Source、Packages、Demos、Docs四类。Source放的是运行期单元,Packages放的是.dpk、.dproj、.cbproj和设计期注册单元,Demos是验证用例,Docs里可能有版本说明和编译顺序。真正决定能不能编译的是Source和Packages,Demos只用来做最小验证。

先用表格把目录和职责对上,不然后面改搜索路径时容易把设计期单元和运行期单元混在一起。

路径内容编译时的作用常见误用
Source\Common基础类型、工具函数、坐标与矩形定义运行期包和设计期包都依赖只加Source不加Common,报找不到基础单元
Source\SpreadSheet表格核心、单元格、公式、样式运行期包主体把设计期注册单元也编进运行期包
Source\SpreadSheet\Functions内置公式函数实现公式引擎依赖漏加后公式能写但算不出值
Packages\Delphi.dpk、.dprojDelphi 包编译入口直接编译.dproj但没先编译依赖包
Packages\BCB.bpk、.cbprojC++Builder 包编译入口用 Delphi 的 DCU 去链 BCB 工程
Demos示例工程验证安装是否成功拿示例工程当源码包编译入口

判断依赖顺序有个笨办法但很稳:先看.dpk的requires子句。运行期包通常只依赖 RTL 和 VCL 基础包,设计期包会requires运行期包,并在contains里放注册单元。只要requires里出现另一个自定义包名,那个包就必须先编译。源码包里如果同时给了 Delphi 和 BCB 的包工程,不要混用,Delphi 编出来的 DCU 和 BCB 编出来的 OBJ 不是一回事,混用后的报错往往不是“版本不对”,而是链接期一堆符号找不到。

还有一个细节:源码包里的单元名可能和已安装的运行时包重名。如果 IDE 里已经装过同系列控件的二进制版本,先确认搜索路径里源码路径排在前面,否则编译器会优先找到旧 DCU,改源码等于没改。这个坑在后面第 5 章还会展开,这里先记住一个原则:源码包落地时,搜索路径顺序比路径数量更重要。

2.2 Delphi 与 BCB 的编译顺序:用命令行复现一次干净构建

图形界面里点“编译”不是不行,但排查问题时命令行更可控。我习惯先写一个构建脚本,把环境初始化、输出目录、搜索路径固定下来。Delphi 这边用msbuild编译.dproj,BCB 这边编译.cbproj。脚本里不要写死 IDE 安装路径,用环境变量或手工指定的DELPHI_ENV指向rsvars.bat,这样换机器时只改一处。下面这个脚本先清输出目录,再按“基础包 → 运行期包 → 设计期包”的顺序编译,最后把编译日志落到build\log下。

@echo off setlocal rem 按实际机器修改:指向 IDE 的 rsvars.bat 所在目录 if "%DELPHI_ENV%"=="" set DELPHI_ENV=D:\tools\delphi_env call "%DELPHI_ENV%\rsvars.bat" set ROOT=%~dp0 set OUT=%ROOT%build set LOG=%OUT%\log if exist "%OUT%" rmdir /s /q "%OUT%" mkdir "%OUT%" mkdir "%LOG%" rem 1. 先编基础包,具体包名按源码包里的 .dproj 替换 msbuild "%ROOT%Packages\Delphi\SpreadSheetCore.dproj" ^ /t:Build /p:Config=Release /p:Platform=Win32 ^ /p:DCC_UnitSearchPath="%ROOT%Source;%ROOT%Source\Common" ^ /p:DCC_OutputDir="%OUT%\dcu" > "%LOG%\core.log" 2>&1 if errorlevel 1 goto fail rem 2. 再编运行期包 msbuild "%ROOT%Packages\Delphi\ExpressSpreadSheet.dproj" ^ /t:Build /p:Config=Release /p:Platform=Win32 ^ /p:DCC_UnitSearchPath="%ROOT%Source;%ROOT%Source\Common;%ROOT%Source\SpreadSheet" ^ /p:DCC_OutputDir="%OUT%\dcu" > "%LOG%\runtime.log" 2>&1 if errorlevel 1 goto fail rem 3. 最后编设计期包,设计期包要能找到刚编出的运行期包 msbuild "%ROOT%Packages\Delphi\ExpressSpreadSheetDsgn.dproj" ^ /t:Build /p:Config=Release /p:Platform=Win32 ^ /p:DCC_UnitSearchPath="%ROOT%Source;%ROOT%Source\Common;%ROOT%Source\SpreadSheet" ^ /p:DCC_OutputDir="%OUT%\dcu" ^ /p:DCC_UsePackageSearchPath=true > "%LOG%\design.log" 2>&1 if errorlevel 1 goto fail echo BUILD OK exit /b 0 :fail echo BUILD FAILED, check %LOG% exit /b 1

这个脚本的关键不是命令本身,而是三个参数:DCC_UnitSearchPath决定编译器去哪里找.pas,DCC_OutputDir决定.dcu落到哪里,DCC_UsePackageSearchPath让设计期包在链接时能找到刚编出的运行期包。前两个参数如果只在 IDE 里配过,命令行构建时不会自动继承,必须显式传进去。日志重定向到文件后,失败时先看第一个error出现的位置,不要从最后一行倒着看,最后一行往往只是“构建失败”的汇总。

BCB 的脚本结构一样,把.dproj换成.cbproj,包名换成Packages\BCB下的工程。BCB 工程在链接阶段对.lib和.bpi的搜索路径更敏感,如果报Unresolved external,先把%OUT%\dcu和包输出目录都加进库搜索路径,再确认没有把 Delphi 编出的.dcu混进来。命令行构建成功后,把build\log里的三个日志留好,后面在 IDE 里装包出问题时,可以用命令行结果做对照,快速判断是源码问题还是 IDE 环境问题。

提示:第一次构建不要开增量编译。/t:Build就是全量构建,能避免旧 DCU 干扰;等编译稳定后再用/t:Compile提速度。

2.3 设计期包安装与最小验证:别急着往窗体上拖控件

命令行编译通过只说明源码能过编译器,不代表 IDE 里能正常用。设计期包安装这一步,我一般先确认.bpl的输出位置,再在 IDE 的包管理里添加设计期包。安装成功后,组件面板里会出现表格控件,但这时候不要直接拖到复杂窗体上,先用一个空窗体做最小验证。验证目标只有三个:控件能创建、单元格能写值、公式能算出结果。下面这段代码放在按钮事件里,跑通后再做样式、合并和导出。

uses dxSpreadSheet, dxSpreadSheetClasses; procedure TForm1.Button1Click(Sender: TObject); var Sheet: TdxSpreadSheetTableView; begin // ActiveSheetAsTable 返回当前活动工作表,类型按源码中的实际类名调整 Sheet := dxSpreadSheet1.ActiveSheetAsTable; Sheet.BeginUpdate; try Sheet.Cells[0, 0].Text := '物料编码'; Sheet.Cells[1, 0].Text := '数量'; Sheet.Cells[0, 1].Text := 'A-001'; Sheet.Cells[1, 1].AsFloat := 12.5; Sheet.Cells[2, 1].Formula := '=B2*2'; finally Sheet.EndUpdate; end; // 主动触发一次重算,确认公式引擎已链接进来 dxSpreadSheet1.Recalculate; Assert(Sheet.Cells[2, 1].AsFloat = 25.0, '公式重算结果不正确'); end;

代码里Cells[ACol, ARow]的坐标顺序要按源码里的声明来,有的版本列在前、行在后,写反了不会报错,只会把值写到另一个格子。AsFloat和Text的区别在于类型:数值列用AsFloat能保留右对齐和后续计算,用Text写进去可能变成文本,公式引用时会得到 0。Recalculate是主动重算,适合验证公式引擎有没有被正确链接;如果这一步抛异常,先查Functions目录下的单元有没有编进运行期包。Assert只在调试配置下生效,Release 下不会中断,所以验证时用 Debug 配置跑一遍更稳。

最小验证通过后,再打开Demos里的示例工程。示例工程的作用不是“跑起来看看”,而是对照它的搜索路径和包引用,检查自己的工程少了哪个依赖。常见情况是示例能跑、自己的工程报找不到单元,差别往往在.dproj的DCC_UnitSearchPath或包的requires列表里。把示例工程的配置和自己的逐项对比,比在 IDE 里反复点“清理并重建”有效得多。

3. 单元格模型与公式引擎:改源码前必须吃透的三层对象

3.1 从 Sheet 到 Cell:坐标、合并区域与样式继承

ExpressSpreadSheet 的对象模型大致分三层:工作簿、工作表、单元格。工作簿管 sheet 集合,工作表管行列和合并区域,单元格管值、公式和样式。改源码时最容易踩的坑是把“单元格样式”当成独立副本,实际上很多表格控件为了省内存,样式是按行、列、单元格三级继承的。你给单元格设了字体加粗,背后可能只是给它挂了一个样式引用;如果直接改引用指向的对象,整行甚至整表都会跟着变。下面这段代码演示写值、合并和样式设置,重点是BeginUpdate/EndUpdate包住批量操作,避免每写一个格子触发一次重绘。

var Sheet: TdxSpreadSheetTableView; begin Sheet := dxSpreadSheet1.ActiveSheetAsTable; Sheet.BeginUpdate; try Sheet.Cells[0, 0].Text := '物料编码'; Sheet.Cells[1, 0].Text := '数量'; Sheet.Cells[0, 1].Text := 'A-001'; Sheet.Cells[1, 1].AsFloat := 12.5; // 合并第 3 行前两列,参数顺序按源码声明确认 Sheet.MergeCells(0, 2, 2, 1); Sheet.Cells[0, 2].Text := '合计'; // 只改当前单元格的字体样式,不直接改共享样式对象 Sheet.Cells[0, 0].Style.Font.Style := [fsBold]; Sheet.Cells[1, 1].Style.Alignment := taRightJustify; finally Sheet.EndUpdate; end; end;

MergeCells(ACol, ARow, AColCount, ARowCount)这类方法的参数顺序在不同版本里可能不同,写之前先在源码里搜声明,不要凭记忆。合并区域后,只有左上角单元格的值有效,其他被合并的单元格值会被隐藏但未必清空,导出 CSV 时如果逐格遍历,可能导出重复内容。样式部分,Style.Font.Style这种写法看起来是直接改字体,实际可能触发样式对象的“写时复制”;如果源码里没有做写时复制,改一个格子会影响一片。验证办法很简单:设完样式后,检查相邻单元格的Style引用是否相同,相同就说明是共享的,改之前要先Assign一份独立样式。

坐标边界也是改源码时的血泪经验。表格控件内部通常有“可见区域”和“数据区域”两套坐标,滚动之后Cells[Col, Row]里的行列是数据坐标,但重绘时用的是可见坐标。你在源码里改绘制逻辑,如果拿数据坐标直接去算屏幕位置,滚动后就会错位。判断当前是哪种坐标,看变量名和函数参数:带Visible、View、Client的通常是可见坐标,带Col、Row、Index的通常是数据坐标。两层坐标转换函数一般在表格核心单元里,改绘制前先找到它。

3.2 公式解析链路:引用、重算和循环检测

公式引擎是这套源码里最值得读的部分。一个公式从字符串到结果,通常经过词法分析、语法树构建、引用解析、依赖收集、重算求值五步。你写=B2*2,引擎先把它拆成操作符和单元格引用,再把引用解析成实际坐标,然后记录“这个公式依赖 B2”,最后在 B2 变化时决定要不要重算。改源码时如果只改求值部分、不改依赖收集,会出现“值改了但公式不更新”的玄学问题。下面这段代码设置公式并主动重算,用来观察依赖链是否正常。

var Sheet: TdxSpreadSheetTableView; begin Sheet := dxSpreadSheet1.ActiveSheetAsTable; Sheet.BeginUpdate; try Sheet.Cells[1, 1].AsFloat := 10; Sheet.Cells[2, 1].Formula := '=B2*2'; Sheet.Cells[3, 1].Formula := '=SUM(B2:C2)'; Sheet.Cells[4, 1].Formula := '=IF(B2>0,"有","无")'; finally Sheet.EndUpdate; end; dxSpreadSheet1.Recalculate; // 改被引用单元格,观察公式是否自动重算 Sheet.Cells[1, 1].AsFloat := 20; dxSpreadSheet1.Recalculate; Assert(Sheet.Cells[2, 1].AsFloat = 40.0, '依赖重算失败'); end;

Formula属性接收的字符串不要带多余空格,不同区域设置下函数参数分隔符可能是逗号也可能是分号,源码里一般有FormulaSeparator之类的配置。SUM(B2:C2)这种区域引用会收集一片依赖,改 B2 或 C2 都应该触发重算;如果只改 B2 不触发,说明依赖收集只记了单个单元格。循环引用是另一个坑:A1 引用 B1、B1 又引用 A1,有的引擎会抛异常,有的会返回 0 或错误值,还有的会直接卡死在重算循环里。改源码时如果动了重算入口,先加一个递归深度保护,超过阈值就标记错误值,不要让它无限递归。

重算范围也要分清。Recalculate是全表重算,适合验证;业务代码里更常见的是按依赖重算。源码里一般有Recalculate(ACell)或RecalculateRange之类的重载,改的时候注意参数是“起始单元格”还是“区域”。全表重算在几千行时可能看不出问题,几万行带公式时就会明显卡顿。判断办法是记录重算前后的GetTickCount,对比全表和单格重算的耗时差,差一个数量级以上就说明依赖重算没走对。

3.3 事件与重绘:为什么改一个单元格会触发整片刷新

表格控件的事件模型和重绘绑定很紧。改一个单元格的值会触发CellChanged,如果事件里又去改别的单元格,很容易递归触发,最后表现为“改一个格子,整片表格闪”。源码里通常用BeginUpdate/EndUpdate做批量抑制,用Recalculate做显式重算。下面这段事件处理代码加了重入保护,只更新依赖当前列的汇总格,避免整表刷新。

procedure TForm1.dxSpreadSheet1CellChanged(Sender: TObject; ACell: TdxSpreadSheetCell); begin if FInUpdate then Exit; FInUpdate := True; try // 只处理数量列的变化,汇总格按行定位 if ACell.Column = 1 then begin dxSpreadSheet1.BeginUpdate; try Sheet.Cells[3, ACell.Row].Formula := '=SUM(B2:B100)'; finally dxSpreadSheet1.EndUpdate; end; end; finally FInUpdate := False; end; end;

FInUpdate是重入标志,没有它的话,事件里改公式又会触发CellChanged,轻则重复计算,重则栈溢出。BeginUpdate/EndUpdate包住的是“批量修改”,它抑制的是重绘,不抑制事件;事件里改值仍然会进CellChanged,所以重入保护不能省。ACell.Column和ACell.Row是数据坐标,事件里拿到的单元格对象可能只对当前行有效,跨行使用时先确认生命周期。

重绘性能还有一个隐蔽点:样式变化比值变化更贵。改值通常只重绘一个格子,改字体、边框、背景色可能触发整行甚至整表重绘。批量设样式时一定要用BeginUpdate,并且尽量按行或按列批量设置,不要在一个循环里对每个格子单独BeginUpdate/EndUpdate。源码里如果有OnDrawCell之类的自绘事件,注意里面不要做数据库查询或复杂计算,自绘事件会频繁触发,放在里面的代码会被放大很多倍。

4. 数据进出与打印输出:把表格接到真实业务数据上

4.1 数据集到表格:字段映射与类型转换

表格控件落地到业务里,第一步通常是把数据集填进表格。这里最容易出的问题不是“填不进去”,而是“填进去后格式不对”:数值变成文本、日期变成一串数字、空值变成字符串Null。下面这段代码按字段类型分流,数值走AsFloat,整数走AsInteger,日期走AsDateTime,其他走Text。填充前先设RowCount和ColumnCount,并用BeginUpdate包住整个循环。

procedure FillSheetFromDataSet(ASheet: TdxSpreadSheetTableView; ADataSet: TDataSet); var Row, Col: Integer; Field: TField; begin ASheet.BeginUpdate; try ASheet.RowCount := ADataSet.RecordCount + 1; ASheet.ColumnCount := ADataSet.FieldCount; for Col := 0 to ADataSet.FieldCount - 1 do ASheet.Cells[Col, 0].Text := ADataSet.Fields[Col].FieldName; Row := 1; ADataSet.First; while not ADataSet.Eof do begin for Col := 0 to ADataSet.FieldCount - 1 do begin Field := ADataSet.Fields[Col]; if Field.IsNull then ASheet.Cells[Col, Row].Text := '' else case Field.DataType of ftFloat, ftCurrency, ftBCD: ASheet.Cells[Col, Row].AsFloat := Field.AsFloat; ftInteger, ftSmallint: ASheet.Cells[Col, Row].AsInteger := Field.AsInteger; ftDateTime, ftDate: ASheet.Cells[Col, Row].AsDateTime := Field.AsDateTime; else ASheet.Cells[Col, Row].Text := Field.AsString; end; end; Inc(Row); ADataSet.Next; end; finally ASheet.EndUpdate; end; end;

RowCount要加 1 留给表头,ColumnCount按字段数设,不要用默认列数硬填,否则多出的字段会被截断。Field.IsNull必须先判断,直接AsFloat对空值可能得到 0,业务上分不清“没有数据”和“数据是 0”。AsDateTime写进去后显示格式由单元格格式决定,如果显示成一串数字,检查Style.NumberFormat有没有设成日期格式。大数据集不要一次性RecordCount全填,RecordCount在某些数据集上会触发全量遍历,可以先填一批再滚动加载,表格的虚拟模式如果支持,优先用虚拟模式。

字段映射还有一个小坑:数据集字段顺序和表格列顺序不一致时,不要用Col直接对应字段索引,先在表头行写字段名,再用字段名反查列号。这样后面调整列顺序时,填充逻辑不用跟着改。字段名大小写在不同数据集上可能不一致,反查时统一转大写或小写比较。

4.2 CSV/XLSX 导入导出:编码、分隔符和空值边界

导出 CSV 看起来简单,实际坑不少:中文编码、字段里的逗号、双引号、换行,任何一个没处理都会让文件在电子表格软件里错列。源码包里如果有现成的导出方法,优先用现成的;如果没有,按下面的方式逐格遍历,并对每个字段做引号转义。编码用 UTF-8,目标环境如果打开乱码,再试带 BOM 的 UTF-8 或本地编码,不要一次改多个变量。

function QuoteCsv(const S: string): string; begin Result := StringReplace(S, '"', '""', [rfReplaceAll]); if (Pos(',', Result) > 0) or (Pos('"', Result) > 0) or (Pos(#13, Result) > 0) or (Pos(#10, Result) > 0) then Result := '"' + Result + '"'; end; procedure ExportSheetToCsv(ASheet: TdxSpreadSheetTableView; const AFile: string); var Lines: TStringList; Row, Col: Integer; Line: string; begin Lines := TStringList.Create; try Lines.Encoding := TEncoding.UTF8; for Row := 0 to ASheet.RowCount - 1 do begin Line := ''; for Col := 0 to ASheet.ColumnCount - 1 do begin if Col > 0 then Line := Line + ','; Line := Line + QuoteCsv(ASheet.Cells[Col, Row].Text); end; Lines.Add(Line); end; Lines.SaveToFile(AFile); finally Lines.Free; end; end;

QuoteCsv先把字段里的双引号翻倍,再判断是否需要整体加引号。逗号、双引号、回车、换行这四个字符只要出现一个,就必须加引号,否则解析方会错列。TStringList.Encoding设成 UTF-8 后保存,是否带 BOM 取决于运行库版本,打开文件如果第一列出现乱码字符,换成带 BOM 的写法或本地编码再试。导出时用Cells[Col, Row].Text而不是AsFloat,因为 CSV 是文本格式,数值格式化应该由单元格的显示格式决定,直接取浮点数可能带出多余小数位。

XLSX 导入导出如果源码包自带方法,先看它依赖哪个库,有的版本依赖第三方库,缺库时编译能过、运行时报错。调用LoadFromFile之前先判断文件扩展名和文件头,不要只看扩展名。导入后检查RowCount和ColumnCount是否合理,恶意或损坏文件可能让行列数异常大,直接分配内存会卡死。导入大文件时用BeginUpdate包住,并在导入后主动Recalculate一次,确认公式没有把界面卡住。

4.3 打印与分页:缩放、页眉页脚和空白页排查

打印是表格控件落地时最容易返工的部分。屏幕上看正常,打印出来可能列被截断、分页错位、最后一页多一张空白。先调三个参数:缩放模式、页边距、打印区域。缩放模式一般有“按页宽”“按页高”“固定比例”几种,按页宽适合列多的表,按页高适合行多的表,固定比例适合需要精确尺寸的场景。页边距不要设成 0,有些打印机驱动会把 0 边距解释成“使用驱动默认值”,结果反而更大。

参数常见取值作用调错后的现象
缩放模式按页宽 / 按页高 / 固定比例决定内容如何适配纸张列被截断或字太小
页边距上下左右各 10~20控制可打印区域0 边距时打印偏移
页眉页脚页码、标题、日期分页信息页脚压住最后一行
打印区域指定行列范围避免空白区域被打印最后一页空白
分页符手动或自动控制换页位置表格从中间断开

空白页排查有个顺序:先看RowCount和ColumnCount是不是比实际数据大,很多空白页是因为表格末尾有空行但被算进了打印区域;再看打印区域有没有包含空列;最后看分页符是不是落在空行上。手动分页符如果加在数据末尾之后,会多打一页。打印前用BeginUpdate包住格式调整,打印完再EndUpdate,避免调整过程中反复重绘。

页眉页脚里的字体和表格字体是两套配置,页脚字体太大可能压住最后一行,表现为“最后一行被截断”,其实不是行高问题。打印预览和实际打印也可能不一致,预览用的是屏幕分辨率,实际打印用打印机 DPI,边框线宽在小数点上取整后可能消失。遇到边框打印不出来,先把边框宽度从 1 改成 2 试,确认是取整问题再调源码里的线宽计算。

5. 避坑与排查:源码编译和运行期最常见的五处翻车

5.1 找不到单元与 DCU 版本混用

现象:命令行构建报F1026 File not found: 'dxSpreadSheetClasses.dcu',或者 IDE 里打开工程一片红,但文件明明在Source目录下。原因通常不是文件丢了,而是搜索路径没包含子目录,或者编译器优先找到了旧版本 DCU。Delphi 的单元搜索是先找.dcu再找.pas,如果build\dcu里有一个旧版本,源码路径加得再多也会被旧 DCU 截胡。BCB 那边还多一层.obj和.lib,混用 Delphi 编出的中间文件会报链接错误。

解决:先把build目录整个删掉,重新全量构建;然后在命令行里显式传DCC_UnitSearchPath,把Source、Source\Common、Source\SpreadSheet、Source\SpreadSheet\Functions都加进去;最后检查 IDE 的库路径里有没有指向旧版控件的路径,有就删掉或下移。判断是不是 DCU 混用,看报错单元名和实际文件时间戳,旧 DCU 的时间戳通常比源码早。BCB 工程还要检查.cbproj里的 include 路径和 library 路径,别只改 Delphi 的那一套。

5.2 公式不重算或显示空白

现象:单元格里写了=B2*2,B2 改成 20,公式格还是显示 10;或者公式格直接空白,点进去能看到公式字符串。原因分两类:依赖收集没触发,或者公式引擎单元没编进包。前者常见于在事件里直接改值但没调重算,后者常见于Functions目录漏加,编译能过是因为公式字符串只是被当文本存了,求值时才去找函数实现。还有一种情况是公式分隔符和区域设置不匹配,SUM(B2,C2)在某些配置下要写成SUM(B2;C2)。

解决:先手动调一次Recalculate,如果手动能算出值,说明求值链路是通的,问题在自动重算的依赖收集;如果手动也算不出,查Functions单元有没有编进运行期包,再看公式字符串里的函数名大小写和参数个数。依赖收集问题可以在改值前后打印依赖列表,或者临时把Recalculate放进CellChanged里验证。循环引用导致的空白,先搜源码里有没有递归深度保护,没有就加一个,返回错误值比卡死好排查。

5.3 中文乱码与日期变文本

现象:导出的 CSV 用电子表格软件打开,中文变成乱码;或者日期列显示成45000这样的数字。原因分别是编码不匹配和单元格格式没设。CSV 默认按本地编码写,目标软件按 UTF-8 读,就会乱码;日期用AsDateTime写进去,但单元格的NumberFormat还是常规格式,就会显示序列号。反过来,如果先把日期转成字符串再写,排序和计算都会失效。

解决:导出时显式设TStringList.Encoding,先试 UTF-8,乱码再试带 BOM 的 UTF-8 或本地编码;导入时先读文件头判断编码,不要假定。日期列在写值之前设Style.NumberFormat为日期格式,或者写完后统一刷一遍格式。判断一个格子是文本还是数值,看它的对齐方式:数值默认右对齐,文本默认左对齐,但这不是绝对,最终还是看DataType或ValueType属性。批量导入后抽查几列的类型,比肉眼扫一遍可靠。

5.4 打印分页错位与最后一页空白

现象:打印预览里表格从中间断开,或者最后一页只有表头没有数据。原因通常是打印区域包含了空行、缩放模式选错、分页符位置不对。表格的RowCount可能比实际数据大,末尾空行被算进打印区域;按页高缩放时,行高不固定会导致分页计算偏差;手动分页符加在空行上会多出一页。

解决:打印前把RowCount收缩到实际数据行,或者显式设打印区域;缩放模式先试“按页宽”,列多时比“按页高”稳;分页符加在有数据的行之后,不要加在末尾空行。打印预览和实际打印不一致时,检查打印机 DPI 和边框线宽,线宽小于 1 的边框在低 DPI 下可能消失。最后一页空白如果只在某些打印机上出现,先换驱动或调页边距,再怀疑源码的分页计算。

5.5 改了源码却没生效

现象:明明改了Source里的.pas,重新编译后行为没变。原因可能是编译器用了旧 DCU、包缓存没清、或者 IDE 加载的是已安装的二进制包而不是源码包。Delphi 的包缓存和 IDE 的已安装包列表是两套东西,命令行编译成功不代表 IDE 里用的就是新包。BCB 还会缓存.bpi和.lib,旧文件不删,链接时用的还是旧符号。

解决:先删build目录和 IDE 的包输出目录,再全量重建;然后在 IDE 里确认已安装包的路径指向新编出的.bpl,不是旧版本;最后检查工程的搜索路径,确保源码路径在已安装包路径之前。判断新包有没有生效,可以在改动的函数里加一行日志或抛一个特定异常,运行后看有没有触发。源码包修改最忌讳“改一点、编一点、试一点”,一次改一个点,编完清缓存再验证,出问题才知道是哪一步引入的。

6. 进阶:用源码钩子做自定义函数与重算耗时基线

6.1 注册自定义公式函数

源码包最大的价值是能改公式引擎。业务里经常需要DISCOUNT(原价,折扣率)这类自定义函数,做法是在源码的函数注册单元里加一个实现,再在初始化段注册。下面这个例子返回折后价,参数个数不对时抛异常,异常会被公式引擎捕获成错误值,不会把整个表格带崩。注册函数名用大写,调用时大小写不敏感,但源码里最好统一。

function MyDiscount(AArgs: TdxSpreadSheetFunctionArguments): Variant; begin if AArgs.Count <> 2 then raise Exception.Create('DISCOUNT 需要两个参数'); Result := AArgs[0].AsFloat * (1 - AArgs[1].AsFloat); end; initialization // 注册名、实现、最少参数、最多参数,具体签名按源码调整 RegisterSpreadSheetFunction('DISCOUNT', MyDiscount, 2, 2);

AArgs[0].AsFloat取第一个参数,参数类型由调用方决定,传文本进来时AsFloat可能得到 0,所以业务函数里最好先判断类型。RegisterSpreadSheetFunction的签名在不同版本里可能多一个“是否易失”参数,易失函数每次重算都会重新求值,非易失函数会缓存结果。注册放在initialization段,确保包加载时函数已经可用;如果放在窗体创建时,公式可能在注册前就被求值,得到“未知函数”错误。

6.2 用重算耗时做性能基线

改公式引擎或依赖收集之前,先测一个基线。用 5000 行、20 列、其中一列带公式的表,记录全表重算耗时;再改代码,用同样的数据测一次。测的时候先BeginUpdate填数据,填完EndUpdate,然后单独计时Recalculate,不要把填数据的时间算进去。第一次测可能受内存分配影响,跑三次取中间值。

var T: Cardinal; I, J: Integer; begin dxSpreadSheet1.BeginUpdate; try Sheet.RowCount := 5000; Sheet.ColumnCount := 20; for I := 1 to 4999 do begin for J := 0 to 18 do Sheet.Cells[J, I].AsFloat := I + J; Sheet.Cells[19, I].Formula := '=SUM(A' + IntToStr(I + 1) + ':S' + IntToStr(I + 1) + ')'; end; finally dxSpreadSheet1.EndUpdate; end; T := GetTickCount; dxSpreadSheet1.Recalculate; Memo1.Lines.Add(Format('全表重算耗时: %d ms', [GetTickCount - T])); end;

GetTickCount精度有限,耗时太短时多跑几轮取平均。公式列用字符串拼行号,注意行号从 1 开始还是从 0 开始,拼错会引用到表头。基线测出来后,改依赖收集再测一次,如果耗时从几百毫秒降到几十毫秒,说明按需重算生效了;如果反而变慢,检查是不是每次改值都触发了全表重算。从那以后我每次改这套源码的公式部分,都先跑一遍这个基线,再动依赖收集的代码,不然改完只能靠感觉判断快慢。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询