
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.RegisterExporterMyEnum((obj, writer) writer.Write(obj.ToString())); JsonMapper.RegisterImporterstring, 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.ToObjectWeapon(node.ToJson()); case Armor: return JsonMapper.ToObjectArmor(node.ToJson()); default: return JsonMapper.ToObjectItem(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.ToObjectSaveData(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.ToObjectSaveData(json); } return null; } }这里有一个点我想专门提一下JSON反序列化过程不是绝对安全的。如果存档文件内容被改动过某个字段类型不匹配LitJson抛异常是常态。所以读取必须套try-catch并且要有“退化路径”——正常文件坏了就用.bak.bak也坏就只能初始化新档。3.4 异步写的考虑Unity里如果存档比较大超过几MBFile.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在处理DictionaryTKey, TValue序列化时是当成JSON对象来处理的键必须是字符串。如果你的字典Key是int或enum它默认能转成字符串存储但如果反序列化后你看到键值错乱或为空可以先确认下TKey类型是否满足要求。另外老版本LitJson对Dictionary的支持是通过IDictionary接口反射实现的如果你的字段声明的是具体类如SortedDictionary可能会走不同的分支建议字段类型直接声明为DictionaryTKey,TValue。5.2 嵌套List反序列化丢失数据如果你序列化一个ListList 或ListDictionarystring, 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比在代码里打断点高效得多。如果你现在正在规划项目的存档模块我建议从数据类设计和版本号开始落笔而不要第一时间就去纠结加密和优化把基础打牢后面的路会顺很多。