☰
Unity存储系统实战:基于Newtonsoft.Json的存档设计与实现
2026/11/6 4:33:41 网站建设 项目流程

1. 为什么要在Unity里自己搭一层存储系统

做过Unity项目的人大概都有过这种体验:PlayerPrefs用着挺顺手,存个音量、存个画质档位、记个新手引导是否完成,几行代码就搞定。可一旦需求变成“保存玩家的背包数据”“记录关卡进度和角色属性”“缓存一份配置表到本地”,PlayerPrefs立刻就不够看了。它本质上就是一层键值对,只能存int、float、string三种基础类型,想存个复杂对象得自己序列化成字符串再塞进去,取出来还得反序列化,数据一多键名满天飞,维护起来非常痛苦。

我接手过好几个中等规模的Unity项目,几乎每个项目到了中期都会遇到同一个问题:存档系统是散的。A功能用PlayerPrefs存了一部分,B功能自己写了个二进制文件,C功能又用JsonUtility存了一份,最后谁也不知道玩家的完整数据到底散落在哪几个地方。等到要做云存档、要做数据迁移、要做版本兼容的时候,才发现根本无从下手。所以后来我养成了一个习惯:项目一开始就把存储层单独抽出来,做成一个统一的、可替换的、带版本管理的模块。而这个模块的核心,我基本都会选Newtonsoft.Json作为序列化引擎。

这篇内容想聊的就是这套东西——Unity存储系统,基于Newtonsoft.Json。它解决的核心问题很明确:把游戏运行时产生的各种结构化数据,以一种可读、可扩展、可维护的方式持久化到本地,并且在需要的时候能够稳定地读回来。适合谁看?如果你正在做Unity项目,被存档、配置缓存、本地数据持久化这些问题困扰过,或者你用过JsonUtility但被它的各种限制恶心到了,那这篇内容应该能帮到你。哪怕你刚接触Unity不久,只要会写基本的C#脚本,跟着思路走也能把这套系统搭起来。

先说清楚一个前提:Newtonsoft.Json不是Unity自带的。Unity官方内置的是JsonUtility,轻量、快,但限制非常多——不支持字典、不支持多态、不支持null的引用类型字段、只能序列化标记了[Serializable]的类且字段必须是public或者带[SerializeField]。这些限制在实际项目里几乎处处碰壁。Newtonsoft.Json(也就是大家常说的Json.NET)功能完整得多,字典、列表、嵌套对象、多态、自定义转换器全都支持,代价是需要额外引入一个DLL或者UPM包。这个取舍后面会详细讲。

2. 整体设计与技术选型拆解

2.1 为什么是Newtonsoft.Json而不是JsonUtility

这个选择是整个存储系统的地基,值得掰开来说。JsonUtility的优点就一个字:快。它是Unity底层用C++实现的,序列化性能确实好,而且零依赖,开箱即用。但它的限制在实际项目里是致命的。

我列个表对比一下,这些都是我在真实项目里踩过的点:

能力项JsonUtilityNewtonsoft.Json
字典Dictionary支持不支持完整支持
多态序列化不支持支持(需配置)
null引用类型字段序列化为默认值保留null
顶层数组/列表不支持支持
自定义序列化逻辑需实现ISerializationCallbackReceiver支持自定义Converter
版本兼容与缺字段容错弱强(可配置MissingMemberHandling)
可读性(格式化输出)支持但选项少完整支持
依赖无需引入DLL或UPM包

最典型的坑:JsonUtility序列化一个包含Dictionary的类,字典字段直接变成空,而且不报错,你存进去的时候以为存了,读出来发现数据没了。这种静默失败在存档系统里是灾难性的。另一个坑是顶层数组,你想存一个List ,JsonUtility直接告诉你“JSON must represent an object type”,必须套一层壳类包起来,非常别扭。

Newtonsoft.Json把这些全解决了。它的代价是引入一个大约几百KB的DLL,以及稍微高一点的序列化开销。但对于存档这种低频操作(不是每帧调用),这点开销完全可以忽略。存档可能几秒才触发一次,序列化一个几百KB的对象也就几毫秒的事,玩家根本感知不到。

