资讯动态

基于Newtonsoft.Json的Unity存档系统设计与实现

发布时间:2026/11/6 7:16:43 来源:尧图企业网站定制
Unity 项目做到中后期几乎绕不开存档这件事。我见过太多团队在原型阶段随手用PlayerPrefs塞几个键值等到需要存背包、存任务进度、存场景状态的时候才发现根本兜不住——类型一多、结构一嵌套读写代码就变成一团乱麻。这时候大多数人会转向 JSON而 Unity 生态里绕不开的一个库就是 Newtonsoft.Json。这篇内容就围绕基于 Newtonsoft.Json 搭一套能长期用的存储系统展开把我自己在多个项目里踩过的坑、做过的取舍、以及最终沉淀下来的结构讲清楚。先说清楚这套东西是什么、解决什么问题。它本质上是一层序列化 文件读写 版本管理的组合封装用 Newtonsoft.Json 把 C# 对象转成 JSON 字符串落到本地文件里读的时候再反序列化回来。相比 Unity 自带的JsonUtilityNewtonsoft 支持字典、多态、可空类型、循环引用处理字段控制也更灵活这些在真实项目里都是刚需。适合谁看如果你已经会写基本的 C# 脚本正在为存档系统发愁或者你现在的存档代码已经开始失控那这篇就是写给你的。哪怕你之前只用过PlayerPrefs跟着思路走也能搭起来。1. 为什么 Unity 自带的序列化在存档场景里不够用很多人第一反应是Unity 不是有 JsonUtility 吗为什么还要引第三方库。这个问题值得认真回答因为选型错了后面全是返工。1.1 JsonUtility 的三个硬伤JsonUtility的设计目标是轻量、快、和 Unity 的序列化系统对齐它并不是为通用存档设计的。第一个硬伤是不支持字典。你只要在存档类里放一个Dictionarystring, Item序列化出来就是空对象{}而且不报错静默失败。这个坑我早期踩过一次存档写进去读出来背包全空排查了半天才发现是字典没被序列化。第二个硬伤是不支持多态。假设你有一个ListQuest里面装的是KillQuest、CollectQuest这些子类JsonUtility序列化时只会按基类字段走子类特有字段全丢。存档系统里任务、道具、技能这类东西天然是多态的这个限制非常致命。第三个是顶层必须是对象。JsonUtility不能直接序列化一个数组或列表必须包一层。虽然不算大问题但用起来别扭。1.2 Newtonsoft.Json 补上了哪些能力Newtonsoft 在这几点上都是原生支持字典直接序列化成 JSON 对象、通过TypeNameHandling处理多态、顶层数组随便写、还能自定义Converter处理特殊类型比如Vector3、DateTime、枚举。它的代价是体积稍大、反射开销略高但对存档这种低频操作来说完全无所谓——你不可能每帧存一次档。能力JsonUtilityNewtonsoft.Json字典序列化不支持支持多态子类字段丢失支持需配置顶层数组不支持支持自定义类型转换不支持支持 Converter空值/可空类型部分支持完整支持性能更快稍慢但够用注意Newtonsoft 在 Unity 里要通过 UPM 或包管理器引入官方包名是com.unity.nuget.newtonsoft-json不要自己去下 DLL 丢进 Assets容易和 IL2CPP 裁剪打架。1.3 什么情况下反而不该用它也不是所有项目都该上 Newtonsoft。如果你的存档就是几个整数、几个布尔PlayerPrefs或者一个简单的二进制写入就够了引一个大库纯属浪费。我的判断标准是只要存档结构里出现了嵌套集合、字典、或者需要向后兼容的版本演进就上 Newtonsoft否则先用轻量方案别过度设计。2. 存储系统的分层设计别把序列化和文件读写搅在一起我见过最典型的烂代码是一个SaveManager里同时干三件事拼 JSON、写文件、处理路径。结果换平台要改路径、换格式要改序列化、加加密又要动一遍牵一发动全身。正确的做法是分层每一层只干一件事。2.1 三层职责划分我习惯分成三层序列化层负责对象和字符串互转存储层负责字符串和文件互转含路径、编码、加密业务层负责决定什么时候存、存什么。这样换存储介质本地文件换云、换加密容器只动存储层换序列化格式只动序列化层业务代码基本不动。public interface ISerializer { string SerializeT(T obj); T DeserializeT(string json); } public interface IStorage { void Write(string key, string content); string Read(string key); bool Exists(string key); void Delete(string key); }这两个接口是整个系统的骨架。业务层只依赖接口具体实现随便换。这种设计在后期加多存档槽云同步加密存档时价值巨大因为你不用去翻业务代码。2.2 为什么接口要拆成两个而不是一个有人会问序列化和存储合成一个SaveSystem不行吗短期行长期不行。举个真实场景某次需求要求存档加密如果两者耦合你得在序列化方法里塞加密逻辑序列化层就被污染了拆开之后加密只是存储层的一个装饰器实现序列化层完全无感。再比如做单元测试你想测序列化正确性拆开后可以直接 new 一个序列化器测不用碰文件系统。2.3 路径管理单独抽出来路径这块单独说因为它是跨平台踩坑重灾区。Application.persistentDataPath在 Windows、Android、iOS 上返回的路径完全不同而且 iOS 上这个目录会被系统备份存档大了会影响审核。我的做法是抽一个PathProvider统一拼路径并且把可清理的缓存和必须保留的存档分目录放。public static class PathProvider { public static string SaveRoot Path.Combine(Application.persistentDataPath, Saves); public static string SlotPath(int slot) Path.Combine(SaveRoot, $slot_{slot}.json); public static void EnsureRoot() { if (!Directory.Exists(SaveRoot)) Directory.CreateDirectory(SaveRoot); } }EnsureRoot这一步千万别省。persistentDataPath本身存在但你自己建的子目录不会自动创建第一次写文件时如果目录不存在会直接抛异常。这个坑我在两个项目里都遇到过都是本地测试好好的打包到真机就崩。3. Newtonsoft 的配置细节默认设置会坑你引入库只是第一步真正决定这套系统稳不稳的是JsonSerializerSettings怎么配。默认配置在存档场景里有几个隐患必须显式处理。3.1 TypeNameHandling 的取舍多态要靠TypeNameHandling但它有安全风险——反序列化时会根据 JSON 里的类型名去实例化如果存档文件被恶意篡改可能实例化出意料之外的类型。对单机游戏来说风险可控但我仍然建议用TypeNameHandling.Auto而不是All只在需要的地方写类型名减少攻击面和文件体积。private static readonly JsonSerializerSettings Settings new() { TypeNameHandling TypeNameHandling.Auto, Formatting Formatting.Indented, NullValueHandling NullValueHandling.Ignore, DefaultValueHandling DefaultValueHandling.Include, ReferenceLoopHandling ReferenceLoopHandling.Ignore, Converters { new Vector3Converter(), new StringEnumConverter() } };Formatting.Indented在开发期强烈建议开着存档文件可读排查问题一目了然发布版可以关掉省体积。ReferenceLoopHandling.Ignore是保命的存档对象里只要有一处循环引用比如父子节点互指不加这个直接栈溢出。3.2 自定义 Converter 处理 Unity 类型Vector3、Quaternion、Color这些 Unity 结构体Newtonsoft 默认序列化会带一堆x/y/z/w之外的无用字段比如normalized、magnitude既丑又容易在反序列化时出问题。写个 Converter 干净利落public class Vector3Converter : JsonConverterVector3 { public override void WriteJson(JsonWriter writer, Vector3 value, JsonSerializer serializer) { writer.WriteStartObject(); writer.WritePropertyName(x); writer.WriteValue(value.x); writer.WritePropertyName(y); writer.WriteValue(value.y); writer.WritePropertyName(z); writer.WriteValue(value.z); writer.WriteEndObject(); } public override Vector3 ReadJson(JsonReader reader, Type objectType, Vector3 existingValue, bool hasExistingValue, JsonSerializer serializer) { var obj JObject.Load(reader); return new Vector3( obj[x]!.Valuefloat(), obj[y]!.Valuefloat(), obj[z]!.Valuefloat()); } }枚举也建议挂StringEnumConverter。默认枚举存成数字一旦你调整了枚举成员的顺序老存档全部错位。存成字符串虽然体积大一点但可读、抗顺序变化这个取舍在存档场景里非常值。3.3 空值和默认值的处理逻辑NullValueHandling.Ignore会让 null 字段不写进 JSON能显著减小文件。但要注意配合DefaultValueHandling如果你希望字段缺失时用默认值那Ignore是对的如果你希望字段缺失时报错那就要用Include并配合[JsonProperty(Required Required.Always)]。存档场景我一般选前者因为向后兼容时老存档缺新字段是常态用默认值兜底最省事。4. 版本演进存档系统真正的难点写完读写只是开始真正让存档系统活过整个项目周期的是版本管理。游戏一更新数据结构就变老玩家的存档不能废。这块没设计好后期每次更新都要写一堆兼容代码越写越乱。4.1 存档里必须带版本号这是铁律。存档根对象里第一个字段永远是Version每次结构变更就加一。读档时先看版本号再决定走哪条迁移路径。[Serializable] public class SaveData { public int Version 3; public PlayerData Player; public WorldData World; public ListQuestData Quests; }版本号不要用字符串用整数方便比较和递增。也不要试图猜版本——没有版本号的老存档你根本不知道它对应哪一版结构。4.2 迁移链式处理我的做法是维护一条迁移链从版本 N 到 N1 写一个迁移函数读档时从存档版本一路迁到当前版本。这样每次更新只需要写上一版到这一版的迁移不用管更早的。public static SaveData Migrate(SaveData data) { if (data.Version 2) MigrateV1ToV2(data); if (data.Version 3) MigrateV2ToV3(data); data.Version CurrentVersion; return data; }这种链式迁移的好处是可测试、可回放。我一般会给每个迁移函数配一个老版本的样例 JSON跑单元测试验证迁移后数据正确。别小看这一步某次我把一个字段从int改成float迁移函数写漏了测试样例一跑立刻暴露。4.3 字段重命名和删除的处理字段改名时用[JsonProperty(oldName)]保留旧名或者写个JsonConverter做映射。字段删除时直接删掉即可反序列化时多余的 JSON 字段会被忽略Newtonsoft 默认行为不会报错。但要注意如果你开了MissingMemberHandling.Error多余字段会抛异常存档场景千万别开这个。提示每次改存档结构务必拿一份真实的老存档跑一遍读档流程别只测新档。新档永远是对的老档才是问题所在。5. 落地实操从零搭一个可用的存档模块前面讲的是设计这一节给一套能直接抄的完整实现。我把它拆成序列化器、文件存储、存档管理器三块代码量不大但五脏俱全。5.1 序列化器实现public class NewtonsoftSerializer : ISerializer { private readonly JsonSerializerSettings _settings; public NewtonsoftSerializer() { _settings new JsonSerializerSettings { TypeNameHandling TypeNameHandling.Auto, Formatting Formatting.Indented, NullValueHandling NullValueHandling.Ignore, ReferenceLoopHandling ReferenceLoopHandling.Ignore, Converters { new Vector3Converter(), new StringEnumConverter() } }; } public string SerializeT(T obj) JsonConvert.SerializeObject(obj, _settings); public T DeserializeT(string json) JsonConvert.DeserializeObjectT(json, _settings); }这里把_settings缓存成字段而不是每次 new因为JsonSerializerSettings构造有开销存档虽然低频但没必要浪费。另外注意DeserializeObjectT返回可能为 null调用方要判空。5.2 文件存储实现含原子写入文件写入最怕的是写到一半崩溃存档损坏。解决办法是原子写入先写临时文件写完再替换正式文件。这样即使中途崩溃正式文件还是完整的。public class FileStorage : IStorage { public void Write(string key, string content) { PathProvider.EnsureRoot(); var finalPath PathProvider.SlotPath(int.Parse(key)); var tempPath finalPath .tmp; File.WriteAllText(tempPath, content, Encoding.UTF8); if (File.Exists(finalPath)) File.Replace(tempPath, finalPath, null); else File.Move(tempPath, finalPath); } public string Read(string key) { var path PathProvider.SlotPath(int.Parse(key)); return File.Exists(path) ? File.ReadAllText(path, Encoding.UTF8) : null; } public bool Exists(string key) File.Exists(PathProvider.SlotPath(int.Parse(key))); public void Delete(string key) { var path PathProvider.SlotPath(int.Parse(key)); if (File.Exists(path)) File.Delete(path); } }File.Replace在部分平台比如某些 Android 版本行为不一致如果遇到问题可以退化成删旧文件再 Move但要接受极小的损坏窗口。编码统一用 UTF8别用默认编码否则中文存档在某些平台会乱码。5.3 存档管理器与调用时机public class SaveManager { private readonly ISerializer _serializer; private readonly IStorage _storage; public SaveManager(ISerializer serializer, IStorage storage) { _serializer serializer; _storage storage; } public void Save(int slot, SaveData data) { var json _serializer.Serialize(data); _storage.Write(slot.ToString(), json); } public SaveData Load(int slot) { var json _storage.Read(slot.ToString()); if (string.IsNullOrEmpty(json)) return null; var data _serializer.DeserializeSaveData(json); return SaveDataMigrator.Migrate(data); } }调用时机上我建议手动存 关键节点自动存结合。手动存给玩家控制权自动存在切场景、完成任务、退出游戏时触发。千万别每帧存也别在OnApplicationQuit里做重活——移动端这个回调不保证执行重要存档要在业务节点主动落盘。6. 那些文档里不会写的坑这一节是我最想写的部分因为下面每一条都是真金白银换来的。6.1 IL2CPP 裁剪导致类型丢失打包成 IL2CPP 后代码裁剪Managed Stripping可能把你只在 JSON 里用到的类型裁掉反序列化时找不到类型直接报错。表现是编辑器里好好的打包就崩。解决办法是在link.xml里保留相关程序集或者给存档类型加[Preserve]特性。这个坑极其隐蔽我第一次遇到时排查了整整一天。6.2 字典键类型不是 string 时的坑Newtonsoft 序列化Dictionaryint, T时键会被转成字符串反序列化时再转回来。大部分情况没问题但如果你的键是自定义结构体或枚举转换可能失败。我的建议是存档里的字典键一律用 string 或 int别用复杂类型省得给自己找麻烦。6.3 大存档的写入卡顿存档大了比如几十 MB 的建造类游戏File.WriteAllText在主线程会卡一下。解决办法是放到后台线程写但要注意 Unity 的 API 大多不能在子线程调用——所以序列化在主线程做纯文件 IO 丢到Task.Run里。另外Formatting.Indented在大存档下会显著增大体积和耗时发布版记得关。6.4 时间戳和时区存档里存时间用DateTime.UtcNow而不是DateTime.Now。玩家改系统时区或跨时区玩本地时间会错乱导致离线收益算错。存 UTC显示时再转本地这是通用做法。坑点表现解决IL2CPP 裁剪打包后反序列化报类型找不到link.xml 或 Preserve字典复杂键反序列化键丢失/异常键用 string/int大存档卡顿存档瞬间掉帧后台线程 IO本地时间跨时区数据错乱统一存 UTC目录不存在真机首次存档崩溃EnsureRoot 预创建6.5 存档损坏的兜底再小心也可能遇到存档损坏断电、存储满、进程被杀。我的做法是每次成功存档后保留一份.bak备份读档失败时自动尝试备份。同时读档流程要 try-catch 包住反序列化异常时给玩家明确提示而不是直接崩游戏。这一点在玩家口碑上影响很大——存档丢了还能救回来和直接白屏体验天差地别。7. 性能与体积的平衡取舍最后聊聊优化。存档系统不需要极致性能但也不能放任。几个实用的取舍点。7.1 什么时候该压缩JSON 是文本体积比二进制大不少。如果存档超过几 MB可以考虑用 GZip 压一下再落盘读的时候解压。压缩比通常能到 5:1 以上代价是几十毫秒的 CPU 开销对存档这种低频操作完全可接受。但小存档几十 KB就别压了压缩头本身的开销不划算。7.2 分文件存储的思路不要把所有东西塞一个文件。玩家数据、世界数据、设置数据分开存好处是改设置不用重写整个世界数据减少写入量和损坏风险某个文件损坏也不至于全丢。代价是读档要读多个文件但这点 IO 开销可以忽略。7.3 序列化对象的引用控制存档对象图别搞太复杂。我见过把整个场景的 GameObject 引用都塞进存档的序列化出来巨大且脆弱。正确做法是存档只存数据ID、数值、状态运行时再根据 ID 去重建对象。存档是数据的快照不是运行时对象的镜像这个观念一定要建立起来。我在实际项目里最大的体会是存档系统的复杂度不在怎么存而在怎么演进。前期多花两天把分层、版本号、迁移链搭好后期每次更新能省下大量返工。反过来如果一开始图省事全塞一个类里等到第三个版本你就会想推倒重来。这套基于 Newtonsoft 的结构我已经在几个项目里复用改动最多的永远是业务层的SaveData定义底下的序列化和存储层几乎没动过——这大概就是分层设计最实在的回报。

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

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

免费获取报价 →
↑