ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Unity游戏开发:Excel配置读取方案全解析与NPOI实战指南

2026/8/7 17:57:17 拓冰建站 浏览量
Unity游戏开发:Excel配置读取方案全解析与NPOI实战指南

1. 项目概述:为什么Unity项目需要读取Excel配置文件?

在Unity项目开发中,尤其是涉及大量数值策划、关卡设计、多语言本地化或道具系统的游戏和应用,数据管理是个绕不开的坎。策划同学习惯用Excel来配置角色属性、技能参数、任务奖励,因为Excel界面直观、公式计算方便、协作门槛低。但Unity运行时并不能直接识别.xlsx或.xls文件,这就需要一个可靠的“翻译官”——一个能够读取Excel文件,并将其内容高效、准确地转换为Unity可用的数据结构(如ScriptableObject、JSON、或直接的内存对象)的插件或方案。

直接使用Unity的Resources.LoadAssetBundle加载二进制文件?不行。把Excel另存为CSV再用字符串分割?对于简单数据尚可,但遇到多工作表、复杂单元格格式(如合并单元格)、公式计算值时就力不从心了,更别提维护的噩梦。因此,一个专门用于Unity读取Excel的插件,其核心价值在于搭建策划与程序之间的高效数据管道,它不仅要解决“读得进来”的问题,更要解决“读得正确、读得高效、易于维护”的问题。

市面上相关的插件和库不少,比如老牌的NPOI、轻量级的ExcelDataReader,以及一些封装好的Unity Asset Store插件。选择哪一个,往往取决于项目的具体需求:是追求无需安装Office环境的纯托管代码方案,还是需要处理复杂格式的兼容性?是希望一键生成强类型的C#类,还是更倾向于灵活的字典查询?接下来,我们就深入拆解这个“翻译官”的构建思路、技术选型、实操细节以及那些只有踩过坑才知道的避雷指南。

2. 核心方案选型与优劣深度剖析

面对“Unity读取Excel”这个需求,我们主要有三条技术路径:使用纯.NET库(如NPOI、ExcelDataReader)、使用COM组件(依赖Microsoft Office)、以及使用Asset Store的第三方插件。每种方案背后都有其特定的应用场景和代价。

2.1 方案一:使用NPOI库

NPOI是Apache POI项目的.NET版本,它完全独立,不依赖本地安装的Microsoft Office。这意味着在任何Windows、macOS甚至Linux的Unity编辑器和运行时环境下,它都能工作。

核心优势:

  1. 环境零依赖:这是最大的优点。无论是团队协作还是CI/CD打包流水线,都不会因为某台机器没装Office而失败。
  2. 格式支持全面:深度支持.xls(HSSF)和.xlsx(XSSF)格式,能读取单元格值、公式(可获取计算后的值)、样式、合并单元格等复杂属性。
  3. 读写兼备:除了读取,还能用于创建和修改Excel文件,适合需要运行时动态生成报表的工具链。

潜在劣势与考量:

  1. DLL大小与兼容性:NPOI由多个DLL组成(如NPOI.dll, NPOI.OOXML.dll等),会增加项目最终包体大小。需要确保将所有必需的DLL放置在Unity的Plugins文件夹下,并注意区分Editor(Any CPU)与运行时(特定平台,如x64)的兼容性。
  2. API相对底层:直接操作HSSFWorkbookISheetIRowICell对象,需要开发者自行处理行列索引、单元格类型判断(数字、字符串、布尔值、公式等),代码量稍多。

实操心得:对于稳定的大型项目,尤其是需要处理复杂Excel格式且部署环境不可控的情况,NPOI通常是首选。建议将NPOI相关的数据加载代码放在Editor目录下,仅用于配置导入,这样DLL就不会被打进运行时包体。

2.2 方案二:使用ExcelDataReader库

ExcelDataReader是另一个流行的、专注于读取的.NET库。它同样不依赖Office,以轻量和读取速度快著称。

