news 2026/5/23 16:06:45

Unity读取Excel实战:NPOI集成、热更与性能优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Unity读取Excel实战:NPOI集成、热更与性能优化

1. 为什么Unity项目里总在Excel和代码之间反复横跳?

“Unity开发——读取Excel表格数据”这个标题看起来平平无奇,但在我带过的二十多个中大型Unity项目里,它几乎出现在每个立项初期的技术评审会上——不是作为“可选优化项”,而是被列为“首周必须打通的基建红线”。为什么?因为真实项目里,策划写数值、关卡配表、本地化翻译、剧情对话树、装备属性池……90%以上非程序逻辑的数据源,根本不在C#脚本里,而是在策划用Excel维护的.xlsx文件里。你不可能让策划去改C#类,更不能每次调一个数值就让程序员重编译一次。于是问题来了:当Unity编辑器里双击打开一个Excel文件,看到的只是个空白窗口;运行时用System.IO直接读.xlsx?抛出NotSupportedException: No data is available for encoding 936——连中文都打不开。这不是Unity“不支持Excel”,而是Unity默认根本不认识.xlsx这种基于Open XML标准的压缩包结构。它只认纯文本(CSV)、JSON、XML这些轻量级格式,而Excel恰恰是这三者的“豪华封装版”:一个.xlsx文件解压后其实是几十个XML文件+二进制流+资源索引的ZIP包。所以,所谓“读取Excel”,本质是两件事:第一,把Excel这个“黑盒压缩包”正确解包并解析成结构化数据;第二,把解析结果安全、高效、可维护地注入Unity运行时环境。我见过太多团队踩坑:用第三方插件却没搞懂它的序列化机制,导致热更新时表格字段增减直接崩溃;自己手写NPOI解析,结果在iOS真机上因反射限制报错;甚至用Editor脚本导出CSV再读取,结果策划改了Excel公式,导出的CSV却是静态值……这些都不是技术难度问题,而是对“Unity+Excel”协作链路的理解断层。这篇文章不讲泛泛的“几种读取方式对比”,而是从一个老手的实际工作流出发:当你拿到策划给的《角色属性表.xlsx》时,从编辑器内实时预览、到运行时按需加载、再到热更时安全替换,每一步背后的原理、选型依据、避坑细节,全部摊开讲透。适合所有正在用Unity做商业项目的开发者,无论你是刚接手表格系统的新人,还是正被热更兼容性折磨的主程。

2. Excel文件的本质:别再把它当“表格”,它是个ZIP压缩包

要真正读懂Excel,第一步必须扔掉“双击打开就是表格”的惯性认知。打开任意一个.xlsx文件,把它后缀名改成.zip,然后用7-Zip或WinRAR解压——你会看到类似这样的目录结构:

[Content_Types].xml _rels/ xl/ workbook.xml worksheets/ sheet1.xml sheet2.xml styles.xml sharedStrings.xml

这才是Excel的真实面目:一个遵循ECMA-376 Open XML标准的ZIP容器。其中最关键的是三个XML文件:

  • sharedStrings.xml:存储所有单元格的字符串字典。Excel为了节省空间,不会在每个<c>标签里重复存“攻击力”“生命值”这种文字,而是存一个索引号(如<t>0</t>),再在sharedStrings.xml里查第0号字符串是什么。这就是为什么直接读XML会看到一堆数字索引,而不是明文。

  • sheet1.xml:工作表的核心数据。每个<row r="1">代表一行,<c r="A1" t="s">代表A1单元格,t="s"表示该单元格内容是sharedStrings里的字符串索引,t="n"表示数字,t="b"表示布尔值。注意r="A1"只是显示位置,实际顺序由<c>标签的排列决定。

  • workbook.xml:定义工作表顺序、名称、是否隐藏等元信息。比如<sheet name="角色属性" sheetId="1" r:id="rId1"/>告诉你第一个工作表叫“角色属性”,对应xl/worksheets/sheet1.xml

那么问题来了:Unity运行时能直接解压ZIP并解析XML吗?答案是——能,但极其危险。原因有三:

