ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity自动化数据导入:Excel转ScriptableObject全流程实现

Unity自动化数据导入:Excel转ScriptableObject全流程实现 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环境下有几个主流选择EPPlus一个强大的.NET库但需要注意其较新的版本4.5在部分Unity版本中可能存在许可证或依赖冲突。对于Unity我们通常使用一个兼容的、较旧的版本或者寻找社区移植的版本。NPOI另一个流行的.NET Excel处理库功能全面对xls和xlsx格式支持都很好且没有EPPlus的许可证问题在Unity中兼容性通常更佳。ExcelDataReader一个轻量级的读取库专注于读取数据而非写入或复杂格式操作。如果只是单纯读取数据它是非常高效和简洁的选择。我的选择与理由在多数生产项目中我倾向于使用ExcelDataReader。原因很简单我们的需求核心是“读取”数据它足够轻量依赖少在Unity各版本中引入问题概率低。对于复杂格式如合并单元格、复杂公式我们通常要求策划规范表格结构保证第一行为有效列名第二行开始为数据这也能让工具更健壮。如果项目确实需要处理复杂Excel文件NPOI是更强大的后备选择。其次是代码生成部分。我们不需要复杂的模板引擎Unity的System.CodeDom命名空间或直接使用StringBuilder进行字符串拼接就足以生成格式正确的C#类代码字符串然后通过File.WriteAllText写入到项目脚本目录。最后是ScriptableObject的创建与序列化。这完全依赖于Unity自身的APIScriptableObject.CreateInstance和AssetDatabase.CreateAsset。2.2 工作流设计整个工具的工作流可以设计为一个编辑器窗口Editor Window或一个简单的菜单项其逻辑流程如下选择Excel文件用户通过文件对话框选择目标Excel文件。解析表头读取Excel第一行每一列的表头名将作为生成C#类的属性名。这里需要处理命名规范例如将“角色ID”转换为“RoleId”。推断数据类型读取第二行或前几行的数据样本尝试推断每一列的数据类型int, float, string, bool等。更严谨的做法是允许用户在工具界面手动指定或通过特定命名规则如“_int”后缀来标注类型。生成C#类文件根据表头名和推断的类型拼接出完整的C#类定义字符串并保存到项目的Scripts/Data或类似目录。这个类需要标记为[System.Serializable]并且继承自ScriptableObject。创建ScriptableObject资产遍历Excel数据行为每一行数据实例化一个上一步生成的类的对象并通过反射Reflection将单元格数据赋值给对应的属性。保存资产将每个实例化的对象使用AssetDatabase.CreateAsset保存为.asset文件通常存放在Resources或特定的Assets/Data文件夹下方便管理和加载。这个流程确保了从数据源Excel到运行时可用资产ScriptableObject的全链路自动化。3. 核心实现细节与实操要点理解了整体设计我们来深入每个环节的实现细节和需要注意的坑。3.1 Excel读取与数据推断使用ExcelDataReader基本读取代码如下using System.IO; using ExcelDataReader; ... public static ListDictionarystring, object ReadExcel(string filePath) { var dataList new ListDictionarystring, 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 Liststring(); 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 Dictionarystring, 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; }生成代码时要注意属性名合法性将表头中的空格、中文、特殊字符转换为合法的C#标识符。通常采用Pascal命名法首字母大写。文件保存位置生成的.cs文件应放在Unity项目的Assets目录下的某个文件夹如Assets/Scripts/AutoGenerated/这样Unity会自动编译。编译等待生成.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);这里有几个大坑类型转换异常Convert.ChangeType不是万能的如果单元格是空值或格式不对会抛出异常。必须用try-catch包裹或者实现更健壮的转换方法。性能问题反射在批量处理成千上万行数据时可能会有性能开销。如果数据量极大可以考虑预编译委托或者使用System.Reflection.Emit来生成动态方法但这会大大增加复杂度。对于大多数游戏配置表几百上千行直接反射的性能是可以接受的。依赖关系如果某列数据是引用另一个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() { GetWindowExcelToSOWindow(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; // 至少包含表头一行和数据一行 Liststring headers rawData[0]; // 第一行是表头 ListListobject 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 ListListobject ReadExcelData(string filePath) { // ... 使用前面提到的ExcelDataReader代码读取数据返回ListListobject } private static ListFieldInfo ParseHeaders(Liststring rawHeaders, ListListobject sampleData) { var infos new ListFieldInfo(); 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, ListFieldInfo 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, ListFieldInfo fields, ListListobject 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($第{i2}行字段‘{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(ListListobject 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.CreateAsset和SaveAssets。1. 将生成过程放入后台线程或使用EditorApplication.delayCall分帧处理。2. 批量创建资产最后统一调用一次AssetDatabase.SaveAssets。Excel中有公式的单元格读出来是公式本身而非计算结果ExcelDataReader默认读取的是公式。在读取时配置reader.AsDataSet(new ExcelDataSetConfiguration() { ConfigureDataTable (_) new ExcelDataTableConfiguration() { UseHeaderRow true, ReadOfficeCachedValues true } })。或者要求策划在提供表格前将公式转换为值。生成的类文件覆盖了手动修改的类工具设计为每次生成都覆盖原文件。在工具中增加选项“是否覆盖已有类文件”。或者实现一个增量更新机制只更新数据类保留手动添加的方法。5.2 性能优化与稳定性提升缓存反射信息在CreateScriptableObjects循环中每次循环都通过dataType.GetField查找字段信息是低效的。可以在循环开始前一次性获取所有FieldInfo并缓存起来。var fieldInfosCache new ListSystem.Reflection.FieldInfo(); foreach (var fieldName in fieldNames) { var fi dataType.GetField(fieldName); if (fi ! null) fieldInfosCache.Add(fi); } // 在循环内使用 fieldInfosCache[j].SetValue(...)分帧处理与进度显示处理上万行数据时一定要分帧避免阻塞主线程。可以使用EditorCoroutine需导入包或EditorApplication.update模拟分帧。同时用EditorUtility.DisplayProgressBar显示进度条提升用户体验。错误处理与日志在整个流程的每个关键步骤读取文件、解析表头、类型转换、创建资产都要加入try-catch并输出有意义的错误信息到Unity控制台方便快速定位是Excel数据问题还是工具逻辑问题。5.3 高级功能扩展基础功能稳定后可以考虑以下扩展让工具更强大多Sheet支持一个Excel文件包含多个工作表如“物品表”、“角色表”。可以修改工具让用户选择导入哪个Sheet或者为每个Sheet生成独立的类和资产集合。复杂数据类型支持数组如“技能ID列表”可以在Excel中用“1,2,3”表示工具解析为int[]、枚举表头如“品质_enum:Common,Rare,Epic”、甚至嵌套引用如“装备ID”对应另一个ScriptableObject资产。这需要更复杂的表头约定和解析逻辑。增量更新与合并不是每次生成都删除旧资产而是根据Excel中的唯一ID如ID列来更新已有资产的字段值。这对于线上游戏的热更新配置非常有用。数据校验在导入过程中加入校验规则。例如检查ID是否唯一数值是否在合理范围内攻击力不能为负数引用ID是否存在等。发现错误时生成详细的错误报告而不是直接导入失败。生成数据管理器除了生成数据类本身还可以自动生成一个DataManager的单例类提供便捷的接口来按ID查找、加载所有配置数据。这进一步将数据使用也标准化了。这套从Excel到ScriptableObject的自动化流水线一旦搭建完成将成为项目数据驱动的基石。它不仅仅是一个工具更是一种规范引导策划和程序以一种高效、可靠的方式协同工作。最初的搭建可能需要一两天时间但它为整个项目周期节省的时间将是数十倍甚至上百倍。我的经验是在项目早期就引入并规范这套流程是保证中大型项目开发效率的关键决策之一。
返回列表