☰
Unity游戏存档实战:LitJson序列化与反序列化全指南
2026/10/13 16:57:27 网站建设 项目流程

LitJson这个库,我从Unity 5时代就开始用了,换过几个项目,兜兜转转还是觉得它在游戏存档这个场景最顺手。别急着反驳,我并不是说它是万能的——程序员都知道“没有最好的库,只有最合适的方案”。但如果你做的是中小型独立游戏,或者团队里没那么多精力去搭复杂的数据层框架,LitJson配上合理的存档架构,基本就是性价比最高的组合。

这篇博文不是官方文档翻译,而是我基于几个真实项目经验总结出来的“LitJson存档实战手册”。你会看到我为什么选JSON而不是XML,为什么不用Unity自带的JsonUtility,存档数据类长什么样,以及最关键的——我在实际开发中踩过哪些坑、怎么排查。内容里包含可以直接抄走的代码框架,也包含那些文档上搜不到的细节。

1. 存档方案选型:为什么绕了一圈还是JSON + LitJson

先说结论:游戏存档的本质是“把运行时内存中的数据结构,变成可持久化的字节或文本,下次启动时再原样还原”。这个需求在游戏开发里催生了五花八门的方案,但真正落到工程实际时,其实可选项就那么几个。

1.1 我试过的几种方案及其痛点

入行前几年,我和很多人一样,最偷懒的办法是全塞PlayerPrefs。简单场景确实没问题,存个音量、亮度、解锁关卡序号,三两行代码搞定。但一旦存档里要放背包列表、任务状态、NPC好感度这类结构化的数据,PlayerPrefs就开始变味了——你要么手动拼字符串,要么用分隔符硬凑,写出来的代码过两周自己都看不懂。而且PlayerPrefs底层在不同平台上的实现机制不一样,数据膨胀以后读写效率也会肉眼可见地下降。

我也认真考虑过XML。可读性是好,嵌套关系也清晰,但问题在于冗余标签太多,同样一份数据,JSON 200字节能写完的,XML可能要450字节。再加上C#里XML的序列化和反序列化,属性、节点、命名空间一堆概念绕来绕去,写起来远没有JSON直观。

至于二进制序列化,性能倒是最好,但最大的问题是不透明。坏了就是坏了,你对着十六进制字节一点排查头绪都没有。而且跨平台、跨版本时,类型变更很容易导致反序列化直接崩掉。对于绝大多数游戏来说,这完全属于性能过剩、维护成本过高。

1.2 LitJson在Unity环境里的具体优势

有人可能会问:Unity 5.5之后引擎自带了JsonUtility,为什么不用它?

这个问题我确实被问过很多次。JsonUtility的优势是它是Unity官方维护、支持MonoBehaviour序列化规则,理论上性能也不错。但实际使用时,它有几个让做存档的我非常难受的限制:第一,不支持Dictionary字典类型,而我的任务系统、成就系统特别依赖字典结构;第二,不能直接反序列化为接口或抽象类类型,多态场景很难做;第三,序列化得到的JSON无法格式化,调试时一坨挤在一起,看着头大。

LitJson在这几点上刚好是互补的。它是纯C#代码、单DLL引用、零第三方依赖,在Unity里放Plugins目录就能生效。支持反射,能处理复杂的嵌套泛型和字典;支持把JSON缩进格式化输出,方便调试;还允许自定义导入导出规则,通过JsonMapper.RegisterImporter和RegisterExporter扩展类型转换逻辑。对于存档这个需求来说,这些能力几乎是量身定制的。

提示:如果项目特别小、存档量只有十几个int,那用PlayerPrefs无可厚非。但只要你预期存档会超过“几十个字段”这个量级,建议直接上JSON方案,省得后期重构。

2. 序列化模型设计:先把存档的数据结构想清楚

很多新人上手写存档,第一件事就是写Save()函数,这其实是反的。存档代码的架构核心不是“怎么存”,而是“存什么、以什么结构存”。我建议先把数据模型设计好,再动手写读写逻辑。

2.1 存档根类与版本号设计

一份健壮的存档,至少要包含三块信息:版本号、存档元数据、实际的游戏数据。版本号太重要了,游戏没上线前你可能不觉得,上线后每次更新版本,存档格式一改,如果没有版本号做兼容处理,老玩家的进度就全毁了。

我常用的存档根类长这样:

