
做 Delphi 开发这些年TPrototypeBindSource 是我在 LiveBindings 体系里用得比较多也踩过不少坑的组件。它干的事情很纯粹不连数据库、不写查询语句拖一个组件到窗体上定义好字段列表再关联数据生成器界面就能立刻获得源源不断的虚拟数据。做原型演示、调报表排版、在没有后端的场景下验证界面效果这个组件能省下大量造数时间。不过用久了它的“重”就暴露出来了。TPrototypeBindSource 从根上继承了 TDataSet 的体系依赖 Data.DB字段、游标、书签这些语义都是完整的一套这既是它的优点也是它的成本。当项目切换到定制渲染层、自绘列表或者只是想在一个不引入 Data.DB 的轻量模块里拿到“字段定义 数据生成”的能力时直接上 TPrototypeBindSource 会显得非常笨重。于是我自己写了一个 ratorAdapter 组件目的就是把它那一套经典工作方式“定义字段列表关联数据生成器”抽出来做成一个更轻、更好控制、也更容易嵌入现代 UI 框架的适配层。这篇文章梳理一下我从需求分析到落地实现的完整过程包括字段元数据怎么组织、数据生成器怎么关联、UI 消费端怎么对接以及实测过程中遇到的几个有代表性的坑。适合对 LiveBindings 有一定了解、想做一个轻量数据生成层或者打算封装一套“原型数据源”组件的开发者参考。1. TPrototypeBindSource 的工作拆解字段列表和数据生成器到底在做什么1.1 从放组件到出数据标准链路长什么样先回顾一下 TPrototypeBindSource 的标准操作流程因为 ratorAdapter 要复现的核心链路就在这里面。第一步在窗体上放一个 TPrototypeBindSource。第二步双击组件打开字段编辑器右键添加字段设置 FieldName、DisplayName、DataType、Size。第三步每个字段关联一个生成器比如字符串字段关联 TDataGenerator 里名字格式、数字字段关联随机整数。第四步往窗体上放几个 TEdit、TLabel打开 LiveBindings Designer把字段拖拽到控件的 Text 属性上。第五步运行程序数据就自动一列一列地出现。这套链路本质上是三件事维护一份字段定义列表描述“这个数据源有哪些列、每列叫什么、什么类型、多长多大”。为每个字段安排一个数据生成策略解决“这一列的值从哪来”。提供一种消费方式把生成出来的行数据暴露给 UI 控件让控件能拿到值并且响应翻页、刷新。TPrototypeBindSource 用 TDataSet 的子类身份把所有事件串起来字段定义走 TField 体系生成策略走 TDataGenerator消费端走 LiveBindings 表达式绑定。这三者耦合得比较紧但也正因为紧才保证了开箱即用。1.2 依赖面和不适用的场景TPrototypeBindSource 的问题不在功能而在依赖面。它继承自 TDataSet意味着任何使用它的单元都会把 Data.DB 拉进来。如果你的项目是基于 FireMonkey 做自绘列表或者用 Skia 画自定义控件又或者只是某个工具类模块需要造数据这时候引入整个 Data.DB 是很不划算的。另一个痛点是 LiveBindings 表达式的调试。绑定表达式写起来快但出了问题排查麻烦。表达式的错误往往在运行时才暴露IDE 里调试器能给的信息有限。对于一个轻量数据生成模块我希望消费端是普通的接口调用或事件回调而不是一长串绑定表达式。还有一个场景很典型单元测试。给某个服务层写测试时需要传入结构稳定的假数据但又不希望依赖数据库。TPrototypeBindSource 在脱离 VCL/FMX 组件的纯逻辑测试环境里并不好用而一个轻量数据生成器可以很自然地嵌入 DUnitX 测试流程。1.3 ratorAdapter 的设计目标基于上面的痛点我给 ratorAdapter 定了几个硬性目标不依赖 Data.DB字段元数据自持。数据生产策略可替换每个字段可以独立指定生成器。消费端提供多种接入方式既能事件回调也能游标式访问还能直接导出为内存对象列表。支持行数控制、当前行号、重置等基本操作满足原型数据源的核心需求。这样一来ratorAdapter 的定位就不是“精简版 TPrototypeBindSource”而是一个“字段契约 数据生成器 消费接口”的轻量适配组件TPrototypeBindSource 只是我拆解需求时的参照物。2. 字段列表的实现用元数据定义数据契约而不是再造一个数据集2.1 字段元数据类型设计字段列表是 ratorAdapter 的地基。在设计初期我对比过两条路直接复用 TField 数组还是自建元数据类。最后选了后者。原因很简单TField 是 Data.DB 体系的一部分它背后的属性非常多而我只关心几个要素名字、显示名、类型、长度、生成器标识。最终实现的元数据类是这样的type TAdapterFieldType (aftString, aftInteger, aftFloat, aftBoolean, aftDate, aftEnum); TAdapterFieldMeta class private FName: string; FCaption: string; FDataType: TAdapterFieldType; FSize: Integer; FProviderId: string; public property Name: string read FName write FName; property Caption: string read FCaption write FCaption; property DataType: TAdapterFieldType read FDataType write FDataType; property Size: Integer read FSize write FSize; property ProviderId: string read FProviderId write FProviderId; end;字段类型我用了枚举而不是 Delphi 的 TFieldType是为了把字段类型限定在“数据生成”需要的范围内。字符串、整数、浮点、布尔、日期、枚举覆盖了 90% 以上的原型数据场景。Size 字段对字符串表示最大长度对数值表示精度控制具体怎么解释交给生成器。2.2 字段列表的注册与查询字段列表的容器类负责增删查设计上严格限制了重复字段名type TAdapterFieldDefs class private FList: TObjectListTAdapterFieldMeta; public function AddField(const AName, ACaption: string; ADataType: TAdapterFieldType; ASize: Integer 0; const AProviderId: string ): TAdapterFieldMeta; function FindByName(const AName: string): TAdapterFieldMeta; function Count: Integer; function Items(AIndex: Integer): TAdapterFieldMeta; end;AddField 内部会先做重名校验FindByName 采用大小写不敏感的比较方式。这个细节很重要后面踩坑记录里会细说。字段注册的顺序决定了生成行数据时列的顺序所以 Items 的 index 语义必须稳定。我特意没给容器加插入、删除的公开接口字段列表一经注册结构就不允许动态变化避免消费端拿到一个“列数在变”的数据源。2.3 为什么不用 TField 数组有人可能会问直接用 TField 不是也能定义字段吗确实可以甚至 TPrototypeBindSource 本身就是这么干的。但 TField 附带太多 Data.DB 相关语义数据集归属、验证事件、编辑状态、书签等等。这些语义在“生成虚拟数据”这个场景下是多余的而且会带来不必要的约束。我在最初原型里试过用 TStringField、TIntegerField 的实例来保存字段定义结果发现每次访问都要处理 DataSet 为 nil 的情况代码里到处是空指针保护非常别扭。自建元数据的另一个好处是序列化友好。TField 不是为 JSON 持久化设计的而元数据类可以很自然地映射为 JSON 对象这为后面做字段定义的配置化铺平了路。3. 数据生成器的关联逻辑让字段列表真正“长出”数据3.1 生成器接口与默认实现字段定义好了接下来是核心问题每一行的值怎么来。我设计了一个很小的生成器接口type IDataProvider interface [{9E626E2F-9A62-4C2A-93D1-AA39A9D0A11E}] procedure Reset; function Generate(const AMeta: TAdapterFieldMeta): TValue; end;接口只有两个方法Reset 把内部状态恢复到初始值Generate 根据字段元数据产出下一个值。返回值用 TValue 而不是 Variant因为 TValue 类型更精确跨平台行为也更可控。这个接口刻意做得窄实现成本低测试也容易。默认提供三个实现类TSequenceDataProvider按递增序列生成整数值或带前缀的编号字符串比如订单号“ORD0001、ORD0002”。TRandomDataProvider按字段类型生成随机值字符串从预设字符集里抽取整数在指定范围内浮动日期在区间内抖动。TEnumCycleDataProvider按枚举值循环输出适合生成状态列比如“待处理、处理中、已完成”。以随机字符串为例核心逻辑不复杂但有一个细节值得注意function TRandomDataProvider.Generate(const AMeta: TAdapterFieldMeta): TValue; const CharSet ABCDEFGHJKLMNPQRSTUVWXYZ0123456789; var I, L: Integer; S: string; begin L : AMeta.Size; if L 0 then L : 8; for I : 1 to L do S : S CharSet[Random(Length(CharSet)) 1]; Result : S; end;这里我特意在字符集里去掉了易混淆的字母 I 和 O避免生成的编码串在视觉上产生歧义。这种细节不影响功能但直接影响原型演示的效果。3.2 字段如何绑定生成器字段元数据里有一个 ProviderId 字段存的是生成器标识字符串。组件内部维护一个生成器注册表按名字存放 IDataProvider 实例type TProviderRegistry class private FMap: TDictionarystring, IDataProvider; public procedure RegisterProvider(const AId: string; AProvider: IDataProvider); function GetProvider(const AId: string): IDataProvider; end;字段关联生成器的时候只需要把 ProviderId 设置成注册表中已有的标识。默认情况下组件会在构造函数里注册三个内置生成器seq、random、enum-cycle。如果调用方没有指定 ProviderId组件会按字段类型自动分配到 random 生成器。这种“标识符关联”的方式比直接持有对象引用更灵活。它把字段定义和生成器实例的生命周期解耦了调用方可以随时替换某个标识对应的生成器实例而字段定义完全不用改。这在做测试时尤其有用测试用例里可以注册一个返回固定值的 fake provider保证断言稳定。3.3 生成一行数据的完整流程生成一行数据的流程比较直接也是组件最核心的一段代码function TRatorAdapter.CreateRow(const ARowIndex: Integer): TArrayTValue; var I: Integer; Meta: TAdapterFieldMeta; Provider: IDataProvider; begin SetLength(Result, FFieldDefs.Count); for I : 0 to FFieldDefs.Count - 1 do begin Meta : FFieldDefs.Items(I); if Meta.ProviderId then Provider : GetDefaultProvider(Meta.DataType) else Provider : FProviders.GetProvider(Meta.ProviderId); if Assigned(Provider) then Result[I] : Provider.Generate(Meta) else Result[I] : GetDefaultValue(Meta.DataType); end; end;这段逻辑看起来简单但隐含了一个重要原则生成器不持有行上下文每行数据都是独立生成的。这样设计的好处是支持随机访问消费端可以直接跳到第 N 行获取数据而不需要从第一行顺序生成到第 N 行。TPrototypeBindSource 因为是 TDataSet 模型游标必须顺序移动这在虚拟列表的场景里比较受限ratorAdapter 则天然支持任意行索引取值。4. 消费端对接UI 怎么安全地“吃到” ratorAdapter 的数据4.1 轻量事件回调模式数据生成层做完了要解决的问题是数据怎么送出去。最简单的方案是事件回调。组件暴露一个 OnGetRow 事件每翻一行触发一次事件参数里带上行号和当前行数据数组type TGetRowEvent procedure(Sender: TObject; const ARowIndex: Integer; const ARow: TArrayTValue) of object;调用方在事件里把 TValue 数组映射到具体控件就行。这个模式适合手写绑定的场景代码直白出问题容易定位。我在一个自绘分组列表的 DEMO 里用过这个模式每行只需要从 ARow 里取两个字段值事件处理函数十几行写完没有任何黑魔法。4.2 游标式访问模式有些消费端希望自己控制翻页逻辑不想被事件驱动。这时候可以暴露游标式接口组件内部维护当前行号提供 MoveFirst、MoveNext、GetFieldValue 这几个方法。procedure MoveFirst; procedure MoveNext; function GetFieldValue(const AName: string): TValue; function GetFieldValueByIndex(const AIndex: Integer): TValue;MoveNext 内部边界保护很关键超过行数上限时游标保持在最后一行并返回 False。调用方可以通过返回值判断是否翻页结束。我做这个模式的初衷是兼容老的 ListView 逐行填充逻辑实测下来在 VCL 的虚拟 ListView 里性能还不错。4.3 批量导出模式第三种模式是批量导出。组件提供一个方法一次性把所有行数据导出为对象列表type TAdapterRecord record RowIndex: Integer; Fields: TDictionarystring, TValue; end; function ExportAllRows(const AMaxCount: Integer -1): TObjectListTAdapterRecord;这个方法适合单元测试。测试代码拿到 TObjectList 之后可以任意断言某一行某个字段的值。因为 TObjectList 是内存对象测试用例里甚至可以修改数据再验证业务逻辑完全绕开了数据库依赖。三种模式其实可以并存我最后采用了组合式设计组件默认支持游标式访问事件回调是游标式访问的封装批量导出是基于游标式访问实现的。这样核心只有一个状态机避免三种模式各维护一套内部状态导致逻辑不一致。三种模式的适用场景对比如下消费模式适用场景优点缺点事件回调自绘列表、逐行刷新代码直白、易调试事件内逻辑多时难以复用游标式访问VCL 虚拟列表、FMX 列表手动控制翻页、边界清晰调用方需要自己维护循环批量导出单元测试、数据转换可直接断言、便于断言全量生成占用内存5. 实测踩坑记录类型映射、生命周期与刷新时序5.1 TValue 和原生类型转换TValue 虽然好用但用起来有个坑从 TValue 取原生类型时方法名容易让人产生错觉。AsString、AsInteger 这些方法在某些情况下会抛出异常比如一个 TValue 里存的是 Int64你调用 AsInteger 就可能出问题因为内部类型严格匹配。我第一次接 UI 时踩的就是这个坑。生成器返回整数时用的是 TValue.From 回调里我用 AsString 去取文本结果运行时抛了异常。后来统一改成先判断类型再取值if AValue.IsTypeInteger then ShowValue : AValue.AsInteger.ToString else if AValue.IsTypestring then ShowValue : AValue.AsString;这个写法丑但可靠。涉及跨组件传值时类型一致性比代码美观更重要。后来我干脆封装了一个 ToDisplayString 方法内部集中处理所有字段类型到字符串的转换UI 层再也不用关心 TValue 类型判断了。5.2 生成器释放顺序的悬空引用第二个坑是组件的释放顺序。我在早期的版本里FProviders 字典和 FFieldDefs 列表都是组件拥有的对象。组件析构时我先释放了 FFieldDefs然后才释放 FProviders。结果发现内置的 enum-cycle 生成器持有字段元数据的引用字段释放后生成器内部出现悬空引用。虽然不是每次必现但偶尔会在析构时访问到已释放的内存表现是崩溃时堆栈完全对不上。排查后我把析构顺序调整为先释放 FProviders再释放 FFieldDefs并且给生成器接口增加了 Release 约定任何实现 IDataProvider 的类不允许在接口方法中持有超过一次调用周期的字段引用。如果需要保留字段快照必须由调用方显式传入独立副本。这个约定写进组件文档后后面扩展自定义生成器时少了一类隐患。5.3 数据量变大时的生成效率原型数据源经常被忽视的问题是数据量。有人会觉得反正是假数据生成 10 万行无所谓。实测下来纯随机字符串每行 8 个字段、每字段 10 字符生成 10 万行大约耗时 200 毫秒左右肉眼感知不明显。但如果每行的字符串长度增加到 50并且生成器内部用了低效的字符串拼接耗时立刻翻到 3 秒以上。这里的关键优化点是字符串构建。早期我直接用 S : S Char 的方式循环拼字符串在小数据量下看不出来数据量一上去就成瓶颈。后续改用了 TStringBuilder 或在固定长度下直接操作字符数组性能提升非常明显。另一个优化是 batch 预生成批量导出时组件内部先按块预生成 TValue 数组再填充列表减少接口调用次数。大数据量还带来一个问题UI 线程的刷新。FMX 的列表滚动时如果每次都在事件里触发整个列表重建帧率会断崖式下降。我的处理办法是只在数据源的行数变化或字段结构变化时重建列表项滚动过程中按需访问 GetFieldValueByIndex不再触发全量刷新。5.4 字段大小写与重名字段查找的坑来自真实使用调用方在 JSON 配置里写了 “CustomerID”代码里注册的是 “customerid”FindByName 找不到界面就显示空白。TPrototypeBindSource 的字段名在 IDE 里由字段编辑器统一维护不容易出现这种不一致但 ratorAdapter 支持动态注册大小写问题就变得很现实。我最后在 FindByName 里统一走 AnsiSameText 比较同时在 AddField 时强制校验重名不管大小写写法的差异都视为冲突。文档里也明确写了“字段名不区分大小写但建议统一为小写开头的驼峰命名”实测下来团队协作时命名没有出现歧义。6. 扩展方向让 ratorAdapter 变成一套可复用的小框架6.1 基于 JSON 的字段定义与持久化字段元数据自持后一个很自然的扩展是支持 JSON 配置。把字段列表序列化成 JSON可以让整个原型数据源的字段定义脱离代码由配置文件驱动。这个能力在项目里非常实用产品和设计同学调整字段时不需要等开发改代码重新编译。序列化逻辑并不复杂核心就是 TAdapterFieldMeta 到 JSONObject 的映射。反序列化时走 AddField 批量注册校验逻辑也复用同一套。这样一套逻辑下来ratorAdapter 从“代码里定义字段”变成了“配置驱动字段”灵活性上了一个台阶。6.2 可复现的种子随机随机数据的一个痛点是不可复现。同一个界面每次运行显示的数据都不一样这在做截图对比、回归测试时不友好。TDataGenerator 本身没有很好的种子控制机制而 ratorAdapter 因为是自建体系实现种子随机成本很低。我去掉了全局 Random 调用改为每个随机生成器持有独立的 System.TRandom 实例并提供 Seed 属性。设置相同 Seed 后同一字段定义生成的序列就完全一致。这个特性在日常开发里可能感知不强但在自动化测试里非常关键测试结果可以稳定复现不用每次跑用例都手动核对数据。6.3 虚拟模式与按需生成最后一个扩展方向是虚拟模式。当前实现里批量导出是“全量生成再返回”10 万行数据会一次性生成并占满内存。这对原型工具来说可以接受但如果想把这个组件嵌入真实业务按需生成是更好的方案。虚拟模式的思路是组件不预先生成任何行只在访问 GetFieldValue(ARowIndex, AFieldIndex) 时才临时调用对应生成器生成值。这样内存占用基本为零代价是每次取值都要走一遍生成逻辑。实测在虚拟列表场景里由于只有可视区域的十几行会触发取值整体开销远小于全量生成。我把虚拟模式设计成一个开关默认关闭开启后 MoveNext 不再做行缓存只维护当前行号。这个改动让组件从“原型数据源”向“轻量内存数据适配层”又迈进了一步。写到这里ratorAdapter 从最初“再造一个 TPrototypeBindSource”的想法已经变成了一套字段定义、生成器注册、消费接口和扩展机制都很清晰的轻量组件。回过头看最有价值的不是我写了多少代码而是把 TPrototypeBindSource 那套沉重的依赖剥离之后核心逻辑反而更容易理解和维护。如果你也要做一个脱离数据库的假数据层建议先别急着照搬 TPrototypeBindSource 的完整机制分清楚你真正需要的是字段定义、数据生成还是那套 LiveBindings 绑定链路然后按需组装会比直接堆组件要轻松得多。