核心优势:

  1. 轻量快速:库文件更小,在仅需读取数据的场景下,其性能表现往往优于NPOI。
  2. API简洁:使用AsDataSet()方法可以轻松将整个工作表转换为熟悉的System.Data.DataSetDataTable,对于习惯数据库操作模式的开发者来说非常友好。
  3. 专注于读:因为不做写入,所以API设计更纯粹,学习曲线平缓。

潜在劣势与考量:

  1. 需要配合ExcelDataReader.DataSet:为了将数据方便地转为DataSet,通常需要额外安装ExcelDataReader.DataSet扩展包。
  2. 对某些复杂格式支持可能不足:在处理非常古老的.xls格式或带有极其复杂样式的文件时,可能会遇到一些边缘情况。但对于标准的数值、文本配置表,它完全胜任。
  3. 中文编码问题:这是一个经典坑点。如果Excel文件包含中文,直接读取可能会出现乱码。必须在读取后,对DataTable中的字符串列进行编码转换,通常使用System.Text.Encoding.GetEncoding(936)(GB2312)或UTF-8进行处理。

2.3 方案三:使用COM组件(Microsoft.Office.Interop.Excel)

此方案通过.NET的COM互操作服务调用本地安装的Microsoft Excel应用程序。这本质上是启动了一个Excel进程来操作文件。

核心劣势(为何不推荐):

  1. 强环境依赖:要求运行机器必须安装完整版的Microsoft Office,这在服务器、无头模式或多数移动设备上无法满足。
  2. 性能开销大:启动和关闭Excel进程开销巨大,不适合需要频繁或快速读取的场景。
  3. 稳定性风险:Excel进程可能意外卡住或崩溃,影响主程序稳定性。
  4. 无法用于运行时:在Unity打包后的游戏运行时(如Windows Standalone),即使PC有Office,也极易因权限、进程管理等问题导致失败。

结论:在Unity项目开发中,应绝对避免使用COM方案。它仅在某些特定的、运行于特定Windows环境下的编辑器工具开发中有极窄的适用面,对于游戏或应用的通用数据配置需求,是错误的选择。

2.4 方案四:Asset Store插件

Unity Asset Store上有不少封装好的Excel读取插件,如“Excel to ScriptableObject”、“Easy Save”的表格功能模块等。这些插件通常提供了图形化界面,可以一键将Excel表格拖拽生成ScriptableObject或JSON。

核心优势:

  1. 开箱即用,效率极高:极大简化了流程,策划和程序都可以通过点击按钮完成配置导入和更新。
  2. 深度集成Unity:直接生成Unity资产(ScriptableObject),管理方便,支持版本控制(虽然二进制Asset会有差异问题,但比Excel好)。
  3. 功能丰富:很多插件支持数据验证、下拉菜单关联、自动生成枚举等高级功能。

潜在劣势与考量:

  1. 成本:大多数好用的插件需要付费购买。
  2. 黑盒化与定制困难:插件逻辑被封装,如果遇到特殊需求(如读取一种自定义的单元格格式),修改和扩展会比较困难。
  3. 项目依赖:引入了第三方插件依赖,需要评估其长期维护性和与未来Unity版本的兼容性。

选型建议总结:

  • 追求稳定、可控、处理复杂格式:选择NPOI。适合中大型团队,程序希望完全掌控数据解析流程。
  • 追求轻量、快速、仅读取简单配置表:选择ExcelDataReader。适合小型项目或原型开发。
  • 追求团队协作效率、策划驱动:投资购买一款评价高的Asset Store插件。适合策划人员多,需要频繁调整配置,且团队预算允许的情况。

3. 基于NPOI的完整实现流程与核心代码解析

我们以最经典、可控性最强的NPOI方案为例,详细拆解从零搭建一个Unity Excel配置读取器的全过程。这个过程不仅仅是调用API,更涉及工程架构的思考。