[Serializable] public class SaveData { public int version = 1; // 存档结构版本 public string saveTime; // 存档时间字符串 public string gameVersion; // 游戏版本号 public PlayerData player; // 玩家基础数据 public InventoryData inventory; // 背包数据 public QuestData quest; // 任务数据 public SettingsData settings; // 设置数据 }

注意这里我用了四个子类分别管理不同模块的数据。这样做的好处是:每个模块的数据结构变化,影响的只是对应的子类,升级存档时也能针对性做conversion逻辑,而不是在一个巨大的扁平结构里东拆西补。

2.2 字段设计的最佳实践

用LitJson序列化,本质上是靠反射遍历对象的公开字段(field)和属性(property)。这几个经验我从实践中趟出来的:

第一,字段类型尽量保持简单。能用int、float、string、bool就尽量别引入复杂类型。比如日期时间,直接存字符串“yyyy-MM-dd HH:mm:ss”或Unix时间戳long,而不是存C#的DateTime对象。LitJson对DateTime的支持不算友好,序列化出来的格式反序列化时偶尔会出问题,干脆绕开。

第二,枚举默认存成数字。LitJson默认会把枚举转成int保存,读回来时只要枚举定义顺序不变,就没问题。如果你的枚举顺序以后可能会调整,那就用字符串存储:

JsonMapper.RegisterExporter<MyEnum>((obj, writer) => writer.Write(obj.ToString())); JsonMapper.RegisterImporter<string, MyEnum>((string str) => (MyEnum)Enum.Parse(typeof(MyEnum), str));

第三,字典的Key尽量用字符串或整数,别用自定义对象当Key。JSON对象天然是以字符串为键的,LitJson内部会做转换,虽然能转,但调试和兼容性都会打折。

2.3 多态与继承的处理策略

LitJson在反序列化时,如果遇到声明类型是基类、实际需要还原成子类的情况,默认是做不到的。比如你有一个Item基类,里面有Weapon、Armor、Consumable三种子类,直接把List 存进去,读出来永远全是基类对象,子类字段全丢。

我的方案有两种。一种是自描述类型——序列化时额外记一个typeName字段,读回来时根据typeName手动分支实例化:

public class Item { public string typeName; public string itemId; public int count; } public static Item CreateItemFromJson(JSONNode node) // 伪代码 { switch (node["typeName"]) { case "Weapon": return JsonMapper.ToObject<Weapon>(node.ToJson()); case "Armor": return JsonMapper.ToObject<Armor>(node.ToJson()); default: return JsonMapper.ToObject<Item>(node.ToJson()); } }

另一种是采用“平面化设计”,把差异字段全部放基类里,用可空类型或额外字段表示。对于存档这样追求稳定的场景,我其实更推荐第二种,简单、可靠、不容易出错。

2.4 不序列化的字段怎么处理

比如某些临时计算缓存、运行时状态,不需要存进存档。在LitJson里可以用[JJsonIgnore]标注跳过。但这个特性不是老版本都有,如果没有,就用更朴素的办法——把它们放到一个标记为nonSerialized的类里,或者根本不放在存档数据类中,而是放在运行时管理器中。

注意:存档数据类建议单独建一个目录/命名空间,只放“纯数据”类,不要混入业务逻辑。这样职责清晰,序列化字段和运行时字段一目了然。

3. 核心封装:一个能直接用的存档管理器

数据结构定好了,接下来围绕文件读写做封装。这部分要让代码既有容错性,又能处理“存档损坏”这种极端情况。

3.1 存档文件存储路径怎么选

很多新手会写到Application.dataPath下面,这个是项目工程目录,在开发时能读写,但打包后用户设备上是只读的,会直接报错。正确姿势是选Application.persistentDataPath,这是Unity为每个游戏分配的可读写目录,不同平台会自动映射到合适的位置,一般不需要用户手动清理。

如果你的存档希望支持云存档、导出分享,那就自己搭一层“导出/导入”逻辑,把persistentDataPath里的存档复制到用户指定位置或从外部导入。我做一个单机游戏时,就是靠这个实现“复制存档分享给朋友”的。

路径拼接建议用Path.Combine,避免手写斜杠:

public static string GetSavePath(int slot) { string dir = Path.Combine(Application.persistentDataPath, "GameSaves"); if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); return Path.Combine(dir, $"save_{slot}.json"); }

3.2 写存档的完整流程

写存档的流程,我拆成三步:数据收集、序列化、落盘。

public void SaveGame(int slot) { SaveData data = new SaveData(); data.version = currentSaveVersion; data.saveTime = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); data.gameVersion = Application.version; data.player = playerManager.ToSaveData(); data.inventory = inventoryManager.ToSaveData(); data.quest = questManager.ToSaveData(); data.settings = settingsManager.ToSaveData(); string json = JsonMapper.ToJson(data); // 可选:格式化输出,方便调试 // json = JsonMapper.ToJson(data, true); string path = GetSavePath(slot); string tempPath = path + ".tmp"; File.WriteAllText(tempPath, json, Encoding.UTF8); if (File.Exists(path)) File.Delete(path); File.Move(tempPath, path); // 写备份 File.Copy(path, path + ".bak", true); }