注意:引入Newtonsoft.Json时要注意版本和Unity的兼容性。较新的Unity版本可以通过Package Manager的“Add package from git URL”方式引入官方维护的UPM包,也可以直接把编译好的DLL放进Plugins目录。两种方式各有优劣,UPM包便于版本管理,DLL方式更简单直接。选哪种取决于团队的工作流,如果项目用Git管理且多人协作,UPM包更规范。

2.2 存储层的分层架构思路

我不喜欢把“序列化”和“存文件”这两件事揉在一起。很多新手写存档,直接一个方法里既做JSON转换又做File.WriteAllText,代码短是短,但一旦要换存储介质(比如从本地文件换成云端、换成数据库),整个方法都得重写。

我的做法是分三层。最底层是序列化层,只负责对象和JSON字符串之间的转换,用Newtonsoft.Json的JsonConvert或者JsonSerializer完成。中间层是存储介质层,负责把字符串写到某个地方再读回来,可以是本地文件、可以是PlayerPrefs(小数据)、也可以是内存缓存。最上层是数据管理层,负责管理有哪些存档、存档的版本号、读写时机、加密解密等业务逻辑。

这样分层的好处是每一层都可以独立替换和测试。序列化层可以单独写单元测试验证对象转JSON再转回来是否一致;存储介质层可以mock一个内存实现来做测试;数据管理层则专注于业务规则。实际项目里,我通常会把存储介质层抽象成一个接口,比如ISaveStorage,本地文件实现是FileSaveStorage,将来要加云存档就再加一个CloudSaveStorage,上层代码一行不用改。

2.3 数据版本管理的前置设计

这一点很多人一开始想不到,等想到了已经晚了。游戏是会迭代的,今天存档里存的是玩家等级和金币,下个版本可能要加一个“成就列表”字段,再下个版本可能要把“金币”拆成“金币”和“钻石”。如果存档没有版本号,老存档读进新代码里,轻则字段丢失,重则反序列化异常导致存档损坏。

所以我在设计之初就会在存档根对象里放一个version字段。读取时先看版本号,如果低于当前代码期望的版本,就走一套迁移逻辑。迁移逻辑可以很简单,比如v1到v2只是新增字段,那Newtonsoft.Json默认就会把缺失字段留为默认值,基本不用处理;但如果涉及字段重命名或结构变化,就需要写专门的迁移代码。这个设计后面在实操部分会给出具体实现。

3. 核心细节解析与实操要点

3.1 序列化配置的几个关键参数

Newtonsoft.Json的默认配置对大多数场景够用,但存档系统有几个地方我建议显式设置,避免踩坑。

第一个是Formatting.Indented。开发阶段强烈建议开启格式化输出,存出来的JSON是带缩进、可读的,出问题时直接打开文件就能看,排查效率极高。发布版本可以关掉以减小文件体积,但说实话存档文件通常不大,我一般发布版也保留格式化,方便线上出问题时让玩家把存档发过来分析。

第二个是NullValueHandling。默认是Include,也就是null字段也会写进JSON。对于存档,我倾向于用Include,因为“字段存在但值为null”和“字段不存在”在语义上是不一样的,保留null能更准确地还原对象状态。但如果你的存档里有大量可选字段,用Ignore可以显著减小体积。

第三个是MissingMemberHandling。默认是Ignore,也就是JSON里有但代码里没有的字段会被忽略。这个默认值对存档系统是友好的——老存档里有新代码不认识的字段,忽略掉就好,不会报错。千万别设成Error,否则版本升级时老存档直接读不了。

第四个是ReferenceLoopHandling。如果你的存档对象里有循环引用(比如A引用B,B又引用A),默认会抛异常。存档对象一般不应该有循环引用,但万一有,设成Ignore可以避免崩溃,代价是循环的那部分数据会丢失。我的建议是存档用的数据类尽量设计成树形结构,从根上避免循环引用。

代码上大概是这样:

private static readonly JsonSerializerSettings Settings = new JsonSerializerSettings { Formatting = Formatting.Indented, NullValueHandling = NullValueHandling.Include, MissingMemberHandling = MissingMemberHandling.Ignore, ReferenceLoopHandling = ReferenceLoopHandling.Ignore, TypeNameHandling = TypeNameHandling.None };

