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 早期版本
1.2 新版(Revolution)
1.3 最本质的区别
旧: 数据文件 → 反射 → 对象 (每一行、每一个字段都反射一次) 新: 数据文件 → 生成代码直接赋值 → 对象 (编译器帮你把反射写死了)
这是两版最本质的区别,也是"能不能扛住几万行配置"的分水岭。
2逐项对比
| # | 维度 | 早期版本 | 新版(Revolution) | 影响 |
|---|---|---|---|---|
| 1 | 工具形态 | Unity Editor 菜单 | 独立 WPF 桌面程序 | 策划/TA 不用开 Unity 就能导表 |
| 2 | xlsx 解析 | 第三方 ExcelDataReader(Editor-only) | 自研 ZipArchive + XDocument | 去掉一个第三方依赖,且跨平台 |
| 3 | 数据格式 | .tang 二进制;JSON 用 Newtonsoft | 纯文本 TXT | 去掉 Newtonsoft,数据可读可 diff |
| 4 | 依赖总数 | Excel 解析库 + Newtonsoft | 0 | 升级不受第三方库绑架 |
| 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 可扩展 | 生成部分与手写部分分离 | 生成文件整体覆盖,手写扩展放业务侧 | ⚠️ 简化 |
| 字符串存 ID | StaticStringId,真实字符串在 C++ 侧 | 直接存 string | ❌ 不做 |
| 显式内存布局 + 指针 | unsafe + [FieldOffset] + 解引用 | 不用 unsafe | ❌ 不做 |
dtIndex + DllImport | C# 通过原生函数查 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,用解析速度换可维护性 |
5诚实说清代价与边界
新版不是没有代价,用之前要知道:
- TXT 比二进制大。字段名注释行 + 文本数字,体积通常是二进制的 1.5 ~ 3 倍。对配置表通常无所谓(还能被 AB 压缩);若某天表大到几 MB,需要考虑"导出时不写注释行"的开关。
- 纯文本可被随手改。二进制更难篡改,TXT 没有任何防改机制。配置表一般不在意,涉及数值公平性时请配合资源包校验。
- 自研 xlsx 解析有边界。只支持
.xlsx(Excel 2007+),不支持老的.xls(OLE 复合文档);不处理图表、批注、条件格式;公式取其缓存值。 - 全量重写两个代码文件。表很多时生成文件会变大,每次导表都会触发 Unity 重编译。当前 3 张表的演示规模远未到瓶颈;到几百张表时需要评估分文件策略。
- 还没有 schema 版本号。参考实现
struct带version属性,我们没做"数据文件版本与代码版本不匹配"的检查。当前靠"导表同时覆盖代码和数据"来保证一致。 - 运行时解析错误只跳过不报错。容器用
errorCount记下坏行数但不中断(避免一张表笔误让游戏起不来);目前没有把它输出到日志的地方 —— 接入项目时建议在RevDataTableManager里加一句日志。
6附:关键文件索引
导表工具(WPF,零依赖)
| 文件 | 职责 |
|---|---|
Core\XlsxReader.cs | xlsx 解析(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"); // 按表名