最近这两个月,在好几个技术群里反复被同一个问题刷屏:Unity 的存档到底用什么方式落地最稳?配表读取、主城数据、设置项存盘,到底该 PlayerPrefs 一把梭还是手写文件解析?我的答复一直很统一:用 Json 做序列化,写到 Application.persistentDataPath 下,这是绝大多数 Unity 项目里性价比最高、也最不容易出问题的数据可持续化方案。
这个结论不是拍脑袋来的。我做过小体量的独立游戏,也接过需要热更和 SDK 接入的商业项目,Json 在 Unity 里承担的任务从一行设置项保存,到几个 MB 的玩家存档,再到服务器下发的配置表,几乎都有覆盖。它不像二进制那么难调试,也不像 XML 那样冗余得让人头皮发麻。这篇文章就把我踩过的坑、总结的模板,以及排查问题的一套思路完整写出来,希望能让正在为存档发愁的朋友少走弯路。
1. 为什么我推荐用 Json 做 Unity 持久化
1.1 三种主流持久化方案的横向对比
很多新手一上来就喜欢问“哪个方案最好”,其实这类问题没有标准答案,只有最合适。真正落到 Unity 项目里,绕不开的无非是三种:PlayerPrefs、二进制序列化、Json 文本序列化。我先用自己的经验把这三种方案摊开对比一下。
| 方案 | 上手难度 | 调试友好度 | 可读性 | 版本兼容性 | 性能 | 典型场景 |
|---|---|---|---|---|---|---|
| PlayerPrefs | 极低 | 低 | 中等 | 差,键多了难迁移 | 高 | 设置项、音量、画质选项 |
| 二进制 | 中 | 极差 | 极差 | 很差,结构一变就废 | 最高 | 核心战斗数据、超大数据量存档 |
| Json | 低 | 高 | 高 | 好,可加版本号迁移 | 中上 | 玩家存档、配表、服务器通信 |
PlayerPrefs 的本质是平台层的键值存储,它在 Windows 上写注册表、在 Android 上写 XML 文件,一旦键数量膨胀,查找和整理都是灾难。而且它的定位是“轻量偏好”,塞大段字符串进去虽然能跑,但以后想迁移到别的平台或服务器,数据拿不出来,这是很痛的。
二进制方案我最早也迷恋过,序列化快、体积小,但调试起来真的会崩溃。线上玩家反馈存档损坏,你拉回来一个 .bytes 文件,用十六进制工具打开,完全是天书。更麻烦的是二进制布局和运行时类型强绑定,版本一迭代,老存档几乎不可读。Json 恰好在这两者之间达到了一个大多数人能接受的平衡点。
1.2 Json 方案的适用边界
我之前负责一个中轻度卡牌项目,玩家存档里包含角色等级、背包物品、任务进度、设置项和少量统计信息。如果把整套数据用 PlayerPrefs 拆成键值对,大概要维护上百个键,想想就头大。后来我统一用一个存档类包住所有数据,直接 ToJson 成字符串存本地文件,读取时 FromJson 还原,代码量骤减,后来接后端时 JSON 结构还能直接复用,相当于省了一轮模型层重写。
但要注意,Json 并不是万能的。如果你的存档每帧产生大量浮点数组,或者有几十 MB 的关卡录像类数据,Json 的文本膨胀和解析开销就不太划算。这种场景我建议核心数据走二进制,元数据、可读配置、玩家可见信息走 Json,两者可以共存,没必要一棵树吊死。
还有一种场景是“高度反作弊敏感”的存档,比如排行榜分数、竞速类计时器。Json 明文文件玩家拿记事本就能改,这是它的天然弱点。此时必须叠加加密和完整性校验,而不是指望格式本身提供安全。
1.3 选第三方 Json 库前必须想清楚的事
Unity 自带 JsonUtility,但它的限制不少,后面我会详细说。很多人一开始就上 LitJson 或 Newtonsoft.Json,这当然可以,但选型前有四个问题一定要先问自己:目标平台是否需要 IL2CPP 打包?包体大小敏感不敏感?数据结构里有没有 Dictionary、多态这类 JsonUtility 不支持的类型?服务器和客户端是否共用同一套模型?
IL2CPP 是最大的坑。Newtonsoft.Json 在 IL2CPP 下如果用到了反射创建类型,可能会被代码裁剪干掉,必须配 link.xml 或在类上打 preserve 标记。这个问题网上问了无数遍,本质上不是库坏了,而是裁剪规则没写好。如果只是简单存档,JsonUtility 其实完全够用;一旦涉及复杂结构或者要和服务端统一模型,再引入第三方库不迟。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. JsonUtility 核心用法与限制盘点
2.1 基础序列化和反序列化
JsonUtility 是 UnityEngine 命名空间下的工具类,不需要额外引入第三方包。它的基础用法非常清爽:类上标记 [System.Serializable],然后 ToJson 和 FromJson 来回转。
csharp复制[System.Serializable]
public class PlayerData
{
public string playerName;
public int level;
public float hp;
public bool isNewPlayer;
}
// 序列化
PlayerData data = new PlayerData();
data.playerName = "小明";
data.level = 10;
data.hp = 100f;
data.isNewPlayer = false;
string json = JsonUtility.ToJson(data, true);
// true 表示格式化输出,带缩进,方便查日志
Debug.Log(json);
// 反序列化
PlayerData loaded = JsonUtility.FromJson<PlayerData>(json);
Debug.Log(loaded.playerName + " " + loaded.level);
我建议在开发期保留第二个参数 true,输出带缩进的格式化字符串,日志里能直接看清字段层级。正式发布版为了省一点存储空间,可以传 false,或者直接不传直接 ToJson(data),默认就是紧凑模式。这个参数对性能影响很小,但对你调试时的眼睛影响很大。
这里还有个隐藏知识点:FromJson 传入空字符串或 null 时并不会抛异常,而是返回一个默认实例。字段会保持默认值。所以如果你发现读取后数据全是 0 或 null,别急着怪 Json,先查是否文件本身是空的,或者路径根本没写对。
2.2 字段类型支持和不支持的情况
JsonUtility 的序列化规则和 Unity 的 Inspector 规则高度一致,这一点是好事也是坏事。好事是你熟悉了序列化面板后,基本能猜出哪些字段会被 Json 处理;坏事是很多从 Java 或 C# 原生 JSON 库转过来的人,会对它的“不处理 Dictionary、不处理多态”非常不适应。
支持的情况相对比较简单:基础类型 int、float、string、bool,一维数组、List,以及标记了 [Serializable] 的嵌套类、struct、enum,还有 Vector2、Vector3 这类 Unity 原生结构。只要你把字段声明为 public,或者标记了 [SerializeField],JsonUtility 就能识别。
不支持的情况就要注意了:
| 类型/特性 | 表现 | 解决办法 |
|---|---|---|
| Dictionary | 直接忽略,不报错但不输出 | 转成 List |
| 属性(get/set) | 不参与序列化 | 改成 public 字段或加 backing field |
| 静态字段 | 不参与序列化 | 自己实现读写逻辑 |
| 接口字段 | 不参与序列化 | 用具体类替代 |
| 继承多态 | 只会序列化声明类型的字段 | 用 JsonUtility.FromJsonOverwrite 配合自处理 |
| 只读字段 | 不参与序列化 | 改为可写字段,或自定义类 |
我最早用 JsonUtility 存一个“成就系统”数据时,内部用了 Dictionary 存成就 ID 到解锁状态的映射。调试时 ToJson 不报错,但输出结果里这个字段直接消失。后来才反应过来,就是“忽略但不报错”这个特性最坑人,因为它不给你任何提示,你要靠肉眼对比 JSON 文本才能发现问题。
2.3 嵌套结构、枚举与数组的实操示例
嵌套类和枚举是项目里最常用的结构,比如存档里一个 PlayerData 里嵌了 InventoryData,InventoryData 里再挂一个 List
csharp复制[System.Serializable]
public class ItemData
{
public string itemId;
public int count;
public ItemType type;
}
[System.Serializable]
public class InventoryData
{
public List<ItemData> items = new List<ItemData>();
}
[System.Serializable]
public enum ItemType
{
Weapon,
Armor,
Consumable
}
枚举默认序列化成数字,如果你想存成字符串名字方便阅读和跨版本稳定,官方 JsonUtility 做不到,只能存 int 值。这种设计是否要改成字符串,主要看你项目到底有没有人去直接编辑存档文件。单纯程序内部读写,存数字完全没问题;如果后面要做编辑器工具,让策划在 Json 文件里配表,那把枚举改成字符串校验的字段会更友好。
对于数组长度不一致的情况,FromJson 会尽量按已有字段填充,缺失的用类型默认值顶上,多出来的部分忽略。这既是优点也是缺点:容错性高,但你也无法靠枚举 JSON 字段来判断数据是否完整,必须自己加版本号或校验字段。
3. 一套可直接抄的存档系统实现
3.1 数据模型设计:版本号永远放在第一位
存档系统最容易被忽略的设计,就是版本号。很多项目上线后第一次改存档结构,才发现所有玩家旧存档全部作废,被喷得狗血淋头。我在设计存档模型时,第一件事就是塞一个 version 字段和 saveTime 时间戳,这比任何功能字段都重要。
csharp复制[System.Serializable]
public class SaveData
{
public int version = 1;
public string saveTime;
public PlayerData player;
public InventoryData inventory;
public List<string> unlockedLevels = new List<string>();
public GameSettings settings;
}
version 字段用于存档迁移,saveTime 用于显示“最近保存时间”和调试对齐问题。player 和 inventory 这些子结构单独定义类,不要把所有字段平铺在 SaveData 里,否则后期迭代时改一个子区域会影响公共模型,非常被动。子结构单独成类还有个好处:你可以针对每个子模块写独立的序列化测试,不用每次都生成整份存档。
3.2 SaveManager 封装:原子写入与备份
封装一个 SaveManager 类并没有多高大上,关键是处理好几个隐藏问题:路径、目录创建、写入安全性。我见过很多新手直接把 File.WriteAllText 往 persistentDataPath 上怼,结果在 Android 上偶发“存档损坏”,多数原因是写入途中进程被杀,文件被截断了一半。所以我在封装时一定要用“临时文件 + 覆盖”的方式。
csharp复制using System;
using System.IO;
using UnityEngine;
public static class SaveManager
{
private static string GetSavePath(string fileName)
{
string dir = Path.Combine(Application.persistentDataPath, "Saves");
if (!Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
return Path.Combine(dir, fileName);
}
public static void Save<T>(string fileName, T data)
{
string path = GetSavePath(fileName);
string tmpPath = path + ".tmp";
string json = JsonUtility.ToJson(data);
File.WriteAllText(tmpPath, json, System.Text.Encoding.UTF8);
if (File.Exists(path))
{
File.Delete(path);
}
File.Move(tmpPath, path);
}
public static T Load<T>(string fileName) where T : new()
{
string path = GetSavePath(fileName);
if (!File.Exists(path))
{
return new T();
}
string json = File.ReadAllText(path, System.Text.Encoding.UTF8);
return JsonUtility.FromJson<T>(json);
}
}
这个写法虽然多了一次 File.Move,但极大降低了文件半写入的风险。移动文件在同一个文件系统内是原子操作,比先删后写安全得多。如果你的项目对数据极端敏感,比如玩家付费道具状态,我还会额外保留一份 .bak 备份,读取时如果主存档校验失败,自动尝试加载备份。
3.3 存档版本迁移:别让旧玩家卡死在加载界
版本迁移的通用思路很简单:读取时拿到 version,如果比当前代码版本低,就走对应的升级管线。迁移要写成一个方法链,从 v1 一步步升到 v2、v3,不要直接写一个巨大的 switch 然后跳版本。
csharp复制public static SaveData LoadWithMigration(string fileName)
{
SaveData data = Load<SaveData>(fileName);
int currentVersion = 3; // 假设当前存档版本是3
while (data.version < currentVersion)
{
switch (data.version)
{
case 1:
MigrateV1ToV2(data);
data.version = 2;
break;
case 2:
MigrateV2ToV3(data);
data.version = 3;
break;
}
}
return data;
}
private static void MigrateV1ToV2(SaveData data)
{
// 比如 v1 没有 settings 字段,需要手动给一个默认值
if (data.settings == null)
{
data.settings = new GameSettings();
}
// v1 的关卡列表是 List<string>,v2 改成自定义结构,也要在这里转换
}
关键是“从低到高逐级迁移”,不要试图一步到位。这样以后每次存档结构变更,只需要在末尾追加一个迁移方法和版本号加一,旧逻辑完全不碰。实际项目里我靠这个模式,三年迭代了六个存档版本,从没出现过玩家旧档直接崩掉的情况。
3.4 存档加密与完整性校验再加一道锁
Json 明文存档有个尴尬:玩家用记事本打开就能改。对于单机轻度游戏,改就改了,影响不大;但涉及排行榜、成就、商城数据,就最好做一层完整性校验和加密。我的轻量做法是:对 JSON 字符串做一次不可逆哈希(比如 MD5 或 SHA256,取前若干位),拼到存档内容里,读取时重新计算校验,不一致则判定存档被篡改。防重放攻击的更高阶手段不在这次讨论范围,项目如果到了那一步,应该直接上后端服务。
加密方面如果只是防“顺手改一下”,做一层异或混淆就够了,没必要上完整 AES,除非合规或商业上明确要求。注意一点:客户端里的密钥和校验算法,本质上都只能提高篡改门槛,不能提供真正的安全。真正防作弊一定要以服务器数据为准,客户端本地校验更多是劝退小白。
4. 读写路径与性能优化:别让小存档拖垮主线程
4.1 Unity 各路径对比:为什么 persistentDataPath 是主角
Unity 里跟文件读写相关的路径有好几个,很多人分不清该用哪个。我在这里把常用路径和平台表现整理一遍,免得你每条路径都踩过坑才记住。
| 路径 | 平台表现 | 可写性 | 使用建议 |
|---|---|---|---|
| Application.dataPath | 项目根目录/安装目录 | PC 可写,移动端只读 | 不要用来存运行时数据 |
| Application.streamingAssetsPath | 只读资源目录 | 移动端在包内只读 | 放初始配置、首次启动数据 |
| Application.persistentDataPath | 不同平台映射到程序数据目录 | 可写 | 存档、日志、下载缓存的首选 |
| Application.temporaryCachePath | 系统临时目录 | 可写 | 可再生成的缓存文件 |
persistentDataPath 在不同平台的具体物理路径差别很大,但你不需要关心它到底在哪,因为系统会自动把数据备份到云同步(iOS)或者应用数据目录下。它最大的价值是:应用更新时数据不丢,卸载时才会被清除。这在苹果的隐私合规、安卓的存储分区适配下都省心,因为它不需要额外申请存储权限。
4.2 同步写还是异步写:JsonUtility 的线程约束
JsonUtility.ToJson 和 FromJson 都不是线程安全的,官方也不建议在工作线程里调用,因为内部可能访问 Unity 的一些原生对象。所以标准做法是:在主线程把对象转成 JSON 字符串,再丢到工作线程里写文件;或者反过来,在工作线程读文本文件,回到主线程做 FromJson。
csharp复制public static void SaveAsync<T>(string fileName, T data, Action<bool> onDone)
{
string path = GetSavePath(fileName);
string json = JsonUtility.ToJson(data);
System.Threading.ThreadPool.QueueUserWorkItem(_ =>
{
try
{
string tmpPath = path + ".tmp";
File.WriteAllText(tmpPath, json, System.Text.Encoding.UTF8);
if (File.Exists(path))
{
File.Delete(path);
}
File.Move(tmpPath, path);
onDone?.Invoke(true);
}
catch (Exception e)
{
Debug.LogError(e);
onDone?.Invoke(false);
}
});
}
这套写法的核心思想是:JSON 字符串生成必须留在主线程,文件 IO 放到线程池。它适合存档体积在几千 KB 以内的场景,一张图几 MB 的写盘也基本能接受。如果数据量非常大,我建议走“定时增量存档”,不要写一次就全量序列化全量落盘。
4.3 频繁写入卡顿的原因与优化方案
我自己踩过最深的一个坑:战斗过程里每局结束统计生命值、金币、经验值,顺手就调一次 SaveManager.Save,结果低端机上明显感觉到卡顿。后来用 profiler 一看,卡点不在序列化,而在 File.WriteAllText 这类高频文件操作触发了磁盘同步。优化办法有两个,一个是削频率,一个是削数据。
削频率的典型做法是引入一个“脏标记”,只有数据发生变化才写入,并且批量合并多次变化为一次写入。比如玩家在背包界面里连续卖掉 5 件道具,UI 上可能是 5 次操作,但存档不需要每次都写,等最后一个操作结束再回写就行。
削数据则是把大存档拆成多个文件,核心进度写高频小文件,稀有统计写低频大文件。比如“最近关卡进度”每次过图就更新,“历史总时长”每 10 分钟写一次。两者分开后,低频大文件不会拖慢每天玩几十次的进程切换。
5. 常见问题与排查技巧实录
5.1 中文变成 \uXXXX 是被转义了,不是乱码
很多人在日志里看到 JsonUtility 输出中文变成了 \u5C0F\u660E,第一反应是编码坏了。其实这是 JsonUtility 对非 ASCII 字符的默认转义行为,属于 JSON 的合法表示。你用任何标准 JSON 解析器去解析这串文本,都能还原成中文,所以它不算 bug。真正需要关心的是文件本身读出来是否乱码,那是读写编码不一致的问题。
我建议所有读写存档文件的代码,统一使用 UTF-8 编码,并显式传参 Encoding.UTF8。不要在 Windows 上让 File.WriteAllText 的默认编码悄悄变成 ANSI,一旦存档在中文系统里写出来,换到别的编码环境读,就真成了乱码。我的 SaveManager 里现在每一处读写都带编码参数,就是因为当年在繁体系统手机上栽过一次。
5.2 FromJson 返回全空或数组为 0 的排查套路
遇到反序列化出来数据全空,最常见的三个原因几乎一样多:字段名拼错、字段大小写不一致、字段类型不支持。JsonUtility 的字段匹配是区分大小写的,而且你在 JSON 文本里写一个字典结构,它不会报错,只会悄悄忽略。所以排查时先打印出原始 JSON,再写好一个“对照表”,逐字段比对。
我自己的排查顺序是:先确认文件存在且非空,再贴 JSON 到任意在线格式化工具里看结构,最后确认 C# 类字段名和 JSON 字段名完全一致。如果用的是第三方库,还要注意构造函数是否被裁剪、类是否无参构造。通常走完这三步,99% 的反序列化问题都能定位。
5.3 “failed to deserialize the json body into the target type”这类报错的通用定位法
这个报错在搜索引擎里很常见,但它多数时候不是 Unity 原生报错,而是某后端服务在解析 HTTP 请求体时抛出的,比如接了一堆 SDK 或服务端 API 时出现。不过它的排查逻辑和 Unity 里 FromJson 失败的逻辑完全通用:确认请求体里的 JSON 字段结构能否映射到目标类型,缺失必填字段,或者类型不匹配,都会触发这个错误。
我处理这类问题的方法是三步走:把实际发送的 JSON 原样打出来,用一个临时脚本把它反序列化成目标类型,然后逐字段对比类型。如果 Type 是 int,JSON 里给的是字符串 "123",某些严格模式会拒绝;如果后端要求某个字段必须存在,你的客户端模型漏了该字段,也会报错。大多数情况都能被这三步覆盖。
5.4 IL2CPP 裁剪导致第三方 Json 库数据全空
如果你用了 Newtonsoft.Json 或 LitJson 这类反射库,在编辑器里运行一切正常,但打包后的真机上反序列化回来全是 null,大概率是 IL2CPP 代码裁剪把类型信息给裁掉了。这个问题我印象太深了,一个线上项目半夜反馈“所有玩家登录后背包为空”,结果就是打包机器新增了剪裁规则,把背包里的泛型类型给裁没了。
常规解决办法是在 Assets 目录下创建 link.xml,保留需要反射的类型。比如:
xml复制<linker>
<assembly fullname="Assembly-CSharp" preserve="all" />
<assembly fullname="Newtonsoft.Json" preserve="all" />
</linker>
另一种做法是在目标类上标记 [UnityEngine.Scripting.Preserve],或者在构造函数和属性上打 [Preserve] 来阻止裁剪。重点关注:泛型容器类、DTO 模型、子类多态等,这些最容易成为被裁对象。
5.5 Android 构建时字段名被混淆导致存档解析失败
热词里出现过“unity 提高 minimum api level target api level 到 api35”这类搜索趋势,看起来很多人正卡在 Android 构建版本提升的问题上。这里有一个和 Json 相关的典型场景:如果你开启的 Android 构建里启用了代码混淆(minifyEnabled true),而你用的手写 Json 模型没有配置 keep 规则,混淆后字段名会变成 a、b、c,序列化结果自然就解析不回来了。
解决方法是给存档模型类加 ProGuard 保留规则,或者直接关闭混淆。对绝大多数 Unity 项目来说,客户端本地模型并不需要通过混淆来实现“防止破解”,存档安全得靠服务端和加密,所以直接关掉对 Json 模型的混淆往往是最省事的。如果你确实要做包体混淆,那把需要被 Json 处理的类统一放进一个 keep 列表中,别图省事用通配符保留整个程序集,那样混淆就失去意义了。
5.6 常见问题速查表
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 序列化结果里缺字段 | 类型是 Dictionary/接口/属性 | 改为 List 或具体类 |
| 中英文显示为 \uXXXX | JsonUtility 默认转义 | 不是 bug,照常解析 |
| 文件读出来是乱码 | 读写编码不一致 | 统一用 Encoding.UTF8 |
| 数据全空但没报错 | 字段名大小写不匹配 | 打印原始 JSON 逐字段比对 |
| 真机反序列化全 null | IL2CPP 裁剪 | 添加 link.xml 保留规则 |
| Android 构建后字段变 a/b/c | 代码混淆 | 关闭混淆或添加 keep 规则 |
| 用记事本改 key 就导致存档失效 | 无校验 | 加版本号与哈希校验 |
6. 扩展:从本地 Json 存档到服务端通信的衔接
6.1 为什么本地能用的模型一发到服务器就崩
Json 本地存档和服务器通信,经常出现“我本地明明好端端的,一发 HTTP 请求对方就返回解析失败”的诡异情况。这里面大多数原因是两端的数据模型没有对齐。本地存档只有客户端一个消费方,字段多一点、少一点都能容错;但服务器可能直接做严格解析,要求必填字段齐、类型严格匹配。
我在项目里常用的思路是:把客户端和服务端共用的字段抽成一份“协议模型”,存档模型可以比协议模型多字段,但协议模型里的字段在存档模型里必须存在,类型也必须一致。然后把 Json 序列化和反序列化封装在一个公共库里,两端共用,而不是各自手写模型。这样可以极大降低联调时“字段名不一致”的尴尬。
6.2 配合 UnityWebRequest 的 json 收发模板
这里给一个简单的 UnityWebRequest 发送 JSON 的模板,它能和本地 JsonUtility 模型无缝衔接:
csharp复制using System.Collections;
using UnityEngine;
using UnityEngine.Networking;
using System.Text;
public static class ApiClient
{
public static IEnumerator PostJson<T>(string url, T data, System.Action<string> onSuccess, System.Action<string> onError)
{
string json = JsonUtility.ToJson(data);
using (UnityWebRequest request = new UnityWebRequest(url, UnityWebRequest.kHttpVerbPOST))
{
byte[] bodyRaw = Encoding.UTF8.GetBytes(json);
request.uploadHandler = new UploadHandlerRaw(bodyRaw);
request.downloadHandler = new DownloadHandlerBuffer();
request.SetRequestHeader("Content-Type", "application/json");
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
onSuccess?.Invoke(request.downloadHandler.text);
}
else
{
onError?.Invoke(request.error + "\n" + request.downloadHandler.text);
}
}
}
}
注意 Content-Type 一定要写 application/json,有些服务端会因为 Content-Type 不对而拒绝解析。另一方面,如果后端返回的 JSON 结构比你本地模型多字段,你仍然可以用 JsonUtility.FromJson 接收,多出来的字段会被忽略,这点和本地存档的表现一致。
6.3 存档与热更配置的联动思路
项目做到中后期,策划会希望能动态下发调整某些数值,比如关卡体力消耗、掉落概率。这时候常见做法是:本地初始配置放在 StreamingAssets 里,服务器下发的最新配置用 Json 格式存在 persistentDataPath,启动时先读服务器的,没有服务器版本就回退读本地初始配置。两者都是 Json,结构可以共用一个配置类,从本地读和从网络读只是数据来源不同。
我通常会给这份 Json 配置加一个“配置版本号”字段,用来判断服务器远程配置是否比本地新。这比直接比较文件时间戳可靠得多,因为文件时间戳在跨平台、跨网络环境下非常容易不一致。这样一套组合下来,本地持久化和服务器动态更新就形成了闭环:同一个模型,既承担了存档,也承担了配置热更新。
7. 基于热词再聊几个容易忽视的细节
7.1 Unity 版本升级与 Android API Level 变化带来的 Json 风险
最近很多人在搜“unity 提高 minimum api level target api level 到 api35”,这个变化表面上看和 Json 没关系,但实际升级后,有一些旧版第三方 Json 库在 Android 14 和 15 上可能会出现兼容问题。如果发现升级 API Level 后存档无法写入或读取,先确认是不是目标 SDK 的存储权限策略变了。
Android 高版本对应用目录访问的限制更严格,但只要你的数据写在 persistentDataPath 下,系统会合法处理,不需要额外申请权限。如果你以前把存档写在根目录或公共存储目录,升级后大概率出问题。所以统一走 persistentDataPath 不只是规范问题,也是为高版本系统做准备。
7.2 抖音小游戏等生态里 Json 数据可持续化的变通
热词里出现“unity 抖音 侧边栏 接入流程”,说明不少人在做抖音小游戏。这种小游戏生态和原生 App 有一个明显差异:本地文件读写接口被平台限制,persistentDataPath 的表现也和原生端不完全一样。你不能假设 File.WriteAllText 一定成功,所以小游戏端的存档必须做“多级降级”策略:优先正常文件写入,失败则回退到平台的存储接口或者内存缓存,同时主动上报写入结果到日志。
我做过一次抖音小游戏适配,最终的做法是把写文件抽象成接口,原生版用 File API,小游戏版用平台的存储 Bridge。调用层代码完全不用改,底层实现各自适配。Json 序列化的部分完全复用,因为无论存到哪里,数据结构并没有变。
7.3 Json 与 Unity 热更配合时的坑
热词里有人问“hotfix 是什么东西 在 unity 里面用到的”,这里提一句:热更方案(比如 Lua 或混合 C# 热更)里,Json 数据特别容易成为类型认知的盲区。因为热更代码里定义的类和主工程里的类可能是两份,序列化时用的类型不一致,字段自然匹配不上。
我建议所有跨热更边界的 Json 模型,尽量只使用可序列化字段,不要加复杂的函数逻辑,也不要用多态或者接口。把模型类当作纯数据容器,这样不管是主工程还是热更工程,解析结果都能保持一致。凡是模型上要带方法,就放到另一个工具类里,避免序列化层和逻辑层耦合。
8. 写在最后:我踩过的高频坑和个人建议
如果说要给这篇文章划个重点,我最想强调的不是某个 API 怎么用,而是两件事:一是存档系统一定要有版本号,二是读写的编码和路径必须统一。前一个坑,让我见过策划拿着旧存档去测新版本,结果客户端直接崩溃;后一个坑,让我在繁体系统上排查了整整一个下午的乱码问题。这两件事看起来都很小,但到了线上就是事故级别。
另外一个长期受益的小技巧:开发期给 SaveManager 加一个调试面板,用 GUI 或 UGUI 显示当前存档路径、存档文件大小、最近一次写入时间、版本号。以后不管是自己排查还是让测试同学帮忙,都能一句话说清楚存档状态,省掉大量沟通成本。这个面板不用做得好看,能看和能点就行。
最后说一句关于 Json 数据可持续化的大实话:没有银弹方案,但 Json + persistentDataPath + 版本迁移,是我这几年带过多个项目下来最省心的一套底座。你完全可以在这个底座上继续加加密、加压缩、加云同步、加快照回滚,方向都是对的。剩下的,就是多写、多测、多在真机上跑一跑,存档这东西,谎言在编辑器里是看不出来的。
