1. 为什么本地JSON文件在Unity项目里这么重要
做Unity项目这些年,我越来越觉得本地JSON文件是那种"平时不起眼,关键时刻离不开"的东西。不管是做单机游戏、工具类应用,还是需要保存玩家进度、配置关卡数据、记录设置选项,JSON都是最顺手的方案之一。它不像ScriptableObject那样绑定编辑器,也不像PlayerPrefs那样只能存简单键值对,更不像数据库那样需要额外依赖。一个文本文件,结构清晰,人能读能改,程序也能解析,跨平台也没什么坑。
这篇文章我想把Unity里本地JSON的创建、编辑和读取这三件事彻底讲透。所谓创建,就是在运行时或者编辑器里生成一个JSON文件;编辑,是指怎么安全地修改里面的数据,包括在代码里改和手动改两种场景;读取,则是把文件内容解析成C#对象供逻辑使用。这三件事串起来,基本覆盖了本地数据持久化的完整链路。
适合谁看?如果你刚开始接触Unity的数据存储,或者之前一直用PlayerPrefs凑合、现在数据变复杂了想换方案,那这篇正好。如果你已经用过JsonUtility但总踩坑,比如数组读不出来、字段丢失、中文乱码,那这篇也能帮你把坑填上。我会从方案选型讲到具体代码,再到实际项目里遇到的奇葩问题和排查思路,尽量做到你照着抄就能跑。
需要提前说明的是,Unity里处理JSON有好几种路子,内置的JsonUtility、第三方库Newtonsoft.Json(也就是常说的Json.NET)、还有LitJson等等。它们各有各的脾气,选错了后面会很难受。我会在第一节先把选型逻辑讲清楚,后面所有实操都基于选定的方案展开,避免你看到一半发现方向不对。
另外强调一点,本地JSON文件在不同平台上的路径规则差别很大。PC上能直接写文件的地方,到了移动端可能根本没有写权限;编辑器里跑得好好的路径,打包之后直接报错。这些平台差异是新手最容易翻车的地方,我会单独用一节来讲路径选择和权限问题,把各平台的正确写法列出来。
2. 方案选型:JsonUtility、Newtonsoft还是自己写
2.1 三种主流方案的横向对比
在动手写代码之前,选型这一步不能省。我见过太多项目一开始随手用了某个库,结果数据一复杂就各种限制,最后不得不重构。下面这张表是我根据实际使用经验整理的对比,你可以对照自己的需求来选。
| 对比维度 | JsonUtility | Newtonsoft.Json | LitJson |
|---|---|---|---|
| 来源 | Unity内置 | 第三方开源库 | 第三方开源库 |
| 是否需要导入 | 不需要 | 需要导入DLL或UPM包 | 需要导入源码或DLL |
| 支持字典 | 不支持 | 支持 | 支持 |
| 支持数组/列表 | 支持(需包装类) | 原生支持 | 原生支持 |
| 支持null | 有限支持 | 完整支持 | 支持 |
| 性能 | 高 | 中等 | 中等 |
| 包体影响 | 无 | 约几百KB | 较小 |
| 学习成本 | 低 | 中 | 低 |
| 复杂嵌套 | 一般 | 强 | 一般 |
JsonUtility最大的优势是零依赖、性能好,因为它是Unity底层用C++实现的。但它的限制也很明显:不能直接序列化字典,不能序列化顶层数组,字段必须是public或者带SerializeField特性,而且对null的处理比较粗糙。如果你的数据结构简单,就是几个字段加一个列表,JsonUtility完全够用。
Newtonsoft.Json功能最全,字典、嵌套、多态、自定义转换器都支持,社区资料也多。代价是要额外导入,包体会增加,而且在某些平台上反射性能需要注意。LitJson介于两者之间,轻量但功能比JsonUtility强一些,不过维护活跃度不如Newtonsoft。
2.2 我的选型建议和理由
基于这些年的项目经验,我给的建议是这样的:如果只是存个设置、存档这种结构固定的数据,直接用JsonUtility,省事又高效,不用引入任何外部依赖。如果数据里有字典、需要动态结构、或者要和后端接口对接复杂的JSON,那就上Newtonsoft.Json,别犹豫,功能差距值那点包体。
我自己的习惯是,中小型单机项目一律JsonUtility起步,等到确实遇到它搞不定的场景再换。因为换库的成本主要在改序列化代码,如果一开始数据结构设计得干净,后面替换也不会太痛苦。反过来,如果一上来就上重型库,很多时候是杀鸡用牛刀,还增加了构建复杂度。
这里有个细节要提醒:JsonUtility序列化时,只有public字段和标记了[SerializeField]的private字段会被处理,属性(property)不会被序列化。这一点和Newtonsoft不一样,Newtonsoft默认会处理public属性。很多人从Newtonsoft切到JsonUtility时会发现数据丢了,原因就在这里。
注意:JsonUtility无法序列化顶层数组或列表,比如你直接传一个List 进去会报错。解决办法是包一层类,把列表作为字段放进去。这个坑我踩过不止一次。
3. 数据模型设计:先想清楚再写代码
3.1 用类还是用结构体
数据模型这块,我强烈建议用普通的class,而不是struct。原因有几个:class是引用类型,序列化库处理起来更自然;class支持继承和多态(虽然JsonUtility对多态支持有限);class默认可以为null,方便判断数据是否存在。struct是值类型,序列化时容易出一些意料之外的行为,尤其是嵌套struct的时候。
字段命名上,建议和JSON里的key保持一致,避免额外的映射配置。C#字段习惯用驼峰或帕斯卡命名,JSON里也保持一致就行。如果你要和后端对接,后端返回的是下划线命名,那要么改后端,要么在模型里用特性做映射(Newtonsoft支持[JsonProperty])。
3.2 一个可复用的存档数据模型示例
下面这个模型是我在一个模拟项目里用的,包含玩家基础信息、关卡进度、设置项三部分,结构比较典型,你可以参考这个思路设计自己的模型。
using System; using System.Collections.Generic; using UnityEngine; [Serializable] public class SaveData { public string playerName; public int playerLevel; public float playTime; public long lastSaveTimestamp; public List<LevelProgress> levelProgressList = new List<LevelProgress>(); public GameSettings settings = new GameSettings(); } [Serializable] public class LevelProgress { public int levelId; public bool isCompleted; public int bestScore; public float bestTime; } [Serializable] public class GameSettings { public float masterVolume = 1f; public float musicVolume = 0.8f; public bool fullscreen = true; public string language = "zh-CN"; }这里有几个设计要点值得说。第一,所有类都标了[Serializable],这是JsonUtility能序列化的前提,漏了就会得到空数据。第二,列表字段给了初始值new List<>(),避免null导致的空引用。第三,时间戳用long存,跨平台不会有精度问题,比DateTime省心。第四,设置项单独抽成一个类,方便后续扩展。
提示:字段的初始值只在new对象时生效,反序列化时如果JSON里没有对应key,字段会保持默认值(数值为0,引用类型为null)。所以列表这类引用类型一定要在反序列化后做null检查,或者像上面这样给初始值再配合后处理。
4. 创建JSON文件:路径选择与写入实现
4.1 各平台路径的正确选择
路径是本地文件操作里最容易出问题的地方。Unity提供了Application.persistentDataPath这个属性,它指向一个可读写的持久化目录,各平台表现如下:
| 平台 | persistentDataPath 实际位置 |
|---|---|
| Windows | C:\Users\用户名\AppData\LocalLow\公司名\产品名 |
| macOS | ~/Library/Application Support/公司名/产品名 |
| Android | /storage/emulated/0/Android/data/包名/files |
| iOS | /var/mobile/Containers/Data/Application/应用ID/Documents |
| WebGL | 基于IndexedDB的虚拟路径 |
关键点在于,persistentDataPath是唯一一个在所有平台上都保证可写的目录。Application.dataPath在PC上可读,但打包后是只读的;Application.streamingAssetsPath在Android上甚至是压缩在APK里的,不能直接写。所以存档、配置这类需要运行时修改的文件,一律放persistentDataPath。
我见过有人在编辑器里用Application.dataPath写文件,测试一切正常,打包到手机上直接失败,排查半天才发现是路径权限问题。这个坑一定要提前避开。
4.2 写入JSON的完整代码实现
下面这段代码封装了保存逻辑,包含目录检查、序列化、写入三个步骤,可以直接用。
using System.IO; using UnityEngine; public static class SaveSystem { private static string SavePath => Path.Combine(Application.persistentDataPath, "saveData.json"); public static void Save(SaveData data) { try { data.lastSaveTimestamp = System.DateTimeOffset.UtcNow.ToUnixTimeSeconds(); string json = JsonUtility.ToJson(data, true); string directory = Path.GetDirectoryName(SavePath); if (!Directory.Exists(directory)) { Directory.CreateDirectory(directory); } File.WriteAllText(SavePath, json, System.Text.Encoding.UTF8); Debug.Log($"存档已写入: {SavePath}"); } catch (System.Exception e) { Debug.LogError($"存档写入失败: {e.Message}"); } } }几个细节解释一下。JsonUtility.ToJson的第二个参数传true表示格式化输出,带缩进和换行,方便人手动查看和编辑。如果追求文件体积,传false或者不传,输出就是紧凑的一行。File.WriteAllText显式指定UTF8编码,避免中文在某些平台上乱码。try-catch是必须的,磁盘满、权限不足、路径非法都可能抛异常,不捕获的话游戏直接崩。
注意:persistentDataPath目录本身在首次运行时可能不存在,虽然大多数情况下Unity会创建,但保险起见还是加一层Directory.Exists判断。这个习惯能省掉一些莫名其妙的IO异常。
5. 读取JSON文件:解析与容错处理
5.1 基础读取流程
读取就是写入的逆操作,先判断文件是否存在,再读文本,最后反序列化。代码看着简单,但容错处理才是重点。
public static SaveData Load() { if (!File.Exists(SavePath)) { Debug.Log("存档不存在,返回默认数据"); return new SaveData(); } try { string json = File.ReadAllText(SavePath, System.Text.Encoding.UTF8); if (string.IsNullOrWhiteSpace(json)) { Debug.LogWarning("存档内容为空,返回默认数据"); return new SaveData(); } SaveData data = JsonUtility.FromJson<SaveData>(json); if (data == null) { Debug.LogWarning("反序列化结果为null,返回默认数据"); return new SaveData(); } return data; } catch (System.Exception e) { Debug.LogError($"存档读取失败: {e.Message}"); return new SaveData(); } }这里有三层防护:文件不存在返回默认值、内容为空返回默认值、反序列化失败返回默认值。为什么要这么谨慎?因为玩家的存档文件可能被各种原因破坏——手动改错了、写入过程中断电了、磁盘出错了。如果读取失败直接抛异常,玩家一进游戏就崩溃,体验极差。返回默认数据至少能让游戏跑起来。
5.2 反序列化后的数据校验
JsonUtility.FromJson有个特性:如果JSON里缺少某个字段,对应字段会保持默认值,不会报错。这看起来是好事,但会掩盖数据问题。比如你新版本加了一个字段,旧存档里没有,读出来就是0或null,如果不做校验直接用,可能出逻辑错误。
我的做法是在Load之后加一个校验和补全步骤:
private static void ValidateAndFix(SaveData data) { if (data.levelProgressList == null) { data.levelProgressList = new List<LevelProgress>(); } if (data.settings == null) { data.settings = new GameSettings(); } if (string.IsNullOrEmpty(data.playerName)) { data.playerName = "Player"; } if (data.playerLevel < 1) { data.playerLevel = 1; } }这个函数在Load返回前调用,把可能为null的引用类型补上,把明显不合理的数值修正。这样即使存档是旧版本或者被改坏了,游戏也能正常继续。
6. 编辑JSON:代码修改与手动编辑两种场景
6.1 代码中修改数据的正确姿势
在代码里修改数据,本质就是改对象的字段,然后重新保存。但有个细节要注意:JsonUtility序列化的是当前对象的状态,如果你改了字段但没调用Save,改动不会落盘。所以修改和保存要成对出现。
public static void AddScore(int levelId, int score) { SaveData data = Load(); LevelProgress progress = data.levelProgressList.Find(p => p.levelId == levelId); if (progress == null) { progress = new LevelProgress { levelId = levelId }; data.levelProgressList.Add(progress); } if (score > progress.bestScore) { progress.bestScore = score; } Save(data); }这种"读取-修改-保存"的模式在单机游戏里很常见。要注意的是,如果游戏有多个地方同时修改存档,可能出现覆盖问题。简单项目里问题不大,复杂项目建议加一个内存中的单例缓存,所有修改都走同一个对象,退出时统一保存。
6.2 手动编辑JSON文件的注意事项
开发阶段经常需要手动改JSON来测试,比如把等级调到满级、解锁所有关卡。这时候有几个坑要避开。
第一,JSON格式必须严格合法。多一个逗号、少一个引号、括号不匹配,都会导致解析失败。建议用带语法高亮的编辑器打开,比如VS Code,格式错误会直接标红。
第二,字段名必须和C#模型完全一致,大小写敏感。JSON里写"PlayerName"而模型里是"playerName",JsonUtility会忽略这个字段,读出来是默认值,而且不报错。这个坑特别隐蔽,我调试过好几次才发现。
第三,枚举类型在JsonUtility里是按整数序列化的,不是字符串。如果你手动把枚举字段写成字符串,反序列化会失败或者得到错误的值。Newtonsoft默认也是整数,但可以通过配置改成字符串。
第四,中文内容要确保文件是UTF-8编码保存的。有些编辑器默认用GBK,读出来就是乱码。VS Code右下角可以看和切换编码,保存时选UTF-8。
提示:手动编辑前先备份一份原文件,改坏了能快速恢复。这个习惯在调试复杂存档时特别有用。
7. 常见问题与排查技巧实录
7.1 问题速查表
下面这张表整理了我在实际项目里遇到的高频问题,以及对应的排查方向。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 读取后字段全是默认值 | 字段没标public或[SerializeField] | 检查模型字段修饰符 |
| 列表读出来是空的 | 顶层数组未包装,或字段名不匹配 | 确认用包装类,核对key大小写 |
| 中文显示乱码 | 文件编码不是UTF-8 | 用编辑器确认并转成UTF-8 |
| 打包后写入失败 | 用了dataPath等只读路径 | 改用persistentDataPath |
| 反序列化返回null | JSON格式非法或类型不匹配 | 打印原始json,用在线工具校验 |
| 字典字段丢失 | JsonUtility不支持字典 | 换Newtonsoft或改用列表 |
| 枚举值不对 | 手动改成了字符串 | 改回整数,或换库配置 |
7.2 几个我踩过的坑和独家技巧
第一个坑是字段顺序。JsonUtility对字段顺序不敏感,但如果你用Newtonsoft并且开了某些严格模式,顺序可能影响。一般不用管,但心里要有数。
第二个坑是浮点数精度。float序列化后再反序列化,可能有微小误差,比如0.1变成0.0999999。如果对精度敏感,用decimal或者存字符串。游戏里一般无所谓,但涉及数值计算要留意。
第三个技巧是加一个"版本号"字段。存档结构会随版本变化,加个version字段,读取时根据版本做不同的兼容处理,能省掉很多麻烦。
[Serializable] public class SaveData { public int version = 1; // 其他字段... }第四个技巧是调试时把JSON打印出来看。Debug.Log(json)能直观看到序列化结果,比盯着对象猜要快得多。尤其是字段丢失的时候,一看JSON就知道哪个key没写进去。
第五个技巧是给存档加个简单的校验,比如存一个字段的哈希值,读取时比对。虽然不能防篡改,但能发现文件损坏。单机游戏对防篡改要求不高,这个可选。
8. 进阶:加密、压缩与多存档管理
8.1 存档加密的轻量方案
如果不想让玩家轻易改存档,可以做一层简单加密。注意这里说的是防君子不防小人的方案,不是安全级别的加密。最简单的做法是对JSON字符串做一次Base64编码,或者用一个固定密钥做异或运算。这样用文本编辑器打开看到的是乱码,普通玩家就放弃了。
private static string SimpleEncrypt(string plain) { byte[] bytes = System.Text.Encoding.UTF8.GetBytes(plain); for (int i = 0; i < bytes.Length; i++) { bytes[i] ^= 0x5A; } return System.Convert.ToBase64String(bytes); }对应的解密就是反过来操作。这种方案性能好、实现简单,适合单机游戏。真要防破解,那得用更专业的方案,但单机游戏一般没必要。
8.2 多存档槽位的管理思路
很多游戏需要多个存档槽,实现思路是用不同的文件名区分。比如save_0.json、save_1.json,或者用玩家自定义的名字。管理上维护一个存档列表文件,记录每个槽位的元信息(存档时间、玩家等级、游戏进度),读取列表时只读元信息,不用加载完整存档,速度快。
public static string GetSlotPath(int slot) { return Path.Combine(Application.persistentDataPath, $"save_{slot}.json"); }槽位数量一般固定,比如3个或5个。删除存档就是删文件,复制存档就是复制文件,都很直接。要注意的是删除操作要确认,避免误删。
9. 性能与工程化的一些经验
9.1 什么时候该考虑性能
本地JSON的读写性能,在数据量不大的时候完全不用操心。一个几百KB的存档,读写都是毫秒级。但如果你的存档有几MB甚至几十MB,就要注意了。频繁保存会卡顿,尤其是在移动端。
优化思路有几个:一是减少保存频率,比如不要每帧保存,改成定时保存或者关键节点保存;二是异步保存,用Task或协程把IO放到后台线程;三是只保存变化的部分,不过这需要更复杂的设计。
public static async void SaveAsync(SaveData data) { string json = JsonUtility.ToJson(data, true); await System.Threading.Tasks.Task.Run(() => { File.WriteAllText(SavePath, json, System.Text.Encoding.UTF8); }); }异步保存要注意线程安全,Unity的API大多只能在主线程调用,但File操作本身是可以在后台线程做的。序列化最好在主线程完成,只把写文件放到后台。
9.2 工程化建议
最后给几条工程化上的建议。第一,把存档逻辑封装成独立的类或模块,不要散落在各处,方便统一维护和替换。第二,给存档加版本号,为后续兼容留后路。第三,写单元测试或者至少写个编辑器菜单项来测试存档的读写,避免每次都要跑游戏才能验证。第四,日志要打全,出错时能快速定位是路径问题、权限问题还是数据问题。
我在实际项目里还习惯加一个"开发者模式",在编辑器里可以一键清空存档、一键填充测试数据,调试效率能提升不少。这些工具代码不多,但用起来很爽。
关于本地JSON这块,核心就是选对方案、管好路径、做好容错。把这三点做到位,基本不会出大问题。剩下的就是根据项目需求做加密、多槽位、异步这些扩展。希望这些经验能帮你少走点弯路。