在Unity开发里,“数据可持续化”这个词听起来挺唬人的,翻译成人话其实就是:把游戏里要长期保留的数据(存档、设置、排行榜、玩家进度)从内存里搬到硬盘上,下次启动还能读回来。而Json,就是这中间最常用、最顺手、也最值得你花时间搞明白的载体格式。
我做Unity项目这么多年,经手过单机RPG的存档、联网游戏的本地缓存、甚至数字孪生项目里的配置热更新,凡是和数据落地打交道的地方,十有八九最后都回归到Json。你可以不写服务器,可以不接数据库,但只要你做的是单机游戏、工具类App、或者需要离线可用的应用,Json这套组合拳你就躲不开。
这篇文章我不打算给你抄一段官方文档就完事,而是把我自己在实际项目里踩过的坑、试过的方案、优化过的写法,全部摊开来讲。内容适合刚接触Unity、对数据存取还一头雾水的新手,也适合已经会调用PlayerPrefs但想更进一步、把存档系统做得更规范的中级开发者。你把这篇文章看完,应该能做到:用Json写出一个健壮的、可扩展的、抗异常的本机数据持久化层。
1. 内容整体设计与思路拆解
1.1 为什么是Json,而不是PlayerPrefs、XML或二进制
很多人一上手Unity,第一个接触的持久化API是PlayerPrefs。它简单啊,一行代码存一个值,再一行代码读回来。但它的局限性你在项目稍微大一点之后就会立刻感受到:它本质是个键值对存储,你很难把一个完整的、嵌套的、带数组的对象结构直接丢进去。就算你硬塞,也得自己拼字符串、自己解析,绕一大圈回到原点。
XML是严肃的老牌格式,它的优点是严谨、有Schema校验,但缺点也很直接:标签冗余太多了。同样一条数据,XML写出来占用空间大概是Json的两倍甚至更多,解析性能也差一些,尤其在移动端,这种差距会被放大到肉眼可见的程度。
二进制最快、最省空间,但你得自己处理字节序、版本兼容、字段增删,Unity原生的BinaryFormatter在跨平台、跨版本时经常抽风,而且在一些平台上因为安全限制根本不能用。
Json站在了这几者的中间点:可读性好、体积适中、解析速度快、生态成熟。人类能看懂,程序也好处理,配合各种序列化库,一条Java对象或C#类几行代码就能完整映射到文本。对Unity这种需要频繁调试、快速迭代的开发环境来说,Json就是性价比最高的选择。
1.2 Unity里序列化方案的选型逻辑
提到C#的Json库,最有名的三个是:JsonUtility(Unity自带)、LitJson(轻量开源)、Newtonsoft.Json(.NET界的国民库)。很多新手上来就纠结用哪个,其实不用纠结,先理解它们的底层差异,再根据你的场景选。
JsonUtility是Unity官方封装的,接口最简单:JsonUtility.ToJson()和JsonUtility.FromJson()两个方法走天下。但它的限制极其明显——只能序列化[Serializable]标记的类、结构体、List、数组,不支持字典Dictionary,不支持多态,不支持属性(Property),对enum的支持也很弱。你做个背包系统,想存个Dictionary<int, Item>,用JsonUtility直接给你白屏报错。
LitJson是早年从Unity社区火起来的,支持字典、支持属性、支持自定义转换器,性能也不错。但问题是它已经很多年不怎么更新了,对C#新特性、Nullable类型、System.Text.Json风格的属性命名支持都比较老,用起来总有点“上一个时代”的别扭感。
Newtonsoft.Json(通常也叫Json.NET)是功能最全面的,几乎能序列化任何东西:字典、多态、匿名对象、DateTime、枚举字符串化、自定义ContractResolver,没有它搞不定的。Unity官方早在2017版本之后就在UWP、IL2CPP后端里内置了它的一部分能力,而且通过官方包com.unity.nuget.newtonsoft-json可以直接引入完整版。唯一的所谓“缺点”是它比较重、反射用得比较多,在某些对AOT裁剪特别极端的平台需要配置,但这在绝大多数项目里都不构成问题。
我的结论很直接:新项目一律用Newtonsoft.Json,没有例外。如果你的项目真的因为包体紧张、环境受限只能用JsonUtility,那我会在后面给你一套绕过它限制的封装思路。
1.3 数据可持续化整体架构:不只是一个文件读写那么简单
很多教程教你的做法是:把Player数据类,Save()的时候File.WriteAllText(path, JsonConvert.SerializeObject(player)),Load()的时候反过来。这种写法不是不能用,但它把所有逻辑都堆在业务层里,一旦数据复杂度上来,你会陷入三个泥潭:
- 耦合:游戏逻辑到处直接读写文件,IO调度混乱,异步和同步混着用,卡帧卡成PPT。
- 不可维护:字段一多,Json结构就乱,每个模块都想往存档里塞自己的数据,结果就是存档文件越来越大,互相覆盖。
- 不健壮:如果Json文件因为意外被写坏了一半,或者版本升级后格式变了,要么读不出来,要么读出来全是默认值,玩家的进度说丢就丢。
正确的思路是:把持久化抽象成一层独立的“存档服务”。业务模块只管把自己的数据对象传给服务层,服务层负责序列化、加密、文件IO、版本迁移、容错恢复。这也是我下面要重点演示的内容,你跟着做一遍,以后任何项目都能直接套用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 Unity中访问文件系统的正确姿势:路径选择
这一步很多人栽过跟头。你在Unity编辑器里写File.WriteAllText("save.json", data),看到文件出现在工程目录下,觉得挺正常。但你打到手机上试试,要么直接IOException,要么就是文件存在但你找不到它在哪。
原因在于:PC端你可以任意写文件,但移动端、WebGL端出于安全沙箱策略,只有特定目录可以写。Unity给你封装了两个关键的路径常量:
Application.dataPath:指向游戏安装目录。在PC上可以写,但在移动端它是只读的,你往这写必报错。Application.persistentDataPath:指向平台分配的可持久化存储目录,这是跨平台存档的标准选择。iOS、Android、Windows、Mac各不一样,但Unity都帮你处理好了。
我在项目里一般这样封装路径:
csharp复制public static class PathHelper
{
public static string GetSavePath(string fileName)
{
var dir = Path.Combine(Application.persistentDataPath, "SaveData");
if (!Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
return Path.Combine(dir, fileName);
}
}
顺手提一句:别把临时缓存和正式存档混在同一个目录,主存档建议放在persistentDataPath根目录的独立文件夹里,临时缓存丢Application.temporaryCachePath,这样玩家清理缓存时不至于把你的档也清了。
2.2 Newtonsoft.Json在Unity中的安装与兼容配置
用老办法的话,你得去官网下个DLL拖进Plugins文件夹,再加上一堆平台宏控制,麻烦不说,还容易踩IL2CPP的AOT裁剪坑。现在官方出了UPM包,一行搞定:在Packages/manifest.json里加一句依赖:
json复制{
"dependencies": {
"com.unity.nuget.newtonsoft-json": "3.2.1"
}
}
装好之后,你在代码里using Newtonsoft.Json;就能用了。
这里有个很关键的配置提示:如果你的项目开启了IL2CPP,建议在Player Settings的Scripting Define Symbols里加上NEWTONSOFT_JSON,然后去Package的link.xml或者你自己的link.xml里保留Newtonsoft的反射元数据,不然发布后某些类会被裁剪掉,运行时反序列化静默失败——这个问题我第4节会细讲排查方法。
2.3 序列化对象的建模规范:从“所有字段public”开始避免踩坑
Json序列化的本质是把一个内存对象的字段状态,映射成文本。所以你的数据类长什么样,直接决定存档好不好读、乱不乱、能不能扩展。
我见过太多新人直接这么写:
csharp复制public class PlayerData
{
public int hp;
public string name;
public int level;
}
不是说不能这么写,但这样写有几个隐患:字段全是public、类内部没有任何逻辑、后续改字段名时所有地方都要跟着改。更好的建模方式是这样:
csharp复制[Serializable]
public class PlayerData
{
[JsonProperty("name")]
public string Name { get; set; }
[JsonProperty("hp")]
public int Hp { get; set; }
[JsonProperty("level")]
public int Level { get; set; }
[JsonProperty("inventory")]
public List<ItemData> Inventory { get; set; }
}
用属性的好处是:你可以在set访问器里加校验,可以控制哪些字段该暴露给外界,JsonProperty特性可以帮你在字段改名时维持存档兼容(旧的存档里存的还是"hp",你的代码里关注的却是新名字,Newtonsoft会按特性映射,老档就不会崩)。
另一个建议是:存档数据类不要直接混入MonoBehaviour逻辑,它应该是一个纯粹的POCO类(Plain Old C# Object),只保存数据,不持有任何引擎对象。凡是牵扯到Transform、GameObject、Texture这种东西的,都要拆成ID、路径、坐标等可序列化字段,在加载时再重建对应引擎对象。
2.4 字典、数组、枚举这些特殊类型的处理
刚才提到JsonUtility不支持字典,那在Newtonsoft里就完全不是事,它原生就能序列化:
csharp复制[JsonProperty("skillConfig")]
public Dictionary<string, SkillData> SkillConfig { get; set; }
它会序列化成类似这样的Json:
json复制"skillConfig": {
"fireball": { "damage": 50, "manaCost": 20 },
"icebolt": { "damage": 30, "manaCost": 15 }
}
但有一个坑你得知道:字典的键会被强制转成字符串。如果你的键是一个自定义类,Newtonsoft会用它的ToString()来序列化,但反序列化时不保证能原样还原。所以我的建议是:字典的键尽量用string、int、enum等基础类型,复杂键自己转成字符串再存。
枚举类型的处理也值得说。默认情况下,Newtonsoft会把枚举序列化成整数,比如ElementType.Fire如果是0,存档里就是"elementType": 0。但这样的存档可读性很差,而且你在枚举中间插一个新值,所有索引全乱。更合理的做法是存字符串:
csharp复制[JsonProperty("elementType")]
[JsonConverter(typeof(StringEnumConverter))]
public ElementType ElementType { get; set; }
加了StringEnumConverter之后,存档里就是"elementType": "Fire",一眼看懂,插入新枚举值也不影响老档。
2.5 关于Unity自带JsonUtility的“补救方案”
我不遗余力安利Newtonsoft,但如果你是那种“项目已经用了JsonUtility,不想换”的情况,给你一套紧急补救的写法:用JsonUtility序列化容器类,内部字段换成可序列化的数组来模拟字典。
比如你要存一个字典:
csharp复制[Serializable]
public class InventorySaveData
{
public string[] keys;
public ItemData[] values;
public Dictionary<string, ItemData> ToDictionary()
{
var dict = new Dictionary<string, ItemData>();
for (int i = 0; i < keys.Length; i++)
{
dict[keys[i]] = values[i];
}
return dict;
}
}
这当然是种很笨的办法,但至少能让项目在现有技术栈下运转起来。如果你不是被JsonUtility困死的,我还是那句话:早点切换,长痛不如短痛。
3. 实操过程与核心环节实现
3.1 搭建存档服务:从零开始的一次完整实现
我现在带着你,从零到一写一个可以直接复制到项目里用的存档服务。这个服务框架我在三个商业项目里验证过,稳定性和扩展性都经过了实战检验。
先定义接口:
csharp复制public interface IDataService
{
void Save<T>(string fileName, T data);
T Load<T>(string fileName, T fallbackData = default);
bool Exists(string fileName);
void Delete(string fileName);
}
接口存在的意义是:业务层只依赖这个抽象,不关心底层是Json还是别的格式。将来你想换成Protobuf、MessagePack、甚至加密后的Json,只需要再实现一个IDataService,业务层一行不用改。
然后实现Json版本:
csharp复制public class JsonDataService : IDataService
{
private readonly string _saveDir;
public JsonDataService(string saveDir = null)
{
_saveDir = saveDir ?? Path.Combine(Application.persistentDataPath, "SaveData");
if (!Directory.Exists(_saveDir))
{
Directory.CreateDirectory(_saveDir);
}
}
public void Save<T>(string fileName, T data)
{
var path = Path.Combine(_saveDir, fileName);
try
{
var settings = new JsonSerializerSettings
{
Formatting = Formatting.Indented,
NullValueHandling = NullValueHandling.Ignore
};
var json = JsonConvert.SerializeObject(data, settings);
File.WriteAllText(path, json);
}
catch (Exception e)
{
Debug.LogError($"存档失败: {fileName}, 错误: {e.Message}");
}
}
public T Load<T>(string fileName, T fallbackData = default)
{
var path = Path.Combine(_saveDir, fileName);
if (!File.Exists(path))
{
return fallbackData;
}
try
{
var json = File.ReadAllText(path);
return JsonConvert.DeserializeObject<T>(json);
}
catch (Exception e)
{
Debug.LogError($"读档失败: {fileName}, 错误: {e.Message}");
return fallbackData;
}
}
public bool Exists(string fileName)
{
return File.Exists(Path.Combine(_saveDir, fileName));
}
public void Delete(string fileName)
{
var path = Path.Combine(_saveDir, fileName);
if (File.Exists(path))
{
File.Delete(path);
}
}
}
注意我在这里加了一个JsonSerializerSettings:Formatting.Indented让存档有换行缩进,出问题时你能直接打开文件拿文本编辑器查错;NullValueHandling.Ignore把空字段跳掉,存档体积小一些。
这套服务用起来是什么感觉呢?比如游戏主模块有一个存档对象:
csharp复制public class GameSaveData
{
[JsonProperty("player")]
public PlayerData Player { get; set; }
[JsonProperty("world")]
public WorldData World { get; set; }
[JsonProperty("settings")]
public SettingsData Settings { get; set; }
}
你要存档,三行代码:
csharp复制var service = new JsonDataService();
var saveData = new GameSaveData
{
Player = currentPlayer,
World = currentWorld,
Settings = currentSettings
};
service.Save("game_save.json", saveData);
读档也只需三行,加载失败自动回退到默认值:
csharp复制var saveData = service.Load<GameSaveData>("game_save.json", new GameSaveData());
3.2 异步读写与防止卡顿:移动端存档的必由之路
上面这个服务是同步读写的,在PC上无所谓,但你在手机上做存档的时候,大Json的序列化加磁盘写入是有可能造成主线程卡顿的,表现就是玩家点存档按钮之后画面顿一下。存档量小的时候还好,等你做开放世界,存档数据几千条任务、几百个NPC状态,同步写个一两百毫秒很正常。
解决方案是异步化。Unity 2020之后,C#的async/await能配合Task.Run在后台线程做IO。我的建议设计是这样的:
csharp复制public async Task SaveAsync<T>(string fileName, T data)
{
var json = await Task.Run(() => JsonConvert.SerializeObject(data));
var path = Path.Combine(_saveDir, fileName);
await File.WriteAllTextAsync(path, json);
}
但有一个Unity的老大难问题:Unity API不能在非主线程调用。你的序列化对象如果里面引用了UnityEngine.Object类型,在后台线程序列化时可能直接抛异常。所以我的方案是:确保序列化对象是纯POCO(这一点我们在建模规范里已经落实了),然后放心大胆地用Task.Run跑。
写完之后,业务层调用变成:
csharp复制public async void OnSaveButtonClicked()
{
await dataService.SaveAsync("game_save.json", saveData);
Debug.Log("存档完成");
}
async void在日常业务中尽量少用,但作为UI事件回调它是可接受的。如果需要更好的异常处理,可以改成async Task再包一层。
3.3 数据校验与版本迁移:让老玩家无损升到新版本
游戏一迭代,存档结构就变。你今天加了一个字段,明天改了一个属性名,后天把某个旧字段删了——如果不做版本兼容,每个版本升级都是一场灾难:老玩家更新后打开游戏,存档读取失败,从头再来,怒删差评,一套连招。
成熟的方案是:存档里永远带一个version字段,读档时根据版本号做迁移。
csharp复制public class SaveFileContainer
{
[JsonProperty("version")]
public int Version { get; set; }
[JsonProperty("timestamp")]
public long Timestamp { get; set; }
[JsonProperty("data")]
public JObject Data { get; set; }
}
data字段用JObject而不是强类型对象,这样读档时先拿到原始结构,再按版本走迁移逻辑:
csharp复制public GameSaveData LoadWithMigration()
{
var container = service.Load<SaveFileContainer>("game_save.json", null);
if (container == null)
{
return new GameSaveData();
}
var currentVersion = container.Version;
var jsonData = container.Data;
while (currentVersion < LATEST_SAVE_VERSION)
{
jsonData = MigrationRunner.Migrate(currentVersion, jsonData);
currentVersion++;
}
return jsonData.ToObject<GameSaveData>();
}
每个版本的迁移逻辑单独写一个函数,比如“从v1到v2,给所有装备加上品质字段,默认值为白装”,这样一个一个跳板式迁移,不跳跃、不混乱。等到某一天老版本玩家彻底清零了,再删掉中间的迁移代码,只保留最新版本格式。
3.4 加密与防修改:该不该做、怎么做
单机游戏里,很多开发者想知道怎么防止玩家改存档。我的态度是:别把精力花在“完全防住”上,你防不住,但你可以做到让改档变得不值得。
首选方案是Base64加混淆。Base64不算加密,但它能让玩家用普通文本编辑器打开存档时,看到的不是明文Json,从而打消一大部分顺手改档的玩家。
csharp复制public class ObfuscatedJsonDataService : IDataService
{
public void Save<T>(string fileName, T data)
{
var json = JsonConvert.SerializeObject(data);
var bytes = Encoding.UTF8.GetBytes(json);
// 这里做一个简单的异或混淆,密钥固定
for (int i = 0; i < bytes.Length; i++)
{
bytes[i] ^= OBFUSCATION_KEY[(i % OBFUSCATION_KEY.Length)];
}
File.WriteAllBytes(Path.Combine(_saveDir, fileName), bytes);
}
public T Load<T>(string fileName, T fallbackData = default)
{
var path = Path.Combine(_saveDir, fileName);
if (!File.Exists(path)) return fallbackData;
var bytes = File.ReadAllBytes(path);
for (int i = 0; i < bytes.Length; i++)
{
bytes[i] ^= OBFUSCATION_KEY[(i % OBFUSCATION_KEY.Length)];
}
var json = Encoding.UTF8.GetString(bytes);
return JsonConvert.DeserializeObject<T>(json);
}
}
如果你要真正的加密(比如担心玩家用修改器扫描内存、或者存档涉及云端同步需要防篡改),那要上AES对称加密,配合HMAC签名做完整性校验。但这套体系做起来很重,而且密钥终究存客户端里,逆向玩家花点时间照样能破解。对绝大多数Unity项目来说,异或混淆者配合服务端校验,已经足矣。
3.5 自动存档与热更新配置:Json在运行时数据管理中的更多用法
数据持久化不只是“游戏存档”。在Unity开发里,Json还有一类高频用途:作为配置表的热更新载体。
我做数字孪生和工具类应用时,经常把一些业务配置(比如设备点位表、颜色映射表、告警阈值表)放在StreamingAssets或远程服务器上,运行时下载或读取Json配置,动态加载。这种做法的好处是:改配置不需要重新出包,运营和策划拿到Json文件就能调数值。
比如这样一个设备配置Json:
json复制[
{ "deviceId": "sensor_001", "name": "温度传感器", "unit": "°C", "alarmThreshold": 80 },
{ "deviceId": "sensor_002", "name": "湿度传感器", "unit": "%", "alarmThreshold": 70 }
]
在Unity里读取远程配置的标准姿势是UnityWebRequest:
csharp复制private IEnumerator LoadRemoteConfig(string url, Action<List<DeviceConfig>> callback)
{
using (var request = UnityWebRequest.Get(url))
{
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
var json = request.downloadHandler.text;
var configs = JsonConvert.DeserializeObject<List<DeviceConfig>>(json);
callback?.Invoke(configs);
}
else
{
Debug.LogError($"加载远程配置失败: {request.error}");
}
}
}
这种“业务数据与代码分离”的模式,是Data-Driven Development的基石,也是Json在Unity里除了存档之外的第二大应用场景。你如果以后去面中大型项目的Unity岗,面试官大概率会问你配置热更新的方案,这一套讲下来,比背八股文管用得多。
4. 常见问题与排查技巧实录
4.1 反序列化静默失败:字段全是默认值,不报错
这是Newtonsoft.Json用户最容易踩的坑。你写了个类,里面字段名比如_playerName,然后Json文件里是"playerName"。反序列化居然不报错,但就是所有值都是null、0、false。
原因在于:Newtonsoft默认的匹配逻辑是对大小写不敏感的,但对下划线、连字符这些并不完全宽容。_playerName和playerName在默认匹配规则下对不上,Newtonsoft又不会因为你某个字段没匹配上就抛异常,它选择静默跳过。
排查思路很简单:反序列化之后立刻给关键字段做校验,不为空再继续。测试时用Debug打印一遍关键存档数据,一眼就能看出哪块是空的。还有一个杀手锏做法:在JsonSerializerSettings里设置MissingMemberHandling = MissingMemberHandling.Error,让字段对不上时直接抛异常,宁可让错误暴露出来,也不能让它在潜伏状态下毁掉玩家存档。
4.2 字典序列化后变成数组,读回来不是字典
这个问题如果你用Newtonsoft.Json而不小心用了Unity的JsonUtility就会出现。JsonUtility的文档明确写了不支持字典,但报错往往不是“不支持”三个字,而是序列化出来的Json长得像数组,或者直接给你一个[]。这就很迷惑。
我的建议还是回归到2.5节说的:要么换Newtonsoft,要么自己写转换方法。如果你在社区看到有人说“JsonUtility能存字典啊”,多半是他自己封装过了,没告诉你底层是怎么处理的。
4.3 IL2CPP裁剪导致的反序列化异常
你编辑器里跑得好好的,包到Android真机上一加载存档就报错,最常见的异常是MethodAccessException或SerializationException,提示无法找到某个类型或构造函数。
这个坑的根源是IL2CPP的代码裁剪(code stripping)。发布时Unity会把没有直接引用的类型和成员剔掉,而Newtonsoft用的是反射来创建对象——反射找得到、裁剪却已经剪掉了,运行时就炸了。
解决手段有几层,按推荐顺序排:
- 编写
link.xml,把用到的存档类型显式保留:
xml复制<linker>
<assembly fullname="Assembly-CSharp">
<type fullname="MyGame.Data.PlayerData" preserve="all" />
<type fullname="MyGame.Data.WorldData" preserve="all" />
</assembly>
<assembly fullname="Newtonsoft.Json">
<type fullname="Newtonsoft.Json.*" preserve="all" />
</assembly>
</linker>
- 在序列化核心类型上标记
[Preserve]特性,让Unity不裁剪:
csharp复制[Preserve]
public class PlayerData { ... }
- 实在不行,就换用支持AOT的序列化方案(比如MessagePack的
[MessagePackObject]模式),但这是后话,大多数项目靠前两步就解决了。
4.4 存档文件写入一半,进程被杀死,文件损坏
移动端最烦的场景:玩家正在存档,系统来电话,应用被杀,写了一半的Json留在磁盘上,下次启动读取直接崩。我的标准做法是**“临时文件+原子替换”**:
csharp复制public void SaveAtomic<T>(string fileName, T data)
{
var finalPath = Path.Combine(_saveDir, fileName);
var tempPath = finalPath + ".tmp";
var json = JsonConvert.SerializeObject(data);
File.WriteAllText(tempPath, json);
File.Delete(finalPath);
File.Move(tempPath, finalPath);
}
先写.tmp文件,写完之后再替换正式文件。这样就算写一半被杀,最多丢一个.tmp,正式存档还是上一次完整状态。Load端也可以做一次兜底:如果正式文件不存在但.tmp存在,说明上次替换没完成,可以尝试读.tmp;如果.tmp也坏了,就用fallbackData。
4.5 大存档加载卡顿、内存暴涨
早期游戏存档动辄几MB,现在很多联网游戏本地存档不大,但如果你做的是编辑器工具、地图编辑器、认知训练类应用,Json里塞了大量坐标点、路径数据,一个存档上百MB都是可能的。
这时候几个优化手段按性价比排序:
- 压缩:序列化后用
GZipStream压缩,Json的重复度很高,随便压一压体积能缩到十分之一。压缩后读出来的字节流先解压再反序列化。 - 精简字段:使用
JsonProperty的NullValueHandling、或者写自定义JsonConverter,把可以推算的字段去掉,别为了调试方便把大量临时数据全存进去。 - 移动端分块存储:把大Json拆成多个文件,玩家数据、世界数据、设置数据分开,每块只加载需要的那部分。这其实也是提升架构清晰度的一步,建议所有项目都直接这么做,别等大了才拆。
4.6 不同平台路径差异导致的读写失败
我把常见平台的路径差异整理成一个表,你直接照着用就不会出错:
| 平台 | persistentDataPath 典型位置 | 注意事项 |
|---|---|---|
| Windows | C:/Users/用户名/AppData/LocalLow/公司名/产品名 | 路径含用户名,不要硬编码 |
| macOS | /Users/用户名/Library/Application Support/公司名/产品名 | 同上 |
| Android | /storage/emulated/0/Android/data/包名/files | 导出后在电脑上可能看不见,需要adb辅助 |
| iOS | App沙盒/Library/Application Support | 会自动备份到iCloud,需要谨慎处理 |
| WebGL | 浏览器IndexedDB虚拟文件系统 | 不支持同步IO,必须走Unity的WebGL文件系统接口 |
刚开始做多平台支持时,我犯过一个特别蠢的错误:在Windows上调试用自己的路径拼接,没走Application.persistentDataPath,结果程序换个电脑存档就找不到了。记住一条铁律:存档路径一律通过Application.persistentDataPath获取,不要自己拼绝对路径。
4.7 编辑器与真机行为不一致
Application.persistentDataPath在编辑器下是项目目录旁边的一个固定文件夹,你在编辑器里存档、读档都没问题,但发到真机上路径完全变了。调试时我建议做一层抽象,编辑器模式下可以额外把存档复制一份到Application.dataPath下,方便你直接查看;但这只是开发辅助,发布时务必关掉。
另一个常见的不一致是文件编码:Windows默认可能用带BOM的UTF-8,读Json时如果编码不对,第一个字符会多一个不可见字符导致JSON解析失败。稳妥起见,写文件时显式指定无BOM的UTF-8:
csharp复制File.WriteAllText(path, json, new System.Text.UTF8Encoding(false));
5. 高级场景与实战扩展
5.1 存档加一个“元信息”头:不止是数据,还要能展示
游戏主界面经常要做“继续游戏”的功能,需要显示:当前存到第几关、人物等级、总游戏时长、上次存档时间。你当然可以把整个存档全读一遍,提取这几个字段,但那样又慢又浪费。
更优雅的方案是在存档文件里同时保存一个轻量的元信息头,放在Json文件的固定位置。你甚至在文件流层面做文章:让元信息在文件头部的固定字节区间,这样读取时只需要打开文件流读前面几百字节,就能拿到展示信息,不触发整个反序列化流程。
比如:
json复制{
"meta": {
"version": 3,
"timestamp": 1735689600000,
"sceneId": "chapter2",
"playDurationSeconds": 3600,
"level": 12
},
"data": { ... }
}
然后在“继续游戏”的UI上,你只加载meta部分:
csharp复制public SaveMetaInfo ReadMeta(string fileName)
{
var path = Path.Combine(_saveDir, fileName);
using (var reader = new StreamReader(path))
{
// 假设meta总是在Json开头一段较短范围内
char[] buffer = new char[512];
int read = reader.ReadBlock(buffer, 0, buffer.Length);
var prefix = new string(buffer, 0, read);
var obj = JObject.Parse(prefix);
return obj["meta"].ToObject<SaveMetaInfo>();
}
}
你注意,这里只读了512个字符,还没有把整个Json解析出来,性能开销几乎可以忽略。
5.2 Json与ScriptableObject配合:配置编辑与运行时加载的桥梁
在Unity编辑器里,策划调数值最顺手的工具是ScriptableObject资源,但ScriptableObject生成的是.asset文件,没法在外部编辑,也不适合运行时从远端更新。而Json配置正好能补上这块短板。
我经常做的方案是:编辑器内用ScriptableObject编辑配置清单,点一个按钮导出成Json;运行时加载器读取这份Json,映射成普通C#配置对象。这样既能享受编辑器内的类型安全和拖拽便利,又能获得运行时热更新的灵活性。
导出配置的编辑器脚本大概是:
csharp复制[CustomEditor(typeof(WeaponConfigCollection))]
public class WeaponConfigCollectionEditor : Editor
{
public override void OnInspectorGUI()
{
DrawDefaultInspector();
if (GUILayout.Button("导出为Json"))
{
var collection = (WeaponConfigCollection)target;
var json = JsonConvert.SerializeObject(collection.Items, Formatting.Indented);
var path = Path.Combine(Application.dataPath, "StreamingAssets", "Configs", "weapons.json");
File.WriteAllText(path, json, new System.Text.UTF8Encoding(false));
AssetDatabase.Refresh();
Debug.Log($"已导出 {collection.Items.Count} 条武器配置 -> {path}");
}
}
}
这个工作流实测下来很顺:策划在编辑器里改完,点一下导出,出包时Json进StreamingAssets或者服务器,客户端启动时加载到内存。改数值不用重新出程序包,只要替换Json文件,玩家重新联网获取新配置即可。
5.3 JSON Schema校验:上线前的最后一道闸
每次发版最怕什么?配置Json里一个字段拼错了,或者类型写错了,线上运行直接逻辑爆炸。如果只靠人肉眼检查几万行配置Json,早晚得出事。
解决方案是引入Json Schema校验。你定义一份Schema描述“这个Json合法应该长什么样”,然后上线前用校验库检查所有配置。C#这边可以用Newtonsoft.Json.Schema(商业授权)或者NJsonSchema(开源)来做。
Schema的大概长这样:
json复制{
"type": "object",
"properties": {
"deviceId": { "type": "string", "pattern": "^sensor_[0-9]+$" },
"name": { "type": "string", "minLength": 1 },
"unit": { "type": "string", "enum": ["°C", "°F", "%", "kPa"] },
"alarmThreshold": { "type": "number", "minimum": 0, "maximum": 1000 }
},
"required": ["deviceId", "name", "unit", "alarmThreshold"]
}
校验代码:
csharp复制public static bool ValidateJson<T>(string json, string schemaJson, out string error)
{
var schema = JsonSchema.FromJson(schemaJson);
var obj = JObject.Parse(json);
if (obj.IsValid(schema, out IList<string> errors))
{
error = string.Empty;
return true;
}
error = string.Join("; ", errors);
return false;
}
结合CI/CD流程,把Schema校验放进提交前的检查脚本里,配置文件的低级错误基本上就能在源头截住。
5.4 Unity 2026与Json趋势展望
聊到2026年,Unity的序列化生态已经有了明显变化。官方在持续加强Unity.Properties和UI Toolkit绑定层中的序列化能力,但Json作为通用数据交换格式的地位没有动摇。移动端IL2CPP对反射的约束仍然存在,所以Newtonsoft.Json和System.Text.Json这类库都在向Source Generator方向演进——也就是在编译期生成序列化代码,运行时少反射。
Unity开发者关注的点应该是:如果你维护长期项目,多留一分心思把序列化层抽象好。今天我教你的IDataService接口,将来无论底层换成哪个Json库,你的业务层都不用动。
另外,Pico4开发、Unity数字孪生这类项目,Json依然是设备数据、点位映射、场景配置的主选格式。特别是做数字孪生的时候,后端IoT平台下来的数据大多是Json,你在Unity里要做的基本就是那套“Json反转C#对象——更新场景状态”的循环。这一套基础打牢了,做啥项目都不虚。
5.5 从Json到更快的数据:什么时候该考虑二进制序列化
最后我泼一盆冷水:Json不是万能的。当你做到某些极端场景——比如每帧要存/读几千个位置的回放数据、或者做网络同步用的快照,Json的文本解析开销就会变成瓶颈。
我的经验阈值是这样的:单个文件超过1MB、读写频率超过每秒一次、单帧解析超过几毫秒,这时候就要考虑切换到二进制序列化方案了。常见的替代品有MessagePack(紧凑的二进制Json风格)、Protobuf(强类型、跨语言)、或者自己手写二进制布局。
但这里有个关键提醒:别为了性能过早放弃Json。你在项目早期用Json,足够灵活、足够直观,等真的在Profiler里看到Json解析成了热点,再针对性地把那几个模块替换成二进制,整体架构不变,风险也小。这比我一开始就让你上Protocol Buffers要靠谱得多——毕竟很多项目根本没活到需要二进制优化那天,就已经因为太复杂而死在路上了。
写在最后
做Unity数据持久化,Json这套东西说简单也简单,说复杂也复杂。简单的是,你记住三句话就能上手:用Newtonsoft、走persistentDataPath、封装成服务。复杂的是,在实际项目里它会牵扯到路径、版本、平台、性能、安全、多线程,哪个环节没想清楚,玩家和测试就会从各种意想不到的地方帮你找出问题。
我个人经历了从JsonUtility到LitJson再到Newtonsoft的兜兜转转,最后固定下来这套方案之后,后面每个项目的数据层几乎没再返工过。希望这篇文章里沉淀的思路和代码框架,也能让你的存档系统从“能跑”走向“抗造”。下次你在做新项目的存档模块时,可以试着把我这套服务类直接抄进去用,跑一两个版本之后你就知道,前期多花一小时把基础打牢,后面能帮你省下多少个熬夜排查存档丢失的晚上。