3.1 环境准备与NPOI导入

首先,你需要获取NPOI的DLL。可以从其 官网 或通过NuGet下载编译好的版本。将以下核心DLL文件放入你Unity项目的Assets/Plugins文件夹下:

  • NPOI.dll
  • NPOI.OOXML.dll(用于处理.xlsx)
  • NPOI.OpenXml4Net.dll(依赖)
  • NPOI.OpenXmlFormats.dll(依赖)
  • ICSharpCode.SharpZipLib.dll(依赖,处理压缩)

注意Plugins文件夹是Unity识别托管DLL的标准位置。如果你希望代码只在编辑器下运行(这是配置数据导入的常见做法),可以将这些DLL放在Assets/Editor/Plugins下,这样它们就不会被打包到游戏运行时,减少包体。

3.2 定义配置表的数据结构(元数据)

在读取之前,我们必须约定Excel表格的结构。一个规范的配置表通常遵循以下格式:

  • 第一行:字段名(英文或拼音,用于生成C#属性名)。例如:id,name,hp,attack
  • 第二行:字段类型(或注释)。例如:int,string,float,int。这行不是必须的,但强烈建议加上,用于自动生成代码或数据校验。
  • 第三行开始:实际的数据行。

我们需要一个基类来定义单行数据的结构,以及一个管理类来存储所有行。

// 配置表数据行的基类,所有具体配置类都应继承它 public abstract class ConfigDataBase { public int Id; // 通常每行数据都有一个唯一ID } // 例如,角色表配置 [System.Serializable] public class CharacterConfigData : ConfigDataBase { public string name; public int level; public float hp; public float attack; public string prefabPath; // 关联的资源路径 } // 配置表管理器基类,用于加载和存储一种类型的所有配置数据 public abstract class ConfigTableBase<T> where T : ConfigDataBase, new() { protected Dictionary<int, T> _dataDict = new Dictionary<int, T>(); public T GetDataById(int id) { if (_dataDict.TryGetValue(id, out T data)) { return data; } Debug.LogError($"ConfigTable: Data with id {id} not found."); return null; } public Dictionary<int, T> GetAllData() { return new Dictionary<int, T>(_dataDict); } // 抽象方法,子类实现具体的Excel读取和解析逻辑 public abstract void Load(string excelFilePath, string sheetName); }

3.3 实现通用的Excel读取与解析引擎

这是最核心的部分。我们将创建一个ExcelReader工具类,利用NPOI将Excel单元格数据映射到C#对象的属性上。这里会用到反射,但为了性能,可以考虑在编辑器下使用,或配合缓存机制。

using NPOI.SS.UserModel; using NPOI.XSSF.UserModel; // for .xlsx using NPOI.HSSF.UserModel; // for .xls using System.IO; using System.Reflection; public static class ExcelReader { public static List<T> LoadSheet<T>(string filePath, string sheetName) where T : new() { List<T> dataList = new List<T>(); using (FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read)) { IWorkbook workbook; // 根据文件扩展名创建不同的Workbook对象 if (Path.GetExtension(filePath).ToLower() == ".xlsx") { workbook = new XSSFWorkbook(fileStream); } else { workbook = new HSSFWorkbook(fileStream); } ISheet sheet = workbook.GetSheet(sheetName); if (sheet == null) { Debug.LogError($"Sheet '{sheetName}' not found in {filePath}"); return dataList; } // 假设第一行是属性名,第二行是类型(可选),第三行开始是数据 IRow headerRow = sheet.GetRow(0); // 第0行,字段名行 IRow typeRow = sheet.GetRow(1); // 第1行,类型行(可选) if (headerRow == null) return dataList; // 获取目标类型T的所有可写属性 PropertyInfo[] properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance); // 创建一个字典,映射Excel列索引到对应的属性 Dictionary<int, PropertyInfo> columnToPropertyMap = new Dictionary<int, PropertyInfo>(); for (int colIndex = 0; colIndex < headerRow.LastCellNum; colIndex++) { ICell headerCell = headerRow.GetCell(colIndex); if (headerCell == null) continue; string columnName = headerCell.StringCellValue?.Trim(); if (string.IsNullOrEmpty(columnName)) continue; // 在属性数组中查找名称匹配的属性(忽略大小写) PropertyInfo targetProperty = Array.Find(properties, p => p.Name.Equals(columnName, StringComparison.OrdinalIgnoreCase)); if (targetProperty != null && targetProperty.CanWrite) { columnToPropertyMap[colIndex] = targetProperty; } else { Debug.LogWarning($"Column '{columnName}' in sheet '{sheetName}' does not match any property in class {typeof(T).Name}"); } } // 从第2行(索引2)开始读取数据行 for (int rowIndex = 2; rowIndex <= sheet.LastRowNum; rowIndex++) { IRow dataRow = sheet.GetRow(rowIndex); if (dataRow == null) continue; // 跳过空行 T item = new T(); bool rowHasData = false; foreach (var kvp in columnToPropertyMap) { int colIndex = kvp.Key; PropertyInfo property = kvp.Value; ICell cell = dataRow.GetCell(colIndex); if (cell == null) { // 单元格为空,可以跳过或设置默认值 continue; } try { object cellValue = GetCellValue(cell, property.PropertyType); if (cellValue != null) { property.SetValue(item, cellValue); rowHasData = true; } } catch (Exception ex) { Debug.LogError($"Error parsing cell at Row {rowIndex + 1}, Column {colIndex + 1} (Property: {property.Name}) in sheet '{sheetName}': {ex.Message}"); } } if (rowHasData) { dataList.Add(item); } } workbook.Close(); } return dataList; } // 关键方法:将NPOI的ICell转换为具体的C#类型 private static object GetCellValue(ICell cell, Type targetType) { if (cell == null) return null; switch (cell.CellType) { case CellType.Numeric: // 注意:Excel的日期也是Numeric类型 if (DateUtil.IsCellDateFormatted(cell)) { DateTime dateValue = cell.DateCellValue; if (targetType == typeof(string)) return dateValue.ToString("yyyy-MM-dd HH:mm:ss"); else if (targetType == typeof(DateTime)) return dateValue; else return dateValue.ToString(); // 默认转字符串 } else { double numValue = cell.NumericCellValue; // 根据目标类型转换 if (targetType == typeof(int) || targetType == typeof(int?)) return Convert.ToInt32(numValue); else if (targetType == typeof(float) || targetType == typeof(float?)) return Convert.ToSingle(numValue); else if (targetType == typeof(double) || targetType == typeof(double?)) return numValue; else if (targetType == typeof(long) || targetType == typeof(long?)) return Convert.ToInt64(numValue); else // 其他数值类型或默认转为double return numValue; } case CellType.String: string strValue = cell.StringCellValue?.Trim(); if (targetType == typeof(string)) return strValue; else if (targetType == typeof(int) || targetType == typeof(int?)) return int.TryParse(strValue, out int i) ? i : (targetType.IsValueType ? Activator.CreateInstance(targetType) : null); else if (targetType == typeof(float) || targetType == typeof(float?)) return float.TryParse(strValue, out float f) ? f : (targetType.IsValueType ? Activator.CreateInstance(targetType) : null); else if (targetType == typeof(bool) || targetType == typeof(bool?)) { // 处理布尔值,可能是"True/False"或"1/0" if (bool.TryParse(strValue, out bool b)) return b; if (strValue == "1") return true; if (strValue == "0") return false; return false; // 默认 } else return strValue; // 无法转换则返回字符串 case CellType.Boolean: bool boolValue = cell.BooleanCellValue; if (targetType == typeof(bool) || targetType == typeof(bool?)) return boolValue; else if (targetType == typeof(string)) return boolValue.ToString(); else if (targetType == typeof(int)) return boolValue ? 1 : 0; else return boolValue; case CellType.Formula: // 对于公式单元格,尝试获取其缓存的计算值,否则重新计算(可能不稳定) try { switch (cell.CachedFormulaResultType) { case CellType.Numeric: return GetCellValue(new CellWrapper(CellType.Numeric, cell.NumericCellValue), targetType); case CellType.String: return GetCellValue(new CellWrapper(CellType.String, cell.StringCellValue), targetType); case CellType.Boolean: return GetCellValue(new CellWrapper(CellType.Boolean, cell.BooleanCellValue), targetType); default: // 如果无法获取缓存值,直接读取单元格显示字符串(可能不是计算值) return cell.ToString(); } } catch { return cell.ToString(); // 兜底方案 } case CellType.Blank: case CellType.Error: default: return GetDefaultValue(targetType); } } // 辅助方法:获取类型的默认值 private static object GetDefaultValue(Type type) { return type.IsValueType ? Activator.CreateInstance(type) : null; } // 内部包装类,用于处理公式缓存值 private class CellWrapper { public CellType CellType { get; } public object Value { get; } public CellWrapper(CellType cellType, object value) { CellType = cellType; Value = value; } public string StringCellValue => CellType == CellType.String ? (string)Value : null; public double NumericCellValue => CellType == CellType.Numeric ? (double)Value : 0; public bool BooleanCellValue => CellType == CellType.Boolean ? (bool)Value : false; } }

3.4 创建具体的配置表管理器并关联数据资产

现在,我们可以为CharacterConfigData创建一个具体的表管理器。

public class CharacterConfigTable : ConfigTableBase<CharacterConfigData> { public override void Load(string excelFilePath, string sheetName) { _dataDict.Clear(); var list = ExcelReader.LoadSheet<CharacterConfigData>(excelFilePath, sheetName); foreach (var data in list) { if (_dataDict.ContainsKey(data.Id)) { Debug.LogError($"Duplicate ID found: {data.Id} in {sheetName}"); continue; } _dataDict[data.Id] = data; } Debug.Log($"Loaded {_dataDict.Count} character configs from {sheetName}"); } }

为了让策划配置的数据最终变成Unity可高效读取的资产(如ScriptableObject),我们还需要一个编辑器工具。这个工具在Unity Editor中运行,读取Excel,生成对应的ScriptableObject文件。

#if UNITY_EDITOR using UnityEditor; using UnityEngine; public class ExcelConfigImporter : EditorWindow { private string excelFilePath = "Assets/Configs/CharacterConfig.xlsx"; private string sheetName = "Sheet1"; private string outputAssetPath = "Assets/Resources/ConfigAssets/CharacterConfig.asset"; [MenuItem("Tools/Config/Import Excel...")] public static void ShowWindow() { GetWindow<ExcelConfigImporter>("Excel Config Importer"); } void OnGUI() { GUILayout.Label("Excel Import Settings", EditorStyles.boldLabel); excelFilePath = EditorGUILayout.TextField("Excel File Path", excelFilePath); sheetName = EditorGUILayout.TextField("Sheet Name", sheetName); outputAssetPath = EditorGUILayout.TextField("Output Asset Path", outputAssetPath); if (GUILayout.Button("Import and Generate ScriptableObject")) { ImportExcel(); } } private void ImportExcel() { if (!File.Exists(excelFilePath)) { EditorUtility.DisplayDialog("Error", $"Excel file not found at {excelFilePath}", "OK"); return; } // 1. 读取Excel数据 CharacterConfigTable table = new CharacterConfigTable(); table.Load(excelFilePath, sheetName); // 2. 创建或加载ScriptableObject资产 CharacterConfigDatabase asset = ScriptableObject.CreateInstance<CharacterConfigDatabase>(); asset.configList = table.GetAllData().Values.ToList(); // 确保输出目录存在 string directory = Path.GetDirectoryName(outputAssetPath); if (!Directory.Exists(directory)) { Directory.CreateDirectory(directory); } // 3. 保存或更新资产 AssetDatabase.CreateAsset(asset, outputAssetPath); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); EditorUtility.DisplayDialog("Success", $"Config imported to {outputAssetPath}", "OK"); EditorGUIUtility.PingObject(asset); } } // 一个用于存储列表的ScriptableObject public class CharacterConfigDatabase : ScriptableObject { public List<CharacterConfigData> configList; } #endif

至此,一个完整的、基于NPOI的Unity Excel配置读取、解析、并生成可管理资产的工作流就搭建完成了。策划只需维护Excel文件,程序员运行一下编辑器菜单,即可将最新数据同步到项目中。

4. 性能优化、内存管理与进阶技巧

直接使用上述基础方案在小型项目中没问题,但当配置表数量庞大(几十上百张)、单表数据行数过万时,就需要考虑性能和内存优化。

4.1 二进制序列化与按需加载

在运行时直接解析Excel(即使是预处理过的)效率不高。最佳实践是:在编辑器阶段将Excel数据转换为最优的运行时格式

  1. 转换为二进制文件:使用BinaryFormatter(已过时,但原理类似)或更现代的MessagePackProtobuf-net等序列化库,将List<T>序列化成紧凑的二进制文件(.bytes)。运行时使用File.ReadAllBytes配合反序列化,速度极快。
  2. 使用Unity的AssetBundle或Addressables:将ScriptableObject或序列化后的数据文件打包进AssetBundle,实现动态加载和更新。
  3. 按需加载与分块:对于超大型表(如地图格子数据),不要一次性全部加载。可以按区域(Chunk)或按ID范围进行分块,只加载当前需要的部分。
// 示例:使用MessagePack进行高效的二进制序列化(需安装MessagePack-CSharp包) public static byte[] SerializeConfigData<T>(List<T> dataList) { return MessagePackSerializer.Serialize(dataList); } public static List<T> DeserializeConfigData<T>(byte[] bytes) { return MessagePackSerializer.Deserialize<List<T>>(bytes); } // 在编辑器工具中,将读取的Excel数据序列化后保存为.bytes文件 // 在运行时,使用Resources.Load<TextAsset>或AssetBundle.LoadAsset<TextAsset>加载bytes,然后反序列化

4.2 使用字典加速查找

这是最基本也是最重要的优化。在ConfigTableBase中,我们使用Dictionary<int, T>根据ID存储数据。确保GetDataById是O(1)时间复杂度的操作。如果除了ID外还有其他高频查询条件(如按名称),可以建立额外的索引字典。

public class CharacterConfigTable : ConfigTableBase<CharacterConfigData> { private Dictionary<string, CharacterConfigData> _nameIndexDict; // 名称索引 public override void Load(...) { // ... 加载数据到 _dataDict ... _nameIndexDict = new Dictionary<string, CharacterConfigData>(); foreach (var kvp in _dataDict) { if (!string.IsNullOrEmpty(kvp.Value.name)) { _nameIndexDict[kvp.Value.name] = kvp.Value; } } } public CharacterConfigData GetDataByName(string name) { if (_nameIndexDict.TryGetValue(name, out var data)) { return data; } return null; } }

4.3 处理复杂数据类型与引用

配置表里经常不只是数字和字符串,还可能包含资源路径、枚举、甚至是其他配置表的引用。

  • 资源路径:如prefabPath: "Assets/Prefabs/Characters/Warrior.prefab"。在编辑器导入阶段,可以将其转换为Runtime可用的路径(如Addressables的Key),或者直接通过AssetDatabase.LoadAssetAtPath加载并存储对实际UnityEngine.Object的引用(仅限编辑器生成的ScriptableObject)。
  • 枚举:在Excel中用字符串(如"Warrior")表示。在解析时,使用Enum.Parse(typeof(ClassType), cellString)进行转换。务必做好错误处理,因为策划可能拼写错误。
  • 表关联:例如,道具配置表里有一个字段useEffectId关联到效果表。不要在道具表里直接存储一个EffectConfigData对象,而是存储int类型的ID。在运行时,通过EffectConfigTable.Instance.GetDataById(effectId)来获取关联数据。这解耦了数据加载顺序。

4.4 数据验证与版本管理

在导入阶段加入数据验证至关重要,可以提前发现策划配置错误。

private void ValidateData(CharacterConfigData data) { if (data.hp <= 0) { Debug.LogError($"Invalid HP value for character ID {data.Id}. HP must be positive."); } if (string.IsNullOrEmpty(data.name)) { Debug.LogError($"Character ID {data.Id} has an empty name."); } if (!File.Exists(Path.Combine(Application.dataPath, data.prefabPath.Replace("Assets/", "")))) { Debug.LogWarning($"Prefab not found at path: {data.prefabPath} for character ID {data.Id}"); } }

版本管理:Excel文件本身是二进制,不利于Git等版本控制系统进行差异比较。建议:

  1. 将Excel文件导出为CSV(一种纯文本格式)再进行提交,这样可以清晰地看到每次修改了哪些数据。
  2. 或者,使用上文提到的,将最终导出的数据资产(ScriptableObject或.bytes文件)也纳入版本控制。虽然二进制文件差异不明显,但可以配合导入工具,确保数据资产与Excel源文件的一致性。

5. 常见问题、疑难排查与实战避坑指南

在实际开发中,你会遇到各种各样稀奇古怪的问题。下面是我总结的一些高频“坑点”和解决方案。

5.1 中文乱码问题

问题现象:使用ExcelDataReader读取包含中文的.xls文件时,字符串显示为乱码。

根本原因:.xls文件(HSSF格式)内部默认使用的编码可能是GB2312等本地编码,而.NET默认使用UTF-8去解码,导致不匹配。

解决方案:在读取后,对字符串列进行编码转换。

// 使用ExcelDataReader时,配置编码 using (var reader = ExcelReaderFactory.CreateReader(stream)) { var configuration = new ExcelReaderConfiguration { // 对于.xls文件,FallbackEncoding很重要 FallbackEncoding = Encoding.GetEncoding(936) // GB2312 }; // ... 使用reader读取 ... } // 或者,在读取到DataTable后,手动遍历行和列进行转换 foreach (DataRow row in dataTable.Rows) { for (int i = 0; i < dataTable.Columns.Count; i++) { if (row[i] is string str) { byte[] bytes = Encoding.Default.GetBytes(str); // 假设默认编码读取是乱码 row[i] = Encoding.GetEncoding(936).GetString(bytes); // 转换为正确编码 } } }

注意:对于.xlsx文件(OOXML格式),由于其内部使用UTF-8,通常不会出现此问题。

5.2 数值被识别为字符串或日期

问题现象:Excel里明明是数字100,读出来却成了字符串"100",或者一个数字被错误地识别为日期(如数字44762被显示为2022-08-01)。

原因分析:Excel单元格有“格式”属性。一个单元格即使你输入了数字,如果其格式被设置为“文本”,NPOI的cell.CellType返回的就是CellType.String。日期本质上是特殊的数字格式。

解决方案:在GetCellValue方法中,我们已经做了处理。但更稳健的做法是,在读取时优先尝试用cell.NumericCellValue,如果失败再尝试cell.StringCellValue,并记录日志。或者,强制要求策划在配置表类型行(第二行)明确标注类型,解析时根据标注的类型进行强制转换,而非依赖单元格的自动判断。

5.3 公式单元格读取不到计算值

问题现象:单元格里是公式=A1+B1,读出来的是公式字符串=A1+B1,而不是计算结果。

解决方案:如我们在GetCellValue方法中对CellType.Formula的处理所示,优先使用cell.CachedFormulaResultTypecell.NumericCellValue等属性获取缓存的计算值。如果Excel文件是刚被程序修改过还未被Excel应用程序计算保存,缓存值可能不存在。对于关键数据,应避免在配置表中使用复杂的公式,或者要求策划在提交前“将公式转换为值”(Excel中的复制-选择性粘贴-值)。

5.4 内存泄漏与资源释放

问题现象:在编辑器下频繁导入大型Excel文件后,Unity编辑器内存持续增长。

根本原因:NPOI的IWorkbook对象持有对文件流和大量内存中对象的引用。如果没有正确释放,会导致内存泄漏。

解决方案:确保使用using语句包裹文件流和Workbook,或者手动调用workbook.Close()stream.Dispose()。我们的示例代码已经使用了using,这是最佳实践。

// 正确做法 using (FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read)) using (IWorkbook workbook = WorkbookFactory.Create(fs)) { // 操作workbook } // 离开作用域自动释放 // 错误做法 IWorkbook workbook = new HSSFWorkbook(new FileStream(path, FileMode.Open)); // 流和workbook都未释放

5.5 跨平台兼容性

问题:在Windows上开发正常,打包到Android/iOS后读取配置失败。

排查

  1. DLL兼容性:确认NPOI的DLL放置在Assets/Plugins下,并且其对应的平台兼容性设置正确(例如,某些.NET库可能需要特定的CPU架构)。对于移动平台,最好使用纯C#实现的库(如NPOI本身是纯C#),并确保所有依赖项都兼容。
  2. 文件路径与读写权限:运行时(尤其是移动平台)无法直接访问项目Assets目录下的原始文件。你必须将处理好的配置文件(如.bytes或ScriptableObject)放在Resources文件夹内(不推荐大量使用),或者更好的方式是使用AssetBundle或Addressables系统进行加载。编辑器导入流程和运行时加载流程是分离的。
  3. 字符串处理:注意移动端和PC端可能存在的换行符(\nvs\r\n)、路径分隔符(/vs\)差异,使用Path.CombineEnvironment.NewLine来保证兼容性。

5.6 配置热重载

在开发阶段,策划希望修改Excel并保存后,游戏内能立即看到效果,而不用重启游戏或重新打包。

实现思路

  1. 使用FileSystemWatcher监控Excel文件所在目录的更改事件。
  2. 当检测到.xlsx文件被修改时,在Unity主线程(通过DispatcherMainThreadDispatcher插件)触发重新导入流程。
  3. 重新导入数据后,更新内存中对应的配置表字典。
  4. 通知系统:实现一个简单的观察者模式,让那些依赖配置数据的模块(如UI显示、角色属性计算)订阅配置更新事件,在数据重载后自动刷新。
#if UNITY_EDITOR public class ConfigHotReloadManager { private FileSystemWatcher _watcher; private string _configDir; public void Init(string directoryToWatch) { _configDir = directoryToWatch; _watcher = new FileSystemWatcher(_configDir, "*.xlsx"); _watcher.Changed += OnExcelFileChanged; _watcher.EnableRaisingEvents = true; Debug.Log($"开始监控配置目录: {_configDir}"); } private void OnExcelFileChanged(object sender, FileSystemEventArgs e) { // 文件系统事件可能在非主线程触发 UnityEditor.EditorApplication.delayCall += () => { Debug.Log($"检测到文件变更: {e.FullPath}, 重新导入..."); // 调用你的导入方法,例如: // ExcelConfigImporter.ImportSpecificFile(e.FullPath); // 然后通知游戏内系统更新数据 }; } } #endif

这个功能能极大提升策划和程序联调效率,是开发期非常实用的工具。