设计说明 · 对比报告

Revolution 导表工具 对比与设计说明

新:RevolutionFramework Revolution.ExcelTool(WPF)+ Runtime\RevDataLoad

阅读对象:接手本框架的人、需要讲清"为什么重写"的人

0先给结论

问题早期版本答案新版答案
工具在哪运行Unity Editor 菜单(工具/读取Excel表数据/...)独立 WPF 桌面程序,与 Unity 完全解耦
读 xlsx 靠什么第三方 Excel.dll(ExcelDataReader,且仅 Editor 平台)自研 zip + XML 解析,零第三方依赖
数据文件格式自定义二进制 .tang + JSON(依赖 Newtonsoft)纯文本 TXT(自制转义,零依赖)
数据结构public class(堆分配,有 GC)public struct(值类型,零 GC)
数据怎么进内存反射(Activator + GetField 逐个塞)生成强类型 ParseRow,零反射、零装箱
运行期怎么取数据GetTable<T>().dataDic[key] 自己动手XxxTable.Instance.FindByKey(id, out cfg)
数据从哪读streamingAssetsPath/Binary 或 /Json,各平台手写分支统一走资源加载系统,业务只写逻辑路径
能不能卸掉没有单表卸载接口Unload<T>() / UnloadAll(),引用归还资源系统
出错怎么办边读边写,出问题靠日志导出前全量校验,报"第几行哪一列错什么"
一句话 早期版本是"能跑通的最小可用版本";新版是"把数据表当成一等资源来管"的版本。

1两条链路对照

1.1 早期版本

Excel(.xlsx) │ ├─ Unity Editor 菜单点击 ├─ ExcelDataReader(第三方 DLL) ──► DataSet ├─ 按约定取行:第1行名 / 第2行类型 / 第3行标 key / 第4行描述 / 第5行起数据 │ ├─ 生成 public class(无命名空间) │ [Serializable] public class Hero { public int id; ... } ├─ 生成容器 public class │ public class HeroContainer { public Dictionary<int, Hero> dataDic; } │ └─ 写数据文件 ├─ .tang 自定义二进制(只有它自己认识这个格式) └─ .json Newtonsoft.JsonConvert.SerializeObject(依赖第三方库) │ ▼ 运行时 BinaryDataMgr / JsonDataMgr └─ 反射建容器 → 反射取 dataDic 字段 → 反射塞数据 └─ 读 streamingAssetsPath/Binary 或 /Json

1.2 新版(Revolution)

Excel(.xlsx) │ ├─ 独立 WPF 工具(不需要开 Unity) ├─ 自研 XlsxReader(ZipArchive + XDocument,支持共享字符串池 / 内联串 / 稀疏单元格) ├─ 按约定取行:第1行名 / 第2行类型 / 第3行描述 / 第4行起数据,第一个字段是主键 ├─ 全量校验(类型 / 字段名合法性 / 主键为空 / 主键重复 / 数值列可解析) │ ├─ 生成两个文件 │ RevDataStructures.cs namespace Revolution { [Serializable] public struct Hero {...} } │ RevDataTables.cs public sealed class HeroTable : RevDataTable<int, Hero> │ { GetKey() + ParseRow() } ← 只有两个方法 │ └─ 写 TXT #Hero #ID Name Quality ... ← 注释行(表名/字段名/描述,运行时跳过) 1001 亚瑟 3 ... │ ▼ 运行时 RevDataTableManager └─ RevResManager.LoadAsync(table.ResourceRoot, table.ResourceName, typeof(TextAsset)) ← 走资源系统 两段默认就是 ("Data/", "Hero"),也就是 RevResPath.Data 与表名这两段 └─ 容器 LoadText() → 生成代码直接赋值 → Dictionary 索引

1.3 最本质的区别

旧:  数据文件 → 反射     → 对象   (每一行、每一个字段都反射一次)
新:  数据文件 → 生成代码直接赋值 → 对象   (编译器帮你把反射写死了)