第一,System.IO.Compression.ZipFile在Unity 2019.4+的IL2CPP后端(即所有iOS/Android真机)上默认不可用,除非手动开启ENABLE_ZIP_FILE宏,但这会增大包体且部分旧版本有兼容问题;

第二,XmlDocument在IL2CPP下需要额外配置link.xml保留类型,否则发布后直接NullReferenceException

第三,也是最致命的:Excel的XML结构极其复杂。一个合并单元格(MergeCell)会在sheet1.xml里生成<mergeCells count="1"><mergeCell ref="A1:C1"/></mergeCells>,但实际数据只存在A1单元格,B1/C1为空——如果你没处理合并逻辑,读出来的就是空值。

所以,绕过Excel SDK直接啃XML,就像徒手拆核反应堆:理论上可行,但代价远超收益。那有没有更稳妥的方案?有,而且只有两条路:

  • 方案A:用成熟开源库(推荐NPOI)
    NPOI是Apache POI的.NET移植版,专为.NET平台设计,完全支持.xlsx(HSSF/XSSF),且已针对Unity做了大量适配(如移除反射依赖、提供IL2CPP友好的API)。它内部已封装了ZIP解压、XML解析、共享字符串映射、合并单元格处理等全部细节,你只需调用ISheet.GetRow(0).GetCell(0).StringCellValue就能拿到明文。这是目前Unity社区事实标准,超过85%的中大型项目采用。

  • 方案B:让Excel自己导出中间格式(如JSON/CSV)
    策划在Excel里写好数据,用VBA宏一键导出为JSON,Unity用JsonUtility.FromJson<T>解析。优点是零依赖、纯文本、热更友好;缺点是VBA在Mac版Excel不支持,且无法处理Excel公式(导出的是计算结果而非公式本身),策划修改公式后必须重新导出。

我强烈建议选择方案A(NPOI),但必须强调:不要直接用NuGet上的原版NPOI。原版依赖System.Drawing(Unity不支持)和System.Data(部分平台受限),必须使用Unity专用分支——比如NPOI.Unity(GitHub上star最多的定制版)或NPOI-for-Unity(由国内团队维护,含中文文档)。我在《仙侠MMO》项目里实测过:用原版NPOI在Android上首次加载xlsx耗时2.3秒,而用Unity定制版仅0.4秒,且内存峰值降低60%。差异在哪?定制版移除了所有与UI渲染相关的代码,精简了XML解析器,并将SharedStringTable缓存为静态字典——这才是针对Unity生命周期的真正优化。

提示:NPOI.Unity的XSSFWorkbook构造函数接受Streambyte[],这意味着你可以把Excel文件放在Resources文件夹里用Resources.Load<TextAsset>("Config/RoleData").bytes加载,也可以放在StreamingAssets里用File.ReadAllBytes(Application.streamingAssetsPath + "/RoleData.xlsx")读取。后者更适合热更场景,因为StreamingAssets路径在打包后仍可被直接访问。

3. 从编辑器到运行时:一套代码,两种生命周期

很多开发者卡在“编辑器能读,运行时报错”这一步。根本原因在于:Unity的编辑器(Editor)和运行时(Player)是两套完全隔离的执行环境。Editor用的是Mono/.NET Framework,Player用的是IL2CPP或Mono AOT编译后的原生代码。NPOI在Editor里跑得飞起,是因为它能用完整的.NET反射;但到了Player里,如果没做正确裁剪,就会因类型丢失而崩溃。

解决这个问题,核心是理解Unity的编译时条件编译运行时资源加载路径。我们分两步走:先搞定编辑器内的即时预览,再确保Player里稳定加载。

3.1 编辑器内实时预览:让策划改完Excel立刻看到效果

目标:策划在Excel里修改“角色1的攻击力”为5000,保存后,在Unity编辑器里点一下“刷新”按钮,Inspector面板上立即显示新数值,无需重启编辑器。这需要两个关键组件:

  • 自定义Editor脚本:继承Editor类,重写OnInspectorGUI(),添加一个GUILayout.Button("Reload Data")按钮;
  • AssetPostprocessor:监听Excel文件变更,自动触发刷新。

具体实现如下(以RoleData.xlsx为例):

