Unity本地JSON数据持久化实战:创建、编辑与读取全解析
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 三种主流方案的横向对比在动手写代码之前选型这一步不能省。我见过太多项目一开始随手用了某个库结果数据一复杂就各种限制最后不得不重构。下面这张表是我根据实际使用经验整理的对比你可以对照自己的需求来选。对比维度JsonUtilityNewtonsoft.JsonLitJson来源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 ListLevelProgress levelProgressList new ListLevelProgress(); 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 实际位置WindowsC:\Users\用户名\AppData\LocalLow\公司名\产品名macOS~/Library/Application Support/公司名/产品名Android/storage/emulated/0/Android/data/包名/filesiOS/var/mobile/Containers/Data/Application/应用ID/DocumentsWebGL基于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.FromJsonSaveData(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 ListLevelProgress(); } 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而模型里是playerNameJsonUtility会忽略这个字段读出来是默认值而且不报错。这个坑特别隐蔽我调试过好几次才发现。第三枚举类型在JsonUtility里是按整数序列化的不是字符串。如果你手动把枚举字段写成字符串反序列化会失败或者得到错误的值。Newtonsoft默认也是整数但可以通过配置改成字符串。第四中文内容要确保文件是UTF-8编码保存的。有些编辑器默认用GBK读出来就是乱码。VS Code右下角可以看和切换编码保存时选UTF-8。提示手动编辑前先备份一份原文件改坏了能快速恢复。这个习惯在调试复杂存档时特别有用。7. 常见问题与排查技巧实录7.1 问题速查表下面这张表整理了我在实际项目里遇到的高频问题以及对应的排查方向。现象可能原因排查方法读取后字段全是默认值字段没标public或[SerializeField]检查模型字段修饰符列表读出来是空的顶层数组未包装或字段名不匹配确认用包装类核对key大小写中文显示乱码文件编码不是UTF-8用编辑器确认并转成UTF-8打包后写入失败用了dataPath等只读路径改用persistentDataPath反序列化返回nullJSON格式非法或类型不匹配打印原始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这块核心就是选对方案、管好路径、做好容错。把这三点做到位基本不会出大问题。剩下的就是根据项目需求做加密、多槽位、异步这些扩展。希望这些经验能帮你少走点弯路。