
简介面向需要深度定制电子表格控件的 Delphi 与 C Builder 开发者这份 DevExpress.ExpressSpreadSheet v1.38 源代码提供了研究表格引擎、公式解析、数据绑定、样式格式与界面渲染实现方式的完整素材也适合二次开发、性能调优和兼容性排查时对照参考。压缩包共 382 个文件、约 2.81MB其中 73 个 pas 单元与 38 个 cpp 源文件承载主要逻辑45 个 dpk 包定义、26 个 dfm 窗体、83 个 res 资源及若干项目组文件共同构成可编译的工程结构并包含 ExpressGDI 图形绘制、核心公共库与主题管理等模块。已有 447 人学习说明其在同类组件源码资料中具备一定参考价值。读者可借此理解组件分层架构与 API 组织方式掌握复杂计算、数据导入导出和用户界面交互的实现思路并依据项目需求裁剪功能、定位问题。1. 一份带源码的 VCL 电子表格控件能省掉多少排查时间接手一个跑了七八年的老报表模块最难受的不是需求变更而是原来那套表格控件只剩编译好的 dcu 和 bpl公式少算一分钱你连从哪一行查起都不知道。这次我把 DevExpress SpreadSheet 1.38 的源代码包整个翻了一遍就是因为这个一套跑在 Delphi / CBuilder 上的 VCL 电子表格控件单元格模型、公式引擎、样式系统、xlsx 导入导出全在里面出问题能一路跟到实现层而不是对着黑匣子猜。它适合两类人一类是维护老桌面项目、必须在不换框架的前提下把表格能力焊进程序的开发者另一类是想搞清楚「一个电子表格控件内部到底怎么组织数据和重算」的人。1.38 这个版本不算新但正因为老代码里没有太多为了兼容新编译器加的糖读起来反而直。2. 源码包结构与编译前提从目录到第一个可运行窗体拿到压缩包后先不要急着打开工程文件。这类 VCL 控件包的结构都遵循同一套逻辑运行期代码、设计期注册、示例工程、资源文件四块分开。分不清这四块后面编译报错时会一直卡在「明明路径加了却找不到单元」这种地方。2.1 目录分工运行期包、设计期包和示例各管什么我手上这份 1.38 的包按职责大致可以拆成下面几块。不同小版本的目录名会有出入但分层是一致的目录 / 文件类型职责改动频率核心源码目录控件类、Sheet/View 对象模型、单元格与样式低读懂即可公式相关单元表达式解析、函数实现、依赖与重算中扩展函数常动这里格式适配单元xlsx / xls / csv / txt 的读写中导出定制常动绘制相关单元视图信息、行列尺寸、单元格绘制低设计期包工程把组件注册到 IDE 工具面板几乎不动运行期包工程编译出 bpl 与 dcu供业务程序链接几乎不动示例工程最小可运行窗体验证安装是否成功只读资源目录光标、位图、图标几乎不动关键点是运行期包和设计期包是两个独立工程。设计期包在 requires 里引用运行期包只负责注册运行期包才是真正被业务程序链接的东西。很多新手把设计期包当成主包去编译结果 Install 成功、拖控件也正常但一编译自己的程序就报找不到单元——因为业务程序链接的是运行期包而它压根没编译出来。提示先把运行期包单独 Build 一遍确认能在$(BDS)\lib或你指定的输出目录里看到 bpl 和一堆 dcu再去动设计期包。2.2 编译前置版本、搜索路径与字符集1.38 这个年代的包配套的编译器区间大致是老版本到 XE 早期那一段。用太新的编译器打开第一波报错通常来自字符集和泛型容器不是代码逻辑问题。搜索路径是第二个高频问题。这类控件的单元之间是互相引用的核心单元 uses 公式单元公式单元 uses 基础类型单元你把 Source 根目录加进搜索路径是不够的得把 Source 下所有子目录都加进去或者干脆把整棵树加进去。我一般会新建一个独立的编译配置把这些路径写死在这个配置里避免污染全局环境。// 运行期包工程文件.dpk的骨架重点看 requires 和 contains package ExpressSpreadSheetRTL; requires rtl, vcl, vclx; contains dxSpreadSheetCore in Source\Core\dxSpreadSheetCore.pas, dxSpreadSheetClasses in Source\Core\dxSpreadSheetClasses.pas, dxSpreadSheetFormulas in Source\Formula\dxSpreadSheetFormulas.pas, dxSpreadSheetFunctions in Source\Formula\dxSpreadSheetFunctions.pas, dxSpreadSheetXlsx in Source\Formats\dxSpreadSheetXlsx.pas; end.这段的意义在于requires里只能出现 IDE 自带的包名不能写第三方包contains里列出的单元顺序不代表编译顺序编译器会自己解依赖但文件路径必须是相对 dpk 的正确相对路径。如果你把 dpk 从原目录挪出来单独放那些相对路径全断报的错是「找不到文件」而不是「找不到单元」这两种错误要分清。字符集方面的经验老版本默认走 AnsiStringD2009 之后默认 UnicodeString。这套源码里如果有大量string和AnsiString混用在中文字符串上会出现截断或者问号。常见做法是在编译配置里把String的默认类型固定下来然后全量重编一次不要一半用旧 dcu 一半用新源码。2.3 装进 IDE三步拖出第一个表格窗体包编译通过之后打开设计期包工程Build然后右键 Install。工具面板上会出现一个组件页里面就是那个表格控件。验证流程建议按下面三步走每步都能独立确认一件事第一步新建一个空白 VCL 窗体程序把控件拖上去Align设成alClient。能拖上去说明设计期注册成功。第二步编译运行。能起来说明运行期包链接正常这一步过不了基本就是 bpl 路径问题。第三步在窗体上放一个按钮代码里往单元格写点东西。procedure TFormMain.FormCreate(Sender: TObject); var ASheet: TdxSpreadSheetTableView; begin // Sheets[0] 默认就是一个表格视图直接转成 TableView 用 ASheet : dxSpreadSheet1.Sheets[0] as TdxSpreadSheetTableView; // 批量写之前先挂起重绘写完再放开否则每写一格都刷一次界面 ASheet.BeginUpdate; try ASheet.Cells.CreateCell(0, 0, True).AsString : 产品名称; // 参数顺序列, 行 ASheet.Cells.CreateCell(0, 1, True).AsString : 一月; ASheet.Cells.CreateCell(1, 0, True).AsString : A 型; ASheet.Cells.CreateCell(1, 1, True).AsFloat : 128000.50; finally ASheet.EndUpdate; end; end;CreateCell(ACol, ARow, True)的三个参数分别是列索引、行索引、以及单元格不存在时是否自动创建。第三个参数传 False 时如果那个位置没有单元格对象返回的是 nil直接链式赋值会访问违例。这是这套控件最容易翻车的一处后面第 5 章还会展开。3. 对象模型拆解一套 VCL 表格控件的分层设计读源码最怕一上来就扎进绘制代码。这套控件的正确读法是先看对象模型——把「谁是数据、谁是表现、谁负责交互」这三件事分清楚后面的代码基本就是填空题。3.1 控件层与数据层控件、Sheet、View 的三层关系顶层是那个你拖到窗体上的控件类它继承自一个自定义控件基类负责窗口句柄、焦点、滚动条、输入法这些 VCL 层面的东西。它自己不存单元格数据只持有一个 Sheet 集合。中间层是 Sheet。工作簿概念的载体Sheets[0]、Sheets[1]就是这样来的。Sheet 又分不同视图类型最常见的就是表格视图TableView你能看到行列表格的那种。源码里这个分工很明确TableView 管行列结构、单元格容器、合并区域、选择区另一个兄弟类管滚动条与视图信息ViewInfo负责「当前可见区域是哪些行列、每列多宽」。底层才是单元格对象和它的样式对象。这个分层带来一个很实际的好处同一个 Sheet 上可以挂多个视图一个负责显示、一个负责打印布局数据只有一份。二次开发时如果要做「同数据两种展现」不用复制数据加视图就行。我建议读源码的顺序是先看控件类的 published 段把所有对外暴露的属性按「外观 / 行为 / 数据」三堆分类再看 Sheet 类的 published 段最后才进单元格和绘制。这样你对 API 的边界会先建立起来。3.2 单元格存储稀疏结构与内存的真实开销单元格不是二维数组。如果 1000 行 × 100 列都预分配对象光对象头就够呛而且绝大多数格子是空的。这套源码用的是稀疏存储行列结构行高、列宽、隐藏标记单独维护单元格对象按需创建只有被写过值或样式的格子才在容器里占一个位置。Cells[ACol, ARow]这个索引器在读的时候如果那个位置没有对象通常返回一个空的占位单元格让你读到的值是空字符串、0 或者 Null而不是抛异常。但写的时候不一样写之前必须保证对象存在这就是CreateCell存在的理由。参数顺序是列在前、行在后这跟很多人的直觉相反。Delphi 的StringGrid也是这个顺序所以从 StringGrid 迁移过来的人反而不会错从 Excel 那种 A1 记法思维过来的人会频繁写反。内存估算上单个单元格对象的开销大约是几十字节级别加上样式对象如果每格独立创建会更贵。所以真实项目里 10 万格以上的表我一般会做两件事一是相同样式的单元格共享同一个样式对象源码里样式对象本身就支持引用计数式的复用二是超过一定规模就改成只创建有数据的格子其余靠默认样式渲染。// 大数据量写入的正确姿势先算好范围按需创建全程挂起重绘 procedure TFormMain.FillLargeTable(ASheet: TdxSpreadSheetTableView; ARows, ACols: Integer); var R, C: Integer; ACell: TdxSpreadSheetCell; begin ASheet.BeginUpdate; // 挂起重绘与布局计算 try for R : 0 to ARows - 1 do for C : 0 to ACols - 1 do begin ACell : ASheet.Cells.CreateCell(C, R, True); if ACell nil then Continue; // 极端情况下创建失败跳过而不是崩 ACell.AsFloat : R * ACols C; // 这里换成你的业务数据 end; finally ASheet.EndUpdate; // 一次性重算布局并刷新 end; end;BeginUpdate/EndUpdate是这套代码里性价比最高的一个优化点。它内部是一个计数器可以嵌套只有计数归零时才真正触发重绘和布局重算。循环里少这一对调用5000 行数据能卡到十几秒加上之后通常在一秒以内。3.3 公式引擎解析、依赖表与重算顺序公式这块是整套源码里最值得读的部分。它的处理链路大致是四段第一段是解析。单元格里存的公式字符串以等号开头那段被词法分析切成记号再按运算符优先级组装成语法树。源码里这部分被拆成独立的单元跟界面完全解耦你甚至可以在控制台程序里单独引用来算表达式。第二段是函数实现。SUM、IF、VLOOKUP 这类内置函数每个函数对应一个实现类统一继承同一个基类基类定义了参数个数校验、参数求值、返回值的接口。注册表是一张「函数名 → 实现类」的映射解析到函数名时去查表。这也是最容易扩展的地方——加一个自定义函数就是加一个类加一行注册。第三段是依赖关系。每个公式单元格记录它引用了哪些单元格区域反向再维护一张「谁依赖我」的表。改一个单元格的值时靠这张反向表找出所有需要重算的格子打上脏标记。第四段是重算。脏标记收集完之后按依赖顺序求值遇到循环引用要有检测机制否则就是无限递归。常见做法是设一个迭代上限超过就跳出并把单元格标记成错误值。理解这条链路的价值在于当用户反馈「改了 A1B1 的公式没跟着变」你能立刻判断是脏标记没打上、还是重算没触发、还是依赖表没建对。三种原因的修法完全不同。4. 关键实现逐段读赋值、公式、导入导出三条链路对象模型清楚之后就可以挑三条最常用的链路来读实现了。这三条链路覆盖了日常开发 90% 的调用。4.1 写一个单元格到底走了多少层表面上看是一行赋值实际经过的层次不少控件层收到调用转发给 SheetSheet 定位到单元格容器容器判断对象是否存在不存在就创建创建时套用默认样式赋值时判断值类型数字和字符串走不同的存储分支最后标记该区域为脏等待重绘。procedure TFormMain.WriteCellWithStyle(ASheet: TdxSpreadSheetTableView; ACol, ARow: Integer; const AText: string); var ACell: TdxSpreadSheetCell; begin ACell : ASheet.Cells.CreateCell(ACol, ARow, True); if ACell nil then Exit; ACell.AsString : AText; // 样式对象改之前也要挂起否则每改一个属性就触发一次重排 ACell.Style.BeginUpdate; try ACell.Style.Font.Style : [fsBold]; ACell.Style.Font.Size : 10; // TColor 是 $00BBGGRR不是 RGB写反了颜色就成蓝的 ACell.Style.Brush.Color : $00E8F4FF; ACell.Style.HorzAlign : taLeftJustify; ACell.Style.VertAlign : vaCenter; finally ACell.Style.EndUpdate; end; // 数字格式沿用样式对象上的格式串千分位、百分比都靠它 end;这段里有两个参数细节值得说。Brush.Color用的是 Delphi 的TColor低字节是红、中间是绿、高字节是蓝跟 Web 上的 RGB 顺序相反从设计稿抄色值过来必须做一次转换。HorzAlign/VertAlign是这套控件的对齐属性不是 VCL 标准控件的Alignment从别的表格控件迁代码时容易写错。4.2 公式从字符串到结果解析器与函数注册给单元格赋公式和赋值是两条路。赋公式走的是解析入口解析成功之后语法树挂在单元格上同时登记依赖解析失败则整格变成一个错误值。var ASheet: TdxSpreadSheetTableView; ACell: TdxSpreadSheetCell; begin ASheet : dxSpreadSheet1.Sheets[0] as TdxSpreadSheetTableView; // 求和格公式本身存字符串结果由引擎算 ACell : ASheet.Cells.CreateCell(1, 10, True); ACell.Formula : SUM(B2:B11); // 引用另一格的公式 ACell : ASheet.Cells.CreateCell(2, 10, True); ACell.Formula : IF(B120, B12*0.13, 0); // 批量改完之后统一重算一次比每改一格算一次快得多 // 重算入口挂在公式控制器上具体方法名看源码里控制器单元的 public 段 end;读这段源码时注意几个边界。一是公式字符串一定要带等号不带等号会被当普通文本处理界面上看不出来但结果永远是字符串。二是区域引用B2:B11的解析跟单个引用走不同分支区域引用内部展开成行列范围跨表引用的写法还要额外带表名。三是重算不是实时的——改了被引用的单元格引用方不会立刻变要等重算触发。这是性能考量但也是新手最容易误判成 bug 的地方。自定义函数的扩展点在函数注册表上。源码里每个内置函数是一个类实现求值方法构造时从参数列表里取实际值。加自己的函数就是照抄一个最简单的内置函数改个名字改个求值逻辑再在注册表里登记一行。业务上常见的「按自定义汇率换算」「按工号查部门名」都可以这样接进去让用户在公式里直接写。4.3 xlsx 导入导出格式适配层在哪导入导出这块源码里是一层独立的适配代码职责是把内部对象模型和文件格式之间做双向映射。xlsx 本质是一个 zip 包里面几个固定的 xml工作表、共享字符串表、样式表、工作簿关系。procedure TFormMain.LoadAndSave(const AInFile, AOutFile: string); begin // 加载内部会解压、解析 xml、重建单元格与样式 dxSpreadSheet1.LoadFromFile(AInFile); // 加载完最好确认一下工作表数量空文件或者结构异常时这里能第一时间发现 if dxSpreadSheet1.Sheets.Count 0 then Exit; // 改数据……略 // 保存把当前模型重新序列化成 xlsx dxSpreadSheet1.SaveToFile(AOutFile); end;适配层里有两个实现细节值得注意。一个是共享字符串表xlsx 里重复出现的字符串只存一份单元格里存索引导入时要建索引到字符串的映射导出时要做去重这一步做不好大表导出体积会翻几倍。另一个是公式的存储方式xlsx 里公式节点存表达式文本同时可以存一个缓存值。如果导出时不写缓存值某些不重算的阅读器打开会看到空白如果写了缓存值又跟公式对不上就会看到「数字是旧的点进去公式是新的」这种诡异现象。注意导出后拿 Excel 打开对一遍重点看三类公式结果、日期格式、合并单元格。这三类是最容易在序列化时丢信息的。4.4 绘制为什么滚大表不卡绘制这部分很多人跳过但它解释了一个实际现象为什么同样十万行数据有的表格控件滚动丝滑有的卡成 PPT。核心是虚拟化——只绘制当前可见区域的行列不可见的完全不参与布局计算。行高列宽算一次缓存起来滚动时只做偏移量换算不做重新测量。读这块源码能学到一个通用套路把「数据规模」和「绘制规模」解耦。数据可以十万行绘制永远只有屏幕上那几十行。二次开发时如果要加自定义绘制比如给某类单元格画角标挂到单元格绘制事件上就行不要试图遍历全部数据去找要画的东西。5. 踩坑排查编译报错、行列颠倒、公式不重算这一章是我自己踩过、以及帮别人看代码时反复见到的几类问题按「现象 → 原因 → 解决」记下来。5.1 编译报「单元版本不一致」现象包本身能编译但业务工程一引用就报某个单元是用不同版本编译的或者报找不到某个 dcu。原因搜索路径里同时存在旧版 dcu 和新编译出来的 dcu编译器按路径顺序先找到了旧的。也可能是当初只重编了部分单元其余的 dcu 还是老编译器产物。解决把源码目录、输出目录、系统 lib 目录下所有相关 dcu 清一遍然后从运行期包开始全量 Build再 Build 业务工程。清 dcu 这个动作看着粗暴但它能排除掉 90% 的「玄学编译错误」。血泪经验是只要改过编译配置里的字符集或者条件编译开关就必须全量重编不能增量。5.2 数据整体错位一格现象写进去的数据在界面上整体偏了一行或者一列或者第一行永远空着。原因Cells[ACol, ARow]是列在前、行在后。从 Excel 的 A1 思维或者从一维数组思维过来的人很容易写成Cells[Row, Col]。解决在自己的代码里做一层封装别在业务代码里直接调Cells。封装函数的参数按业务语义命名比如SetCell(Row, Col, Value)内部统一转成控件的顺序。这样即使以后换控件改一处就行。我一般会把这个封装做成一个 helper 类顺手把CreateCell的判空也包进去。5.3 公式算出来全是 0 或者不刷新现象公式文本是对的但单元格显示 0或者改了被引用的格子公式格纹丝不动。原因三种可能。一是赋公式时字符串没带等号被当成文本二是整批写入时BeginUpdate之后忘了配对的EndUpdate重绘和重算都被挂起三是值改了但没触发重算脏标记没打上。解决先确认公式字符串带等号再检查BeginUpdate/EndUpdate是否成对且没有在异常路径里漏掉finally最后手动触发一次全量重算看结果对不对。如果手动重算对、自动重算不对那就是依赖登记的问题回去看引用解析那段的实现。5.4 大数据量下界面卡死现象一次性写入几万格窗口假死几十秒任务管理器显示 CPU 单核跑满。原因没有批量挂起重绘每写一格都触发一次布局计算和界面刷新另外每个单元格都独立创建样式对象内存和 GC 压力都大。解决写入全程包在BeginUpdate/EndUpdate里样式尽量复用相同格式的格子共享同一个样式对象实在太大就分批写每批之间Application.ProcessMessages让界面喘口气这一招有副作用只在导入场景用正常编辑流程别用。5.5 中文导出后变乱码现象程序里显示正常导出成 csv 或者 xlsx 再用别的软件打开中文变成问号或者方块。原因字符集不统一。老版本源码默认 AnsiString导出时按本地代码页写新编译器默认 Unicode中间某一步做了隐式转换丢了信息。csv 还额外有 BOM 的问题。解决全链路统一成宽字符串或者 UTF-8导出 csv 时显式写 BOMxlsx 走 xml 序列化那条路本来就没问题——如果 xlsx 也乱码那问题在字符串表那一层去看共享字符串的写入口。6. 二次开发把它改成业务里的报表引擎源码能编、能跑之后真正的活儿才开始。直接拿控件 API 铺业务代码写三个报表就乱成一锅粥。我的做法是在控件外面套一层薄适配层把「业务数据」翻译成「表格模型」业务代码只认适配层。改造点我按优先级列一下改造点落点收益风险数据适配层业务层新增单元业务代码不碰控件 API换控件成本低需要设计好行列表头模型自定义公式函数公式函数注册表用户可在模板里写业务公式参数校验不严会算出错误值模板化导出导入导出适配层先用模板文件占位再填数据模板改版要同步代码只读保护单元格样式与编辑控制防止误改表头与合并单元格叠加时易出错数据适配层是这样写的定义一个描述表格结构的记录包含列定义标题、宽度、对齐、数字格式和数据源再写一个Render方法遍历数据源往 Sheet 里填。这样做的好处是同一个报表既能渲染到控件也能走导出流程代码只有一份。type TColumnDef record Title: string; Width: Integer; IsMoney: Boolean; end; procedure TRenderer.Render(ASheet: TdxSpreadSheetTableView; const ACols: array of TColumnDef; const ARows: TArrayTArrayVariant); var C, R: Integer; ACell: TdxSpreadSheetCell; begin ASheet.BeginUpdate; try // 表头 for C : 0 to High(ACols) do begin ACell : ASheet.Cells.CreateCell(C, 0, True); ACell.AsString : ACols[C].Title; ACell.Style.Font.Style : [fsBold]; end; // 数据区 for R : 0 to High(ARows) do for C : 0 to High(ACols) do begin ACell : ASheet.Cells.CreateCell(C, R 1, True); if ACell nil then Continue; if ACols[C].IsMoney then ACell.AsFloat : VarToFloatDef(ARows[R][C], 0) else ACell.AsString : VarToStr(ARows[R][C]); end; finally ASheet.EndUpdate; end; end;这段的关键不是代码本身而是边界表头固定在行 0数据从行 1 开始列索引直接用数组下标。业务侧只需要提供列定义和二维数据不关心单元格、样式、行列顺序。以后要加「隔行底色」「负数标红」改Render一处就行。验证方法我一般用对拍同一批数据一边用控件渲染后导出 xlsx一边用 Excel 手工算一遍关键指标把两边结果拉到一起比对。金额类的字段精确到分公式类的字段抽查几条带条件分支的。这个过程很枯燥但比上线后被财务追着问强。还有一个技巧值得单独说别急着改源码。这套控件的扩展点已经够多了函数注册、绘制事件、格式适配都能挂自己的代码不到万不得已不要去改核心单元。改了之后每次升级版本都要合并一遍成本很高。确实要改就在自己的单元里继承它的类做子类化把改动收敛在一个文件里。从那以后我每次接手这种带源码的控件包都先把运行期包单独编一遍、跑一遍示例、把 published 段扫一遍确认自己理解了它的边界再动业务代码再没出现过「编译通过但运行时崩」的返工。希望帮到你。本文还有配套的精品资源点击获取