Unity自动化数据导入:Excel转ScriptableObject全流程实现
2026/8/26 21:48:12 网站建设 项目流程

1. 项目概述与核心价值

在Unity项目开发中,尤其是涉及大量配置数据驱动的游戏(如RPG、策略、模拟经营)或工具应用时,策划和运营同学最常用的数据载体就是Excel表格。一个常见的场景是:策划在Excel里调整了角色属性、道具价格或关卡配置,程序需要将这些数据“搬”到Unity里使用。传统做法是手动复制粘贴,或者写一个临时的解析脚本,但这种方法效率低下、容易出错,且难以维护。当表格结构变更或新增表格时,程序员又得加班加点。

“Unity导入Excel文件自动生成Class文件和ScriptableObject文件”这个项目,就是为了根治这个痛点。它的核心思路是自动化:你只需要提供定义好数据结构的Excel文件,运行一个工具,它就能自动为你生成对应的C#数据类(Class),并进一步将Excel中的数据实例化为Unity引擎原生支持的、可序列化的资产文件——ScriptableObject。这样一来,Excel就成了游戏数据的唯一来源,策划修改Excel后,程序只需一键操作,即可在Unity中更新所有数据,实现了策划与程序工作流的无缝、高效对接。

这个方案的价值远不止于“省事”。它强制建立了清晰的数据契约(生成的Class就是契约),减少了沟通成本;ScriptableObject作为资产,可以直接在Inspector面板中查看、被其他脚本引用,也支持运行时动态加载,非常灵活;同时,由于整个过程是自动化的,也极大降低了因手动操作导致的人为错误风险。无论是独立开发者还是大型团队,引入这样一套数据工作流,都能显著提升开发效率和项目质量。

2. 整体方案设计与技术选型

要实现这个自动化流程,我们需要拆解几个关键环节:如何读取Excel、如何根据表头生成C#类、如何将行数据填充到ScriptableObject实例中,以及如何优雅地集成到Unity编辑器。这里我分享一套经过多个项目验证的稳定方案。

2.1 核心组件与工具链

首先,我们需要选择一个可靠的Excel读取库。在Unity环境下,有几个主流选择:

  1. EPPlus:一个强大的.NET库,但需要注意其较新的版本(4.5+)在部分Unity版本中可能存在许可证或依赖冲突。对于Unity,我们通常使用一个兼容的、较旧的版本,或者寻找社区移植的版本。
  2. NPOI:另一个流行的.NET Excel处理库,功能全面,对xls和xlsx格式支持都很好,且没有EPPlus的许可证问题,在Unity中兼容性通常更佳。
  3. ExcelDataReader:一个轻量级的读取库,专注于读取数据而非写入或复杂格式操作。如果只是单纯读取数据,它是非常高效和简洁的选择。

我的选择与理由:在多数生产项目中,我倾向于使用ExcelDataReader。原因很简单:我们的需求核心是“读取”数据,它足够轻量,依赖少,在Unity各版本中引入问题概率低。对于复杂格式(如合并单元格、复杂公式),我们通常要求策划规范表格结构,保证第一行为有效列名,第二行开始为数据,这也能让工具更健壮。如果项目确实需要处理复杂Excel文件,NPOI是更强大的后备选择。

其次,是代码生成部分。我们不需要复杂的模板引擎,Unity的System.CodeDom命名空间或直接使用StringBuilder进行字符串拼接,就足以生成格式正确的C#类代码字符串,然后通过File.WriteAllText写入到项目脚本目录。

最后,是ScriptableObject的创建与序列化。这完全依赖于Unity自身的API:ScriptableObject.CreateInstanceAssetDatabase.CreateAsset

2.2 工作流设计

整个工具的工作流可以设计为一个编辑器窗口(Editor Window)或一个简单的菜单项,其逻辑流程如下:

  1. 选择Excel文件:用户通过文件对话框选择目标Excel文件。
  2. 解析表头:读取Excel第一行,每一列的表头名将作为生成C#类的属性名。这里需要处理命名规范,例如将“角色ID”转换为“RoleId”。
  3. 推断数据类型:读取第二行(或前几行)的数据样本,尝试推断每一列的数据类型(int, float, string, bool等)。更严谨的做法是允许用户在工具界面手动指定或通过特定命名规则(如“_int”后缀)来标注类型。
  4. 生成C#类文件:根据表头名和推断的类型,拼接出完整的C#类定义字符串,并保存到项目的Scripts/Data或类似目录。这个类需要标记为[System.Serializable],并且继承自ScriptableObject
  5. 创建ScriptableObject资产:遍历Excel数据行,为每一行数据实例化一个上一步生成的类的对象,并通过反射(Reflection)将单元格数据赋值给对应的属性。
  6. 保存资产:将每个实例化的对象,使用AssetDatabase.CreateAsset保存为.asset文件,通常存放在Resources或特定的Assets/Data文件夹下,方便管理和加载。

