Unity游戏开发:使用ExcelDataReader实现动态Excel配置表读取与管理
1. 项目概述与核心痛点
在Unity游戏开发这条路上,策划和程序之间的“数据交接”绝对是个老生常谈的痛点。策划同学习惯在Excel里挥斥方遒,用表格定义角色属性、技能效果、关卡配置;而程序这边,需要的是结构化的、能在运行时高效读取的数据。传统的做法五花八门:让策划手动填Json、用ScriptableObject一个个拖拽、或者写个编辑器工具把Excel转成Unity认识的格式。这些方法要么效率低下容易出错,要么不够灵活,每次表格结构一改,程序和策划都得折腾一番。
最近在几个中小型项目里,我尝试并落地了一套新的配置管理思路,核心就是ExcelDataReader这个轻量级库。它不是什么新东西,但在Unity配置管理这个场景下,用对了方法,能带来意想不到的清爽。简单说,就是让游戏在运行时(或编辑器模式下)直接读取策划维护的原始Excel文件,将表格数据动态映射到C#的数据类中,实现“表格即配置,无需二次转换”。这听起来可能有点“离经叛道”,毕竟传统观念里,资源应该预编译或转换成专用格式。但经过多个项目的验证,在特定场景下,它的优势非常明显:策划可以随时修改Excel并立刻看到效果(配合编辑器工具),程序无需为每张表写转换代码,减少了中间环节和潜在的“手滑”错误。
这套方案特别适合配置表数量多、结构变化频繁的项目前期和中期,也适用于那些希望策划能更深度参与数据调试的团队。当然,它并非银弹,对于性能极度敏感的超大型表格或者需要加密的线上配置,可能需要更传统的预烘焙方案。但无论如何,掌握这种动态读取Excel数据的能力,都能让你的工具箱里多一件趁手的兵器。接下来,我就把这套从环境搭建、核心原理到实战优化、避坑指南的“保姆级”流程拆解给你看。
2. 环境准备与ExcelDataReader集成
2.1 为什么选择ExcelDataReader?
市面上处理Excel的库不少,比如EPPlus、NPOI等。选择ExcelDataReader主要有几个考量:首先,它非常轻量,专注于读取(这正是我们需要的核心功能),不包含复杂的写入和格式操作,因此DLL体积小,对Unity项目影响小。其次,它的许可证(MIT)非常友好,可以放心用于商业项目。最重要的是,它支持.NET Standard 2.0,与Unity较新的基于.NET Core/Standard的运行时兼容性很好,能直接读取.xlsx和.xls格式,满足了策划最常用的文件格式需求。
注意:Unity 2021 LTS及更新版本通常内置了较新的.NET运行时。如果你使用的是Unity 2018-2020等旧版本,可能需要确保项目设置为.NET 4.x Equivalent或.NET Standard 2.0,以避免兼容性问题。
2.2 安装与依赖项处理
ExcelDataReader本身依赖于System.Text.Encoding.CodePages这个包来处理一些特定编码的Excel文件。在Unity中安装这些库,最稳妥的方式是通过Unity的Package Manager添加NuGet包,或者直接下载DLL。
方法一:使用NuGet For Unity(推荐)如果你已经安装了NuGetForUnity这个插件,事情就很简单了。在Unity编辑器中,打开NuGet管理器,搜索ExcelDataReader和System.Text.Encoding.CodePages,分别安装即可。插件会自动处理依赖和DLL导入。
方法二:手动下载DLL
- 访问NuGet官网或类似仓库,下载
ExcelDataReader(例如版本3.6.0)和System.Text.Encoding.CodePages(例如版本4.7.0)的.nupkg文件。 - 解压
.nupkg文件(本质是个zip包),在lib文件夹下找到对应.NETStandard2.0或.NETFramework4.6.1的ExcelDataReader.dll和System.Text.Encoding.CodePages.dll。 - 在Unity项目的
Assets文件夹下,创建一个Plugins文件夹(如果还没有),将这两个DLL文件复制进去。 - 选中导入的DLL,在Unity Inspector面板中,确保它们的“Platform Settings”正确。通常
ExcelDataReader.dll需要确保在目标平台(如Windows、macOS、Android)被包含,而System.Text.Encoding.CodePages在非Windows平台(如iOS、Android)可能需要特殊处理或排除,因为其核心功能在非Windows环境可能受限。一个更通用的做法是,我们后续在代码中会处理这个编码依赖的初始化。
手动导入后,你可能会遇到一个常见错误:“Could not load file or assembly ‘System.Text.Encoding.CodePages’”。这是因为Unity在构建时没有自动包含这个依赖。解决方法是在你的代码中,在首次使用ExcelDataReader前,显式注册编码提供程序。我们会在核心代码部分详细说明。
2.3 处理跨平台编码问题(关键步骤)
这是集成过程中最大的一个坑。ExcelDataReader在读取某些Excel文件时,需要CodePages编码支持,尤其是在非Windows环境下。为了解决这个问题,我们需要在程序启动初期(比如在一个静态构造函数或初始化管理器Awake方法中)添加以下代码:
using System.Text; // 在游戏初始化脚本中,例如GameManager或专门的配置管理器 public class ConfigManager : MonoBehaviour { [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] private static void InitializeEncoding() { // 注册CodePages编码提供程序,这是关键! Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); Debug.Log("CodePages encoding provider registered."); } // ... 其他代码 }RuntimeInitializeOnLoadMethod这个属性确保了该方法在游戏场景加载前自动执行,非常适合做这种全局初始化。添加这行代码后,跨平台的编码问题基本就解决了。
3. 核心原理与数据读取流程拆解
3.1 ExcelDataReader 工作流解析
用ExcelDataReader读取Excel并转换成游戏可用数据,整个过程可以分解为几个清晰的步骤,我画了一个简单的逻辑图在脑子里,你可以跟着这个思路来理解:
- 打开文件流:使用
File.OpenRead或Resources.Load等方式,获取Excel文件的二进制流。这里建议将Excel文件放在Assets/Resources或Assets/StreamingAssets目录下,前者适合打包后只读访问,后者适合需要运行时更新配置的场景(但移动平台可能有路径限制)。 - 创建Reader:通过
ExcelReaderFactory.CreateReader(stream)方法,传入文件流,创建一个IExcelDataReader对象。这个Reader就是我们的核心读取器。 - 遍历工作表(Sheet):Excel文件可能包含多个工作表,通过
reader.ResultsCount获取数量,用reader.NextResult()切换到下一个表。 - 遍历行与列:在单个工作表内,通过
while (reader.Read())循环读取每一行。在每一行内,通过reader.FieldCount获取列数,用reader.GetValue(columnIndex)获取特定单元格的值。注意,索引是从0开始的。 - 数据转换与映射:读取到的单元格值是
object类型,可能是string、double、bool、DateTime等。我们需要根据策划表格的表头(通常是第一行)定义的字段名和类型,将这些值转换并赋值给我们预先定义好的C#数据类(如ItemConfig、SkillConfig)。 - 关闭与清理:使用完毕后,务必调用
reader.Close()和stream.Dispose()(或使用using语句自动管理)来释放资源,避免内存泄漏。
3.2 定义数据模型(C#类)
这是连接Excel表格和游戏逻辑的桥梁。策划表格的每一行,都应该对应一个C#类的实例。类的属性名最好与表格的表头名保持一致(或者通过映射关系关联),属性类型也要匹配。
假设我们有一个物品配置表(ItemConfig.xlsx),结构如下:
| ID | Name | Type | AttackPower | Description |
|---|---|---|---|---|
| 1001 | 木剑 | Weapon | 10 | 一把普通的木制长剑 |
| 1002 | 小血瓶 | Consumable | 0 | 回复50点生命值 |
那么对应的C#数据类可以这样定义:
[System.Serializable] // 方便在Inspector中查看或调试 public class ItemConfig { public int ID; public string Name; public string Type; // 或者定义成枚举Enum更好 public int AttackPower; public string Description; }如果表格结构复杂,比如有一列是数组(例如“合成材料ID列表:101,102,103”),那么我们在C#类中可以用List<int>来表示,并在读取时做字符串分割处理。定义清晰、匹配度高的数据模型,是后续自动映射的基础。
3.3 从文件流到数据对象的详细步骤
让我们把上述流程代码化,写一个通用的加载单张表格的方法:
using ExcelDataReader; using System.Collections.Generic; using System.IO; using UnityEngine; public static class ExcelLoader { public static List<T> LoadSheet<T>(string filePath, string sheetName = null) where T : class, new() { List<T> dataList = new List<T>(); // 步骤1: 打开文件流。这里假设文件在StreamingAssets下 string fullPath = Path.Combine(Application.streamingAssetsPath, filePath); if (!File.Exists(fullPath)) { Debug.LogError($"Excel file not found: {fullPath}"); return dataList; } using (var stream = File.Open(fullPath, FileMode.Open, FileAccess.Read)) { // 步骤2: 创建Reader using (var reader = ExcelReaderFactory.CreateReader(stream)) { int targetSheetIndex = 0; bool sheetFound = false; // 步骤3: 遍历工作表,找到目标Sheet do { if (string.IsNullOrEmpty(sheetName) || reader.Name == sheetName) { sheetFound = true; Debug.Log($"Reading sheet: {reader.Name}"); // 步骤4: 读取表头(假设第一行是表头) reader.Read(); // 读取第一行 int columnCount = reader.FieldCount; List<string> headers = new List<string>(); for (int i = 0; i < columnCount; i++) { headers.Add(reader.GetValue(i)?.ToString() ?? $"Column{i}"); } // 步骤5: 遍历数据行 while (reader.Read()) { // 跳过空行(所有单元格都为空) bool isEmptyRow = true; for (int i = 0; i < columnCount; i++) { if (reader.GetValue(i) != null) { isEmptyRow = false; break; } } if (isEmptyRow) continue; // 创建数据对象并映射 T item = new T(); System.Type type = typeof(T); var fields = type.GetFields(); // 获取所有公共字段 for (int col = 0; col < columnCount && col < headers.Count; col++) { object cellValue = reader.GetValue(col); string header = headers[col]; // 根据表头名找到对应的字段 foreach (var field in fields) { if (field.Name == header) { // 类型转换与赋值 SetFieldValue(item, field, cellValue); break; } } } dataList.Add(item); } break; // 找到目标sheet并读取完毕后跳出 } targetSheetIndex++; } while (reader.NextResult()); // 切换到下一个工作表 if (!sheetFound && !string.IsNullOrEmpty(sheetName)) { Debug.LogWarning($"Sheet '{sheetName}' not found in file '{filePath}'."); } } } return dataList; } private static void SetFieldValue(object obj, System.Reflection.FieldInfo field, object value) { if (value == null) return; System.Type fieldType = field.FieldType; try { if (fieldType == typeof(string)) { field.SetValue(obj, value.ToString()); } else if (fieldType == typeof(int)) { // 处理Excel中数字可能是double的情况 if (value is double d) field.SetValue(obj, (int)d); else if (int.TryParse(value.ToString(), out int intVal)) field.SetValue(obj, intVal); } else if (fieldType == typeof(float)) { if (value is double d) field.SetValue(obj, (float)d); else if (float.TryParse(value.ToString(), out float floatVal)) field.SetValue(obj, floatVal); } else if (fieldType == typeof(bool)) { // 处理Excel中的布尔值可能是字符串"TRUE"/"FALSE"或数字1/0 if (value is bool b) field.SetValue(obj, b); else if (value.ToString().ToUpper() == "TRUE" || value.ToString() == "1") field.SetValue(obj, true); else field.SetValue(obj, false); } // 可以继续添加其他类型的转换,如List<int>, Enum等 else { // 简单尝试直接转换,复杂类型需要自定义解析 field.SetValue(obj, System.Convert.ChangeType(value, fieldType)); } } catch (System.Exception e) { Debug.LogError($"Failed to set field '{field.Name}' with value '{value}': {e.Message}"); } } }这个LoadSheet方法是一个通用的模板,它通过反射(GetFields和SetValue)将表格行映射到任意类型T的对象。使用时,只需定义好数据类,然后调用:
List<ItemConfig> itemConfigs = ExcelLoader.LoadSheet<ItemConfig>("Configs/ItemConfig.xlsx", "Sheet1");实操心得:反射虽然方便,但在大量数据加载时可能有性能开销。对于性能要求高的场景,可以考虑为每种配置表预生成专用的、硬编码的解析方法,或者使用表达式树、IL生成等高级技术来优化映射速度。但在项目前期和大多数情况下,反射带来的便利性远大于其微小的性能损耗。
4. 高级应用与工程化实践
4.1 处理复杂数据结构(列表、字典、枚举)
策划的表格不可能总是简单的单值类型。比如,一个技能配置表里,可能有一列“效果ID列表”,用逗号分隔,如“1001,1002,1003”。或者“升级消耗”,是一个字典结构,如“金币:1000,钻石:50”。
处理列表类型:在C#类中定义public List<int> EffectIds;。在SetFieldValue方法中增加对List<int>的解析:
else if (fieldType == typeof(List<int>)) { string strValue = value.ToString(); if (!string.IsNullOrEmpty(strValue)) { var list = new List<int>(); foreach (var numStr in strValue.Split(',')) { if (int.TryParse(numStr.Trim(), out int num)) list.Add(num); } field.SetValue(obj, list); } }处理字典类型:在C#类中定义public Dictionary<string, int> UpgradeCost;。解析逻辑类似,按特定格式(如:分隔键值,,分隔条目)拆分:
else if (fieldType == typeof(Dictionary<string, int>)) { string strValue = value.ToString(); var dict = new Dictionary<string, int>(); if (!string.IsNullOrEmpty(strValue)) { foreach (var pairStr in strValue.Split(',')) { var kv = pairStr.Split(':'); if (kv.Length == 2 && int.TryParse(kv[1].Trim(), out int val)) dict[kv[0].Trim()] = val; } field.SetValue(obj, dict); } }处理枚举类型:这是提升代码可读性和安全性的好方法。首先定义枚举:
public enum ItemType { Weapon, Consumable, Armor, Material }在C#类中使用public ItemType Type;。在解析时,将字符串转换为枚举:
else if (fieldType.IsEnum) { string strValue = value.ToString(); if (System.Enum.TryParse(fieldType, strValue, true, out object enumValue)) // 忽略大小写 { field.SetValue(obj, enumValue); } }4.2 构建高效的数据管理器(ConfigManager)
我们不应该在每次需要配置数据时都去读一次Excel文件。最佳实践是,在游戏启动时(或场景加载时),将所有配置表一次性加载到内存中,并提供一个全局的访问入口。这就是ConfigManager的职责。
using System.Collections.Generic; using UnityEngine; public class ConfigManager : MonoBehaviour { private static ConfigManager _instance; public static ConfigManager Instance => _instance; // 数据存储字典:以数据类型为键,以ID为键的二级字典,方便快速查找 private Dictionary<System.Type, object> _configDatabases = new Dictionary<System.Type, object>(); void Awake() { if (_instance != null && _instance != this) { Destroy(gameObject); return; } _instance = this; DontDestroyOnLoad(gameObject); LoadAllConfigs(); } private void LoadAllConfigs() { // 加载所有配置表 var items = ExcelLoader.LoadSheet<ItemConfig>("Configs/ItemConfig.xlsx"); RegisterConfig(items, config => config.ID); // 注册,指定ID为键 var skills = ExcelLoader.LoadSheet<SkillConfig>("Configs/SkillConfig.xlsx"); RegisterConfig(skills, config => config.SkillID); // ... 加载其他表 Debug.Log("All configs loaded."); } // 注册配置表到数据库 private void RegisterConfig<T>(List<T> configList, System.Func<T, int> idSelector) where T : class { var dict = new Dictionary<int, T>(); foreach (var config in configList) { int id = idSelector(config); if (!dict.ContainsKey(id)) { dict[id] = config; } else { Debug.LogError($"Duplicate ID {id} found in config for type {typeof(T).Name}"); } } _configDatabases[typeof(T)] = dict; } // 对外提供根据ID获取配置的接口 public T GetConfig<T>(int id) where T : class { if (_configDatabases.TryGetValue(typeof(T), out object databaseObj)) { var database = databaseObj as Dictionary<int, T>; if (database != null && database.TryGetValue(id, out T config)) { return config; } } Debug.LogWarning($"Config not found for type {typeof(T).Name} with ID {id}"); return null; } // 获取整个配置列表(用于遍历) public List<T> GetAllConfigs<T>() where T : class { if (_configDatabases.TryGetValue(typeof(T), out object databaseObj)) { var database = databaseObj as Dictionary<int, T>; return new List<T>(database.Values); } return new List<T>(); } }这样,在游戏任何地方,你都可以通过ConfigManager.Instance.GetConfig<ItemConfig>(1001)快速获取到“木剑”的配置数据,高效且方便。
4.3 编辑器扩展:实时预览与热重载
让策划同学能即时看到配置修改的效果,能极大提升效率。我们可以为Unity编辑器创建一个工具窗口,监听Excel文件的变化,并自动重新加载和刷新游戏内的测试数据。
- 使用
FileSystemWatcher监听文件变化:在Editor脚本中,创建一个FileSystemWatcher对象,监控StreamingAssets/Configs目录下.xlsx文件的修改、创建、删除事件。 - 触发重新加载:当文件变化事件触发时,在Editor模式下调用
ConfigManager的重新加载方法(需要将加载逻辑与Awake解耦,提供一个Reload方法)。 - 刷新游戏内测试对象:可以设计一个简单的测试场景,用UI或调试信息展示配置数据。当配置重载后,通知这些测试对象更新显示。
注意事项:
FileSystemWatcher在编辑器中使用时,需要注意事件可能被多次触发(如保存文件时可能触发多个事件)。需要做防抖处理(Debounce),比如在事件触发后等待0.5秒再执行重载,避免短时间内重复操作。另外,确保只在Unity编辑器运行时启用监听,构建后不包含这部分代码。
#if UNITY_EDITOR using UnityEditor; using System.IO; [InitializeOnLoad] public class ExcelConfigWatcher { private static FileSystemWatcher _watcher; private static System.DateTime _lastChangeTime; private const double DEBOUNCE_SECONDS = 0.5; static ExcelConfigWatcher() { EditorApplication.update += OnEditorUpdate; string configPath = Path.Combine(Application.dataPath, "StreamingAssets", "Configs"); if (Directory.Exists(configPath)) { _watcher = new FileSystemWatcher(configPath, "*.xlsx"); _watcher.NotifyFilter = NotifyFilters.LastWrite; _watcher.Changed += OnExcelFileChanged; _watcher.EnableRaisingEvents = true; Debug.Log("Excel config watcher started."); } } private static void OnExcelFileChanged(object sender, FileSystemEventArgs e) { _lastChangeTime = System.DateTime.Now; } private static void OnEditorUpdate() { if (_lastChangeTime != default && (System.DateTime.Now - _lastChangeTime).TotalSeconds > DEBOUNCE_SECONDS) { _lastChangeTime = default; Debug.Log($"Excel file changed, triggering reload..."); // 这里可以发送一个事件,或者直接调用ConfigManager的Reload方法(如果它在Editor下可用) // 例如:ConfigManager.EditorReload(); // 或者使用EditorApplication.delayCall来在主线程执行 EditorApplication.delayCall += () => { if (EditorApplication.isPlaying) { // 假设ConfigManager有个静态方法用于编辑器重载 ConfigManager.EditorRequestReload(); } }; } } } #endif5. 性能优化、内存管理与安全考量
5.1 性能瓶颈分析与优化策略
直接使用反射进行字段映射,在加载成千上万行配置时,可能会成为性能瓶颈。我们可以从几个层面进行优化:
缓存反射信息:
Type.GetFields()和FieldInfo.SetValue在循环中调用开销大。可以在加载前,为每种配置类型T预计算并缓存其字段信息(字段名到FieldInfo的字典)。private static Dictionary<System.Type, Dictionary<string, System.Reflection.FieldInfo>> _fieldCache = new Dictionary<System.Type, Dictionary<string, System.Reflection.FieldInfo>>(); private static Dictionary<string, System.Reflection.FieldInfo> GetCachedFieldsForType(System.Type type) { if (!_fieldCache.TryGetValue(type, out var fieldDict)) { fieldDict = new Dictionary<string, System.Reflection.FieldInfo>(); var fields = type.GetFields(); foreach (var field in fields) { fieldDict[field.Name] = field; } _fieldCache[type] = fieldDict; } return fieldDict; }然后在映射循环中使用这个缓存字典来查找
FieldInfo,避免重复反射。使用
System.Linq优化空行判断:之前的空行判断是手动循环,可以用一行LINQ代替,更简洁(但需注意LINQ的微小开销,在极大量数据时,手动循环可能略优)。bool isEmptyRow = Enumerable.Range(0, columnCount).All(i => reader.GetValue(i) == null);异步加载:如果配置表非常大,同步加载可能导致主线程卡顿。可以考虑将文件读取和解析过程放到异步任务中,使用
async/await。但要注意,Unity的大部分API(如Debug.Log、实例化对象)需要在主线程执行,异步加载完成后需要通过UnityEngine.Dispatchers或主线程队列将数据传递回主线程进行注册。二进制序列化预烘焙(进阶):对于最终发布版本,如果对加载速度有极致要求,可以在构建流程(Build Pipeline)中添加一个后处理步骤。这个步骤在构建时读取所有Excel配置,将其解析并序列化成二进制格式(如使用
BinaryFormatter或MessagePack等更高效的序列化库)。运行时则直接加载这个二进制文件并反序列化,速度极快。这相当于把运行时解析的工作转移到了构建时。
5.2 内存管理与资源释放
ExcelDataReader和FileStream都实现了IDisposable接口,务必使用using语句确保资源被正确释放,尤其是在移动设备上,文件句柄泄漏可能导致问题。我们之前的代码示例已经使用了using,这是最佳实践。
对于加载到内存中的配置数据(List<T>和字典),它们会一直驻留内存直到游戏结束或管理器被销毁。对于大型项目,如果配置数据极其庞大(例如几十MB),需要考虑按需加载和卸载。例如,将配置按功能模块分组,只在进入某个模块时加载对应的配置,离开时释放。ConfigManager可以扩展为支持这种动态加载。
5.3 数据安全与校验
策划填表难免出错,程序需要有健壮的校验机制。
- 类型安全校验:在
SetFieldValue方法中,我们已经做了基本的类型转换和try-catch。可以进一步加强,对于数值类型,检查是否越界(如攻击力不应为负数);对于枚举类型,检查字符串是否有效枚举值。 - 业务逻辑校验:在配置加载完成后,可以遍历所有数据,执行一些业务层面的校验。例如,检查物品合成表中引用的材料ID是否存在于物品配置中;检查技能升级消耗的货币类型是否已定义。可以将这些校验规则抽象出来,方便维护和扩展。
- 数据版本与兼容性:如果表格结构(增删列)发生变化,旧的Excel文件可能导致解析失败或数据错乱。可以考虑在Excel文件中增加一个版本号列,或者在代码中为每种配置表定义版本,加载时进行校验,并提供数据迁移脚本或给出明确的错误提示。
- 敏感数据加密:线上游戏的配置表如果包含敏感信息(如抽奖概率、内部公式),需要对Excel文件或最终导出的数据文件进行加密。可以在构建时对二进制文件加密,运行时解密。但注意,完全阻止破解是不可能的,加密主要增加破解门槛。
6. 常见问题排查与实战技巧
6.1 典型错误与解决方案速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| “Could not load file or assembly ‘System.Text.Encoding.CodePages’” | 编码提供程序未注册,尤其在非Windows平台。 | 在程序启动时调用Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); |
| 读取到的数值全是字符串或类型错误 | Excel单元格格式为“文本”,或GetValue返回类型不匹配。 | 1. 检查Excel单元格格式,确保数字列是“常规”或“数值”格式。 2. 在 SetFieldValue中加强类型转换逻辑,如先尝试as double,再尝试Parse。 |
| 中文等非ASCII字符显示为乱码 | 文件编码问题。 | 确保Excel文件保存时使用正确的编码(如UTF-8)。在创建Reader时,可以尝试指定编码:ExcelReaderFactory.CreateReader(stream, new ExcelReaderConfiguration { FallbackEncoding = Encoding.GetEncoding(1252) });但更推荐从源头解决文件编码。 |
| 只能读取第一个工作表 | 没有正确循环reader.NextResult()。 | 使用do { ... } while (reader.NextResult());循环来遍历所有工作表。 |
| 空行被错误地解析为数据 | 没有判断空行。 | 在读取每一行数据前,检查该行所有单元格是否都为null或空字符串。 |
| 性能问题,加载大文件很慢 | 1. 反射开销大。 2. 单线程同步加载。 | 1. 使用反射缓存(见5.1节)。 2. 对于超大文件,考虑异步加载或预编译为二进制格式。 |
| 在Android/iOS上无法读取文件 | Application.streamingAssetsPath路径在移动平台是只读的,且访问方式不同(Android需要WWW或UnityWebRequest)。 | 1. 对于只读配置,在构建时确保文件在StreamingAssets下,运行时使用UnityWebRequest或File.ReadAllBytes(Android 7+可能需要UnityWebRequest)。2. 对于需要热更的配置,放在可写目录( Application.persistentDataPath),并从服务器下载。 |
| 编辑器下修改Excel后,游戏内数据没更新 | 1. 文件监听未生效。 2. 数据未重新加载。 3. Unity可能没有刷新 StreamingAssets。 | 1. 检查FileSystemWatcher路径和事件。2. 确保调用了重载逻辑。 3. 尝试在Excel保存后,在Unity编辑器中右键点击 Assets文件夹选择Reimport,或使用AssetDatabase.Refresh()。 |
6.2 实战中的“踩坑”心得
- 表头行的重要性:务必和策划约定好,第一行必须是表头,并且表头名与C#类的字段名严格一致(或建立明确的映射关系)。表头行是自动映射的基石。可以增加一个“注释行”或“类型行”(第二行注明类型,如int/string/list),让表格自描述性更强,但解析逻辑会更复杂。
- 空单元格与默认值:Excel中空的单元格,
reader.GetValue()会返回null。在设计C#数据类时,要考虑字段的默认值。对于值类型(如int, float),如果表格中为空,反射设置时会失败(因为无法将null赋值给值类型)。可以在SetFieldValue中为值类型提供默认值(如0),或者策划规范要求必填。 - 数字与字符串的模糊性:Excel里看起来是数字“1001”,程序可能读成double类型的1001.0。如果ID是int,直接转换(int)1001.0没问题。但如果ID是字符串,期望读成"1001",结果
value.ToString()可能会变成"1001"或"1001.0"。最好在策划规范中明确数字列的格式,并在解析代码中做兼容处理。 - 使用ScriptableObject作为缓存或备用方案:虽然本文核心是动态读取Excel,但可以和ScriptableObject结合。例如,开发一个编辑器工具,定期或手动将Excel数据导出为ScriptableObject资源。这样,运行时加载的是高效的ScriptableObject,同时保留了Excel编辑的灵活性。两者不是互斥,而是可以互补的。
- 版本控制友好性:Excel文件是二进制格式,在Git等版本控制中,差异对比不直观。可以考虑让策划使用
.csv(逗号分隔值)格式,这是纯文本,diff起来非常清晰。ExcelDataReader也支持读取.csv文件(使用ExcelReaderFactory.CreateCsvReader)。或者,在提交前将Excel转换成JSON或另一种易读的中间格式进行版本管理。
这套基于ExcelDataReader的配置管理方案,在我经历的几个快速迭代、配置繁多的项目中,确实显著提升了策划和程序的协作效率。它减少了中间转换环节,降低了出错概率,并且通过编辑器工具实现了近乎实时的配置反馈。当然,没有一种方案是完美的,你需要根据自己项目的规模、团队习惯和性能要求,灵活调整和裁剪。例如,对于超大型MMO,可能最终仍需走向客户端-服务器配置分离和预烘焙的方案;但对于中小型项目、独立游戏或原型开发阶段,这套动态读取Excel的思路,无疑是一把能让你更快进入开发状态的利器。
