Unity JSON解析三方案选型与IL2CPP避坑指南
Unity项目里读一份JSON,表面上看是个再小不过的需求:拿到字符串,塞进对象,完事。可真到了项目里,麻烦往往出在“塞”这一步。我见过不少项目,配置表用一套库读、存档用另一套库写、网络协议又换了第三套,最后排查一个字段为空的bug要翻三份文档。Unity生态里读JSON的路径基本就三条:官方的JsonUtility、轻量的LitJson、功能最全的Newtonsoft.Json。三者的能力边界差别很大,选错了不是写起来麻烦,而是会在IL2CPP打包、移动端真机、复杂嵌套结构上集中爆雷。
这篇内容我打算把这三条路都走一遍,重点不在贴API,而在于讲清楚每个方案在什么条件下会失效、失效时表现成什么样、以及怎么在半小时内定位到原因。适合刚接手一个已有项目、需要判断“这份代码为什么读不出数据”的读者,也适合从零搭配置表系统、想一次性选对方案的人。下面所有示例都基于常见工程实践整理,代码可以直接改改就用。
1. Unity读JSON这件事,难点从来不在“读”字上
先把问题拆开看。一份JSON从磁盘或网络到内存对象,中间其实有四段路:字节到字符串、字符串到语法树、语法树到类型、类型到业务字段。绝大多数人卡住的位置不在第一段,也不在最后一段,而是在中间两段的“隐式约定”上——每个库对“什么样的类型能被序列化”都有自己的规矩,而这些规矩通常不会在你写代码的时候报错,只会在运行时安静地给你一个字段全空的默认对象。
1.1 三种方案的分水岭:反射、AOT与功能边界
理解这三套方案,最有效的一个切入点是看它们靠什么工作。JsonUtility是Unity引擎内置的,它不走通用反射,而是和引擎的序列化系统共用一套元数据,所以它只认Unity自己那套规则:[Serializable]标记、公开字段、不支持属性、不支持字典。好处是它在IL2CPP下几乎不需要额外配置,性能也好;代价是它的表达能力被刻意收窄了,遇到字典或者多态结构就完全没辙。
LitJson和Newtonsoft.Json都是走反射的通用库。反射这件事在编辑器里跑得好好的,打到IL2CPP包上就可能因为代码裁剪(Managed Stripping)把“只被反射引用”的构造函数和属性裁掉,运行时抛出找不到构造函数的异常。区别在于,Newtonsoft 生态更成熟,配套的link.xml写法和[Preserve]用法资料多;LitJson 因为分支版本杂乱,很多老教程的配置在新版本上并不适用。
我在实际项目里总结出一条判断线:数据结构是否规整决定了要不要用JsonUtility。如果你的配置表就是一张扁平的字段列表,没有任何字典、没有多态、根节点永远是对象,那JsonUtility是性价比最高的选择,打包和性能都省心。一旦出现Dictionary<string, T>、或者需要一个基类字段装不同子类,就必须换库,这时候再纠结JsonUtility的性能优势没有意义,因为它根本做不到。
1.2 先把文件读进来:不同平台的路径差异
很多人排查JSON解析失败,查了半天库的用法,最后发现是文件压根没读到,json字符串是空的。这一步的坑在Unity里格外集中,因为不同平台的资源路径规则不一样。
编辑器环境和PC平台,Application.streamingAssetsPath是一个普通目录,用File.ReadAllText直接读没问题。Android上这个路径指向APK包内部,是一个jar路径,File.ReadAllText会直接失败,必须走UnityWebRequest异步加载。iOS上虽然可以直接读,但目录是只读的,写入要换到Application.persistentDataPath。
我一般会在项目里封一个统一的读取入口,把平台差异收在这一层,业务代码只拿字符串:
using System.Collections; using System.IO; using UnityEngine; using UnityEngine.Networking; public static class JsonFileLoader { public static IEnumerator LoadFromStreamingAssets(string fileName, System.Action<string> onDone) { string path = Path.Combine(Application.streamingAssetsPath, fileName); #if UNITY_ANDROID && !UNITY_EDITOR using (UnityWebRequest req = UnityWebRequest.Get(path)) { yield return req.SendWebRequest(); if (req.result != UnityWebRequest.Result.Success) { Debug.LogError($"读取失败: {path}, {req.error}"); onDone?.Invoke(null); yield break; } onDone?.Invoke(req.downloadHandler.text); } #else if (!File.Exists(path)) { Debug.LogError($"文件不存在: {path}"); onDone?.Invoke(null); yield break; } onDone?.Invoke(File.ReadAllText(path)); yield break; #endif } }注意:
UnityWebRequest.downloadHandler.text在部分老版本里对BOM的处理不一致,如果读到字符串后解析直接报“unexpected character”,先怀疑文件开头带了BOM。
另外一个小细节:File.ReadAllText(path)不指定编码时默认按UTF-8处理,但如果文件实际是GBK存的,中文会变成乱码。中文乱码不会让解析失败,却会让你的字符串字段变成一堆问号,这种问题在配置表里特别容易被忽略。
2. JsonUtility:官方给的锤子,只砸得动它认的钉子
JsonUtility 的定位很明确——它就是Unity序列化系统的文本形态。你在Inspector里能看到什么字段,JsonUtility就能序列化什么字段。这条对应关系一旦记住,很多“为什么读不出来”的问题自己就有答案了。
2.1 必须满足的三个前置条件
用JsonUtility踩坑最多的地方,是没有满足它的三个硬性条件。第一个是类型必须打[Serializable]标签(ScriptableObject和MonoBehaviour子类除外,它们天然可序列化)。第二个是参与序列化的字段必须是public,或者是私有字段但加了[SerializeField]。第三个是根节点不能是数组。
第三条最容易被忽略。很多人手里有一份这样的配置:
[ {"id": 1001, "name": "剑"}, {"id": 1002, "name": "盾"} ]然后写JsonUtility.FromJson<List<ItemConfig>>(json),结果得到一个空列表或者直接异常。原因就是JsonUtility要求JSON的顶层必须是一个对象{},不能是数组[]。解决办法有两个:要么让策划把配置表外面包一层{"list": [...]},要么在代码里手动拼一层壳。我一般选前者,因为改代码要重新出包,改数据只要换文件。如果数据格式改不了,就得手动包:
[System.Serializable] public class ItemConfig { public int id; public string name; } [System.Serializable] public class ItemConfigList { public List<ItemConfig> list; } // 数据源是数组形式时,手动补一层 string wrapped = "{\"list\":" + rawJson + "}"; ItemConfigList result = JsonUtility.FromJson<ItemConfigList>(wrapped);这个写法有点丑,但它是最省事的兼容手段。需要注意的是,如果你的原始数组是空数组[],拼出来的{"list":[]}是完全合法的,JsonUtility能正常返回空列表,这点不用担心。
2.2 字典、null、多态:JsonUtility明确不支持的三件事
不支持字典是JsonUtility最广为人知的限制。Dictionary<string, T>字段在序列化时会被直接跳过,反序列化时也是空的,而且不报错。这点的危害在于它的静默性——你以为读到了,实际上那个字段一直是null。绕开的办法是把字典展开成两个并行数组,在ISerializationCallbackReceiver的OnAfterDeserialize里重建字典:
[System.Serializable] public class SaveData : ISerializationCallbackReceiver { [System.NonSerialized] public Dictionary<string, int> scores = new Dictionary<string, int>(); [SerializeField] private List<string> keys = new List<string>(); [SerializeField] private List<int> values = new List<int>(); public void OnBeforeSerialize() { keys.Clear(); values.Clear(); foreach (var kv in scores) { keys.Add(kv.Key); values.Add(kv.Value); } } public void OnAfterDeserialize() { scores.Clear(); for (int i = 0; i < keys.Count; i++) scores[keys[i]] = values[i]; } }这段模板我几乎在每一个需要存档的项目里都写过一遍,直接抄就行。ISerializationCallbackReceiver的两个回调时机很关键:OnBeforeSerialize在写之前把字典摊平成列表,OnAfterDeserialize在读之后把列表还原成字典,顺序不能反。
不支持null是第二个容易被忽视的点。JsonUtility序列化时,引用类型的null字段会写成该类型的默认值,而不是写成null。字符串null会变成空串"",对象null会变成一整个默认对象。反序列化时,JSON里的null也会被解析成默认值而不是C#的null。这意味着你没法用if (field == null)来判断“配置文件里这个字段有没有填”。如果你的业务逻辑依赖null和空值的区分,就得额外加一个bool标记字段,或者干脆换库。
不支持多态是第三点。假设你有一个基类BaseEffect和一个子类DamageEffect,字段声明成public BaseEffect effect;,反序列化时JsonUtility只会填充基类的字段,子类的字段全部丢失,而且你拿到的对象类型就是基类。这是设计使然,不是bug。需要多态的场景,只能靠额外的类型标记字段手动分发,或者直接上Newtonsoft。
2.3 FromJsonOverwrite 与性能优势的由来
JsonUtility提供了一个不太常用但很好用的方法FromJsonOverwrite(string json, object target),它不创建新对象,而是把数据覆写到已有实例上。这个特性在频繁刷新的场景里很有价值,比如每帧接收状态同步、或者做对象池复用时,能省掉大量GC。
性能方面,JsonUtility的优势来自它不依赖通用反射。它读取的是Unity序列化系统预先生成的类型信息,这条路径在IL2CPP下是零额外配置的。实测在几百KB量级的配置表上,JsonUtility的解析耗时通常是Newtonsoft的三分之一到二分之一,差距主要体现在大数组上。
但这里要提醒一句:这个优势只在“数据结构简单”时成立。如果为了绕开字典限制而写一大堆ISerializationCallbackReceiver的转换代码,那点性能优势很快就被你自己的转换逻辑吃掉了。我见过一个项目,为了坚持用JsonUtility,硬是把一份带三层嵌套字典的配置拆成了七个类加一堆转换方法,最后维护成本远超换库的代价。工具是拿来用的,不是拿来供着的。
3. LitJson:轻到几乎无感,但版本混乱是个真问题
LitJson 在国内Unity圈子里出现频率很高,主要原因就两个:体积小、上手快。它的源码量很小,一个文件就能拖进工程,对字典和数组的支持也比JsonUtility好得多。但它的版本管理一直是老大难问题,不同分支的API和行为有细微差异,照着老教程抄很容易翻车。
3.1 JsonMapper.ToObject 与 JsonData 两种用法
LitJson的用法分强类型和弱类型两条路。强类型走JsonMapper.ToObject<T>(json),直接把字符串映射到你的类上;弱类型走JsonData对象,用下标一层层往下取,适合结构不确定的返回数据。
using LitJson; using UnityEngine; [System.Serializable] public class PlayerConfig { public int id; public string name; public int level; } public class LitJsonDemo : MonoBehaviour { void Start() { string json = "{\"id\":1001,\"name\":\"测试角色\",\"level\":12}"; // 强类型 PlayerConfig cfg = JsonMapper.ToObject<PlayerConfig>(json); Debug.Log(cfg.name); // 弱类型 JsonData data = JsonMapper.ToObject(json); int id = (int)data["id"]; string name = (string)data["name"]; Debug.Log($"{id} - {name}"); } }JsonData的下标访问返回的还是JsonData,所以必须显式强转成目标类型。这里的强转是有真实开销的,而且类型不匹配时会抛异常而不是返回默认值。我见过新手写int id = data["id"];编译不过,然后加了个ToString(),结果拿到的是字符串再转int,多绕了一圈还埋了隐患。直接(int)data["id"]是对的,前提是确认JSON里那个字段确实是数字。
强类型路线的写法看起来清爽,但有两个隐藏条件:类必须有无参构造函数,字段必须是public或者有可写的属性。LitJson走的是反射赋值,私有字段默认映射不上去。
3.2 数值类型、编码与默认构造函数:三个高频翻车点
第一个坑是数值类型精度。LitJson在解析数字时,会根据字面量判断类型,整数会解析成 int 或 long,小数解析成 double。如果你的目标字段是float,从double转到float会有精度损失,某些边界值上会出现0.30000001这类结果。做数值敏感的逻辑(比如战斗计算)时,这个误差可能被放大。稳妥的做法是配置表里统一用能整除的数值,或者在反序列化后自己做一次round。
第二个坑是中文编码。老版本的LitJson在读取带BOM的UTF-8文件时,可能把BOM当成内容的一部分,导致JsonException。表现是解析第一个字符就报错,错误信息里的位置是0。处理办法很简单:读文件时先剔除BOM。
string text = File.ReadAllText(path); if (text.Length > 0 && text[0] == '\uFEFF') text = text.Substring(1);第三个坑是缺失默认构造函数。如果你的类型只有带参构造函数,LitJson在反射创建实例时会失败,抛出“没有找到无参构造函数”之类的异常。这个问题在编辑器里可能被热重载掩盖,打真机包才暴露。解决办法就是老老实实补一个无参构造,或者用带初始化器的字段。
3.3 项目里出现两份LitJson时的冲突排查
这是LitJson最典型的一类故障,症状是编译报错“类型同时存在于两个程序集中”,或者运行时抛TypeLoadException。原因通常是项目里同时引入了两个来源的LitJson:一个来自某个插件自带的DLL,另一个是你手动拖进工程的源码文件。
排查路径我一般是这么走的:先在工程里全局搜索LitJson.dll和litjson目录,确认有几份。然后看它们的命名空间和程序集名是否一致——如果两个都是LitJson命名空间,编译期就会撞。处理方式有两种,要么删掉多余的那份,要么把其中一份的命名空间改掉(改完记得同步改所有引用处)。
提示:插件目录下的第三方库不要随便删,先确认没有别的代码在依赖它。稳妥做法是保留插件自带的那份,删掉自己手动引入的重复文件,然后统一用插件里的版本。
还有一个变体:两份LitJson都能编译通过,但版本不同,行为不一致。比如一份支持Dictionary<int, T>,另一份只支持Dictionary<string, T>,结果是同一个类在你的模块里读得出来、在别人的模块里读不出来。这种问题最难查,因为代码看起来完全一样。我的经验是,在项目初期就把第三方JSON库的版本固定下来,写进工程说明里,避免后面有人图省事又拖了一份进来。
4. Newtonsoft.Json:功能最全,也最容易被“用过头”
Newtonsoft.Json(也就是常说的Json.NET)是.NET生态里最成熟的JSON库,Unity官方也提供了对应的包,包名是com.unity.nuget.newtonsoft-json,可以直接在Package Manager里按名称添加。它能干的事情远超另外两个:字典、多态、日期格式、自定义转换器、LINQ式查询、引用循环处理,几乎你能想到的需求它都有现成方案。
功能多带来的副作用是配置项多。用得好它是利器,用得随意它会在移动端给你制造一堆性能和裁剪问题。
4.1 JObject/JToken 与强类型反序列化的取舍
Newtonsoft有两条使用路径:JsonConvert.DeserializeObject<T>(json)走强类型,JObject.Parse(json)走动态结构。选哪条取决于你对数据结构的确定程度。
强类型适合协议固定、字段明确的场景,代码可读性最好,字段访问有编译期检查。动态结构适合服务端返回可能带扩展字段的情况,用JObject可以按需取,不用为每个可能的字段都定义一个类。
using Newtonsoft.Json; using Newtonsoft.Json.Linq; using UnityEngine; public class NewtonsoftDemo : MonoBehaviour { void Start() { string json = "{\"id\":1001,\"name\":\"测试角色\",\"tags\":[\"a\",\"b\"]}"; // 强类型 var cfg = JsonConvert.DeserializeObject<PlayerConfig>(json); Debug.Log(cfg.name); // 动态结构 JObject obj = JObject.Parse(json); string name = obj["name"]?.Value<string>(); int tagCount = obj["tags"]?.Count() ?? 0; Debug.Log($"{name}, tags={tagCount}"); } }JObject的取值我习惯用?.Value<T>(),因为字段缺失时返回默认值而不是抛异常,比直接强转安全。如果业务上要求字段必须存在,那就该用强类型加[JsonProperty(Required = Required.Always)],让它在缺失时明确报错,这比事后发现字段是null要好排查得多。
4.2 字典、多态、日期格式的实战配置
字典在Newtonsoft里是开箱即用的,Dictionary<string, T>、Dictionary<int, T>都能正常序列化。注意JSON的对象键必须是字符串,所以Dictionary<int, T>序列化后键会变成"1"、"2"这样的字符串,反序列化时再转回int,这个过程是自动的。如果键类型是自定义结构体,就需要额外写Converter,一般不推荐,改用字符串键更省事。
多态是Newtonsoft相对另外两个方案的核心优势。开启TypeNameHandling后,序列化时会把类型信息写进JSON,反序列化时能还原成正确的子类:
var settings = new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.Auto, NullValueHandling = NullValueHandling.Ignore, DateFormatString = "yyyy-MM-dd HH:mm:ss" }; string json = JsonConvert.SerializeObject(effectList, settings); var list = JsonConvert.DeserializeObject<List<BaseEffect>>(json, settings);这段配置里三个参数各有用途。TypeNameHandling.Auto只在需要时写入$type字段,避免所有对象都带类型信息导致体积膨胀。NullValueHandling.Ignore让null字段不写入JSON,减小体积,但要注意这和JsonUtility的默认行为相反,切库时可能导致下游解析逻辑变化。DateFormatString统一时间格式,避免默认的ISO格式在跨端解析时出现时区偏差。
注意:
TypeNameHandling在反序列化外部来源的数据时存在安全风险,因为它会按JSON里写的类型名去实例化。如果JSON来自不可信的输入,建议只在内部存档这类可信数据上开启,网络协议数据不要开。
4.3 IL2CPP下的裁剪问题与link.xml
这是Newtonsoft在Unity里最容易踩的坑。反射序列化依赖类型信息和构造函数,而IL2CPP打包时的代码裁剪会把“没有被静态引用”的类型裁掉。表现是编辑器里一切正常,打包到真机后抛JsonSerializationException,提示找不到某个构造函数或者成员。
标准解法是在工程里放一个link.xml,把需要用反射序列化的程序集或类型保住:
<linker> <assembly fullname="Assembly-CSharp"> <type fullname="Game.Config.PlayerConfig" preserve="all"/> <type fullname="Game.Config.ItemConfig" preserve="all"/> </assembly> </linker>link.xml放在Assets目录下即可,打包时会自动读取。preserve="all"是最粗放但最保险的写法,会保留类型的所有成员。如果类型很多,逐个列出很麻烦,可以扩大范围到整个命名空间,代价是包体略大。
另一种方式是给类型加[Preserve]特性,这个特性来自UnityEngine.Scripting.Preserve:
using UnityEngine.Scripting; [Preserve] [System.Serializable] public class PlayerConfig { public int id; public string name; }[Preserve]的好处是跟类型定义放在一起,不容易漏;坏处是它会污染业务代码,让类定义依赖Unity的打包细节。我一般对配置类用link.xml集中管理,对零散的几个类型用[Preserve],两者混用也没问题。
还有一个更隐蔽的裁剪问题:字段名被裁剪后,JSON里的字段名和实际字段对不上,反序列化出来是默认值但不报错。这种情况通常发生在用[JsonProperty]重命名过字段的场景。处理办法是确保link.xml里保留了属性,或者干脆关闭对该程序集的裁剪。全关裁剪不是好主意,包体会明显变大,定位到具体类型后精确保留才是正解。
5. 三套方案的横向对比与选型判断
聊完各自的脾气,该做一张对照表了。这张表我在不同项目里改过好几版,下面这版是最贴近实际使用体感的。
5.1 能力矩阵对比
| 能力项 | JsonUtility | LitJson | Newtonsoft.Json |
|---|---|---|---|
| 字典支持 | 不支持 | 支持 | 完整支持 |
| 顶层数组 | 不支持 | 支持 | 支持 |
| 多态还原 | 不支持 | 不支持 | 支持(需配置) |
| 属性(Property)序列化 | 不支持 | 支持 | 支持 |
| null 语义 | 丢失 | 保留 | 保留(可配置) |
| 自定义Converter | 不支持 | 有限 | 完整支持 |
| IL2CPP额外配置 | 无需 | 可能需要 | 通常需要 |
| 中文与BOM处理 | 稳定 | 老版本有坑 | 稳定 |
| 学习成本 | 低 | 低 | 中 |
| 包体占用 | 0 | 小 | 较大 |
看这张表的时候,别只盯着“支持”两个字。比如“多态还原”这一项,Newtonsoft支持但它需要你主动配置TypeNameHandling,默认是不开的。再比如“IL2CPP额外配置”,Newtonsoft在简单类型上可能不需要link.xml也能跑,但只要你用了泛型嵌套或者自定义Converter,就大概率需要补上。表是决策起点,不是结论。
5.2 性能与GC的实测结论
性能这个话题容易陷入绝对值比较的误区,因为不同数据结构的差异太大了。我给一个相对可靠的结论方向:在扁平结构、字段数在几十个以内、数据量在几百KB的配置表场景下,JsonUtility的解析速度通常是LitJson的1.5到2倍,是Newtonsoft的2到3倍。差距主要来自反射调用的开销和中间对象的分配。
但性能不只是看耗时,还要看GC。JsonUtility在反序列化时的临时分配最少,因为它直接按类型布局填充。Newtonsoft在解析过程中会产生大量中间JToken对象,一次解析几百KB的配置表可能产生几MB的临时分配,在移动端会触发明显的GC卡顿。如果你的配置表是在进入战斗场景时加载的,这个卡顿正好卡在加载转场上,用户感知不到;但如果是战斗中动态加载,就需要注意了。
降低Newtonsoft的GC有一个实用技巧:复用JsonSerializer实例,而不是每次调用静态的JsonConvert方法。
private static readonly JsonSerializer Serializer = JsonSerializer.Create(new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }); public static T Parse<T>(string json) { using (var reader = new System.IO.StringReader(json)) using (var jsonReader = new JsonTextReader(reader)) { return Serializer.Deserialize<T>(jsonReader); } }这样能省掉每次创建序列化器实例的开销,实测在大批量解析时提升明显。不过要注意JsonSerializer不是线程安全的,如果在多线程里用,得每个线程各持一份。
5.3 我的选型判断线
说点实际的判断标准。如果这个项目是纯配置表驱动、结构规整、没有字典、打包目标以移动端为主,我会优先用JsonUtility,省心。如果项目里出现了“配置需要按字符串键快速索引”这种需求,同时又不希望引入大库,LitJson是可以接受的折中,前提是锁定版本、统一来源。
如果项目涉及存档系统、需要多态、需要跟服务端交换结构复杂的协议数据,那就不用犹豫,直接上Newtonsoft。为了省那点包体和性能去硬凑另外两个方案,后期维护成本会成倍增长。包体方面,Newtonsoft在Unity里大概增加几百KB,对现在的手游来说完全在可接受范围内。
还有一种混合策略值得提一下:用Newtonsoft写、用JsonUtility读。写入方(比如编辑器工具、服务端)图方便用Newtonsoft,读的一方如果结构规整就用JsonUtility。这种组合的隐患在于两边对null和默认值的处理不一致,容易在边界数据上出问题。如果要用,务必保证写入侧配置NullValueHandling.Ignore,并且读取侧的字段全部是值类型或者允许空串的字符串。
6. 从“读不到数据”到定位原因:一条完整的排查链
前面讲的都是预防,这一节讲救火。JSON解析出问题时,最常见的表现是三种:抛异常、字段全空、部分字段为空。这三种对应的排查路径完全不同,混着查会浪费时间。
6.1 第一步:确认字符串本身是不是合法JSON
不管用哪个库,先把原始字符串打印出来看一眼。打印的时候不要只打前100个字符,要打全长,或者至少打头和尾。我遇到过好几次是文件末尾截断导致的解析失败,只看开头完全正常。
判断合法性最快的办法是用一个在线校验工具跑一遍,或者在本机写个十行的小脚本用Python的json.loads试一下。注意Python的解析器比Newtonsoft严格,尾逗号、单引号、注释都会直接报错,这反而是个优点,能帮你快速发现格式问题。
几个常见的非法格式:
- 尾逗号:
{"a":1,"b":2,},JavaScript允许,严格JSON不允许。JsonUtility和LitJson会直接报错,Newtonsoft默认也报错,但可以通过JsonSerializerSettings里的宽松选项容忍。 - 单引号:
{'a':1},不是合法JSON。有些手写配置会犯这个错。 - 注释:JSON标准不支持注释,但很多人习惯加
//。Newtonsoft支持注释解析,另外两个不支持。 - BOM头:文件开头有
\uFEFF,肉眼看不见,解析器报错位置在0。 - 中文引号:从文档里复制粘贴时,
"变成“,这种错误极难用肉眼发现,打印出来对比ASCII码最快。
6.2 第二步:确认类型定义是否满足方案要求
字符串没问题,那就查类型定义。这一步按方案分支走。
用JsonUtility的话,检查清单是四条:类有没有[Serializable]、字段是不是public、顶层是不是对象、有没有用到字典。这四条里任何一条不满足,表现都是“不报错但字段为空”,所以必须逐条对照。
用LitJson的话,检查有没有无参构造函数、字段是不是可写、数值类型是否匹配。LitJson在字段名大小写上是严格匹配的,JSON里写"Name"而类里是name,就读不到。这一点和Newtonsoft不同,后者默认也是大小写敏感,但可以配置成不敏感。
用Newtonsoft的话,检查有没有[JsonProperty]名字写错、有没有[JsonIgnore]误加、泛型嵌套是否被裁剪。Newtonsoft的错误信息通常比较详细,会告诉你是哪个字段、什么类型、期望什么,直接看异常栈往往就能定位。
有个通用技巧:写一个最小的复现用例。把出问题的JSON截取成一个字段,类型也简化成一个类,单独跑一遍。如果最小用例能跑通,说明问题出在复杂结构上(嵌套、泛型、接口);如果最小用例也跑不通,那就是基础的配置问题。这个二分法能省掉大量猜测。
6.3 第三步:确认运行时环境(裁剪、编码、平台)
如果编辑器里跑得好好的,打包后出问题,基本可以锁定是环境差异。按可能性排序,第一位是IL2CPP裁剪,第二位是文件路径和编码,第三位是平台相关的API差异。
裁剪问题的判断方法很直接:在真机日志里看异常类型。如果是JsonSerializationException且提示找不到构造函数或成员,那就是裁剪。补link.xml或者加[Preserve]即可。
编码问题的表现是中文变乱码或者解析到一半报错。处理方式是统一用UTF-8无BOM保存所有JSON文件,在CI流程里加一步检查,发现BOM就报错。这个小检查能省掉无数次的线上排查。
平台差异最典型的是Android的StreamingAssets读取,前面已经讲过。还有一个容易漏的是iOS的AOT限制,某些依赖动态泛型实例化的写法在iOS上会失败,Newtonsoft的某些Converter就属于这一类。遇到这种情况,改成显式指定类型或者用非泛型API。
7. 几个真实结构下的落地写法
理论讲完,给几个能直接抄的结构模板。这些都是项目里反复出现的形态,改改字段名就能用。
7.1 配置表:大数组加主键索引
配置表最典型的形态是一个大数组,业务代码需要按id快速查找。用JsonUtility的话,因为不支持顶层数组,需要包一层;同时为了查找效率,读完之后要建立索引字典。
[System.Serializable] public class ItemConfig { public int id; public string name; public int quality; public int maxStack; } [System.Serializable] public class ItemConfigTable { public ItemConfig[] items; } public static class ItemConfigLoader { private static Dictionary<int, ItemConfig> _index; public static void Load(string json) { var table = JsonUtility.FromJson<ItemConfigTable>(json); if (table == null || table.items == null) { Debug.LogError("配置表解析失败或为空"); return; } _index = new Dictionary<int, ItemConfig>(table.items.Length); foreach (var item in table.items) { if (_index.ContainsKey(item.id)) { Debug.LogWarning($"重复的物品ID: {item.id}"); continue; } _index[item.id] = item; } } public static ItemConfig Get(int id) { return _index != null && _index.TryGetValue(id, out var cfg) ? cfg : null; } }这里有两个实践要点。一是重复ID的检测,配置表由多人维护时重复ID几乎必然出现,在加载阶段就报出来比运行时取错数据好排查得多。二是索引用Dictionary<int, T>而不是每次都线性查找,配置表动辄几千条,线性查找在热路径上会成为瓶颈。
如果这份表用Newtonsoft读,可以直接把根定义成List<ItemConfig>,少一层包装。但要注意DeserializeObject<List<T>>在数据为空字符串时会返回null而不是空列表,需要自己判空。
7.2 存档:字典加嵌套加版本号
存档是三种方案里最能体现差异的场景。它通常包含字典(背包、任务进度)、嵌套结构(角色属性下面挂装备列表)、以及版本号(用于后续迁移)。这种结构用JsonUtility会很痛苦,我一般直接用Newtonsoft。
using Newtonsoft.Json; using System.Collections.Generic; [System.Serializable] public class SaveData { public int version = 1; public string playerName; public Dictionary<string, int> bagItems = new Dictionary<string, int>(); public List<EquipData> equips = new List<EquipData>(); } [System.Serializable] public class EquipData { public int slot; public int itemId; public int level; } public static class SaveSystem { private static readonly JsonSerializerSettings Settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore, Formatting = Formatting.Indented }; public static void Save(SaveData data, string path) { string json = JsonConvert.SerializeObject(data, Settings); System.IO.File.WriteAllText(path, json); } public static SaveData Load(string path) { if (!System.IO.File.Exists(path)) return new SaveData(); string json = System.IO.File.ReadAllText(path); return JsonConvert.DeserializeObject<SaveData>(json, Settings) ?? new SaveData(); } }version字段是必须的,哪怕现在是1。等以后字段结构变了,读取时先判断version再决定用哪套迁移逻辑,比事后猜测存档格式要靠谱得多。存档写入建议用“先写临时文件再替换”的方式,避免写入过程中断电导致存档损坏,这个细节在移动端尤其重要。
Formatting.Indented会让JSON带缩进,方便人工查看和调试,代价是文件体积变大。存档文件一般不大,我倾向于开着,线上出问题时能直接把存档拉下来看。如果存档体积确实大(比如包含大量坐标数据),再关掉。
7.3 网络返回:结构不确定时的兜底
服务端返回的数据结构有时候会临时加字段,这时候用强类型会丢字段,用JObject更灵活。但JObject用多了代码会变得很难读,满屏的字符串下标。我的折中做法是:外层用JObject做防御性检查,内层确定的部分转成强类型。
using Newtonsoft.Json.Linq; public static PlayerInfo ParsePlayerInfo(string json) { JObject root; try { root = JObject.Parse(json); } catch (System.Exception e) { Debug.LogError($"返回数据不是合法JSON: {e.Message}"); return null; } if (root["code"]?.Value<int>() != 0) { Debug.LogError($"业务错误: {root["msg"]?.Value<string>()}"); return null; } var dataToken = root["data"]; if (dataToken == null || dataToken.Type == JTokenType.Null) { Debug.LogWarning("data字段为空"); return null; } return dataToken.ToObject<PlayerInfo>(); }这段代码的价值在于分层:外层的code、msg、data用动态方式做健壮性检查,内层的业务数据用强类型保证可读性。ToObject<T>()是JToken上的方法,比重新序列化一遍再反序列化效率高。
提示:
Value<T>()在字段不存在时返回默认值,在字段存在但类型不匹配时会尝试转换,转换失败则抛异常。所以外层检查字段存在性用root["x"] == null,取值用?.Value<T>(),这两步不要省。
最后再分享一个排查技巧:把每次解析失败时的原始字符串写到本地日志文件里,带上时间戳和来源标记。线上环境拿不到调试器的时候,这份日志就是唯一的线索。日志文件记得做大小限制和滚动清理,不然跑久了会把设备存储塞满。
上面这些内容基本覆盖了Unity里读JSON会遇到的绝大部分情况。三种方案没有绝对的好坏,JsonUtility胜在稳定省心,LitJson胜在轻量直接,Newtonsoft胜在能力完整。真正决定项目顺不顺的,往往不是选了哪个库,而是有没有在项目早期就把规则定下来并且写进文档:哪些场景用哪个库、第三方库的版本如何锁定、JSON文件的编码规范是什么。这几条约定立住了,后面的人接手时才不会各自为政。