提示:TypeNameHandling默认是None,不要轻易改成All或Auto。它会在JSON里写入类型信息,虽然能支持多态反序列化,但会带来安全风险(反序列化不受信任的数据时可能被利用)和体积膨胀。如果确实需要多态,用自定义Converter更可控。

3.2 存档数据类的设计规范

存档数据类不是随便写个class就行,有几个规范我建议遵守。

首先,所有存档类都标记[Serializable],虽然Newtonsoft.Json不强制要求,但标记了有助于Unity编辑器里调试,也方便将来万一要切回JsonUtility。其次,字段用public或者带public属性的私有字段,Newtonsoft.Json默认序列化public字段和属性,私有字段需要加[JsonProperty]特性。我个人的习惯是存档类全部用public字段,简单直接,反序列化时不用操心构造函数。

第三,避免在存档类里放UnityEngine.Object的引用。比如别在存档类里直接放一个Texture2D或者GameObject字段,这些东西没法序列化成有意义的JSON,就算序列化了也是一堆InstanceID,读回来根本还原不了。存档里只放纯数据,资源引用用资源路径或者资源ID字符串代替。

第四,给每个字段设默认值。C#里字段不初始化就是默认值(int是0,引用类型是null),但业务上“金币为0”和“金币字段缺失”可能含义不同。我习惯在字段声明时就给一个合理的初始值,比如public int gold = 100;,这样新玩家初始就有100金币,老存档缺这个字段时也会得到100,逻辑上更自然。

一个典型的存档根类长这样:

[Serializable] public class SaveData { public int version = 1; public string playerName = "Player"; public int level = 1; public int gold = 100; public List<ItemData> inventory = new List<ItemData>(); public Dictionary<string, int> achievements = new Dictionary<string, int>(); public long lastSaveTimeTicks = 0; } [Serializable] public class ItemData { public string itemId = ""; public int count = 0; }

3.3 存储路径的选择与跨平台考量

存档文件放哪,这个问题在不同平台上答案不一样。Unity提供了Application.persistentDataPath,这是一个跨平台可写的目录,Windows下大概在用户目录的AppData里,Android下在应用私有目录,iOS下在沙盒的Documents附近。这个路径是官方推荐的持久化数据存放位置,各平台都保证可读写且不会被系统随意清理。

但有几个细节要注意。第一,persistentDataPath在不同平台返回的路径格式不同,Windows用反斜杠,移动平台用正斜杠,拼接路径时一定要用Path.Combine而不是字符串加斜杠,否则在某些平台上会出问题。第二,这个目录在打包后才会正确指向可写位置,编辑器里指向的是项目相关的本地目录,测试时要注意区分。第三,WebGL平台的持久化比较特殊,它底层用的是浏览器的IndexedDB,容量有限且需要主动Flush,这块如果项目要发WebGL得单独处理。

我的做法是在存储介质层里封装一个路径获取方法,统一用Path.Combine拼接,并且把存档目录再套一层子目录,比如Path.Combine(Application.persistentDataPath, "Saves"),这样存档文件和其它临时文件分开,便于管理和清理。

3.4 原子写入:防止存档写到一半崩溃

这是很多人忽略但极其重要的一点。假设你直接File.WriteAllText往存档文件里写,写到一半游戏崩溃了或者手机没电了,存档文件就变成了半截内容,下次读的时候直接反序列化失败,玩家进度全丢。这种事故在真实项目里是会引发客诉的。

解决办法是原子写入:先写到临时文件,写完后把临时文件替换成正式文件。这样即使写临时文件的过程中崩溃,正式文件还是完好的旧版本。具体实现上,先写save.tmp,确认写成功后,如果save.json存在就先删掉(或者用File.Replace),再把tmp重命名为json。File.Replace在部分平台上有兼容性问题,稳妥起见我用“写tmp -> 删旧 -> 重命名”的流程,虽然删旧和重命名之间有个极短的空窗期,但比直接覆盖安全得多。