// RoleDataEditor.cs - 放在Assets/Editor/目录下 [CustomEditor(typeof(RoleData))] public class RoleDataEditor : Editor { public override void OnInspectorGUI() { DrawDefaultInspector(); // 显示默认属性 if (GUILayout.Button("Reload from Excel")) { // 1. 从AssetDatabase获取Excel文件路径 string excelPath = AssetDatabase.GetAssetPath(target); if (!string.IsNullOrEmpty(excelPath) && excelPath.EndsWith(".xlsx")) { // 2. 用NPOI读取Excel using (FileStream fs = new FileStream(excelPath, FileMode.Open, FileAccess.Read)) { XSSFWorkbook workbook = new XSSFWorkbook(fs); ISheet sheet = workbook.GetSheetAt(0); // 默认读第一个工作表 // 3. 解析为RoleData对象(后续章节详解序列化逻辑) RoleData data = ParseExcelToRoleData(sheet); // 4. 将解析结果赋值给当前ScriptableObject实例 target.GetType().GetField("roles", BindingFlags.Public | BindingFlags.Instance) .SetValue(target, data.roles); // 5. 标记为dirty,触发保存 EditorUtility.SetDirty(target); AssetDatabase.SaveAssets(); } } } } private RoleData ParseExcelToRoleData(ISheet sheet) { List<RoleInfo> roles = new List<RoleInfo>(); for (int i = 1; i <= sheet.LastRowNum; i++) // 跳过表头行 { IRow row = sheet.GetRow(i); if (row == null) continue; RoleInfo role = new RoleInfo(); role.id = (int)row.GetCell(0).NumericCellValue; role.name = row.GetCell(1).StringCellValue; role.attack = (int)row.GetCell(2).NumericCellValue; role.hp = (int)row.GetCell(3).NumericCellValue; roles.Add(role); } return new RoleData { roles = roles.ToArray() }; } }

这段代码的关键在于:AssetDatabase.GetAssetPath(target)能精准定位Excel在项目中的相对路径,FileStream在Editor环境下完全可用。但注意,RoleData必须是一个继承自ScriptableObject的类,这样才能在Inspector里显示并保存。此时策划的工作流是:Excel改→Ctrl+S保存→Unity编辑器自动弹出“Importing assets...”→点“Reload from Excel”按钮→数据立即更新。整个过程不到3秒。

3.2 运行时动态加载:Player环境下的安全读取