这是两版最本质的区别,也是"能不能扛住几万行配置"的分水岭。

2逐项对比

#维度 早期版本 新版(Revolution) 影响
1工具形态Unity Editor 菜单独立 WPF 桌面程序策划/TA 不用开 Unity 就能导表
2xlsx 解析第三方 ExcelDataReader(Editor-only)自研 ZipArchive + XDocument去掉一个第三方依赖,且跨平台
3数据格式.tang 二进制;JSON 用 Newtonsoft纯文本 TXT去掉 Newtonsoft,数据可读可 diff
4依赖总数Excel 解析库 + Newtonsoft0升级不受第三方库绑架
5数据结构public class(引用类型)public struct(值类型)上万行表:省掉全部托管堆分配
6命名空间无namespace Revolution不会和其他库的 Hero 撞名
7容器形态public class + Dictionary 字段sealed class : RevDataTable<TKey,TData>查询能力继承自基类,不用重复生成
8填数据方式运行时反射(Activator/GetField/SetValue)生成期写死 data.Id = RevDataFieldParser.ToInt(cells[0]);零装箱、零反射
9主键约定第 3 行标 key,找不到则默认第 0 列;JSON 路径硬性要求主键字段名是 id 且为 string默认第一个字段,类型不限(int / string 均可)少一行元数据;字符串主键可直接用业务代号
10表头占几行名 / 类型 / key / 描述 = 4 行名 / 类型 / 描述 = 3 行策划少填一行
11查询 API无(自己 dataDic[k])FindByKey / Get / TryGet / GetByIndex / ContainsKey / All业务代码统一、可预测
12数据来源streamingAssetsPath/Binary、/Json,Android/iOS 手写分支逻辑路径 → 资源系统策略链业务不关心从哪读
13平台分支GetBinaryFilePath 里 #if 判平台,iOS 还要拼 file://无(RevABLoader 内部处理)业务零平台代码
14缓存无资源系统缓存(同表只读一次盘)不会重复读盘
15引用计数无资源系统引用计数不会被提前卸掉
16卸载无单表卸载Unload<T>() / UnloadAll(),归还资源引用表能被真正释放
17分组卸载无RevResGroup.Config + Shutdown()切场景可整组回收
18校验时机生成过程中导出前全量校验报错更完整
19报错粒度异常 / 日志第 5 行 [Cost]:"abc" 不是合法的 int直接指给策划
20坏表处理抛异常 / 中断坏表警告跳过,好表照常导出一张笔误不拖累全批
21输出目录生成前清空整个目录只覆盖两个固定文件名不会误删同目录的其他东西
22主键重复运行时决定(Overwrite/Skip/ThrowError)导出期就报错,运行期保守跳过问题前移到导表
23类型容错ConvertValue,bool 认 true/1/是/yes同样宽容 + InvariantCulture欧/中系统小数点是逗号也不会读错
24空单元格依赖 DataRow 默认值明确取默认值 0 / 0f / false / ""行为可预期
25格式契约写端(Editor)与读端(Runtime)各写一套RevDataTextFormat.cs 同一份源码被两端引用从根上不可能写读不一致
26文件编码—代码 UTF-8 带 BOM;数据 UTF-8 不带 BOM中文注释不乱码 / 注释行首字符正确
27导出粒度每次全部重生成(代码 + 数据一锅端)两种模式:仅生成数据(只写 txt、不碰代码、不触发脚本编译)/ 全量生成(代码 + 数据);另有"内容没变就不写"的增量判断只改数值的日常高频导出不用再等一次脚本编译

3新版的六个"好在哪"

1零依赖:不再被第三方库绑架

早期版本:框架被迫链上 Newtonsoft.Json(JsonDataMgr 与导表工具都用 JsonConvert),Excel 解析还要一个 Excel.dll。

新版:零第三方依赖。xlsx 本质是 zip + XML,用 ZipArchive + XDocument 就够;数据格式自己定,不需要 JSON 库。