public void Write(string content) { string tmpPath = filePath + ".tmp"; File.WriteAllText(tmpPath, content, Encoding.UTF8); if (File.Exists(filePath)) File.Delete(filePath); File.Move(tmpPath, filePath); }

注意:移动平台(尤其是Android)上文件IO相对慢,写存档不要在主线程序列化后立刻同步写,建议放到协程或者异步任务里,避免卡帧。但要注意异步写的时候如果玩家连续触发两次存档,可能出现并发写同一个文件的问题,需要加一个写入锁或者队列。

4. 完整实操过程与核心环节实现

4.1 环境准备与Newtonsoft.Json引入

第一步是把Newtonsoft.Json弄进项目。前面说过两种方式,这里说下具体操作。如果走UPM包,在Unity的Package Manager里点加号,选“Add package from git URL”,填入官方维护的包地址即可(具体地址以官方文档为准,不同Unity版本对应的包版本可能不同)。如果走DLL方式,去Newtonsoft.Json的官方发布页下载对应.NET版本的DLL,放进Assets/Plugins目录下,Unity会自动识别。

引入之后写个最简单的测试脚本验证一下:

using Newtonsoft.Json; using UnityEngine; public class JsonTest : MonoBehaviour { void Start() { var data = new SaveData(); data.gold = 999; string json = JsonConvert.SerializeObject(data, Formatting.Indented); Debug.Log(json); var back = JsonConvert.DeserializeObject<SaveData>(json); Debug.Log(back.gold); } }

如果控制台能打印出格式化的JSON并且gold读回来是999,说明环境没问题。这一步看着简单,但经常有人卡在DLL版本不匹配或者命名空间冲突上。如果项目里同时有别的插件也引用了Newtonsoft.Json,可能出现重复DLL的报错,这时候要检查Plugins目录下是不是有多份,保留一份即可。

4.2 序列化层的封装实现

序列化层我封装成一个静态工具类,把JsonSerializerSettings集中管理,对外只暴露Serialize和Deserialize两个泛型方法。这样上层调用永远不用关心配置细节,将来要换配置也只改一处。

public static class JsonSerializerWrapper { private static readonly JsonSerializerSettings Settings = new JsonSerializerSettings { Formatting = Formatting.Indented, NullValueHandling = NullValueHandling.Include, MissingMemberHandling = MissingMemberHandling.Ignore, ReferenceLoopHandling = ReferenceLoopHandling.Ignore }; public static string Serialize<T>(T obj) { return JsonConvert.SerializeObject(obj, Settings); } public static T Deserialize<T>(string json) { return JsonConvert.DeserializeObject<T>(json, Settings); } }

这里有个细节值得说:Deserialize失败时JsonConvert会抛JsonException,存档系统里这个异常必须捕获,不能让它冒泡到上层导致游戏崩溃。捕获后应该走“存档损坏”的兜底逻辑,比如加载一个默认存档或者提示玩家。我在实际项目里会在Deserialize外面再包一层TryDeserialize,返回bool表示是否成功,失败时输出详细的错误日志(包含出错的JSON片段),方便定位问题。

4.3 存储介质层的接口与文件实现

存储介质层定义一个接口,把“读字符串”和“写字符串”抽象出来:

public interface ISaveStorage { bool Exists(); string Read(); void Write(string content); void Delete(); }

文件实现就是前面说的原子写入逻辑。这里补充一个读取时的容错:如果正式文件不存在但tmp文件存在(说明上次写到一半崩溃了),可以选择尝试读tmp文件,或者直接忽略tmp。我的做法是忽略tmp并删除它,因为tmp的内容不保证完整,读它反而可能引入脏数据。正式文件不存在就当作没有存档处理。

public class FileSaveStorage : ISaveStorage { private readonly string filePath; public FileSaveStorage(string fileName) { string dir = Path.Combine(Application.persistentDataPath, "Saves"); if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); filePath = Path.Combine(dir, fileName); } public bool Exists() => File.Exists(filePath); public string Read() => File.ReadAllText(filePath, Encoding.UTF8); public void Write(string content) { string tmpPath = filePath + ".tmp"; File.WriteAllText(tmpPath, content, Encoding.UTF8); if (File.Exists(filePath)) File.Delete(filePath); File.Move(tmpPath, filePath); } public void Delete() { if (File.Exists(filePath)) File.Delete(filePath); } }