Player环境不能用FileStream直接读取项目路径(如Assets/Config/RoleData.xlsx),因为打包后该路径不存在。必须用Unity的资源系统:

  • 方案1:Resources文件夹(适合小项目)
    把Excel放到Assets/Resources/Config/RoleData.xlsx,用Resources.Load<TextAsset>("Config/RoleData").bytes获取字节数组,再传给NPOI:

    // RuntimeExcelLoader.cs public static class RuntimeExcelLoader { public static T LoadExcel<T>(string path) where T : class { TextAsset asset = Resources.Load<TextAsset>(path); if (asset == null) { Debug.LogError($"Excel not found: {path}"); return null; } using (MemoryStream ms = new MemoryStream(asset.bytes)) { XSSFWorkbook workbook = new XSSFWorkbook(ms); ISheet sheet = workbook.GetSheetAt(0); return ParseSheetToType<T>(sheet); } } }

    优点:简单直接,无需额外配置;缺点:Resources文件夹内所有资源都会被打包进APK/IPA,无法热更,且Resources.Load有性能开销(全路径扫描)。

  • 方案2:StreamingAssets文件夹(推荐,热更必备)
    把Excel放在Assets/StreamingAssets/Config/RoleData.xlsx,打包后该文件夹会原样复制到APK/IPA的assets目录(Android)或Bundle内(iOS)。运行时用Application.streamingAssetsPath拼接路径:

    public static async Task<T> LoadExcelFromStreamingAssetsAsync<T>(string fileName) where T : class { string fullPath = Path.Combine(Application.streamingAssetsPath, fileName); byte[] bytes; #if UNITY_ANDROID && !UNITY_EDITOR // Android真机:用UnityWebRequest异步读取 UnityWebRequest www = UnityWebRequest.Get("jar:file://" + Application.dataPath + "!/assets/" + fileName); await www.SendWebRequest(); if (www.result != UnityWebRequest.Result.Success) { Debug.LogError("Failed to load Excel: " + www.error); return null; } bytes = www.downloadHandler.data; #elif UNITY_IOS && !UNITY_EDITOR // iOS真机:用File.ReadAllBytes(StreamingAssets在iOS上是可读路径) bytes = File.ReadAllBytes(fullPath); #else // Editor或PC/Mac:直接读取 bytes = File.ReadAllBytes(fullPath); #endif using (MemoryStream ms = new MemoryStream(bytes)) { XSSFWorkbook workbook = new XSSFWorkbook(ms); ISheet sheet = workbook.GetSheetAt(0); return ParseSheetToType<T>(sheet); } }

    这段代码的关键是平台差异化处理:Android的StreamingAssets在APK内是只读的jar包,必须用UnityWebRequest;iOS则可以直接File.ReadAllBytes。我曾在《开放世界RPG》项目里实测:用UnityWebRequest读取1MB的Excel,在中端Android手机上耗时约120ms,而File.ReadAllBytes在iOS上仅需35ms。两者都比Resources.Load快3倍以上,且完全支持热更——你只需把新Excel文件推送到CDN,客户端下载后覆盖StreamingAssets同名文件即可。

注意:StreamingAssets路径在不同平台有差异。Android的完整路径是jar:file://<dataPath>!/assets/xxx.xlsx,iOS是<bundlePath>/Data/Raw/xxx.xlsx,务必用Application.streamingAssetsPath获取基础路径,避免硬编码。

4. 数据建模与序列化:让Excel结构自动映射到C#类

读取Excel的终极目的不是“拿到字符串”,而是“把策划写的‘角色1’自动变成RoleInfo对象”。如果每次都要手写row.GetCell(0).NumericCellValue,不仅效率低,而且极易出错(比如策划调整列顺序,代码就全崩)。解决方案是基于特性(Attribute)的自动映射,让C#类字段和Excel列名一一绑定。

4.1 定义Excel映射特性

创建一个自定义特性ExcelColumnAttribute,指定列名或列索引:

[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)] public class ExcelColumnAttribute : Attribute { public string HeaderName { get; } // Excel表头名称,如"ID" public int Index { get; } // 列索引,从0开始,优先级高于HeaderName public ExcelColumnAttribute(string headerName) { HeaderName = headerName; } public ExcelColumnAttribute(int index) { Index = index; } }

4.2 构建通用解析器

编写一个泛型方法ParseSheetToType<T>,自动遍历Excel表头,匹配特性,填充对象:

private static T ParseSheetToType<T>(ISheet sheet) where T : class, new() { if (sheet.LastRowNum < 1) return null; // 1. 读取第一行(表头) IRow headerRow = sheet.GetRow(0); Dictionary<string, int> headerMap = new Dictionary<string, int>(); for (int i = 0; i < headerRow.LastCellNum; i++) { ICell cell = headerRow.GetCell(i); if (cell != null && !string.IsNullOrEmpty(cell.StringCellValue)) { headerMap[cell.StringCellValue] = i; } } // 2. 获取T类型的所有带ExcelColumn特性的字段 var fields = typeof(T).GetFields(BindingFlags.Public | BindingFlags.Instance) .Where(f => f.GetCustomAttribute<ExcelColumnAttribute>() != null) .ToList(); // 3. 遍历数据行,逐行创建T实例 List<T> results = new List<T>(); for (int i = 1; i <= sheet.LastRowNum; i++) { IRow row = sheet.GetRow(i); if (row == null) continue; T instance = new T(); foreach (var field in fields) { ExcelColumnAttribute attr = field.GetCustomAttribute<ExcelColumnAttribute>(); int columnIndex = attr.Index > -1 ? attr.Index : (headerMap.ContainsKey(attr.HeaderName) ? headerMap[attr.HeaderName] : -1); if (columnIndex == -1 || columnIndex >= row.LastCellNum) continue; ICell cell = row.GetCell(columnIndex); if (cell == null) continue; // 4. 根据字段类型自动转换值 object value = GetCellValue(cell, field.FieldType); field.SetValue(instance, value); } results.Add(instance); } // 如果T是数组或List,返回集合;否则返回单个(按需扩展) return typeof(T).IsArray ? (T)(object)results.ToArray() : results.Count > 0 ? results[0] : null; } private static object GetCellValue(ICell cell, Type targetType) { if (cell.CellType == CellType.Numeric) { if (targetType == typeof(int) || targetType == typeof(int?)) return (int)cell.NumericCellValue; if (targetType == typeof(float) || targetType == typeof(float?)) return (float)cell.NumericCellValue; if (targetType == typeof(double) || targetType == typeof(double?)) return cell.NumericCellValue; } else if (cell.CellType == CellType.String) { if (targetType == typeof(string)) return cell.StringCellValue; if (targetType == typeof(int) || targetType == typeof(int?)) { if (int.TryParse(cell.StringCellValue, out int i)) return i; } } return null; }

4.3 在C#类中应用特性

现在,你的RoleInfo类可以这样写:

[System.Serializable] public class RoleInfo { [ExcelColumn("ID")] public int id; [ExcelColumn("角色名称")] public string name; [ExcelColumn("攻击力")] public int attack; [ExcelColumn("生命值")] public int hp; [ExcelColumn(4)] // 第5列(索引4),即使表头名变更也不影响 public string description; }

当策划把Excel表头从“攻击力”改成“ATK”,你只需把特性改成[ExcelColumn("ATK")],无需改动解析逻辑。如果策划新增一列“暴击率”,你只需在RoleInfo里加一个[ExcelColumn("暴击率")] public float critRate;,解析器会自动识别并填充。

我在《二次元卡牌》项目里用这套方案管理了200+张Excel表,策划每周更新数值,程序侧零代码修改。唯一要注意的是:Excel表头名必须唯一。如果出现两个“ID”列,headerMap会覆盖,导致数据错位。解决方案是在解析前加校验:

// 检查重复表头 var duplicateHeaders = headerMap.GroupBy(kvp => kvp.Key) .Where(g => g.Count() > 1) .Select(g => g.Key) .ToList(); if (duplicateHeaders.Count > 0) { Debug.LogError($"Excel has duplicate headers: {string.Join(",", duplicateHeaders)}"); return null; }

5. 热更新与版本管理:如何让Excel更新不炸服

热更Excel听起来简单,但实际落地时有三大雷区:文件覆盖冲突、版本校验缺失、增量更新缺失。我亲眼见过一个项目因热更Excel导致全区玩家登录后卡在Loading界面——原因是新Excel里新增了一列“稀有度”,但旧版客户端解析器没这个字段,反序列化时抛出MissingFieldException,而异常被捕获后静默失败。

5.1 文件覆盖冲突:Android/iOS的原子性写入

StreamingAssets在Android上是只读的,热更文件必须写入Application.persistentDataPath(如/sdcard/Android/data/com.xxx.xxx/files/)。但直接File.WriteAllBytes有风险:如果写入中途App被杀,文件会损坏。解决方案是先写临时文件,再原子性重命名

public static bool SafeWriteExcel(string fileName, byte[] content) { string tempPath = Path.Combine(Application.persistentDataPath, fileName + ".tmp"); string finalPath = Path.Combine(Application.persistentDataPath, fileName); try { File.WriteAllBytes(tempPath, content); // 原子性替换:Windows/macOS用Move,Android/iOS用Delete+Rename if (File.Exists(finalPath)) File.Delete(finalPath); File.Move(tempPath, finalPath); return true; } catch (Exception e) { Debug.LogError("SafeWriteExcel failed: " + e.Message); if (File.Exists(tempPath)) File.Delete(tempPath); return false; } }

5.2 版本校验:用CRC32而非文件名判断更新

很多团队用“文件名相同即认为是同一文件”来跳过热更,这是大忌。策划可能改了Excel内容但忘了改名,导致客户端永远用旧数据。正确做法是计算Excel文件的CRC32校验码,和服务端版本号比对:

public static uint CalculateCRC32(byte[] data) { const uint poly = 0xEDB88320; uint crc = 0xFFFFFFFF; foreach (byte b in data) { crc ^= b; for (int i = 0; i < 8; i++) { if ((crc & 1) == 1) crc = (crc >> 1) ^ poly; else crc >>= 1; } } return ~crc; } // 热更流程中 string localPath = Path.Combine(Application.persistentDataPath, "RoleData.xlsx"); if (File.Exists(localPath)) { byte[] localBytes = File.ReadAllBytes(localPath); uint localCRC = CalculateCRC32(localBytes); if (localCRC == serverVersion.CRC32) { Debug.Log("Excel already up-to-date"); return; } }

CRC32计算快(1MB文件约2ms),且对内容敏感:哪怕改一个字节,校验码就完全不同。

5.3 增量更新:只下载变化的Sheet,而非整个.xlsx

一个10MB的.xlsx里可能只有“角色属性”Sheet被修改,其他Sheet(如“技能表”“装备表”)完全没动。全量下载浪费流量。NPOI支持只读取指定Sheet,但不支持“只下载指定Sheet”。真正的增量方案是:服务端生成Delta包。例如:

  • 服务端维护每个Sheet的独立CRC32;
  • 客户端请求/excel/delta?version=1.2.0,服务端返回JSON:
    { "sheets": [ {"name": "角色属性", "crc32": 1234567890, "url": "https://cdn/role_attr_v2.xlsx"}, {"name": "技能表", "crc32": 9876543210, "url": "https://cdn/skill_v1.xlsx"} ] }
  • 客户端对比本地各Sheet CRC,只下载变化的Sheet文件(仍是.xlsx格式,但只含一个Sheet);
  • 运行时用NPOI的XSSFWorkbook合并多个单Sheet文件(需定制NPOI,或用SpreadsheetLight库)。

我在《SLG策略手游》里实现了该方案,热更包体积从平均8MB降至0.3MB,下载耗时从12秒降至1.5秒。当然,这需要服务端配合,但如果项目已接入热更框架(如ResKit、ABF),增加Sheet级校验是性价比极高的优化。

6. 性能优化与内存控制:别让Excel吃光你的RAM

Excel解析最大的性能陷阱是内存泄漏。NPOI的XSSFWorkbook对象内部持有大量ICellIRow引用,如果不用usingDispose(),在频繁热更场景下,GC无法及时回收,导致内存持续增长。我在一个AR项目里遇到过:每分钟加载一次Excel(用于动态更新POI数据),30分钟后内存占用飙升至1.2GB,最终OOM崩溃。

6.1 必须显式释放Workbook

所有NPOI Workbook对象必须用using包裹,或手动调用Close()

// ❌ 危险:未释放 XSSFWorkbook workbook = new XSSFWorkbook(stream); // ✅ 正确:using自动调用Dispose() using (XSSFWorkbook workbook = new XSSFWorkbook(stream)) { ISheet sheet = workbook.GetSheetAt(0); // ... 解析逻辑 } // 此处workbook.Close()被自动调用 // ✅ 或手动释放 XSSFWorkbook workbook = new XSSFWorkbook(stream); try { ISheet sheet = workbook.GetSheetAt(0); // ... 解析逻辑 } finally { workbook.Close(); // 关键! }

Close()会释放所有底层流、XML解析器、字符串缓存等资源。NPOI文档明确警告:“Failure to call Close() may result in memory leaks”。

6.2 避免重复解析:缓存解析结果

如果同一Excel被多次读取(如战斗系统每帧查角色属性),每次都解析是巨大浪费。解决方案是LRU缓存

public static class ExcelCache { private static readonly Dictionary<string, object> _cache = new Dictionary<string, object>(); private static readonly object _lock = new object(); public static T GetOrAdd<T>(string key, Func<T> factory) where T : class { lock (_lock) { if (_cache.TryGetValue(key, out object value) && value is T t) return t; T result = factory(); _cache[key] = result; // LRU:限制缓存大小,超限时移除最早项 if (_cache.Count > 100) _cache.Remove(_cache.Keys.First()); return result; } } } // 使用 var roleData = ExcelCache.GetOrAdd<RoleData>("RoleData", () => RuntimeExcelLoader.LoadExcelFromStreamingAssetsAsync<RoleData>("Config/RoleData.xlsx").Result);

6.3 大文件专项优化:分块读取与流式解析

当Excel超过5MB,XSSFWorkbook会一次性将整个ZIP解压到内存,极易OOM。NPOI提供SXSSFWorkbook(Streaming UserModel),但它只支持写入,不支持读取。替代方案是SAX解析器——不加载整个XML到内存,而是事件驱动式逐行读取。NPOI的XSSFReader类支持此模式:

public static List<RoleInfo> ParseLargeExcel(string filePath) { List<RoleInfo> results = new List<RoleInfo>(); using (FileStream fs = new FileStream(filePath, FileMode.Open)) using (XSSFReader reader = new XSSFReader(fs)) { StylesTable styles = reader.SST; SharedStringsTable sst = reader.SST; // 获取第一个Sheet的XML流 XmlReader sheetReader = reader.GetSheet("rId1"); XmlTextReader textReader = new XmlTextReader(sheetReader); RoleInfo currentRole = null; string currentTag = ""; while (textReader.Read()) { if (textReader.NodeType == XmlNodeType.Element) { currentTag = textReader.Name; if (currentTag == "row" && textReader.GetAttribute("r") != "1") // 跳过表头 { currentRole = new RoleInfo(); } } else if (textReader.NodeType == XmlNodeType.Text && currentRole != null) { if (currentTag == "v") // 单元格值 { string value = textReader.Value; // 根据列顺序赋值(需预知列结构) if (/* 是ID列 */) currentRole.id = int.Parse(value); else if (/* 是名称列 */) currentRole.name = value; // ... } } else if (textReader.NodeType == XmlNodeType.EndElement && currentTag == "row" && currentRole != null) { results.Add(currentRole); currentRole = null; } } } return results; }

SAX模式内存占用恒定(约2MB),但开发成本高,需手动处理XML事件。我建议:5MB以下用XSSFWorkbook,5MB以上用SAX。在《模拟经营》项目里,我们有一个12MB的“全地图资源分布表”,用SAX解析耗时1.8秒,内存峰值仅2.1MB,而XSSFWorkbook需8秒且峰值内存1.4GB。

7. 实战排错:那些让你熬夜到凌晨三点的Excel Bug

最后分享几个我在项目中踩过的、文档里绝不会写的坑,每一个都曾让我对着Log抓狂半小时。

7.1 Bug:Excel里明明写了“100”,cell.NumericCellValue却是100.00000000000001

根因:Excel底层用IEEE 754双精度浮点数存储数字,100在二进制中无法精确表示,导致微小误差。NPOI直接返回原始值,而Unity的int强制转换会截断小数部分,但100.00000000000001int仍是100——看似正常,但当这个值参与==比较时就会出问题。

修复:在GetCellValue方法中,对Numeric类型加Math.Round

if (cell.CellType == CellType.Numeric) { double rawValue = cell.NumericCellValue; if (targetType == typeof(int) || targetType == typeof(int?)) { // 四舍五入到整数,消除浮点误差 int rounded = (int)Math.Round(rawValue); return rounded; } }

7.2 Bug:策划在Excel里用“Ctrl+1”设置千分位,读出来却是“1,000”

根因cell.StringCellValue返回的是格式化后的字符串,而非原始数字。NPOI的cell.NumericCellValue才是原始值,但策划如果设置了自定义格式(如#,##0),cell.StringCellValue会返回带逗号的字符串。

修复:永远优先用NumericCellValue读数字,仅当CellType == CellType.String时才用StringCellValue。并在解析前检查cell.CellType

if (cell.CellType == CellType.Numeric) value = cell.NumericCellValue; else if (cell.CellType == CellType.String) value = cell.StringCellValue; else value = cell.ToString(); // 兜底

7.3 Bug:iOS真机上File.ReadAllBytes返回空数组,但文件明明存在

根因:Unity 2021.3+在iOS上对StreamingAssets路径的处理有变更。Application.streamingAssetsPath返回的路径末尾可能多了一个/,导致Path.Combine生成错误路径(如/var/containers/Bundle/Application/xxx.app//Data/Raw/xxx.xlsx),双斜杠使File.Exists返回false。

修复:标准化路径,移除多余斜杠:

string CleanPath(string path) { return Regex.Replace(path, @"[/\\]+", "/"); } string fullPath = CleanPath(Path.Combine(Application.streamingAssetsPath, fileName));

7.4 Bug:热更后Excel能读,但中文全是乱码()

根因:Excel文件保存时编码不是UTF-8。Windows记事本默认用GBK(代码页936),而NPOI在解析sharedStrings.xml时假设是UTF-8。当sharedStrings.xml里有GBK编码的中文,NPOI用UTF-8解码就成乱码。

修复:强制NPOI用正确的编码读取。在XSSFWorkbook构造前,先用StreamReader以GBK读取并转UTF-8:

// 仅当检测到是GBK编码时启用 private static byte[] FixExcelEncoding(byte[] originalBytes) { // 检查BOM或常见GBK特征 if (originalBytes.Length > 2 && originalBytes[0] == 0xFF && originalBytes[1] == 0xFE) // UTF-16 LE BOM return originalBytes; // 尝试用GBK解码再UTF-8编码 try { string str = Encoding.GetEncoding(936).GetString(originalBytes); return Encoding.UTF8.GetBytes(str); } catch { return originalBytes; // 保持原样 } }

这个Bug在《武侠RPG》项目里出现过,策划用WPS保存Excel,默认GBK,导致全区中文对话乱码。加了这段修复后,问题消失。

我在实际使用中发现,最省心的做法是统一规范策划的Excel保存格式:要求必须用Excel 2016+,另存为→“Excel 工作簿(*.xlsx)”→不勾选“保留兼容性”,这样默认就是UTF-8。技术手段是兜底,流程规范才是根治。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/23 16:03:00

Wireshark深度解析TLS 1.3与HTTP/2隐性故障pcap样本

1. 这不是一份普通pcap&#xff0c;而是一份“网络故障诊断教科书级样本”你有没有遇到过这样的情况&#xff1a;客户发来一个几十MB的pcap文件&#xff0c;标题叫“系统登录超时”&#xff0c;你打开Wireshark&#xff0c;密密麻麻全是TCP重传、RST包、DNS超时&#xff0c;但翻…

作者头像 李华
网站建设 2026/5/23 16:02:03

Windows服务器SWEET32漏洞(CVE-2016-2183)四层加固实战

1. 这个“心脏出血”后遗症&#xff0c;为什么三年后还在Windows服务器上反复发作&#xff1f;CVE-2016-2183——这个编号听起来像一段被遗忘的旧闻。2016年9月&#xff0c;OpenSSL官方披露了SWEET32攻击&#xff08;Sweet32: Birthday attacks on 64-bit block ciphers in TLS…

作者头像 李华
网站建设 2026/5/23 15:57:39

CVE-2024-YIKES:一次给所有初级开发者的“安全意识启动课”

CVE-2024-YIKES&#xff1a;一次给所有初级开发者的“安全意识启动课” 最近&#xff0c;一个代号为 CVE-2024-YIKES 的漏洞在开发者社区悄然升温——它没有惊天动地的远程代码执行链&#xff0c;也没有零日武器化的新闻通稿&#xff0c;却在 Hacker News 上悄然获得 551 票&a…

作者头像 李华
网站建设 2026/5/23 15:56:37

多智能体通信调度:让AI学会何时说话、何时沉默

1. 项目概述&#xff1a;这不是写个定时任务&#xff0c;而是让一群“会商量”的智能体真正协同起来“Learn to Schedule Communication between Cooperative Agents”——光看标题&#xff0c;很多人第一反应是&#xff1a;“哦&#xff0c;多智能体系统里的通信调度问题”。但…

作者头像 李华
网站建设 2026/5/23 15:53:05

在ubuntu上配置claude code使用taotoken替代官方api的经验分享

&#x1f680; 告别海外账号与网络限制&#xff01;稳定直连全球优质大模型&#xff0c;限时半价接入中。 &#x1f449; 点击领取海量免费额度 在 Ubuntu 上配置 Claude Code 使用 Taotoken 替代官方 API 的经验分享 作为一名日常在 Ubuntu 20.04 环境下工作的开发者&#xf…

作者头像 李华