1. 项目概述为什么Unity开发者需要处理Excel数据在游戏开发尤其是Unity项目中我们经常遇到一个看似简单却让人头疼的需求如何把策划同学用Excel精心配置的数值表、关卡数据、道具属性安全、高效地读取到游戏里你可能试过把Excel另存为CSV然后用StreamReader一行行解析但很快就会发现中文乱码、逗号转义、数据类型混乱等问题。你也可能听说过一些重量级的插件功能强大但配置复杂对于一个小功能来说有点“杀鸡用牛刀”。这个项目要解决的就是提供一个简单、直接、可复现的Unity读取Excel表格的解决方案。它不追求大而全而是聚焦于“能用、好抄、不报错”。我会分享一个我打磨了多个项目、经过实战检验的轻量级方法并附上可以直接拖入项目使用的完整资源包。无论你是想快速读取角色属性表、本地化语言包还是动态加载关卡配置这套方案都能让你在10分钟内跑通流程把精力重新放回核心的游戏逻辑开发上。2. 核心方案选型为什么是EPPlus 内存流面对Unity读取Excel的需求社区里常见的方案有好几种比如System.Data.OleDb、NPOI、ExcelDataReader以及我这里主推的EPPlus。下面这张表格对比了它们的核心差异帮你理解我为什么做出这个选择。方案优点缺点Unity适配度System.Data.OleDb.NET原生无需额外DLL。严重依赖系统安装的Excel或Access数据库引擎在Mac、Linux或未安装Office的Windows上基本不可用。跨平台是噩梦。★☆☆☆☆NPOI开源强大支持.xls和.xlsx不依赖Office。DLL体积相对较大API略显陈旧和繁琐对于简单的读取操作有点重。★★★☆☆ExcelDataReader轻量读取速度快专为读取设计。主要专注于读取对Excel格式的支持如复杂单元格样式、合并单元格不如EPPlus全面且需要额外的ExcelDataReader.DataSet扩展来处理数据。★★★★☆EPPlus功能全面读写能力俱佳API现代易用。纯托管代码跨平台无忧。社区活跃文档丰富。在Unity旧版本如2018.3之前的.NET 3.5等价脚本运行时下需要寻找兼容版本。主要支持.xlsx格式。★★★★★为什么最终选择EPPlus对于绝大多数Unity项目尤其是2018.4 LTS及以后版本使用.NET 4.x等效脚本运行时EPPlus的优势是决定性的。它的API设计非常直观比如worksheet.Cells[行, 列].Value就能直接取到值学习成本极低。更重要的是它是纯C#实现的这意味着你编译好的游戏可以在Windows、Mac、Android、iOS上无缝运行无需担心任何外部依赖。这正是Unity跨平台开发的核心诉求。注意关于Unity版本与.NET如果你使用的是较老的Unity版本如2017.x默认使用的是.NET 3.5等效的脚本运行时此时直接使用最新版EPPlus要求.NET 4.5会编译报错。解决方案有两种1升级Unity到2018.4 LTS或更高版本并在Player Settings中切换到.NET 4.x等效。2寻找并导入专门为.NET 3.5编译的EPPlus 4.5.x版本DLL。强烈推荐方案1因为新版本Unity和.NET环境能带来更多语言特性和性能优化。“内存流”是关键技巧直接使用File.ReadAllBytes或File.OpenRead在Unity中可能会遇到路径权限问题尤其是在WebGL、移动平台。更优雅的做法是将Excel文件作为TextAsset虽然是二进制文件放入Resources文件夹或者通过AssetBundle加载获得字节数组byte[]后直接传入MemoryStream。EPPlus的ExcelPackage类完美支持从Stream初始化。这样做的好处是跨平台安全完全规避了文件系统的直接访问。便于资源管理可以和其他游戏资源一起打包、加载、更新。加载速度快内存流操作效率很高。3. 实战准备导入资源包与配置环境我的资源包里已经为你准备好了适配Unity的EPPlus DLL以及一个完整的示例场景和脚本。你只需要几步就能搭建好环境。3.1 导入EPPlus资源包获取资源包你可以从本文提供的链接下载UnityExcelReader.unitypackage。导入Unity在Unity编辑器中依次点击Assets - Import Package - Custom Package...选择下载的.unitypackage文件导入全部内容。检查导入结果导入后你的项目目录应该包含以下关键文件Plugins/EPPlus.dll: 核心的EPPlus库。确保其在Plugins文件夹内Unity会自动为其配置正确的平台兼容性。Examples/ExcelExample.unity: 一个演示场景。Examples/Scripts/ExcelReaderManager.cs: 核心的管理器脚本。Examples/Scripts/DataModels/: 这里定义了如何将Excel行数据映射到C#类数据模型。Resources/ExcelFiles/ItemConfig.xlsx: 一个示例Excel配置文件。3.2 理解项目结构在动手写代码前花两分钟理解这个设计模式它能让你以后处理任何配置表都游刃有余。Assets/ ├── Plugins/ │ └── EPPlus.dll ├── Resources/ │ └── ExcelFiles/ │ └── ItemConfig.xlsx └── Examples/ ├── ExcelExample.unity └── Scripts/ ├── ExcelReaderManager.cs └── DataModels/ └── ItemConfigData.csExcelReaderManager: 单例管理器负责统筹所有Excel文件的加载和缓存。你只需要跟它打交道。DataModels: 这里面的每个C#类对应一张Excel表的结构。例如ItemConfigData类它的属性public int ID {get; set;}就对应Excel表中的列。这是一种“约定优于配置”的简单ORM对象关系映射思想。Excel文件: 放在Resources文件夹下是为了演示方便可以用Resources.Load加载。在实际项目中对于大量或需要热更的配置更推荐使用AssetBundle。3.3 创建你的第一个Excel配置表让我们模仿示例创建一个“英雄等级配置表”。打开Excel或WPS创建新文件保存为HeroLevelConfig.xlsx。在第一个工作表Sheet1中第一行必须是列标题这将被用作数据模型的属性名。建议使用英文避免解析麻烦。LevelHpBaseAttackBaseExpToNext110010100215015200322022350将制作好的HeroLevelConfig.xlsx文件复制或拖拽到Unity项目的Assets/Resources/ExcelFiles/目录下。Unity会将其识别为TextAsset。实操心得Excel表格规范单表单页尽量将一类数据放在一个工作表的第一个页签Sheet1。管理器默认读取第一个工作表。首行是列名这是将表格列映射到C#类属性的关键。列名应使用合法的C#属性名无空格不以数字开头。数据类型清晰Excel单元格本身类型模糊在C#中读取时EPPlus返回的是object。我们需要在代码中做安全的类型转换。在表格中保持数据格式一致如数字不要混入中文逗号能减少转换出错。4. 核心代码解析从Excel到C#对象光说不练假把式现在我们来深入核心代码看看一行行Excel数据是如何变成程序中一个个可用的C#对象的。4.1 定义数据模型类首先在DataModels文件夹或你喜欢的任何脚本文件夹下创建一个C#脚本HeroLevelConfigData.cs。这个类的属性必须和你的Excel表头严格对应名称和顺序。// HeroLevelConfigData.cs using System; [Serializable] // 方便序列化非必须但建议添加 public class HeroLevelConfigData { // 属性名必须与Excel第一行的列名完全一致大小写敏感 public int Level { get; set; } public int HpBase { get; set; } public int AttackBase { get; set; } public int ExpToNext { get; set; } // 你可以添加一个ToString方法方便调试 public override string ToString() { return $Level:{Level}, Hp:{HpBase}, Attack:{AttackBase}, ExpToNext:{ExpToNext}; } }4.2 剖析ExcelReaderManager的核心方法打开ExcelReaderManager.cs这是整个方案的大脑。我们重点看两个方法LoadExcelBytes和ParseSheetT。LoadExcelBytes– 安全地加载字节流这个方法负责从不同来源Resources或AssetBundle获取Excel文件的二进制数据。private byte[] LoadExcelBytes(string filePathWithoutExtension) { // 方案一从Resources加载适用于打包在项目内的配置 TextAsset textAsset Resources.LoadTextAsset($ExcelFiles/{filePathWithoutExtension}); if (textAsset ! null) { return textAsset.bytes; } // 方案二从AssetBundle加载适用于热更新 // 这里假设你已经有一个管理AssetBundle的模块并提供了加载方法。 // byte[] bytes YourAssetBundleManager.LoadBytes($excel/{filePathWithoutExtension}.bytes); // return bytes; Debug.LogError($Excel file not found: {filePathWithoutExtension}); return null; }关键技巧文件路径Resources.Load的参数是相对于Resources文件夹的路径且不包含文件扩展名。所以我们的文件放在Resources/ExcelFiles/HeroLevelConfig.xlsx传入的路径就是ExcelFiles/HeroLevelConfig。ParseSheetT– 使用EPPlus解析的核心这是最核心的方法利用EPPlus将内存流中的数据解析成泛型列表ListT。public ListT ParseSheetT(string filePathWithoutExtension) where T : class, new() { byte[] fileBytes LoadExcelBytes(filePathWithoutExtension); if (fileBytes null || fileBytes.Length 0) { return new ListT(); } ListT dataList new ListT(); using (MemoryStream ms new MemoryStream(fileBytes)) // 使用内存流 using (ExcelPackage package new ExcelPackage(ms)) // EPPlus从流创建包 { ExcelWorksheet worksheet package.Workbook.Worksheets[0]; // 获取第一个工作表 int rowCount worksheet.Dimension.Rows; // 总行数 int colCount worksheet.Dimension.Columns; // 总列数 if (rowCount 1) return dataList; // 只有表头无数据 // 获取类型T的所有属性用于反射赋值 System.Reflection.PropertyInfo[] properties typeof(T).GetProperties(); // 从第2行开始遍历第1行是表头 for (int row 2; row rowCount; row) { T item new T(); // 遍历每一列 for (int col 1; col colCount; col) { object cellValue worksheet.Cells[row, col].Value; if (cellValue null) continue; string propertyName worksheet.Cells[1, col].Value?.ToString(); if (string.IsNullOrEmpty(propertyName)) continue; // 找到对应的属性 System.Reflection.PropertyInfo prop Array.Find(properties, p p.Name propertyName); if (prop ! null prop.CanWrite) { // 关键类型转换。将Excel的object转换为属性对应的类型。 try { object convertedValue Convert.ChangeType(cellValue, prop.PropertyType); prop.SetValue(item, convertedValue, null); } catch (Exception e) { Debug.LogError($Error converting value at R{row}C{col} ({propertyName}): {cellValue}. Error: {e.Message}); } } } dataList.Add(item); } } // using语句结束自动释放MemoryStream和ExcelPackage资源避免内存泄漏 return dataList; }代码逐行解读与避坑指南using语句MemoryStream和ExcelPackage都实现了IDisposable接口。使用using语句确保它们在用完后立即被释放这是避免内存泄漏的好习惯。worksheet.Dimension这是EPPlus提供的属性能快速获取工作表的数据区域范围。注意如果表格全是空的Dimension可能为null所以稳健的代码应该先判断。反射赋值typeof(T).GetProperties()获取了数据模型类T的所有公共属性。然后通过prop.SetValue将Excel单元格的值赋给对象的属性。这是实现灵活映射的关键。类型转换Convert.ChangeType这是最容易出错的地方。Excel单元格可能返回double、string、DateTime甚至bool。Convert.ChangeType尝试将cellValue转换为属性定义的类型如int。如果转换失败例如单元格里是字符串“abc”却要转成int就会抛出异常。因此必须用try-catch包裹并给出明确的行列错误信息方便你回头修改Excel。空值处理对cellValue和propertyName都做了null检查防止程序崩溃。4.3 在游戏中使用解析的数据管理器写好了模型也定义了现在就是享受成果的时刻。在任何需要读取配置的脚本中如游戏管理器、英雄属性模块你可以这样调用using UnityEngine; using System.Collections.Generic; public class GameConfigLoader : MonoBehaviour { void Start() { // 1. 获取单例管理器 ExcelReaderManager excelManager ExcelReaderManager.Instance; // 2. 解析Excel得到英雄等级配置列表 ListHeroLevelConfigData heroLevelList excelManager.ParseSheetHeroLevelConfigData(ExcelFiles/HeroLevelConfig); // 3. 使用数据 foreach (var config in heroLevelList) { Debug.Log($英雄等级 {config.Level}: 血量{config.HpBase}, 攻击力{config.AttackBase}); // 例如可以将数据存入一个字典方便通过ID(Level)查询 // GameDataManager.Instance.HeroLevelDict[config.Level] config; } // 示例查询3级英雄的基础属性 HeroLevelConfigData level3Config heroLevelList.Find(c c.Level 3); if (level3Config ! null) { Debug.Log($3级英雄基础攻击力为{level3Config.AttackBase}); } } }5. 进阶技巧与性能优化基础功能跑通后我们来看看如何让它更健壮、更高效。5.1 处理复杂数据类型上面的例子都是int但实际配置中会有float、string、bool甚至数组。字符串和浮点数Convert.ChangeType可以直接处理。确保Excel中数字格式正确如1.5而不是1,5。布尔值Excel中的TRUE/FALSE或是/否读取后可能是bool或string。可以在数据模型类中做二次处理或者在Excel里统一用1/0表示。数组如技能ID列表这是一个常见需求。可以在Excel中用特定分隔符存储如101,203,305然后在数据模型的set访问器中进行分割。// 在HeroLevelConfigData类中增加一个技能ID数组 private int[] _skillIds; public string SkillIdsStr { set { _skillIds Array.ConvertAll(value.Split(,), int.Parse); } } // 或者用一个只读属性暴露 public int[] SkillIds _skillIds;在Excel中对应列就填写101,203,305。这样解析器会将字符串赋给SkillIdsStr然后在set中自动转换为数组。5.2 实现数据缓存与按需加载频繁从磁盘或Resources加载、解析Excel是耗时的。一个良好的实践是只解析一次缓存起来。修改ExcelReaderManager增加一个字典缓存private Dictionarystring, object _dataCache new Dictionarystring, object(); public ListT ParseSheetT(string filePathWithoutExtension) where T : class, new() { string cacheKey ${filePathWithoutExtension}_{typeof(T).Name}; // 检查缓存 if (_dataCache.TryGetValue(cacheKey, out object cachedObj)) { return cachedObj as ListT; } // ... (原有的加载和解析逻辑) ... // 解析完成后存入缓存 _dataCache[cacheKey] dataList; return dataList; } // 提供一个清空缓存的方法在热更新配置后调用 public void ClearCache(string filePathWithoutExtension null) { if (string.IsNullOrEmpty(filePathWithoutExtension)) _dataCache.Clear(); else // 清除指定文件的所有类型缓存可能需要遍历匹配key var keysToRemove _dataCache.Keys.Where(k k.StartsWith(filePathWithoutExtension)).ToList(); foreach (var key in keysToRemove) _dataCache.Remove(key); }5.3 异常处理与日志完善健壮的程序必须能妥善处理错误。我们在核心解析方法中已经加入了try-catch。可以进一步扩展让错误信息更友好。try { object convertedValue Convert.ChangeType(cellValue, prop.PropertyType); prop.SetValue(item, convertedValue, null); } catch (FormatException) { Debug.LogError($格式错误文件‘{filePathWithoutExtension}’第{row}行第{col}列(‘{propertyName}’)的值‘{cellValue}’无法转换为类型{prop.PropertyType}。请检查Excel中是否为正确格式。); // 可以选择给一个默认值或者让这一行数据失效 // prop.SetValue(item, GetDefaultValue(prop.PropertyType), null); } catch (Exception e) { Debug.LogError($未知错误文件‘{filePathWithoutExtension}’第{row}行第{col}列错误信息{e.Message}); }6. 常见问题排查与解决方案实录在实际使用中你肯定会遇到各种“坑”。下面是我总结的常见问题速查表附上原因和解决方案。问题现象可能原因解决方案编译错误找不到OfficeOpenXml命名空间EPPlus DLL未正确导入或版本不兼容。1. 确认EPPlus.dll在Assets/Plugins文件夹内。2. 检查Unity的API Compatibility LevelEdit - Project Settings - Player - Other Settings。如果是.NET 4.x确保DLL是兼容版本。运行时错误System.IO.FileNotFoundException代码中使用了File.OpenRead等路径但在移动平台或WebGL上权限不足。绝对不要使用System.IO直接访问文件路径。统一改用Resources.Load或AssetBundle加载为TextAsset然后使用MemoryStream。读取的数据全是null或只有第一行1. Excel文件路径错误。2. Excel工作表有多个代码默认读了空的那个。3. 数据模型属性名与Excel表头不匹配大小写、空格。1. 打印fileBytes.Length确认文件已加载且非空。2. 在ParseSheet方法中打印worksheet.Name确认是你要的工作表。3. 在循环内打印propertyName与数据模型的属性名仔细比对。数字解析出来是小数如10.0Excel中数字单元格格式可能设置为“数值”带小数位或者EPPlus统一以double类型读取整数。在数据模型中使用double类型接收或者使用Convert.ToInt32()进行转换。也可以在Excel中确保单元格格式为“常规”或“数字”且小数位为0。中文内容显示为乱码通常发生在读取CSV文件时编码问题。本方案直接读取.xlsx二进制格式无编码问题。如果你必须处理CSV使用StreamReader时指定编码new StreamReader(stream, System.Text.Encoding.UTF8)。性能问题读取大量数据时卡顿1. 每次调用都重新加载和解析文件。2. 反射SetValue在数据量极大时万行以上有开销。1. 实现如上所述的缓存机制。2. 对于超大数据表考虑在导出环节做文章比如让策划导出为更高效的二进制格式如Protobuf、MessagePackUnity端直接反序列化。在IL2CPP下报错AOT编译可能裁剪掉EPPlus或反射用到的某些代码。1. 在Assets目录下创建link.xml文件告诉IL2CPP不要裁剪EPPlus相关程序集。2. 对于反射确保所有通过反射访问的类型在代码中有“显式引用”。关于IL2CPP与代码裁剪的额外说明这是Unity发布到iOS、某些Android平台以及WebGL时会遇到的进阶问题。IL2CPP为了减小包体会裁剪掉它认为“未使用”的代码。而我们的反射和EPPlus的内部逻辑可能被误判。解决方案是在Assets文件夹根目录创建一个名为link.xml的文件内容如下linker assembly fullnameEPPlus preserveall/ assembly fullnameSystem.Core type fullnameSystem.Linq.Expressions.Interpreter.LightLambda preserveall/ /assembly /linker这指示IL2CPP保留整个EPPlus程序集以及表达式树相关的一些必要类型通常能解决大部分问题。7. 扩展思路不止于读取掌握了基础的读取你可以基于此方案进行很多有趣的扩展配置表校验工具写一个Editor扩展在导入Excel或点击按钮时自动解析并校验数据合法性如ID是否重复、数值范围是否合理、引用是否存在及早发现策划配置错误。多语言本地化用一张巨大的Excel表管理所有UI文本Key, ZH, EN, JP...游戏运行时根据语言设置读取对应列轻松实现本地化。关卡编辑器数据导出你的关卡编辑器可能输出Excel格式的关卡数据怪物位置、类型、波次用这个方案直接读进游戏生成关卡。与ScriptableObject结合解析Excel数据后不直接使用ListT而是动态创建或更新ScriptableObject资产。这样可以利用Unity编辑器对ScriptableObject的友好支持如 Inspector 显示、资源引用。网络数据更新将Excel文件放在服务器上游戏启动时或定时下载最新的配置文件字节流通过MemoryStream解析实现不更新客户端就能修改游戏数值的“热配置”功能。我个人在实际项目中的体会是这套EPPlus 内存流 数据模型反射的方案在90%的Unity配置表读取场景下都是最优解。它平衡了简单性、灵活性和性能。最大的优势在于**“可预测”**——所有的数据转换逻辑都掌握在自己写的C#代码里出了错能快速定位是Excel格式问题还是代码逻辑问题而不是在黑盒插件里迷茫。最后分享一个我踩过的坑曾经有一次策划在Excel里用了一个叫“2D攻击”的列名我在C#里定义的属性是Attack2D结果怎么也读不到数据。调试了半天才发现是名字没对上。所以保持Excel表头和C#属性名严格一致并养成在解析失败时立刻打印出当前行列和属性名进行比对的习惯能节省你大量时间。