你可能注意到我用了“先写临时文件,再替换正式文件”的套路。这样做是为了防止写文件中途崩溃(比如断电、进程被杀)导致原存档损坏。临时文件写完再Move,移动是原子性相对较高的操作,能极大降低存档写坏的概率。写完后又顺手复制了一份.bak备份,下面读取时会用到。

3.3 读存档与损坏修复

读取时第一步是检查文件是否存在。存在就读取文本内容,反序列化,然后做版本兼容和健壮性检查。

public SaveData LoadGame(int slot) { string path = GetSavePath(slot); if (!File.Exists(path)) { // 尝试加载备份 if (File.Exists(path + ".bak")) path = path + ".bak"; else return null; } try { string json = File.ReadAllText(path, Encoding.UTF8); SaveData data = JsonMapper.ToObject<SaveData>(json); if (data == null) { throw new Exception("save data is null"); } return data; } catch (Exception e) { UnityEngine.Debug.LogWarning("存档解析失败,尝试备份: " + e.Message); if (path.EndsWith(".bak") == false && File.Exists(path + ".bak")) { string json = File.ReadAllText(path + ".bak", Encoding.UTF8); return JsonMapper.ToObject<SaveData>(json); } return null; } }

这里有一个点我想专门提一下:JSON反序列化过程不是绝对安全的。如果存档文件内容被改动过,某个字段类型不匹配,LitJson抛异常是常态。所以读取必须套try-catch,并且要有“退化路径”——正常文件坏了就用.bak,.bak也坏就只能初始化新档。

3.4 异步写的考虑

Unity里如果存档比较大(超过几MB),File.WriteAllText同步写在主线程会有轻微卡顿。但一般游戏存档就几十到几百KB,问题不大。如果真要做异步,注意两点:一是Unity主线程的API(比如PlayerPrefs、Application相关)不能在工作线程调用;二是存档对象一旦传给异步线程,就不能中途修改,否则序列化可能数据不一致。比较稳妥的做法是先在主线程用ToJson把对象转成字符串,然后只把字符串丢给线程写文件:

string json = JsonMapper.ToJson(data); System.Threading.ThreadPool.QueueUserWorkItem((_) => { File.WriteAllText(path + ".tmp", json, Encoding.UTF8); });

因为字符串是不可变的,在线程里写最安全。

4. 存档加密与防篡改:做到“防小白”程度就够了

很多刚入行的朋友会把存档加密当成“安全需求”,这就想多了。单机游戏本体的客户端代码都在用户手里,真要逆向,加密存档只是提高门槛而已。我的建议是加密做到“防止玩家用记事本直接改数值”这个级别就行,别陷入军备竞赛的泥潭。

4.1 基于异或的轻量加密

最基础的做法是对字节流做异或处理。比如先用UTF-8把JSON字符串转成byte[],然后每个字节异或一个固定密钥(或循环密钥),再写进文件。读取时反向异或回来。

private static byte[] XOREncrypt(byte[] data, byte[] key) { byte[] result = new byte[data.Length]; for (int i = 0; i < data.Length; i++) { result[i] = (byte)(data[i] ^ key[i % key.Length]); } return result; }

这种方案的优点是代码量少、性能几乎可以忽略。缺点是固定密钥容易从反编译程序里提取出来。所以通常把密钥做成“运行时拼接”的,别写死一大段明文。

4.2 增加校验信息防止坏档

加密不是为了防高手,但校验必须有。我习惯在加密后的文件末尾附一段哈希摘要。写入时对原始JSON计算MD5或SHA1,和密文一起存。读取时先校验哈希,不匹配就认为存档被修改或损坏,走备份恢复逻辑:

public static void WriteEncryptedSave(string path, string json) { byte[] jsonBytes = Encoding.UTF8.GetBytes(json); byte[] hash = MD5.Create().ComputeHash(jsonBytes); byte[] encrypted = XOREncrypt(jsonBytes, saveKey); // 将hash和encrypted顺序写入文件 using (var fs = new FileStream(path, FileMode.Create)) using (var bw = new BinaryWriter(fs)) { bw.Write(hash.Length); bw.Write(hash); bw.Write(encrypted.Length); bw.Write(encrypted); } }

这样即使玩家改了某个数值,哈希校验就会失败,我们直接重置或恢复备份,不会出现“读出来一个数值诡异、逻辑出BUG的存档”。

4.3 加密方案与纯文本方案怎么选

我给一般项目的建议是:上线前可以用纯文本JSON + 版本号方案,方便自己调试;上线时切到“异或加密 + 哈希校验”,改动成本不高,玩家体验差异也不大。如果做的是竞技性很强的排行榜游戏,那还需要考虑服务端校验,那已经超出LitJson范畴了,单靠客户端存档是防不住改内存的。

注意:加密务必在“存档数据组装完成、ToJson之后”再做,而不是对原始对象做。原因很简单,LitJson反射规则不能保证你的类内部字段顺序稳定,加密字节流就不可复现。

5. 常见问题与排查技巧实录

这部分是干货重灾区,我列几个实际开发中被问过最多、也让我自己吃过亏的问题。

5.1 反序列化时Dictionary字段全是空

这问题通常不是LitJson的错,而是你的字典Key类型设置有问题。LitJson在处理Dictionary<TKey, TValue>序列化时,是当成JSON对象来处理的,键必须是字符串。如果你的字典Key是int或enum,它默认能转成字符串存储,但如果反序列化后你看到键值错乱或为空,可以先确认下TKey类型是否满足要求。另外,老版本LitJson对Dictionary的支持是通过IDictionary接口反射实现的,如果你的字段声明的是具体类如SortedDictionary,可能会走不同的分支,建议字段类型直接声明为Dictionary<TKey,TValue>。

5.2 嵌套List反序列化丢失数据

如果你序列化一个List<List >或List<Dictionary<string, List >>这种嵌套结构,大概率能成功保存。但反序列化时偶尔会出现内部List变成单元素或空的情况,最可能是LitJson版本太老、泛型解析不完整导致的。我推荐去拉一次LitJson较新的维护分支,很多老版本在泛型反射上有已知问题。如果不想升级,可以用JsonMapper.ToObject (json)这种显式泛型调用,并在调用前确保T类型完全对应。

5.3 LitJson和JsonUtility混用会踩坑

同一个类,LitJson和JsonUtility的序列化规则不一样。LitJson默认包含私有字段吗?某些配置下会包含。而JsonUtility只处理[SerializeField]或public字段。最怕的是你同一个存档数据类,一部分用LitJson写,一部分用JsonUtility写。我见过一个项目,导出存档和读档分别用了两套逻辑,结果读出来的数据全是默认值。统一用一套序列化器,别混。

5.4 中文乱码问题

File.WriteAllText(path, json, Encoding.UTF8)后,再用记事本打开看没问题,但游戏读回来中文是乱码,这通常是读取时没有指定UTF-8编码。虽然Unity的File.ReadAllText默认会探测BOM,但如果没有BOM,可能被识别成ANSI。建议写入和读取两边都显式指定Encoding.UTF8,别偷懒。另外,LitJson在序列化字符串时默认也会做转义,中文变成\uXXXX形式,这个是正常现象,不影响反序列化还原。

5.5 存档损坏后的处理流程

我在测试时经常模拟“往存档里随机塞几个字符”来验证容错逻辑。最稳妥的处理策略是:当主存档解析失败时,尝试读取.bak;如果.bak也失败,就重新初始化新存档,并在界面上提示“存档损坏,已创建新存档”。这比无限弹错误框让用户窒息要好得多。同时,损坏的存档不要立刻覆盖删除,建议改名成save_0.json.corrupt保留下来,方便后续分析原因。

5.6 版本升级时老存档怎么兼容

保存version字段就是为了这个。当version小于当前存档版本时,逐级执行升级逻辑:

public SaveData Migrate(SaveData oldData) { if (oldData.version == 1) { oldData.settings.fullscreen = true; // 补默认值 oldData.version = 2; } if (oldData.version == 2) { // 继续升级到3 oldData.version = 3; } return oldData; }

这种方式比一次性判断版本号写死逻辑要清晰得多。同时,在版本升级后,建议立刻用新版本结构再保存一次,完成存档自动升级。

6. 再多说一句:LitJson在我项目里的最终定位

LitJson在我最近的项目里,承担的不只是存档。它还被我用来解析一些策划配置表的JSON,以及和服务器通信时的本地Mock数据。它的代码量小、容易定制,这也是它在Unity社区这么多年还没被淘汰的原因。

我个人在实际操作中的体会是,存档系统的稳定性,60%取决于数据类的设计,而不是存取函数本身。结构设计得干净、版本控制做得好、异常处理考虑全,用什么序列化库都很难出大事;反之,数据类乱七八糟,表达能力再强的库也会被用出问题来。

最后再分享一个小技巧:在开发期写一个“一键导出JSON收据”的调试窗口,把当前存档内容实时显示在屏幕上或写到文本文件。你会发现排查问题的时候,能看到结构清爽的JSON比在代码里打断点高效得多。如果你现在正在规划项目的存档模块,我建议从数据类设计和版本号开始落笔,而不要第一时间就去纠结加密和优化,把基础打牢,后面的路会顺很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询