资讯动态

Unity读取Json三方案选型与IL2CPP避坑

发布时间:2026/10/26 22:03:52 来源:尧图企业网站定制
做 Unity 项目做到一定阶段配置文件这件事基本躲不掉。任务表、道具表、关卡参数、本地化文案、存档结构早晚会从ScriptableObject和硬编码里搬出来变成一堆.json文件。这时候问题就来了Unity 里读 Json 到底该用哪套方案官方自带的JsonUtility够不够用LitJson这种老牌轻量库还值不值得上Newtonsoft.Json功能最全但在包里塞进去会不会拖慢启动、在 IL2CPP 下会不会被裁剪到崩溃这篇内容围绕Unity 读取 Json 的三种主流方案JsonUtility、LitJson、Newtonsoft展开把我这几年在几个中型项目里反复折腾出来的结论摊开讲包括每种方案的适用边界、完整的实操代码、参数与结构怎么设计、打包之后会踩哪些坑以及一套可以直接抄走的配置读取模块写法。刚接触 Unity 配置系统的新人看完能照着做已经用过其中一两种、但一直没搞明白为什么“Editor 里好好的、真机上就报错”的老手重点看第三、四、六、七章那里全是现场记录。1. 先想清楚Unity 里读 Json 到底难在哪1.1 三种方案背后是三种不同的取舍很多人以为选库就是选“谁的功能多”其实不是。这三个东西的设计目标从一开始就不一样理解了这个后面的选型几乎是自动的。JsonUtility是 Unity 引擎序列化系统的一层外壳。它复用了引擎自己那套序列化规则——就是 Inspector 里能看到的字段规则。好处是它跑的是原生代码不需要反射扫描类型速度快、GC 少、包体零增量。坏处也同样明显引擎序列化支持什么它就只能支持什么字典、属性、多态、顶层数组统统不认。LitJson是一个很小的纯 C# 库单文件级别靠反射读写字段和属性。它的定位是“够用就好”动态解析JsonData和强类型映射都能做代码量小塞进项目几乎无感。但它年头久了维护不活跃在数字类型和 AOT 环境上有一些众所周知的老毛病。Newtonsoft.Json也就是常说的 Json.NET是 .NET 生态里事实上的标准库功能覆盖极全特性标注、自定义转换器、部分反序列化、JObject/JArray动态树、流式读写、各种空值与默认值策略。代价是包体大、反射开销高在 IL2CPP 下需要处理代码裁剪问题。所以真正的取舍是你要的是启动速度和包体还是字段映射的灵活度和容错能力。这两个东西在 Unity 里往往是互斥的。1.2 选型前必须先确认的四个前提在动手写第一行代码之前有四个问题必须先有答案否则选完还得返工。第一个是运行环境。Editor 和 Mono 下什么都好说一旦切到 IL2CPP ARM64 的移动平台反射、泛型实例化、动态代码统统要重新评估。Newtonsoft和LitJson在这个环节都可能因为托管代码裁剪Managed Stripping而丢掉构造函数表现就是真机上抛MissingMethodException或者干脆反序列化出来一堆 null。第二个是数据规模。几十条配置和几万条配置完全是两种场景。几千行以内的表反射开销可以忽略上万行的战斗数值表反序列化时间会直接体现在加载条上这时候JsonUtility的原生解析优势就非常明显。第三个是是否需要热更。配置走Resources目录最省事但资源全在包体里改一个数字就得重新发版走StreamingAssets或 AssetBundle 才能做到只更新配置不动主程序。而不同平台对StreamingAssets的读取方式还不一样后面第六章会专门讲。第四个是谁在写这些 Json。如果是程序自己用工具导出的字段名、类型都能保证那用最严格的JsonUtility最省心字段写错了直接报错如果是策划手改、或者对接第三方接口返回的数据字段缺失、类型漂移是常态那就得用容错能力强的方案。提示别一上来就追求“统一方案”。一个项目里同时用JsonUtility读本地配置表、用Newtonsoft处理服务端返回的复杂结构是完全正常且推荐的做法。关键是每个场景用对工具而不是强迫所有地方用同一个库。2. JsonUtility官方自带速度快但规矩多2.1 它的能力边界在哪里先把结论摆出来JsonUtility能做的事序列化和反序列化带[Serializable]标记的类或结构体、ListT和数组字段、Unity 内置的Vector2/Vector3/Vector4/Quaternion/Color/Rect/Bounds等结构、嵌套的自定义[Serializable]类型。它的序列化结果和 Inspector 里看到的字段是一一对应的这一点很关键——意味着你在 Inspector 里调好的默认值和 Json 里的字段是对齐的。它不能做的事我这里列一份清单这份清单是我在实际项目里一条一条撞出来的不支持DictionaryTKey, TValue一个都不行SerializableDictionary那种自定义实现也不认因为它不是引擎序列化体系内的类型。不支持属性property只认字段field。写了public int Level { get; set; }会发现永远是默认值。不支持顶层数组或顶层基础类型。直接FromJsonint[]([1,2,3])会失败必须套一层对象。不支持多态和接口字段。父类引用指向子类实例反序列化回来只会得到父类的那部分字段。字段必须是public或者带[SerializeField]的private。static、const、readonly字段一律忽略。null的处理很粗放。Json 里写null反序列化后拿到的可能是null也可能是默认构造的空实例取决于类型不要指望它精确还原。关于最后一条我多说两句。JsonUtility在反序列化引用类型字段时如果 Json 里给了null字符串字段会得到null但自定义类字段经常会给你一个“字段全是默认值”的新实例而不是null。所以判断“这个配置项有没有配”时千万不要用if (cfg.sub ! null)要用一个明确的enabled布尔字段或者版本号字段来表达。2.2 最小可运行示例数据结构这样写注意[Serializable]和字段可见性using System; using System.Collections.Generic; using UnityEngine; [Serializable] public class ItemConfig { public string id; public string name; public int quality; public float weight; public Liststring tags; public Vector3 iconOffset; }读取的时候最省事的做法是把 Json 文件当TextAsset引用。Unity 会把.json、.txt、.bytes、.csv这些扩展名都识别成TextAsset直接拖到脚本的[SerializeField]字段上就行using UnityEngine; public class ItemConfigLoader : MonoBehaviour { [SerializeField] private TextAsset itemConfigAsset; private ItemConfig config; private void Awake() { config JsonUtility.FromJsonItemConfig(itemConfigAsset.text); Debug.Log($id{config.id}, weight{config.weight}); } }如果是一张表、也就是 Json 顶层是数组那就套一层壳[Serializable] public class ItemConfigTable { public ListItemConfig items; } // 使用 var table JsonUtility.FromJsonItemConfigTable(text);对应的 Json 长这样{ items: [ { id: 1001, name: 铁剑, quality: 1, weight: 3.5, tags: [weapon], iconOffset: { x: 0, y: 0, z: 0 } } ] }写回文件用JsonUtility.ToJson(obj, true)第二个参数true表示格式化输出带缩进方便人看线上数据建议传false能省不少体积。2.3 六个必须提前知道的限制与绕行写法上一节列的清单是“不能做什么”这一节讲“不能做的时候怎么办”这部分才是真正决定项目能不能顺利推进的。字典的替代方案。如果你的配置天然是 key-value 结构比如id - 配置对象最直接的做法是把 Json 写成数组读进ListT然后在代码里自己转成Dictionary。多写三行代码换来的是零依赖和最快的解析速度。如果实在想让 Json 里保持对象形态另一种写法是存成{keys:[a,b],values:[{...},{...}]}的双数组结构读完之后手动拼装。字段名不一致的问题。JsonUtility的字段名匹配是大小写敏感的而且 Json 里多出来的键会被静默忽略——这一点最坑因为字段名打错了不会报错只会悄悄拿到默认值。我的做法是在每个配置类里加一个Validate()方法在加载完成后跑一遍关键字段的合法性检查把问题在启动阶段就暴露出来而不是等策划跑到某个界面发现数值是 0。FromJsonOverwrite减少 GC。如果同一份配置需要反复读取比如每帧轮询的场景配置用JsonUtility.FromJsonOverwrite(json, target)覆盖到已有实例上能避免每次新建对象带来的 GC 压力。这个方法在需要频繁刷新配置的场景里很有用。编码问题。这是我最想强调的一条。JsonUtility对 UTF-8 BOM 非常敏感用某些编辑器另存为“UTF-8 with BOM”之后反序列化会直接抛ArgumentException: JSON parse error: Invalid value.。解决办法有两个一是保存时选“UTF-8 无 BOM”二是在读取时主动去掉text.TrimStart(\uFEFF)。我现在的习惯是在所有加载入口统一做一次 BOM 清洗省得后面反复排查。注意JsonUtility报错信息非常吝啬只会告诉你“位置在第几个字符附近”不告诉你缺了哪个字段。定位这类问题的效率很低所以格式化输出ToJson(obj, true)在调试期一定要开着用格式化后的结果和源文件做 diff 是最快的排查手段。枚举的处理。JsonUtility支持枚举字段但存的是整数值不是名字。如果配置表里写的是type: Weapon这种字符串形式它读不出来会变成 0。要么让导出工具统一存整数要么自己加一个字符串字段做映射。空数组与缺失字段。Json 里没有tags这个键时反序列化后tags字段是null而不是空列表直接用会空引用。统一在Validate()里补?? new Liststring()比较稳妥。3. LitJson轻量灵活中小项目的老朋友3.1 它的定位和典型用法LitJson的历史很长早年间几乎是 Unity 社区里 Json 方案的首选原因很简单源码就几个文件拖进Plugins目录就能用不像 Newtonsoft 那样要处理一堆依赖。它的核心就两个入口JsonMapper做对象映射JsonData做动态解析。和JsonUtility最大的区别在于LitJson是纯反射驱动所以它支持属性、支持Dictionarystring, T、支持顶层数组。这三点直接决定了它在很多场景下比JsonUtility好用。代价是每次反序列化都要反射扫描类型的所有成员GC 分配也明显更多。如果你在维护一个老项目LitJson大概率已经躺在Plugins里了那就继续用没必要为了“升级”去换库。如果你在开新项目我的建议是数据量不大、需要字典和属性、又不想引入重型依赖的场景LitJson依然是个合理选择。但上了几万行配置表、或者对加载耗时敏感的还是优先考虑JsonUtility。3.2 强类型与动态解析两条路强类型映射这条路和JsonUtility的用法几乎一样字段和属性都能映射using LitJson; public class SkillConfig { public int id { get; set; } public string name { get; set; } public float cooldown; public Dictionarystring, float paramsMap; } // 读取 string json System.IO.File.ReadAllText(path); SkillConfig cfg JsonMapper.ToObjectSkillConfig(json);注意这里的类不需要[Serializable]也不需要 public 字段属性就能用。这点在对接后端接口时特别方便因为后端给的字段名通常和 C# 命名规范不一致LitJson允许你直接用 Json 里的字段名做成员名虽然不符合规范但省事。动态解析这条路是LitJson真正灵活的地方。当你面对一份结构不确定的 Json——比如运营活动配置字段随活动类型变化——就不适合定义强类型了JsonData data JsonMapper.ToObject(json); // 对象取值 string name (string)data[name]; int level (int)data[level]; // 数组遍历 foreach (JsonData node in data[rewards]) { string itemId (string)node[itemId]; int count (int)node[count]; Debug.Log(${itemId} x {count}); } // 判断键是否存在 if (data.ContainsKey(extra)) { // ... }写回也很直接JsonData root new JsonData(); root[version] 2; root[items] new JsonData(); root[items].Add(new JsonData()); root[items][0][id] 1001; string output root.ToJson();JsonData内部其实是在object上做了一层包装取值时的强制转换本质上是装箱拆箱。所以动态解析用起来爽但它比强类型映射还要慢而且很容易在转换上翻车。3.3 数字类型与 IL2CPP 的两个老坑第一个坑是数字的装箱类型。LitJson解析数字时会根据字面量决定内部类型没有小数点且值在int范围内就存成int超出就存成long有小数点就存成double。听起来合理但问题是这个“决定”发生在运行时如果你的代码写死了(int)data[count]而 Json 里恰好写了个很大的数比如时间戳、货币数量内部存成long这一行就会抛InvalidCastException。我在一次活动系统上线后遇到过这个问题测试环境的数据量小一直没问题正式环境一个玩家身上带了超过int上限的经验值登录直接崩。修复方式是把所有数字取值统一走一层安全转换public static int ToInt(JsonData data) { if (data null) return 0; // 统一走字符串中转避免 int/long/double 的装箱差异 return int.TryParse(data.ToString(), out var v) ? v : 0; } public static float ToFloat(JsonData data) { if (data null) return 0f; return float.TryParse(data.ToString(), out var v) ? v : 0f; }这个包装函数看起来笨但胜在稳定再也没出过类型转换的崩溃。第二个坑是 IL2CPP 下的代码裁剪。LitJson靠反射找类的成员而 IL2CPP 打包时会做托管代码裁剪Managed Stripping Level 默认是 Low但很多人为了减小包体会调到 Medium 或 High。一旦你的配置类被判定为“没人用”构造函数和成员就被裁掉了运行时反射拿到空反序列化出来的对象全是默认值——注意是静默失败不报错特别难查。解决办法是在工程里加一个link.xml放在Assets目录下把配置类所在的程序集或具体类型保下来linker assembly fullnameAssembly-CSharp type fullnameGame.Config.SkillConfig preserveall/ type fullnameGame.Config.ItemConfig preserveall/ /assembly /linker如果配置类很多可以在配置类命名空间下统一加[Preserve]特性或者在link.xml里直接按命名空间保留。我个人更倾向于显式列类型因为按命名空间全保留往往会把一堆无关的类一起塞进包里。提示LitJson还有一个不太常见的限制——它要求目标类型有公共无参构造函数。如果你写的是只有带参构造函数的类会抛异常。这一点在写 DTO 的时候要注意别把类设计得太“面向对象”。4. Newtonsoft.Json功能最全的重装方案4.1 安装与引入方式在 Unity 里装 Newtonsoft 有三种方式我按推荐程度排序。第一种通过 Package Manager 装官方 NuGet 包。Unity 2020.1 之后Package Manager 里可以搜索到Newtonsoft Json包名com.unity.nuget.newtonsoft-json直接 Install 就行。这是最推荐的方式因为它是 Unity 官方维护的适配版本解决了 AOT 下的部分兼容问题而且升级走 Package Manager不会把Plugins目录搞得一团乱。第二种手动拖 DLL。从 NuGet 下载.dll丢进Assets/Plugins。这种方式在老旧版本2019 及以前上比较常见缺点是版本管理靠人肉多平台尤其 iOS还要注意.dll的兼容性设置。第三种从 Git URL 添加。在 Package Manager 里选 “Add package from git URL”填入对应的仓库地址。适合需要锁定特定版本、或者需要改源码的场景。装完之后脚本里using Newtonsoft.Json;和using Newtonsoft.Json.Linq;就能用了。4.2 常用 API 与序列化设置Newtonsoft 的核心 API 其实就三个JsonConvert.DeserializeObjectT、JObject.Parse、JsonConvert.SerializeObject。前两个对应“强类型”和“动态”两条路和LitJson的设计思路是一样的只是功能丰富得多。强类型反序列化using Newtonsoft.Json; public class QuestConfig { [JsonProperty(quest_id)] public int QuestId { get; set; } [JsonProperty(quest_name)] public string QuestName { get; set; } public ListQuestStep Steps { get; set; } // Json 里没有的字段保持默认值 public bool IsDaily { get; set; } // 私有 setter 也能写这对只读配置很有用 public int MaxLevel { get; private set; } } var quest JsonConvert.DeserializeObjectQuestConfig(json);[JsonProperty(xxx)]这个特性是 Newtonsoft 最实用的功能之一。它让你在 C# 里用规范命名在 Json 里用下划线命名两边互不干扰。JsonUtility做不到这一点LitJson也做不到只能迁就 Json 的命名。动态解析用JObject/JArray比LitJson的JsonData强在类型系统更完整ValueT()会做真正的类型转换而不是强制拆箱using Newtonsoft.Json.Linq; JObject root JObject.Parse(json); int version root.Valueint(version); string title root.Valuestring(title); JArray rewards root[rewards] as JArray; if (rewards ! null) { foreach (JToken token in rewards) { string itemId token.Valuestring(itemId); int count token.Valueint(count); } }ValueT()在键不存在时返回default(T)不会抛异常这一点比LitJson的强制转换安全太多。全局序列化设置能解决很多“字段缺失 / 多余字段 / 空值”的问题public static readonly JsonSerializerSettings Settings new JsonSerializerSettings { // Json 里没有的字段不要报错 MissingMemberHandling MissingMemberHandling.Ignore, // 序列化时跳过 null NullValueHandling NullValueHandling.Ignore, // 反序列化时Json 里的 null 不覆盖默认值 DefaultValueHandling DefaultValueHandling.Populate, // 枚举用字符串表示 Converters { new Newtonsoft.Json.Converters.StringEnumConverter() }, // 日期格式统一 DateFormatString yyyy-MM-dd HH:mm:ss, // 允许注释和尾逗号方便策划手写 // 注意这两个要通过 JsonTextReader 的配置开启见下方说明 };需要说明的是允许注释和尾逗号这两个特性不在JsonSerializerSettings里要在读取时用JsonTextReader打开using (var reader new JsonTextReader(new StringReader(json))) { reader.SupportMultipleContent true; // 老版本叫 Comments新版本已默认允许注释 var serializer JsonSerializer.Create(Settings); var obj serializer.DeserializeQuestConfig(reader); }允许注释这一条对于“策划手改配置”的场景价值极高。加一行// 这条是春节活动专用就能让后面接手的人少猜半小时。4.3 AOT / IL2CPP 下的裁剪与性能对策Newtonsoft 在 IL2CPP 下最常见的问题也是裁剪导致的MissingMethodException尤其是泛型转换器和自定义JsonConverter里用到的类型。处理方式和LitJson类似靠link.xml加保留linker assembly fullnameNewtonsoft.Json preserveall/ assembly fullnameAssembly-CSharp type fullnameGame.Config.QuestConfig preserveall/ /assembly /linker把所有 Newtonsoft 程序集整体保留是最稳的做法包体会增加几 MB但对于需要用到它的项目来说这个代价通常可以接受。如果对包体极其敏感可以只保留用到的类型但要做好反复排查的准备。性能方面Newtonsoft 是三个方案里最慢的主要开销在反射和JToken的中间表示上。如果某个配置表在加载时明显拖慢加载条有两个优化方向一是改用JsonUtility如果结构允许二是用源生成或者手写JsonConverter跳过反射。第二种成本较高只在极少数热点路径上值得做。另外提醒一点Newtonsoft 的dynamic相关功能在 IL2CPP 下是不可用的编译能过但运行会崩。看到别人代码里写dynamic obj JsonConvert.DeserializeObject(json)一定要改掉换成JObject。5. 三者横向对比与选型建议5.1 关键指标对比表光讲原理不够直观我把实际项目里最关心的几个维度整理成表方便对照维度JsonUtilityLitJsonNewtonsoft.Json来源Unity 引擎内置第三方轻量库第三方全功能库包体增量0极小几个源文件较大数 MB解析速度最快原生实现中等反射最慢反射 JToken字典支持不支持支持支持属性支持不支持支持支持顶层数组不支持支持支持字段名映射必须一致大小写敏感必须一致支持[JsonProperty]重命名多态支持不支持弱强TypeNameHandling / 自定义 ConverterNull 与默认值策略粗放一般精细可配注释与尾逗号不支持不支持支持IL2CPP 风险无需link.xml需link.xml风险更高上手成本极低低中关于速度这一行我给的是相对量级而不是具体数字。我在一个约 200 字段的配置表上做过简单对比同等数据量下JsonUtility比 Newtonsoft 快出一个数量级左右LitJson居中偏慢。但要说清楚这个差距在“加载一次配置表”的场景里往往只有几十毫秒感知不明显真正能感觉到差距的是每帧都在反序列化的场景比如实时同步的战斗数据包。5.2 我的选型判断逻辑抛开具体指标我实际的判断顺序是这样的。第一步看数据来源是否可控。工具导出的、字段稳定的配置表一律优先JsonUtility——它不给你犯错的空间字段名写错就是拿不到数据反而逼着你把结构对齐。这一步能省掉后面大量的“为什么这个数是 0”的排查。第二步看是否真的需要字典和属性。很多团队一上来就说“我们要用字典”实际上把Dictionarystring, T改成ListT再在初始化时建索引代码只多五行性能还更好。真正需要字典的场景没那么多别为了省五行代码把最快的方案排除掉。第三步看是否有无法回避的复杂结构。比如对接第三方接口返回的 Json 层级深、字段变体多、还带各种可选嵌套这时候硬用JsonUtility会写出大量包装类维护成本反而更高。这种情况直接上 NewtonsoftJObject的灵活性能省下大量胶水代码。第四步看团队熟悉度。这一条经常被忽略但很重要。一个团队如果没人用过 Newtonsoft 的特性系统强行上它只会写出一堆半吊子代码反之如果团队已经有成熟的 Newtonsoft 使用规范硬换成JsonUtility会引发大量返工。技术选型最终要落到人身上。我自己的典型配置是本地配置表用JsonUtility服务端消息用 Newtonsoft少量历史遗留用LitJson保持不动。三套并存听起来很乱但每个库都在做它最擅长的事实际维护起来比强行统一要轻松。为了让三者共存不打架我会在项目里加一层薄封装IConfigReader调用方只认接口具体用哪个库由配置决定。6. 实战一套可复用的配置读取模块6.1 数据结构与目录约定光会调 API 还不够配置系统真正的难点在工程组织。我现在的做法是固定三件事目录结构、命名规则、版本字段。目录上所有需要热更的配置放在Assets/StreamingAssets/Config/下按模块分子目录比如Config/Item/items.json、Config/Quest/quests.json。不参与热更、随包发版的走Resources或者直接拖TextAsset引用。这条线一定要在项目启动阶段就划清楚等做到一半再想“这个配置要不要热更”返工量会很大。命名上文件名用复数items.json根键用items和类名ItemConfig对应。这套约定看起来是小事但能让“猜文件在哪”这件事从每次两分钟变成零成本。版本字段是我强烈建议加的[Serializable] public class ConfigHeader { public int schemaVersion; // 结构版本结构不兼容变更时 1 public string contentVersion; // 内容版本可以理解成策划改动的批次号 public long exportedAt; // 导出时间戳排查问题非常有用 }每份配置表把这三个字段放在最外层加载时先校验schemaVersion是否和代码里期望的一致不一致直接报错并打印出期望值和实际值。这一条在多人协作、配置和代码不同步的项目里救命。6.2 读取、缓存与热更路径读取入口我只暴露一个泛型方法内部根据路径前缀决定走哪种加载方式public static class ConfigLoader { private static readonly DictionaryType, object Cache new DictionaryType, object(); public static T LoadT(string relativePath) where T : class { if (Cache.TryGetValue(typeof(T), out var cached)) return cached as T; string json ReadText(relativePath); if (string.IsNullOrEmpty(json)) { Debug.LogError($[Config] 配置文件为空或不存在: {relativePath}); return null; } // 统一清洗 BOM json json.TrimStart(\uFEFF); T result JsonUtility.FromJsonT(json); if (result null) { Debug.LogError($[Config] 反序列化失败: {relativePath}); return null; } Cache[typeof(T)] result; return result; } private static string ReadText(string relativePath) { #if UNITY_ANDROID !UNITY_EDITOR // Android 上 StreamingAssets 在 APK 内部必须走 UnityWebRequest return ReadFromStreamingAssetsSync(relativePath); #else string full Path.Combine(Application.streamingAssetsPath, relativePath); return File.Exists(full) ? File.ReadAllText(full, Encoding.UTF8) : null; #endif } }这里有一个必须展开讲的坑Android 平台的StreamingAssets不是普通目录。它被打进了 APK路径形如jar:file:///data/app/xxx.apk!/assets/...用File.ReadAllText一定失败返回空或者抛FileNotFoundException。iOS 上StreamingAssets是普通目录File.ReadAllText可以正常工作。这种“平台行为不一致”是配置文件读取报错的头号原因。Android 上的标准做法是用UnityWebRequest异步读取。如果你的加载流程不能异步可以用协程包装成同步等待会有短暂阻塞但配置加载通常发生在 Loading 界面可以接受private static string ReadFromStreamingAssetsSync(string relativePath) { string url Path.Combine(Application.streamingAssetsPath, relativePath); using (var request UnityWebRequest.Get(url)) { var op request.SendWebRequest(); while (!op.isDone) { } if (request.result ! UnityWebRequest.Result.Success) { Debug.LogError($[Config] 读取失败: {url} / {request.error}); return null; } return request.downloadHandler.text; } }如果你在项目里用了 AssetBundle 或 Addressables 做热更那么配置文件的读取就直接走对应的资源加载接口拿到的TextAsset.text送进Load即可上面这部分平台判断可以省掉。我的建议是新项目直接上 Addressables不要碰StreamingAssetsFile.ReadAllText这套组合它能省掉你至少两天跨平台踩坑的时间。6.3 容错、日志与版本字段配置模块最容易出问题的地方不是读取而是读取之后没人知道数据对不对。我的做法是给每个配置类加一个Validate()方法在加载完成后立刻执行public interface IConfigValidatable { bool Validate(Liststring errors); } [Serializable] public class ItemConfig : IConfigValidatable { public string id; public string name; public int quality; public bool Validate(Liststring errors) { bool ok true; if (string.IsNullOrEmpty(id)) { errors.Add($ItemConfig.id 为空, name{name}); ok false; } if (quality 0 || quality 5) { errors.Add($ItemConfig[{id}] quality 越界: {quality}); ok false; } return ok; } }统一在加载器里跑一遍把所有错误收集起来一次性打印而不是遇到第一个就抛异常。这样策划改配置的时候能一次看到所有问题不用来回试。日志上也有个小技巧配置加载完成时打印一条包含“表名 条数 耗时 内容版本号”的日志。这条日志在排查线上问题时价值极高——比如玩家反馈某个道具属性不对看一眼日志就知道他客户端上的配置是哪一版。var sw System.Diagnostics.Stopwatch.StartNew(); var table ConfigLoader.LoadItemConfigTable(Item/items.json); sw.Stop(); Debug.Log($[Config] items 加载完成: {table.items.Count} 条, 耗时 {sw.ElapsedMilliseconds}ms, 版本 {table.header.contentVersion});注意这套缓存是静态的在 Editor 里改完 Json 不会自动生效需要重启播放或者手动清缓存。调试期我会加一个#if UNITY_EDITOR的分支让Load每次都重新读文件正式包才走缓存。这个开关一定要留否则调试效率会低到让人抓狂。7. 常见问题与排查速查表7.1 报错对照表下面这张表是我这几年攒下来的按“现象 → 最可能的原因 → 处理方式”整理。看到类似症状先查表比盲猜快得多。现象最可能原因处理方式JsonUtility抛JSON parse error: Invalid valueUTF-8 带 BOM另存为无 BOM或读取时TrimStart(\uFEFF)反序列化成功但字段全是默认值字段是属性、非 public、或类缺[Serializable]改成 public 字段或加[SerializeField]顶层数组读取失败JsonUtility不支持顶层数组加一层{ items: [...] }包装字典字段读不到JsonUtility不支持字典改ListT手动建索引Android 真机读配置返回空用了File.ReadAllText读StreamingAssets改用UnityWebRequest或 Addressables真机上对象全为 null / 抛MissingMethodExceptionIL2CPP 裁剪掉了构造函数加link.xml或[Preserve]LitJson抛InvalidCastException数字被存成long代码强转int统一走ToString()再TryParseNewtonsoft 报Cannot deserialize the current JSON arrayJson 顶层是数组代码按对象解析用JArray.Parse或DeserializeObjectListT中文显示成乱码文件编码不是 UTF-8统一存 UTF-8读时指定Encoding.UTF8枚举字段永远是 0Json 里写的是字符串库按整数解析加StringEnumConverter或改存整数7.2 现场踩坑记录表格之外还有几个值得单独说的经历。第一个是“编辑器里正常、真机崩溃”的经典案例。一个项目在 Editor 和 iOS 上都跑得好好的Android 上线后大量用户反馈进不去游戏。日志里只有一行NullReferenceException堆栈指向配置表初始化。查了半天才发现读取StreamingAssets的代码里写了File.Exists做判断Android 上这个判断永远返回false于是走了“文件不存在”的分支返回null后面直接空引用。这个坑的教训是凡是涉及跨平台的路径判断一律不要在 Editor 里验证通过就认为没问题必须在真机上跑一遍完整流程。第二个是裁剪导致的静默失败。有个模块在Development Build下一切正常切到Release之后反序列化出来的对象字段全是 0还不报错。折腾了一下午才想到去看Managed Stripping Level果然是项目为了减包体从Low调到了High。加了link.xml之后恢复正常。从那以后我养成了一个习惯每次改打包配置里的裁剪等级、脚本后端、API 兼容级别都要重新完整回归一遍配置加载流程。第三个是编码引发的中文乱码。Json 文件是策划用某编辑器改的保存成了 GB2312程序读出来中文全变问号。因为这个文件在 Editor 里显示正常Editor 会做一定程度的编码容错一直没被发现。现在的做法是在配置导出工具里强制做一次编码校验检测到非 UTF-8 直接拒绝导出。第四个是字段名拼写错误被静默忽略。JsonUtility遇到 Json 里多出来的键不报错所以quailty: 3这种拼写错误会一直潜伏到策划说“品质怎么全是 0”。这个问题的根治办法只有一个在导出工具端用同一份结构定义生成 Json 和 C# 类两边永远同步。做不到的话至少要用Validate()把关键字段的空值检查做起来。最后分享一个我一直在用的小工具函数专门用来定位JsonUtility的字段匹配问题——把 Json 解析成JObject用 Newtonsoft然后和反射拿到的字段列表做差集一次性打印出“Json 里有但类里没有的键”和“类里有但 Json 里没有的键”。在 Editor 下给每个配置表跑一遍能提前消灭掉九成的字段名问题。这个检查只放在 Editor 的构建前钩子里不影响运行时也不引入运行时依赖用起来很省心。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