这个流程确保了从数据源(Excel)到运行时可用资产(ScriptableObject)的全链路自动化。

3. 核心实现细节与实操要点

理解了整体设计,我们来深入每个环节的实现细节和需要注意的坑。

3.1 Excel读取与数据推断

使用ExcelDataReader,基本读取代码如下:

using System.IO; using ExcelDataReader; ... public static List<Dictionary<string, object>> ReadExcel(string filePath) { var dataList = new List<Dictionary<string, object>>(); using (var stream = File.Open(filePath, FileMode.Open, FileAccess.Read)) { using (var reader = ExcelReaderFactory.CreateReader(stream)) { var result = reader.AsDataSet(); var table = result.Tables[0]; // 假设只读取第一个工作表 // 读取表头(第一行) var headers = new List<string>(); for (int col = 0; col < table.Columns.Count; col++) { headers.Add(table.Rows[0][col].ToString()); } // 读取数据(从第二行开始) for (int row = 1; row < table.Rows.Count; row++) { var rowData = new Dictionary<string, object>(); for (int col = 0; col < table.Columns.Count; col++) { rowData[headers[col]] = table.Rows[row][col]; } dataList.Add(rowData); } } } return dataList; }

数据类型推断是关键且容易出错的一步。一个单元格在Excel里可能是数字,但读出来可能是double类型,而我们需要的是int。简单的推断逻辑可以是:

  • 尝试转换为int,成功则为int
  • 尝试转换为float/double,成功则为float
  • 尝试转换为bool(如“是/否”、“True/False”),成功则为bool
  • 其他情况,一律作为string处理。

更推荐的做法是在Excel表头中通过后缀约定类型,例如“攻击力_int”、“价格_float”、“描述_string”、“是否解锁_bool”。这样工具解析时直接根据后缀确定类型,更加准确可靠。

3.2 C#类文件生成

生成类文件本质是字符串拼接。我们需要根据表头生成属性。例如,对于表头“ID_int”和“Name_string”,需要生成如下类:

using UnityEngine; [System.Serializable] public class ItemData : ScriptableObject { public int ID; public string Name; }

生成代码时要注意:

  1. 属性名合法性:将表头中的空格、中文、特殊字符转换为合法的C#标识符。通常采用Pascal命名法(首字母大写)。
  2. 文件保存位置:生成的.cs文件应放在Unity项目的Assets目录下的某个文件夹(如Assets/Scripts/AutoGenerated/),这样Unity会自动编译。
  3. 编译等待:生成.cs文件后,需要调用AssetDatabase.Refresh()来触发Unity重新编译脚本。在编译完成之前,下一步创建ScriptableObject实例时会找不到这个新类,导致反射失败。因此,可能需要添加一个短暂的延迟或等待编译完成的逻辑。

3.3 通过反射创建与填充ScriptableObject

这是最核心也最需要小心的一步。我们有了类名(如“ItemData”)和一行行数据字典,需要动态创建该类的实例并赋值。

// 假设className是生成的类名,如“ItemData” System.Type dataType = System.Type.GetType(className); if (dataType == null) { // 处理类型未找到的情况,可能是编译未完成 Debug.LogError($"Class {className} not found. Please ensure script compilation is complete."); return; } // 创建实例 ScriptableObject instance = ScriptableObject.CreateInstance(dataType); // 通过反射为属性赋值 foreach (var kvp in rowData) { var prop = dataType.GetProperty(kvp.Key); // 或GetField,取决于生成的是属性还是字段 if (prop != null && prop.CanWrite) { // 类型转换:将object类型的单元格值转换为属性对应的类型 object convertedValue = Convert.ChangeType(kvp.Value, prop.PropertyType); prop.SetValue(instance, convertedValue, null); } } // 创建资产文件 string assetPath = $"Assets/Resources/Data/{instance.ID}.asset"; // 假设用ID作为文件名 AssetDatabase.CreateAsset(instance, assetPath);

这里有几个大坑:

  1. 类型转换异常Convert.ChangeType不是万能的,如果单元格是空值或格式不对,会抛出异常。必须用try-catch包裹,或者实现更健壮的转换方法。
  2. 性能问题:反射在批量处理成千上万行数据时可能会有性能开销。如果数据量极大,可以考虑预编译委托或者使用System.Reflection.Emit来生成动态方法,但这会大大增加复杂度。对于大多数游戏配置表(几百上千行),直接反射的性能是可以接受的。
  3. 依赖关系:如果某列数据是引用另一个ScriptableObject的ID,那么赋值时就不是简单的类型转换,而是需要根据ID去加载对应的资产。这需要在工具设计阶段就考虑好,并实现一个资源查找逻辑。

3.4 编辑器集成与用户体验

为了让策划或其他非技术人员也能使用,我们需要提供一个友好的编辑器界面。可以创建一个EditorWindow

using UnityEditor; using UnityEngine; public class ExcelToSOWindow : EditorWindow { private string excelPath = ""; private string outputClassPath = "Assets/Scripts/Data/"; private string outputAssetPath = "Assets/Resources/Data/"; [MenuItem("Tools/Excel to ScriptableObject")] public static void ShowWindow() { GetWindow<ExcelToSOWindow>("Excel to SO"); } void OnGUI() { GUILayout.Label("Excel导入设置", EditorStyles.boldLabel); excelPath = EditorGUILayout.TextField("Excel文件路径", excelPath); if (GUILayout.Button("浏览...")) { excelPath = EditorUtility.OpenFilePanel("选择Excel文件", "", "xlsx,xls"); } outputClassPath = EditorGUILayout.TextField("类文件输出路径", outputClassPath); outputAssetPath = EditorGUILayout.TextField("资产文件输出路径", outputAssetPath); if (GUILayout.Button("开始生成")) { if (string.IsNullOrEmpty(excelPath) || !File.Exists(excelPath)) { EditorUtility.DisplayDialog("错误", "请选择有效的Excel文件路径", "确定"); return; } // 调用核心生成逻辑 ExcelToSOGenerator.Generate(excelPath, outputClassPath, outputAssetPath); AssetDatabase.Refresh(); // 刷新资源数据库 EditorUtility.DisplayDialog("完成", "生成完毕!", "确定"); } } }

在界面中,我们还可以增加更多选项,比如选择工作表、设置类型推断规则、是否覆盖已有文件等,让工具更加灵活。

4. 完整实操流程与代码示例

让我们整合以上模块,形成一个完整的、可运行的生成器核心类。为了清晰,我将关键步骤封装在一个静态类中。

// ExcelToSOGenerator.cs using UnityEngine; using UnityEditor; using System; using System.Collections.Generic; using System.IO; using System.Text; using ExcelDataReader; public static class ExcelToSOGenerator { public static void Generate(string excelFilePath, string classOutputPath, string assetOutputPath) { // 1. 读取Excel var rawData = ReadExcelData(excelFilePath); if (rawData == null || rawData.Count < 2) return; // 至少包含表头一行和数据一行 List<string> headers = rawData[0]; // 第一行是表头 List<List<object>> dataRows = rawData.GetRange(1, rawData.Count - 1); // 剩余是数据 // 2. 清洗和解析表头,推断类型 var fieldInfos = ParseHeaders(headers, dataRows); // 3. 生成类名(通常用Excel文件名) string className = Path.GetFileNameWithoutExtension(excelFilePath); className = ToPascalCase(className) + "Data"; // 4. 生成C#类文件 string classContent = GenerateClassCode(className, fieldInfos); string classFilePath = Path.Combine(classOutputPath, className + ".cs"); WriteFileAndRefresh(classFilePath, classContent); // 5. 等待编译(这里简单处理,实际可能需要协程或回调) EditorUtility.RequestScriptReload(); System.Threading.Thread.Sleep(1000); // 等待1秒,让Unity编译 // 6. 创建ScriptableObject资产 CreateScriptableObjects(className, fieldInfos, dataRows, assetOutputPath); Debug.Log($"生成完成!类文件:{classFilePath},资产目录:{assetOutputPath}"); } private static List<List<object>> ReadExcelData(string filePath) { // ... 使用前面提到的ExcelDataReader代码读取数据,返回List<List<object>> } private static List<FieldInfo> ParseHeaders(List<string> rawHeaders, List<List<object>> sampleData) { var infos = new List<FieldInfo>(); for (int i = 0; i < rawHeaders.Count; i++) { string rawHeader = rawHeaders[i]; // 清洗表头,例如“角色ID_int” -> 字段名“RoleId”, 类型“int” string fieldName, fieldType; // 这里实现你的解析逻辑,例如按“_”分割 var parts = rawHeader.Split('_'); if (parts.Length == 2) { fieldName = ToPascalCase(parts[0]); fieldType = parts[1].ToLower(); // “int", "float", "string", "bool" } else { // 无类型后缀,尝试从样本数据推断 fieldName = ToPascalCase(rawHeader); fieldType = InferTypeFromSample(sampleData, i); } infos.Add(new FieldInfo { Name = fieldName, Type = fieldType }); } return infos; } private static string GenerateClassCode(string className, List<FieldInfo> fields) { StringBuilder sb = new StringBuilder(); sb.AppendLine("using UnityEngine;"); sb.AppendLine(); sb.AppendLine("[System.Serializable]"); sb.AppendLine($"public class {className} : ScriptableObject"); sb.AppendLine("{"); foreach (var field in fields) { sb.AppendLine($" public {field.Type} {field.Name};"); } sb.AppendLine("}"); return sb.ToString(); } private static void CreateScriptableObjects(string className, List<FieldInfo> fields, List<List<object>> dataRows, string assetPath) { System.Type dataType = System.Type.GetType(className); if (dataType == null) { // 尝试带上命名空间查找 dataType = System.Type.GetType(className + ",Assembly-CSharp"); if (dataType == null) { Debug.LogError($"无法找到类: {className}。请确认脚本已编译。"); return; } } // 确保输出目录存在 if (!Directory.Exists(assetPath)) { Directory.CreateDirectory(assetPath); } for (int i = 0; i < dataRows.Count; i++) { var row = dataRows[i]; ScriptableObject instance = ScriptableObject.CreateInstance(dataType); for (int j = 0; j < fields.Count && j < row.Count; j++) { var field = fields[j]; var value = row[j]; var prop = dataType.GetField(field.Name); // 假设生成的是public字段 if (prop != null) { try { object convertedValue = SafeConvert(value, field.Type); prop.SetValue(instance, convertedValue); } catch (Exception e) { Debug.LogWarning($"第{i+2}行,字段‘{field.Name}’赋值失败: {e.Message}"); } } } // 假设第一个字段是ID,用于命名资产文件 string idField = fields[0].Name; var idValue = dataType.GetField(idField)?.GetValue(instance); string fileName = (idValue ?? $"data_{i}").ToString(); string fullPath = Path.Combine(assetPath, $"{fileName}.asset").Replace("\\", "/"); AssetDatabase.CreateAsset(instance, fullPath); } AssetDatabase.SaveAssets(); } private static object SafeConvert(object value, string targetType) { if (value == null || value is DBNull) return GetDefaultValue(targetType); string strValue = value.ToString(); try { switch (targetType) { case "int": return Convert.ToInt32(value); case "float": return Convert.ToSingle(value); case "bool": return Convert.ToBoolean(value); case "string": default: return strValue; } } catch { return GetDefaultValue(targetType); } } private static object GetDefaultValue(string type) { switch (type) { case "int": return 0; case "float": return 0f; case "bool": return false; case "string": default: return ""; } } // 辅助结构体和工具方法... private class FieldInfo { public string Name; public string Type; } private static string ToPascalCase(string input) { /* 实现转换逻辑 */ } private static string InferTypeFromSample(List<List<object>> sample, int colIndex) { /* 实现推断逻辑 */ } private static void WriteFileAndRefresh(string path, string content) { /* 写文件并刷新AssetDatabase */ } }

这个生成器类集成了读取、解析、生成、创建的全过程。使用时,只需要在编辑器窗口中调用ExcelToSOGenerator.Generate(...)即可。

5. 常见问题、优化与高级技巧

在实际使用中,你肯定会遇到各种各样的问题。下面是我踩过坑后总结的一些常见问题和进阶优化方案。

5.1 常见问题排查表

问题现象可能原因解决方案
运行工具后,Unity报错“找不到类型”1. 生成的.cs文件未编译。
2. 类名包含命名空间,反射时未指定。
1. 生成后调用AssetDatabase.Refresh()并等待。可在生成后添加一个进度条或提示“编译中”。
2. 在反射Type.GetType()时,使用"Namespace.ClassName, AssemblyName"格式。
生成的ScriptableObject资产中,某些字段值为空或默认值1. Excel单元格为空。
2. 类型转换失败(如字符串转int)。
3. 字段名不匹配(表头清洗后与反射使用的名称不一致)。
1. 在SafeConvert方法中处理空值。
2. 加强类型转换的健壮性,记录转换失败的日志。
3. 确保生成类时的字段名与反射赋值时查找的字段名完全一致(包括大小写)。
导入大量数据时,编辑器卡死或无响应1. 单线程同步处理大量数据。
2. 频繁调用AssetDatabase.CreateAssetSaveAssets
1. 将生成过程放入后台线程或使用EditorApplication.delayCall分帧处理。
2. 批量创建资产,最后统一调用一次AssetDatabase.SaveAssets
Excel中有公式的单元格,读出来是公式本身而非计算结果ExcelDataReader默认读取的是公式。在读取时配置reader.AsDataSet(new ExcelDataSetConfiguration() { ConfigureDataTable = (_) => new ExcelDataTableConfiguration() { UseHeaderRow = true, ReadOfficeCachedValues = true } })。或者要求策划在提供表格前将公式转换为值。
生成的类文件覆盖了手动修改的类工具设计为每次生成都覆盖原文件。在工具中增加选项:“是否覆盖已有类文件”。或者实现一个增量更新机制,只更新数据类,保留手动添加的方法。

5.2 性能优化与稳定性提升

  1. 缓存反射信息:在CreateScriptableObjects循环中,每次循环都通过dataType.GetField查找字段信息是低效的。可以在循环开始前,一次性获取所有FieldInfo并缓存起来。

    var fieldInfosCache = new List<System.Reflection.FieldInfo>(); foreach (var fieldName in fieldNames) { var fi = dataType.GetField(fieldName); if (fi != null) fieldInfosCache.Add(fi); } // 在循环内使用 fieldInfosCache[j].SetValue(...)
  2. 分帧处理与进度显示:处理上万行数据时,一定要分帧,避免阻塞主线程。可以使用EditorCoroutine(需导入包)或EditorApplication.update模拟分帧。同时,用EditorUtility.DisplayProgressBar显示进度条,提升用户体验。

  3. 错误处理与日志:在整个流程的每个关键步骤(读取文件、解析表头、类型转换、创建资产)都要加入try-catch,并输出有意义的错误信息到Unity控制台,方便快速定位是Excel数据问题还是工具逻辑问题。

5.3 高级功能扩展

基础功能稳定后,可以考虑以下扩展,让工具更强大:

  1. 多Sheet支持:一个Excel文件包含多个工作表(如“物品表”、“角色表”)。可以修改工具,让用户选择导入哪个Sheet,或者为每个Sheet生成独立的类和资产集合。

  2. 复杂数据类型:支持数组(如“技能ID列表”可以在Excel中用“1,2,3”表示,工具解析为int[])、枚举(表头如“品质_enum:Common,Rare,Epic”)、甚至嵌套引用(如“装备ID”对应另一个ScriptableObject资产)。这需要更复杂的表头约定和解析逻辑。

  3. 增量更新与合并:不是每次生成都删除旧资产,而是根据Excel中的唯一ID(如ID列)来更新已有资产的字段值。这对于线上游戏的热更新配置非常有用。

  4. 数据校验:在导入过程中加入校验规则。例如,检查ID是否唯一,数值是否在合理范围内(攻击力不能为负数),引用ID是否存在等。发现错误时,生成详细的错误报告,而不是直接导入失败。

  5. 生成数据管理器:除了生成数据类本身,还可以自动生成一个DataManager的单例类,提供便捷的接口来按ID查找、加载所有配置数据。这进一步将数据使用也标准化了。

这套从Excel到ScriptableObject的自动化流水线,一旦搭建完成,将成为项目数据驱动的基石。它不仅仅是一个工具,更是一种规范,引导策划和程序以一种高效、可靠的方式协同工作。最初的搭建可能需要一两天时间,但它为整个项目周期节省的时间将是数十倍甚至上百倍。我的经验是,在项目早期就引入并规范这套流程,是保证中大型项目开发效率的关键决策之一。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询