收益:升级 Unity / 换 .NET 版本时不用等第三方库跟进,也不必为了"读个表"把整个 JSON 库拖进包里。

2零 GC:struct + 生成的强类型解析

早期版本每一行每个字段都走反射:

// 反射填数据
FieldInfo f = t.GetField(name);
f.SetValue(instance, ConvertValue(cell, f.FieldType));  // 每格一次装箱

SetValue(object) 对 int/float/bool 都要装箱。5 万行 × 10 个数值列 = 50 万次装箱,全变成 GC 垃圾。

新版是生成出来的直接赋值:

data.Id     = RevDataFieldParser.ToInt(cells[0]);
data.Attack = RevDataFieldParser.ToFloat(cells[3]);

收益:加载期几乎不产生 GC;值类型连续存放,缓存友好。

3生命周期正确:表是一种"资源"

早期版本只有 LoadTable<T,K>() 与 GetTable<T>(),没有卸载,tableDic 只增不减;且从 StreamingAssets 直接读(Android 要 UnityWebRequest、iOS 要拼 file://),绕过资源系统 = 没有缓存、没有引用计数。

新版统一走资源系统,白拿四件事:

await RevDataTableManager.LoadAsync<HeroTable>();
// ① 缓存   ② 引用计数
// ③ 分组卸载  ④ 平台透明

并且禁止直接拼 StreamingAssets 路径 —— 那等于绕开上面四条。

4数据可读、可 diff

早期版本 .tang 二进制:策划改一个数值,git diff 只会说"文件已更改",看不出改了哪一行。

新版 TXT 带表头注释:

#Hero
#ID  Name  Quality  Attack ...
#英雄ID 英雄名 品质(1-5) 攻击力 ...
1001  亚瑟  3  120.5  3600  3.8  false  秩序  持剑的圣骑士

收益:diff 直接显示"亚瑟攻击力从 120 改成 120.5";评审、查数值改动成本从"开工具比对"降到"看一眼"。

5错误前置:导表是集中暴露问题的唯一机会

早期版本边解析边生成,主键重复甚至要拖到运行时由 DuplicateKeyBehavior 决定。

新版导出前全量校验,并分级处理:

[错误] 第 5 行 [Cost]:"abc" 不是合法的 int
[错误] 第 6 行:主键 [Id] = 101 重复
[错误] 字段名 [英雄 id] 不能作为 C# 标识符
[提示] 表 [Item] 校验未通过,已跳过导出(2 个错误)

行号与 Excel 左上角一致;坏表跳过、好表照导。

6契约唯一:写端和读端是同一份源码

最容易踩、最难查的一类 bug:工具写出来的格式,运行时读不懂。

早期版本写端在 ExcelTool.cs、读端在 BinaryDataMgr.cs,格式各写一遍,一方改了另一方没同步就要到真机才暴露。

新版格式规则只存在于 RevDataTextFormat.cs,工具工程直接链接它:

<Compile Include="..\...\RevDataLoad\Core\RevDataTextFormat.cs"
         Link="Shared\RevDataTextFormat.cs" />

收益:改一处两端同时生效,"写读不一致"在结构上不可能发生。

7主键类型不限,表名可字符串驱动

早期版本两条路径的主键约定互相打架:JSON 路径硬性要求主键字段名必须是 id 且类型为 string(直接 GetField("id")),想用 int 主键就只能走二进制那条路。

新版主键类型完全由 Excel 第 2 行决定,int / string 都支持:

ID:int     →  RevDataTable<int, Hero>       FindByKey(1001)
ID:string  →  RevDataTable<string, Buff>    FindByKey("BUFF_ATK_UP")

运行时还能按字符串表名取表,"表名来自配置"的场景也能写:

RevIDataTable t = RevDataTableManager.Get("Buff");
RevDataTableManager.TryGet<BuffTable>("Buff", out var buff);
// 内部两张索引指向同一实例:
//   表名 → 容器(字符串查询) / 容器类型 → 容器(泛型,O(1))

工具侧还加了主键类型体检:用 float 或 bool 做主键会提示(浮点精度会导致"表里明明有却查不到";bool 最多两行数据,基本是配错了)。

4运用了参考实现导表系统的哪些设计哲学

参考文档:《参考实现导表工具与数据表系统总览》系列(00 ~ 08)

参考实现设计哲学参考实现的体现本框架的落地是否照搬
工具生成代码TDR 生成 struct + 容器类生成 struct + XxxTable : RevDataTable<,>✅ 照搬思想
数据结构是值类型partial struct + [FieldOffset]public struct✅ 只取值类型
容器类统一接口FindByKey / Count / GetByIndex同名三件套 + Get/TryGet/ContainsKey/All,且做成基类共享实现✅ 更彻底
数据与代码分离.bytes 数据 + 生成代码.txt 数据 + 生成代码✅ 照搬
零序列化按内存布局直接读 C++ 内存生成代码直接赋值,不经反射/装箱✅ 目标同手段异
本地读取 + 资源热更新数据文件走资源热更下发走资源加载系统(AB 可热更)✅ 照搬
一份定义多用同一份 XML 生成 C# / C++ / TS同一份格式源码被工具与运行时共用✅ 降维版
声明式定义cs_res_data.xml 定义表结构Excel 本身就是声明(行列约定),不额外维护 schema⚠️ 简化
容器类 partial 可扩展生成部分与手写部分分离生成文件整体覆盖,手写扩展放业务侧⚠️ 简化
字符串存 IDStaticStringId,真实字符串在 C++ 侧直接存 string❌ 不做
显式内存布局 + 指针unsafe + [FieldOffset] + 解引用不用 unsafe❌ 不做
dtIndex + DllImportC# 通过原生函数查 C++ 内存托管 Dictionary 查表❌ 不做

4.1 为什么"容器类统一接口"这一条我们做得更彻底

参考实现为每张表都生成一遍 FindByKey / Count / GetByIndex(几千张表 = 几千份重复代码,TdrGeneratedCode.cs 有 5.41 MB)。

我们把这些查询能力全部放进基类 RevDataTable<TKey, TData>,生成器只输出两个方法:

protected override int GetKey(in Hero data) => data.Id;                 // 主键怎么取
protected override bool ParseRow(string[] cells, out Hero data) {...}  // 一行怎么解析

于是每张表的生成代码只有二十来行,FindByKey 的行为由基类保证全表一致 —— 业务看一张表会查,就会查所有表。这是"在能简化的地方简化"。

4.2 为什么故意不抄 FieldOffset / StaticStringId / dtIndex

参考实现的手段它解决的问题本框架为什么不需要
[FieldOffset] 显式内存布局C# 要按字节对齐去读 C++ 侧那一致的内存数据完全在托管侧,用不着按字节定位
unsafe + 指针 + 值拷贝把 C++ 内存整块拷成 struct没有原生层,无从拷起
StaticStringId 字符串池几千条重复字符串只存一份 ID表规模小;引入字符串池要维护 ID 表,复杂度换不来收益
dtIndex + DllImport所有表共用一套 C++ 查询函数,靠索引区分托管 Dictionary 查主键是 O(1),够用
.bytes 二进制体积小、解析快、难篡改我们选可读可 diff 的 TXT,用解析速度换可维护性
一句话 参考实现的方案是为"几千张表 + C++ 内核 + 跨语言共享定义"量身定做的;本框架是纯 C# UnityEngine 项目,规模差好几个数量级。学它的设计哲学,不学它的实现手段 —— 否则会为了一个 3 万行的项目引入 5 MB 的生成代码和一堆原生互操作。

5诚实说清代价与边界

新版不是没有代价,用之前要知道:

  1. TXT 比二进制大。字段名注释行 + 文本数字,体积通常是二进制的 1.5 ~ 3 倍。对配置表通常无所谓(还能被 AB 压缩);若某天表大到几 MB,需要考虑"导出时不写注释行"的开关。
  2. 纯文本可被随手改。二进制更难篡改,TXT 没有任何防改机制。配置表一般不在意,涉及数值公平性时请配合资源包校验。
  3. 自研 xlsx 解析有边界。只支持 .xlsx(Excel 2007+),不支持老的 .xls(OLE 复合文档);不处理图表、批注、条件格式;公式取其缓存值。
  4. 全量重写两个代码文件。表很多时生成文件会变大,每次导表都会触发 Unity 重编译。当前 3 张表的演示规模远未到瓶颈;到几百张表时需要评估分文件策略。
  5. 还没有 schema 版本号。参考实现 struct 带 version 属性,我们没做"数据文件版本与代码版本不匹配"的检查。当前靠"导表同时覆盖代码和数据"来保证一致。
  6. 运行时解析错误只跳过不报错。容器用 errorCount 记下坏行数但不中断(避免一张表笔误让游戏起不来);目前没有把它输出到日志的地方 —— 接入项目时建议在 RevDataTableManager 里加一句日志。

6附:关键文件索引

导表工具(WPF,零依赖)

文件职责
Core\XlsxReader.csxlsx 解析(zip + XML,共享字符串 / 内联串 / 稀疏单元格)
Core\ExcelTable.cs表模型 + 配置规则校验(类型 / 主键 / 字段名 / 数值可解析)
Core\CodeGenerator.cs生成数据结构类 + 容器类
Core\DataTextWriter.cs生成 TXT 数据文件
Core\ExportService.cs导出编排(过滤 / 重名检查 / 编码 / 落盘)
MainWindow.xaml(.cs)界面

运行时

文件职责
RevDataLoad\Interfaces\RevIDataTable.cs容器统一契约
RevDataLoad\Core\RevDataTable.cs容器基类(Dictionary + List + 查询 API)
RevDataLoad\Core\RevDataTextFormat.cs★ 格式唯一实现(与工具共用同一份源码)
RevDataLoad\Core\RevDataFieldParser.cs字段取值(InvariantCulture、失败取默认值)
RevDataLoad\Manager\RevDataTableManager.cs对外唯一入口(加载 / 查询 / 卸载,走资源系统)
RevDataLoad\Manager\RevDataTableLoadException.cs加载失败异常

演示产物

路径内容
Revolution.Demo\ExcelTool\Excel\DemoConfig.xlsx示例表(Hero 英雄 / Item 物品 / Skill 技能)
Revolution.Demo\ExcelTool\Code\生成的结构体与容器类
Revolution.Demo\ExcelTool\Data\生成的 TXT 数据

业务侧用法

// 加载(走资源系统:编辑器直读工程 / 真机走 AB)
await RevDataTableManager.LoadAsync<HeroTable>();

// 查询(int 主键)
if (HeroTable.Instance.FindByKey(1001, out Hero hero))
    Debug.Log($"{hero.Name} 攻击力 {hero.Attack} 是否远程 {hero.IsRanged}");

// 查询(string 主键的表:ID 那一列写成 string 类型即可)
if (BuffTable.Instance.FindByKey("BUFF_ATK_UP", out Buff buff))
    Debug.Log($"{buff.Name} 持续 {buff.Duration}s");

// 字符串驱动:表名来自配置时也能取到表
if (RevDataTableManager.TryGet<BuffTable>("Buff", out BuffTable tbl))
    Debug.Log($"表 {tbl.TableName} 共 {tbl.Count} 行");

// 遍历
for (int i = 0; i < HeroTable.Instance.Count; i++)
    Handle(HeroTable.Instance.GetByIndex(i));

// 卸载
RevDataTableManager.Unload<HeroTable>();   // 按类型
RevDataTableManager.Unload("Buff");       // 按表名