4.4 数据管理层的读写流程与版本迁移

数据管理层是整个系统的门面,对外提供Save和Load两个核心方法。Save的流程是:拿到当前游戏数据对象 -> 更新version和lastSaveTimeTicks -> 序列化成JSON -> 通过存储介质写入。Load的流程是:检查存储是否存在 -> 读取字符串 -> 反序列化 -> 检查version并执行迁移 -> 返回数据对象。

版本迁移我设计成一个简单的链式处理。假设当前代码版本是3,读到的存档版本是1,那就依次执行v1到v2的迁移、v2到v3的迁移。每个迁移方法只负责一个版本跨度,逻辑清晰,也便于测试。

public class SaveManager { private const int CurrentVersion = 3; private readonly ISaveStorage storage; public SaveManager(ISaveStorage storage) { this.storage = storage; } public void Save(SaveData data) { data.version = CurrentVersion; data.lastSaveTimeTicks = DateTime.UtcNow.Ticks; string json = JsonSerializerWrapper.Serialize(data); storage.Write(json); } public SaveData Load() { if (!storage.Exists()) return new SaveData(); string json = storage.Read(); SaveData data; try { data = JsonSerializerWrapper.Deserialize<SaveData>(json); } catch (Exception e) { Debug.LogError($"存档解析失败: {e.Message}"); return new SaveData(); } data = Migrate(data); return data; } private SaveData Migrate(SaveData data) { if (data.version < 2) { // v1 -> v2: 假设v2新增了achievements字段,默认已初始化,无需特殊处理 data.version = 2; } if (data.version < 3) { // v2 -> v3: 假设v3把gold拆成了gold和diamond // 这里演示字段迁移逻辑 data.version = 3; } return data; } }

迁移逻辑里有个经验:每次加版本迁移,都要写一个对应的老存档测试用例。我一般会在项目里保留几个不同版本的手工存档JSON文件,每次改迁移逻辑就跑一遍,确保老存档都能正确升级。这个习惯帮我避免过好几次线上事故。

4.5 自动存档与手动存档的触发时机

存档什么时候触发,这个设计直接影响玩家体验和数据安全。我的经验是分两类:关键节点手动存档和定时自动存档。

关键节点包括:关卡完成、购买物品成功、任务提交、退出游戏前。这些时机数据变化明确,存一次成本低、收益高。定时自动存档则是兜底,比如每60秒存一次,防止玩家在两次关键节点之间意外退出丢进度。定时存档要注意不要在战斗高潮或者加载过程中触发,避免卡顿,我一般用协程配合一个“是否允许存档”的标志位来控制。

退出游戏前的存档要特别小心。移动平台上应用切到后台不一定有机会执行完整的存档流程,所以不能完全依赖OnApplicationQuit。更稳的做法是在数据发生变化时就标记dirty,然后在合适的时机(比如切场景、暂停菜单打开)检查dirty并保存。OnApplicationPause在移动端比OnApplicationQuit更可靠,可以在这两个回调里都尝试保存一次。

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

5.1 反序列化后字段全是默认值

这是最常见的问题,表现是读存档成功没报错,但数据全是初始值。原因通常有两个:一是JSON里的字段名和类里的字段名对不上(大小写敏感),二是类没有无参构造函数或者字段不是public。Newtonsoft.Json默认按字段名精确匹配,如果JSON里是"Gold"而类里是public int gold,就匹配不上。解决办法要么统一命名规范,要么用[JsonProperty("Gold")]显式指定。

还有一种隐蔽情况:字段是属性而不是字段,且只有get没有set,Newtonsoft.Json没法写回去,也会导致读出来是默认值。存档类里尽量用字段,别用只读属性。

5.2 字典序列化后变成空对象

如果你用的是JsonUtility,字典必然丢。如果用的是Newtonsoft.Json还丢,检查一下字典的键类型。Newtonsoft.Json对非字符串键的字典支持有限,比如Dictionary<int, Item>序列化出来键会变成字符串,反序列化时可能出问题。稳妥起见,存档里的字典键统一用string,int键的话在存取时做一层转换。

5.3 存档文件越来越大导致读写变慢

存档体积膨胀通常是因为把不该存的东西也存了。比如把整个配置表、所有静态数据都塞进存档,或者列表里存了大量重复数据。我的做法是存档只存“变化量”——玩家改了什么就存什么,静态配置从配置表读,不放进存档。另外定期检查存档JSON,看看有没有意料之外的大字段。曾经有个项目存档到了几MB,排查发现是把一个日志列表也序列化进去了,去掉之后降到几十KB。

5.4 多平台路径与编码问题

Windows上读写正常,打包到Android后读不到存档,八成是路径问题。检查是不是用了硬编码的斜杠或者相对路径。另外编码统一用UTF8,读写时显式指定Encoding.UTF8,避免中文玩家名字在某些平台上乱码。还有一点,iOS的沙盒路径每次应用更新可能会变,所以绝对不要在存档里存绝对路径,只存相对标识。

5.5 常见问题速查表

现象可能原因排查方向
读档后数据全默认字段名不匹配/无setter检查命名、用JsonProperty标注
字典读出来是空键类型非string统一用string键或自定义Converter
打包后读不到存档路径硬编码/平台差异用persistentDataPath+Path.Combine
存档文件损坏非原子写入改为tmp+替换的写入流程
存档体积异常大存了静态数据/日志审查序列化对象,剔除冗余字段
迁移后数据错乱迁移逻辑顺序错误按版本号链式迁移并写测试用例

提示:排查存档问题时,最有效的手段是把出问题的JSON文件拿到手,用在线的JSON格式化工具看一眼结构,再和代码里的类定义逐字段对照。大部分问题在这一步就能定位。

6. 我踩过的坑与实操心得

说几个文档里不会写、只有实际做过才知道的点。

第一个是关于序列化性能的误区。很多人担心Newtonsoft.Json慢,其实对于存档这种低频操作完全不是瓶颈。真正影响体验的是文件IO,尤其是移动平台。所以优化重点应该放在减少存档频率和减小存档体积上,而不是纠结序列化引擎。我实测过一个包含几千条物品记录的存档,序列化加反序列化总共也就十几毫秒,但一次文件写入在低端安卓机上可能就要几十毫秒,所以异步写入比换序列化引擎更有效。

第二个是关于加密。有些项目要求存档加密防止玩家修改。我的建议是:单机游戏别过度纠结这个,玩家真想改存档你防不住,加密只会增加自己的维护成本。如果确实需要,用简单的AES对称加密就够了,密钥硬编码在代码里(虽然也能被逆向,但门槛够了)。千万别自己发明加密算法,也别用明文存敏感信息。加密解密放在存储介质层做,序列化层不感知。

第三个是关于编辑器下的调试便利。我习惯在编辑器里加一个菜单项,一键打开存档目录,再写一个“清空存档”的按钮。开发阶段频繁测试存档逻辑,有这个能省很多事。另外可以在Inspector里做一个存档查看器,把当前存档对象以只读方式展示出来,比每次去翻JSON文件直观。

第四个是关于多存档槽。如果游戏需要多个存档槽,存储介质层的文件名要参数化,SaveManager也要能指定操作哪个槽。设计时把“槽位标识”作为参数传进去,而不是写死在类里,扩展起来会轻松很多。槽位元信息(比如存档时间、玩家等级、游戏时长)可以单独存一个小文件,用于存档列表界面展示,避免为了显示列表而加载全部存档。

这套基于Newtonsoft.Json的存储系统,我在几个项目里用了几年,中间修修补补,核心结构一直没大改。它的价值不在于技术多高深,而在于把“存数据”这件小事做扎实了——该有的容错有,该留的扩展点留了,出问题能查。如果你正准备给自己的项目搭存档系统,不妨按这个思路走一遍,先把序列化和存储介质分开,再把版本管理加上,最后补上原子写入和异常兜底,基本就能覆盖绝大多数场景了。

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

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

立即咨询