1. 项目概述:为什么我们需要一个Excel配置工具?
在Unity游戏开发中,策划同学最常用的工具是什么?十有八九是Excel。从角色属性、技能效果、关卡配置到道具列表,Excel表格承载了游戏里绝大部分的静态数据。然而,从策划的Excel到程序能用的数据,这中间往往隔着一道“人工翻译”的鸿沟。程序需要手动将Excel内容转换成C#的类定义、解析代码,再在运行时加载和管理。这个过程不仅重复、枯燥,而且极易出错——策划改个字段名,程序就得跟着改代码;表格结构一变,解析逻辑可能就崩了。
我经历过不止一个项目,因为配置表管理混乱而踩坑。比如,一个字段从int改成float,但解析代码忘了同步更新,导致线上出现诡异的数值错误;又或者,为了热更新,配置表需要从Excel导出成Json、CSV、二进制等多种格式,手动操作费时费力,版本还容易对不上。所以,打造一个自动化、一体化的Excel配置工具,就成了提升团队协作效率和项目稳定性的刚需。
这个工具的核心目标很明确:让策划专注于填表,让程序专注于逻辑,让工具完成所有中间转换工作。它需要自动读取Excel文件,根据表头结构智能生成对应的C#数据类和解析代码,并将数据导出为项目所需的多种格式(如Json、ScriptableObject、二进制等)。最后,还需要一个运行时管理器,统一、高效地加载和提供这些配置数据。接下来,我将详细拆解这个工具的完整设计与实现思路。
2. 工具整体架构与设计思路
一个健壮的Excel配置工具,不能只是一个简单的格式转换脚本。它需要考虑到整个工作流的闭环:从策划编辑、程序使用,到最终打包发布。我的设计思路是将其拆分为三个核心模块:编辑器扩展模块、代码生成与数据导出模块、以及运行时数据管理模块。
2.1 核心模块划分与职责
编辑器扩展模块:这是工具的“门面”,集成在Unity Editor中。它需要提供友好的GUI,让策划和程序都能方便地操作。主要功能包括:指定Excel文件目录、配置导出规则(如哪些Sheet需要导出、导出成什么格式)、一键执行导出操作,以及查看导出日志和错误信息。
代码生成与数据导出模块:这是工具的“大脑”,负责最核心的解析与转换逻辑。它需要:
- 解析Excel结构:读取.xlsx或.xls文件,分析表头行(通常前两行定义字段名和数据类型),理解表格的意图。
- 生成C#数据类:根据表头信息,动态生成一个强类型的C#类。例如,一个
HeroConfig表,有id(int)、name(string)、hp(float)字段,工具就要生成对应的HeroConfig类。 - 生成解析代码:生成一个专用的
Loader类,这个类知道如何从Json/二进制等文件中,将数据反序列化到第2步生成的HeroConfig对象列表中。 - 导出多格式数据:将Excel中的数据内容,按照配置导出为Json、CSV、二进制(如使用
BinaryFormatter或MessagePack)等格式,甚至直接生成Unity的ScriptableObject资产。
运行时数据管理模块:这是工具的“心脏”,在游戏运行时发挥作用。它需要提供一个统一的接口(例如ConfigManager),让游戏逻辑能够方便、快速地获取任何配置数据。这个管理器要负责资源的加载(同步/异步)、缓存、以及可能的依赖管理和内存释放。
2.2 关键技术选型与考量
实现这个工具,有几个关键的技术选型点:
1. Excel读取库的选择Unity本身不提供Excel的直接读写能力。常见的方案有:
- EPPlus(需要.NET 4.x或更高):功能强大,支持.xlsx格式,但需要注意其在部分Unity版本下的兼容性,以及商业用途的许可问题。
- NPOI:一个开源的.NET库,同时支持.xls和.xlsx,兼容性较好,但API相对老旧一些。
- 轻量级解析(如CSV过渡):如果团队规范严格,可以要求策划将Excel另存为UTF-8编码的CSV文件,然后使用
StreamReader和字符串分割来解析。这种方式最简单,但失去了Excel的多Sheet、单元格格式等特性。
我的选择与理由:对于中型以上项目,我推荐使用NPOI。因为它开源免费,兼容性好,能处理新旧格式,且社区稳定。我们将通过一个独立的.NET Standard 2.0类库项目来封装NPOI的读取逻辑,然后在Unity中引用这个DLL,这样可以保持核心解析代码的纯净和可复用性。
2. 代码生成技术我们需要动态创建.cs文本文件。这里不需要复杂的编译器服务,直接用StringBuilder拼接字符串,然后使用System.IO.File.WriteAllText写入到项目的Scripts目录即可。关键在于生成的代码要规范、易读,并且符合项目的编码规范(如命名空间、注释)。
3. 数据序列化格式
- Json:人类可读,便于调试,通用性强。可以使用
Newtonsoft.Json(功能全)或Unity自带的JsonUtility(性能好但功能受限)。 - 二进制:文件小,加载快,但不可读。可以使用
BinaryFormatter(已过时,不推荐用于跨版本)、Protobuf-net或MessagePack。 - ScriptableObject:Unity原生资产,可以在编辑器内可视化编辑和引用,但数据直接包含在资源文件中,不适合纯数据配置的大规模使用。
- CSV:结构简单,但缺乏层次结构,适合扁平列表数据。
我的选择与理由:采用“Json为主,二进制为辅”的策略。开发阶段导出Json,便于策划和程序查看验证。发布版本则使用MessagePack导出二进制格式,兼顾性能与文件体积。同时,提供一个开关,允许为某些特殊表(如需要编辑器引用的)额外生成ScriptableObject。
4. 运行时管理策略管理器需要采用懒加载与缓存机制。即当第一次请求某个配置表时,才从磁盘(或AssetBundle)加载并解析,之后存入内存字典中。这避免了游戏启动时加载所有配置导致的卡顿。同时,管理器应设计为单例,并提供泛型接口,如T GetConfig<T>(int id),让使用方无需关心具体加载细节。
3. 核心实现细节拆解
3.1 Excel表头约定与解析规则
工具要和策划约定好Excel的编写规范,这是自动化解析的前提。我推荐一种经过多个项目验证的“三行表头法”:
- 第一行(字段名行):定义C#类中的属性名称。例如:
id,name,baseHp。这行决定了生成的类有什么字段。 - 第二行(数据类型行):定义每个字段对应的C#数据类型。例如:
int,string,float。工具需要解析这些字符串,映射到真正的类型。还可以支持简单容器,如int[]、List<string>,甚至自定义格式如Vector3(需要约定字符串格式,如1,2,3)。 - 第三行(注释行):可选,用于描述字段含义,这部分内容可以生成为C#属性的
[Tooltip]或/// <summary>注释,提升代码可读性。
从第四行开始,才是真正的数据内容。
解析器(使用NPOI)的工作就是按这个约定,读取前两行,构建一个FieldInfo列表,包含字段名、类型、注释。然后遍历数据行,将每个单元格的值根据类型行定义进行转换(如字符串转int,解析数组等)。
注意事项:单元格为空时的处理逻辑必须明确。是赋予默认值(如数值为0,字符串为空),还是跳过整条记录?通常建议赋予默认值,并在日志中给出警告,防止因策划漏填导致整张表解析失败。
3.2 C#代码的自动生成逻辑
代码生成是工具的灵魂,目标是生成“开箱即用”的代码。我们为每张需要导出的Sheet生成两个文件:
1. 数据定义类(如 HeroConfig.cs)这个类是一个纯粹的数据容器(DTO),只包含公共属性和一个唯一ID(通常对应表格的第一列)。为了便于序列化,属性通常使用{get; set;}自动属性。
// 自动生成的 HeroConfig.cs namespace Game.Config { [System.Serializable] public class HeroConfig { /// <summary> /// 角色ID /// </summary> public int id { get; set; } /// <summary> /// 角色名称 /// </summary> public string name { get; set; } /// <summary> /// 基础生命值 /// </summary> public float baseHp { get; set; } /// <summary> /// 技能ID列表 /// </summary> public int[] skillIds { get; set; } } }注意[System.Serializable]特性,这是为了支持Unity的序列化(如果用到JsonUtility)。对于数组/列表,生成工具要能正确解析数据类型行中的int[]标记。
2. 数据加载器类(如 HeroConfigLoader.cs)这个类封装了从数据文件到内存对象列表的转换逻辑。它内部持有一个字典或列表,并提供根据ID查询等接口。
// 自动生成的 HeroConfigLoader.cs namespace Game.Config { public class HeroConfigLoader { private static List<HeroConfig> _dataList; private static Dictionary<int, HeroConfig> _dataDict; // 初始化加载数据 public static void LoadData(string jsonText) { _dataList = Newtonsoft.Json.JsonConvert.DeserializeObject<List<HeroConfig>>(jsonText); _dataDict = _dataList.ToDictionary(x => x.id, x => x); } // 根据ID获取配置 public static HeroConfig GetById(int id) { if (_dataDict.TryGetValue(id, out var config)) return config; Debug.LogError($"HeroConfig 未找到ID: {id}"); return null; } // 获取所有配置 public static List<HeroConfig> GetAll() { return _dataList; } } }生成工具需要根据表头中定义的“ID字段名”(通常是第一个字段)来生成GetById方法。如果表没有唯一ID概念,则只生成列表和相关查询方法。
3.3 多格式数据导出策略
工具应该支持同时导出多种格式,以满足不同阶段的需求。在编辑器菜单中,我们可以提供一个配置面板,让用户勾选需要导出的格式。
Json导出:这是最直接的。将内存中反序列化好的对象列表(例如List<HeroConfig>),直接用Newtonsoft.Json序列化,并格式化输出,保存为.json文件。为了减小文件体积,发布时可以切换为不格式化的紧凑模式。
二进制导出(以MessagePack为例):首先需要通过NuGet为工具项目安装MessagePack库。导出逻辑与Json类似,但序列化器不同。
using MessagePack; byte[] bytes = MessagePackSerializer.Serialize(dataList); File.WriteAllBytes(outputPath, bytes);为了在Unity运行时能反序列化,游戏项目中也需要引用MessagePack库,并且数据类需要添加[MessagePackObject]和[Key]特性。我们可以在生成C#数据类时,通过条件编译符号来包含这些特性。
ScriptableObject导出:这个过程稍复杂。需要为每张表创建一个继承自ScriptableObject的类,并在其中包含一个List<HeroConfig>。然后,在导出流程中,实例化这个SO,填充数据,并使用AssetDatabase.CreateAsset和AssetDatabase.SaveAssets将其保存为.asset文件。这种方式生成的配置可以直接拖拽到Inspector面板中使用,适合编辑器扩展开发。
导出路径管理:清晰的目录结构至关重要。我建议:
Assets/ ├─ ConfigTool/ (工具代码) ├─ Generated/ (自动生成目录,不进版本控制) │ ├─ Scripts/ (生成的C#代码) │ ├─ JsonData/ (导出的Json文件) │ ├─ BinaryData/ (导出的二进制文件) │ └─ ScriptableObjects/ (导出的.asset文件) └─ Resources/ 或 StreamingAssets/ (运行时加载的数据文件,由工具复制过来)工具在导出后,应自动将对应格式的数据文件(如Json或Binary)复制到StreamingAssets或指定的Resources子目录下,供运行时加载。
4. 编辑器界面与工作流集成
4.1 自定义EditorWindow设计
为了让工具易用,我们需要创建一个EditorWindow。通过UnityEditor.EditorWindow.GetWindow<>可以打开一个自定义窗口。窗口内主要包含以下区域:
- 配置区:一个
ObjectField用于选择Excel文件所在的文件夹。一个ToggleGroup用于选择导出的数据格式(Json、Binary、ScriptableObject)。一个输入框用于设置生成的C#代码的命名空间。 - 执行区:一个显眼的“一键导出”按钮。点击后,开始遍历指定文件夹下所有
.xlsx文件,执行解析、生成、导出全套流程。 - 日志区:一个可滚动的
TextArea或ScrollView,实时显示导出进度、成功信息和错误警告(如类型解析失败、ID重复等)。错误信息需要高亮显示,并点击可以定位到具体的Excel文件和单元格。
4.2 自动化与性能优化
增量导出:每次导出都全量生成和复制文件是低效的。可以记录每个Excel文件的最后修改时间,只有当文件发生变化时,才重新处理该文件。这需要工具维护一份简单的元数据文件(如last_export_meta.json)。
异步操作与进度条:处理大量Excel文件时,UI会卡住。可以使用EditorApplication.update回调模拟协程,或者利用async/await(需注意Unity主线程限制),将耗时的文件读取和写入操作放到后台线程,并在主线程更新进度条。EditorUtility.DisplayProgressBar可以显示一个进度条。
错误恢复与日志:解析过程中,任何一行、一个单元格的错误都不应该导致整个流程崩溃。应该用try-catch包裹每个文件的处理逻辑,捕获异常并记录到日志区,然后继续处理下一个文件。这样策划可以一次性看到所有表格的问题,而不是改一个错导一次。
5. 运行时数据管理器的实现
运行时管理器的目标是让业务代码用最简单的方式获取配置。它的核心设计是一个泛型单例类ConfigManager。
5.1 统一加载接口与缓存机制
public class ConfigManager : MonoBehaviour { private static ConfigManager _instance; private Dictionary<Type, object> _configCache = new Dictionary<Type, object>(); public static T GetConfig<T>(int id) where T : class, IConfig { var loader = GetLoader<T>(); return loader?.GetById(id); } public static List<T> GetAllConfigs<T>() where T : class, IConfig { var loader = GetLoader<T>(); return loader?.GetAll(); } private static IConfigLoader GetLoader<T>() where T : class, IConfig { Type configType = typeof(T); if (_instance._configCache.TryGetValue(configType, out object loaderObj)) { return loaderObj as IConfigLoader; } // 动态加载:通过反射调用对应的 XxxConfigLoader.LoadData() string loaderTypeName = configType.Name + "Loader"; Type loaderType = Type.GetType($"Game.Config.{loaderTypeName}"); if (loaderType == null) { Debug.LogError($"未找到加载器类型: {loaderTypeName}"); return null; } // 假设数据文件在StreamingAssets下,以Json格式存储 string jsonPath = Path.Combine(Application.streamingAssetsPath, $"{configType.Name}.json"); string jsonText = File.ReadAllText(jsonPath); // 实际项目可能需要异步加载或WWW/UnityWebRequest MethodInfo loadMethod = loaderType.GetMethod("LoadData", BindingFlags.Public | BindingFlags.Static); loadMethod?.Invoke(null, new object[] { jsonText }); // 获取Loader的单例实例(假设Loader提供了Instance属性或静态方法) PropertyInfo instanceProp = loaderType.GetProperty("Instance", BindingFlags.Public | BindingFlags.Static); IConfigLoader loader = instanceProp?.GetValue(null) as IConfigLoader; if (loader != null) { _instance._configCache[configType] = loader; } return loader; } } // 所有配置类需要实现的空接口,用于约束 public interface IConfig { } public interface IConfigLoader { }这是一个简化版本。实际项目中,GetLoader<T>中的加载逻辑会更复杂,需要处理不同的数据格式(Json/Binary)、异步加载、以及从AssetBundle加载等情况。缓存机制避免了重复加载和解析,提升了运行时性能。
5.2 支持热更新与多语言扩展
热更新:如果配置数据需要热更新,就不能放在StreamingAssets(只读)里。可以将数据文件打包成AssetBundle,放在服务器上。运行时,ConfigManager首先检查本地持久化路径是否有更新版本的配置文件,如果没有则从服务器下载并加载最新的AssetBundle。管理器需要版本比对和下载逻辑。
多语言:配置工具可以很好地支持多语言。在Excel中,可以为需要翻译的字段(如name、description)建立多列,如name_cn、name_en。工具导出时,根据当前语言设置,选择对应的列数据导出到最终的Json/Binary文件中。或者,更专业的做法是单独维护一个语言键值表,配置表中只存储语言Key,运行时由ConfigManager根据Key去查询当前语言的实际文本。
6. 常见问题、调试技巧与进阶优化
6.1 开发与使用中的典型坑点
类型解析失败:这是最常见的问题。策划在“数据类型行”写错了类型,比如写了
int但单元格里是“一百”,或者自定义类型Vector3的格式不对。解决方案:工具必须在解析每个单元格时进行严格的类型校验,一旦失败,立即在日志中输出精确的错误位置(文件、Sheet、行、列),并赋予该字段安全的默认值。ID重复或为空:作为主键的ID列出现重复值或空值,会导致运行时查询出错。解决方案:在导出过程中,工具应增加一个校验步骤,对ID列进行唯一性和非空检查,并将错误视为严重错误阻止导出,直到策划修复。
代码生成覆盖手动修改:自动生成的代码如果被程序员手动修改过,下次导出又会被覆盖。解决方案:采用“部分类”(
partial class)设计。工具只生成一个partial的数据类,里面只包含属性定义。程序员可以在另一个单独的文件中,为这个类添加方法、扩展逻辑等。这样两边的代码互不干扰。大型表格导出慢:一个有几万行的配置表,导出Json或生成ScriptableObject时可能会卡住编辑器。解决方案:对于超大型表格,可以优化序列化逻辑,考虑流式写入文件而非一次性在内存中构建完整对象树。对于ScriptableObject,可以评估是否真的需要此格式,或者将其拆分为多个小文件。
6.2 高级特性与扩展方向
- 数据关联与校验:工具可以解析简单的关联关系。例如,
HeroConfig中有一个weaponId字段,工具可以检查这个ID是否存在于WeaponConfig表中。这需要在导出时,等所有表都解析到内存后,再进行一轮关联校验。 - 生成编辑器工具:基于生成的ScriptableObject,可以进一步为策划生成简单的编辑器界面,让他们能在Unity内直接编辑、预览配置效果,而无需总是打开Excel。
- 集成到CI/CD流程:将配置导出工具集成到版本管理(如Git)的提交后钩子(post-commit hook)或持续集成(如Jenkins)流程中。每当策划提交新的Excel文件,服务器自动触发导出流程,并运行单元测试校验数据有效性,确保进入版本库的配置数据总是可用的。
- 差分导出与合并:对于线上运营的游戏,可能只需要导出和更新变化的部分配置。工具可以计算当前版本与上次导出版本的差异,只生成增量数据包,减少热更新时的下载量。
6.3 一个实操心得:处理复杂数据结构
有时策划需要配置一些复杂结构,比如一个技能效果,包含作用目标、伤害公式、附加效果列表等。简单的单行数据无法表达。我们的解决方案是,在Excel中采用“子表”或“JSON字符串”的方式。
“子表”方式:在同一个Excel文件里,用多个Sheet表示。例如,主表SkillConfig的effects字段配置为EffectConfig[]。然后有一个名为SkillEffect的Sheet。工具在解析时,需要识别这种关联,将子表的数据作为对象列表,嵌入到主表对应的字段中。这要求策划和程序有更严格的约定。
“JSON字符串”方式:在Excel单元格中直接写入一个JSON字符串。工具在解析时,将该单元格的字符串用JsonUtility或Newtonsoft.Json反序列化成对应的复杂对象。这种方式给了策划最大的灵活性,但牺牲了Excel本身的表格校验能力,且容易写错JSON格式。折中方案是,工具提供一个“JSON校验”按钮,在导出前检查所有标记为JSON的单元格语法是否正确。
实现这样一个完整的Excel配置工具,初期投入确实需要一些时间,但一旦搭建完成,它将为整个项目团队节省无数的人力,并极大降低因配置错误导致的线上BUG。它不仅仅是程序员的工具,更是连接策划与程序、提升开发管线自动化水平的重要桥梁。从我个人的经验来看,在第二个项目引入这套工具后,配置相关的工作量减少了至少70%,策划也更愿意进行数值调整和尝试,因为反馈成本变得极低。