
1. 为什么本地 JSON 是 Unity 项目里最不该被忽视的一环做 Unity 项目这些年我见过太多团队在联网方案上反复折腾却对本地数据存储敷衍了事。配置表直接硬编码在 C# 脚本里、玩家存档用PlayerPrefs一把梭、关卡数据散落在各种 ScriptableObject 里——项目小的时候确实跑得通一旦策划要改数值、运营要调活动、测试要造异常数据整个团队就开始互相甩锅。本地 JSON 文件恰恰是解决这类问题成本最低、收益最直接的方案。这篇文章要聊的就是 Unity 里本地 JSON 文件的创建、编辑和读取这一整套流程。它解决的核心问题是让游戏运行时产生的数据、策划配置的静态数据以一种人类可读、可手动修改、可版本管理的方式落地到磁盘上。适合谁看刚入行不久、对Application.persistentDataPath和StreamingAssets还分不太清的 Unity 开发者也适合那些项目已经跑起来、但数据管理一团乱麻、想找个轻量方案重构的团队。我先把结论摆在这里Unity 自带的JsonUtility足够应付 80% 的本地数据场景剩下的 20% 用 Newtonsoft.Json 补位。关键不在于选哪个库而在于你要清楚数据存在哪个目录、什么时候写、什么时候读、格式怎么设计才不容易崩。这几个问题想明白了代码本身没几行。下面我会从目录选择、数据建模、序列化库对比、创建与编辑的完整实现、读取与容错、以及实际项目里踩过的坑这几个角度把这件事彻底讲透。每一段都尽量给出可以直接抄的代码和参数说明同时解释清楚为什么这么做。2. 先搞清楚文件该放在哪persistentDataPath 与 StreamingAssets 的本质区别很多人写本地存储第一步就错了——文件路径随手一拼编辑器里跑得好好的打包到真机就找不到文件。这个问题的根源在于没理解 Unity 各个特殊目录的定位。我先把最常用的两个目录掰开讲清楚。2.1 Application.persistentDataPath运行时可读写的唯一可靠选择Application.persistentDataPath是 Unity 官方指定的、跨平台可读写的持久化目录。它的实际路径在不同平台上完全不一样平台实际路径示例WindowsC:\Users\用户名\AppData\LocalLow\公司名\产品名Android/storage/emulated/0/Android/data/包名/filesiOS/var/mobile/Containers/Data/Application/应用ID/DocumentsmacOS~/Library/Application Support/公司名/产品名这个目录的特点是应用有完全的读写权限卸载应用时数据会被清除但应用更新时数据保留。玩家存档、设置、运行时生成的配置都应该放这里。我习惯在项目里封装一个统一的路径工具避免到处拼字符串using UnityEngine; using System.IO; public static class LocalPath { // 所有本地 JSON 的统一根目录 public static string DataRoot { get { string root Path.Combine(Application.persistentDataPath, LocalData); if (!Directory.Exists(root)) Directory.CreateDirectory(root); return root; } } public static string GetJsonPath(string fileName) { // 统一补上 .json 后缀避免调用方忘记 if (!fileName.EndsWith(.json)) fileName .json; return Path.Combine(DataRoot, fileName); } }这里有个细节值得说Directory.CreateDirectory对已存在的目录不会报错所以放在 getter 里每次检查是安全的不用担心重复创建的性能问题——它内部会先判断。注意不要用Application.dataPath来存运行时数据。这个路径在打包后指向的是只读的安装目录Android 上更是直接封在 APK 里写操作必然失败。我早期项目就吃过这个亏编辑器里一切正常出包后存档全部丢失。2.2 StreamingAssets只读的初始数据仓库Application.streamingAssetsPath是另一个常被误用的目录。它的定位是存放随包发布的只读资源比如初始配置表、预置的关卡数据、默认的本地化文本。关键限制是在 Android 上这个目录的内容被封在压缩包内不能用普通的File.ReadAllText直接读必须用UnityWebRequest异步读取。在 iOS 和桌面平台可以直接用文件 API 读取。任何平台都不应该往这里写数据。所以正确的做法是把默认配置放在 StreamingAssets首次启动时读取它然后拷贝一份到persistentDataPath之后的读写都针对 persistentDataPath 里的副本。这样既保证了初始数据随包发布又保证了运行时可修改。using UnityEngine; using UnityEngine.Networking; using System.IO; using System.Collections; public static class ConfigLoader { // 首次运行时把 StreamingAssets 里的默认配置拷到可写目录 public static IEnumerator EnsureDefaultConfig(string fileName) { string targetPath LocalPath.GetJsonPath(fileName); if (File.Exists(targetPath)) yield break; // 已经存在不用拷贝 string sourcePath Path.Combine(Application.streamingAssetsPath, fileName); #if UNITY_ANDROID !UNITY_EDITOR // Android 必须走 UnityWebRequest using (UnityWebRequest req UnityWebRequest.Get(sourcePath)) { yield return req.SendWebRequest(); if (req.result UnityWebRequest.Result.Success) File.WriteAllText(targetPath, req.downloadHandler.text); else Debug.LogError($默认配置读取失败: {req.error}); } #else if (File.Exists(sourcePath)) File.WriteAllText(targetPath, File.ReadAllText(sourcePath)); else Debug.LogError($StreamingAssets 中找不到 {fileName}); yield return null; #endif } }这段代码里的平台判断是必须的我见过太多人因为忽略了 Android 的特殊性导致真机上配置读取直接抛异常。UnityWebRequest的result属性是较新版本 Unity 才有的老版本用isNetworkError和isHttpError迁移时要注意。2.3 路径选择的决策逻辑到底该放哪个目录其实可以用一个简单的问题来判断这份数据会不会在运行时被修改会改 →persistentDataPath不改只是随包发布的初始值 →StreamingAssets既要初始值又要能改 → StreamingAssets 放初始值首次启动拷贝到 persistentDataPath这个判断逻辑看起来简单但它是整个本地存储方案的地基。地基没打对后面写再多代码都是白费。3. 数据建模什么样的类结构才能被 JSON 正确序列化路径搞定了接下来是数据本身。Unity 的JsonUtility对可序列化的类有明确要求不符合要求的类序列化出来就是空对象或者一堆默认值而且它不会报错这是最坑的地方。我先把规则列清楚。3.1 JsonUtility 的序列化硬性条件要让一个类能被JsonUtility.ToJson正确处理必须满足类上标注[System.Serializable]特性。字段必须是public或者标注了[SerializeField]的private字段。字段类型必须是 Unity 能识别的基本类型int、float、string、bool、Unity 内置类型Vector3、Color 等、以及同样满足上述条件的自定义类。不支持 Dictionary不支持接口、抽象类、多态。不支持顶层数组或 List必须包在一个类里。第 4 和第 5 条是最容易踩的。我见过有人直接JsonUtility.ToJson(myList)结果得到一个空字符串排查半天才发现问题。using System; using System.Collections.Generic; using UnityEngine; [Serializable] public class PlayerSaveData { public string playerId; public int level; public float playTime; public Vector3 lastPosition; public ListItemData inventory new ListItemData(); public SettingsData settings new SettingsData(); } [Serializable] public class ItemData { public int itemId; public int count; public string acquiredTime; } [Serializable] public class SettingsData { public float bgmVolume 0.8f; public float sfxVolume 1.0f; public bool fullscreen true; }注意inventory和settings都给了初始值。这不是可有可无的习惯——JsonUtility.FromJson在遇到 JSON 里缺失的字段时会保留字段的默认值但如果字段本身是 null 的引用类型反序列化后可能还是 null后续访问就空引用了。给个初始值能省掉很多判空代码。3.2 用包装类绕过不支持顶层数组的限制前面说了JsonUtility不支持顶层 List解决办法就是包一层[Serializable] public class ItemDataList { public ListItemData items new ListItemData(); }用的时候ItemDataList wrapper new ItemDataList(); wrapper.items myItemList; string json JsonUtility.ToJson(wrapper, true); // true 表示格式化输出第二个参数prettyPrint设为 true 后输出的 JSON 会带缩进和换行方便人工查看和编辑。开发阶段强烈建议开着发布时如果在意文件体积可以关掉——不过本地存档那点体积说实话开着也无所谓。3.3 字段命名与 JSON 键名的映射JsonUtility默认用字段名作为 JSON 的键名大小写完全一致。这意味着如果你想让 JSON 里的键名符合某种规范比如全小写下划线字段名就得跟着改。它不支持像 Newtonsoft 那样的[JsonProperty]重命名特性。我的建议是本地 JSON 的键名直接用 camelCase 的字段名别折腾。因为这份文件主要是给程序和策划看的可读性比规范更重要。如果确实需要和外部系统对接、键名有硬性要求那就换 Newtonsoft.Json。3.4 什么时候该放弃 JsonUtilityJsonUtility的短板很明确不支持 Dictionary、不支持多态、不支持 null 值的精确控制。当你的数据结构出现下面这些情况时就该考虑 Newtonsoft.Json 了需要Dictionarystring, T这种键值对结构需要序列化接口或抽象类的多态实例需要精确控制哪些字段为 null 时也输出需要处理嵌套很深的复杂对象图Newtonsoft.Json 在 Unity 里通过 UPM 包com.unity.nuget.newtonsoft-json引入官方维护稳定性没问题。它的用法和JsonUtility类似但功能强得多using Newtonsoft.Json; string json JsonConvert.SerializeObject(data, Formatting.Indented); PlayerSaveData data JsonConvert.DeserializeObjectPlayerSaveData(json);代价是性能略低、包体略大。对于本地存档这种低频操作性能差异可以忽略。我的选型原则是能用 JsonUtility 就用 JsonUtility遇到它搞不定的结构再上 Newtonsoft。不要一上来就引入重型库增加不必要的复杂度。4. 创建与编辑把数据可靠地写到磁盘上数据类定义好了接下来是真正落盘。这一步看似简单但可靠两个字里藏着不少门道。我见过存档写到一半程序崩溃导致文件损坏的也见过多线程同时写同一个文件导致内容错乱的。下面把创建和编辑的完整流程拆开讲。4.1 创建从内存对象到磁盘文件最基础的创建流程就是三步构造对象、序列化成字符串、写文件。using System.IO; using UnityEngine; public static class SaveSystem { public static bool SavePlayerData(PlayerSaveData data, string fileName player_save) { try { string json JsonUtility.ToJson(data, true); string path LocalPath.GetJsonPath(fileName); File.WriteAllText(path, json); Debug.Log($存档已写入: {path}); return true; } catch (System.Exception e) { Debug.LogError($存档写入失败: {e.Message}); return false; } } }这段代码能跑但不够健壮。问题在于File.WriteAllText是非原子操作——如果写到一半断电或者程序被杀文件就损坏了。对于玩家存档这种不能丢的数据必须用写临时文件 原子替换的模式。4.2 原子写入防止存档损坏的关键技巧原子写入的思路是先把内容写到临时文件确认写成功后再用替换操作覆盖目标文件。这样即使中途失败原文件依然完好。public static bool SaveAtomic(string json, string targetPath) { string tempPath targetPath .tmp; try { // 第一步写临时文件 File.WriteAllText(tempPath, json); // 第二步如果目标存在先删掉File.Replace 在部分平台有兼容问题 if (File.Exists(targetPath)) File.Delete(targetPath); // 第三步把临时文件移动成正式文件 File.Move(tempPath, targetPath); return true; } catch (System.Exception e) { Debug.LogError($原子写入失败: {e.Message}); // 清理可能残留的临时文件 if (File.Exists(tempPath)) { try { File.Delete(tempPath); } catch { } } return false; } }为什么不用File.Replace因为它在某些平台尤其是部分 Android 设备上行为不一致而删除 移动的组合虽然多了一步但兼容性最好。删除和移动之间理论上有个极短的窗口期如果这时候崩溃会丢失存档——但这个窗口期比直接写文件小得多实际项目中足够可靠。提示如果你的存档极其重要可以再加一层备份机制——每次保存前把旧文件重命名为.bak读取时如果主文件损坏就回退到备份。这个策略在单机游戏里很常见。4.3 编辑运行时修改数据的正确姿势编辑在 Unity 本地 JSON 的语境下有两层含义一是程序运行时修改内存对象再保存二是开发者手动用文本编辑器改 JSON 文件。前者是代码问题后者是流程问题都值得说。程序内修改很简单就是改字段再调保存public void AddItem(PlayerSaveData data, int itemId, int count) { ItemData existing data.inventory.Find(i i.itemId itemId); if (existing ! null) { existing.count count; } else { data.inventory.Add(new ItemData { itemId itemId, count count, acquiredTime System.DateTime.Now.ToString(yyyy-MM-dd HH:mm:ss) }); } SaveSystem.SavePlayerData(data); }这里有个性能上的小提醒如果一次操作要改很多字段不要每改一个就保存一次。磁盘 IO 是慢操作频繁写会卡顿。正确做法是改完所有字段后统一保存一次。我见过有人在 Update 里每帧保存结果帧率直接掉一半。手动编辑 JSON 文件时最容易出的问题是格式错误导致解析失败。多一个逗号、少一个引号、中文引号混入都会让FromJson抛异常。我的经验是用支持 JSON 语法高亮的编辑器VS Code、Sublime 都行编辑前先备份一份改完用在线 JSON 校验工具过一遍数组最后一个元素后面不要加逗号4.4 编辑器扩展让策划也能安全地改配置如果项目里有策划需要频繁调整的配置数据写个简单的 Editor 窗口比让他们直接改 JSON 靠谱得多。用EditorGUILayout几行就能搭出来#if UNITY_EDITOR using UnityEditor; using UnityEngine; using System.IO; public class ConfigEditorWindow : EditorWindow { private ItemDataList config; private string configPath; [MenuItem(Tools/本地配置编辑器)] public static void Open() { GetWindowConfigEditorWindow(本地配置编辑器); } private void OnEnable() { configPath Path.Combine(Application.streamingAssetsPath, items.json); if (File.Exists(configPath)) config JsonUtility.FromJsonItemDataList(File.ReadAllText(configPath)); else config new ItemDataList(); } private void OnGUI() { if (config null) return; // 简单展示实际项目可以用 SerializedObject 做更漂亮的界面 foreach (var item in config.items) { EditorGUILayout.BeginHorizontal(); item.itemId EditorGUILayout.IntField(ID, item.itemId); item.count EditorGUILayout.IntField(数量, item.count); EditorGUILayout.EndHorizontal(); } if (GUILayout.Button(添加条目)) config.items.Add(new ItemData()); if (GUILayout.Button(保存)) { File.WriteAllText(configPath, JsonUtility.ToJson(config, true)); AssetDatabase.Refresh(); } } } #endif这个窗口虽然简陋但已经能避免策划手改 JSON 时的格式错误。实际项目里可以配合SerializedObject和ReorderableList做得更完善但核心思路就是这个——把结构化编辑的能力交给工具而不是交给人的细心程度。5. 读取与容错让程序在数据异常时也能优雅运行写数据相对可控读数据才是真正考验健壮性的地方。文件可能不存在、可能被改坏、可能是旧版本格式、可能字段缺失——每一种情况都要有对应的处理策略否则线上就是一堆崩溃日志。5.1 基础读取与存在性判断最基础的读取逻辑public static PlayerSaveData LoadPlayerData(string fileName player_save) { string path LocalPath.GetJsonPath(fileName); if (!File.Exists(path)) { Debug.Log(存档不存在返回默认数据); return new PlayerSaveData(); } try { string json File.ReadAllText(path); PlayerSaveData data JsonUtility.FromJsonPlayerSaveData(json); // FromJson 在解析失败时返回 null必须判空 return data ?? new PlayerSaveData(); } catch (System.Exception e) { Debug.LogError($存档读取失败: {e.Message}); return new PlayerSaveData(); } }这里的关键点是JsonUtility.FromJson在 JSON 格式错误时不会抛异常而是返回 null。很多人只写了 try-catch 就以为万事大吉结果拿到 null 后直接访问字段空引用崩溃。所以?? new PlayerSaveData()这个兜底是必须的。5.2 版本迁移当数据结构发生变化时游戏迭代过程中存档结构几乎必然会变。加字段、删字段、改字段类型都会导致旧存档和新代码不匹配。JsonUtility的行为是JSON 里有的字段就赋值没有的字段保留默认值。这意味着加字段是安全的旧存档读进来新字段是默认值但删字段和改类型是危险的。我的做法是在存档里加一个版本号字段读取时根据版本号做迁移[Serializable] public class PlayerSaveData { public int dataVersion 2; // 当前版本 public string playerId; public int level; // ... 其他字段 } public static PlayerSaveData LoadWithMigration(string fileName) { string path LocalPath.GetJsonPath(fileName); if (!File.Exists(path)) return new PlayerSaveData(); string json File.ReadAllText(path); PlayerSaveData data JsonUtility.FromJsonPlayerSaveData(json); if (data null) return new PlayerSaveData(); // 版本迁移链 if (data.dataVersion 2) { // 从 v1 升到 v2 的逻辑比如给新字段赋合理默认值 data.dataVersion 2; } // 未来 v2 - v3 继续往下加 return data; }版本号机制看起来有点重但它是唯一能让你在半年后还敢改存档结构的保障。没有它每次改结构都要担心老玩家的存档会不会崩。5.3 损坏文件的恢复策略即使有原子写入文件仍可能因为磁盘故障、用户手动编辑出错等原因损坏。这时候备份机制就派上用场了public static PlayerSaveData LoadWithFallback(string fileName) { string path LocalPath.GetJsonPath(fileName); string backupPath path .bak; // 先尝试主文件 PlayerSaveData data TryLoad(path); if (data ! null) return data; // 主文件失败尝试备份 Debug.LogWarning(主存档损坏尝试读取备份); data TryLoad(backupPath); if (data ! null) { // 备份可用恢复成主文件 File.Copy(backupPath, path, true); return data; } // 都失败返回全新数据 Debug.LogError(主存档和备份均损坏使用默认数据); return new PlayerSaveData(); } private static PlayerSaveData TryLoad(string path) { if (!File.Exists(path)) return null; try { string json File.ReadAllText(path); return JsonUtility.FromJsonPlayerSaveData(json); } catch { return null; } }配合前面原子写入时把旧文件备份成.bak就形成了一套完整的容错链路。这套机制我在几个上线项目里用过确实救过场——有玩家反馈存档丢失日志显示主文件损坏但备份成功恢复数据一点没丢。5.4 读取性能什么时候该缓存什么时候该直读本地 JSON 读取本身很快一个几十 KB 的存档读一次也就几毫秒。但如果你的代码在 Update 里反复读同一个文件那就是灾难。原则很简单配置类数据启动时读一次缓存在内存里全局访问。存档类数据进入游戏时读一次运行中改内存对象退出或存档点才写盘。频繁访问的小数据如果确实需要频繁读用内存缓存 脏标记只在数据变化时写盘。public class ConfigManager { private static ItemDataList _itemConfig; public static ItemDataList ItemConfig { get { if (_itemConfig null) { string path Path.Combine(Application.streamingAssetsPath, items.json); // 注意Android 上这里需要异步实际项目要处理 _itemConfig JsonUtility.FromJsonItemDataList(File.ReadAllText(path)); } return _itemConfig; } } }这个懒加载模式在桌面和 iOS 上没问题Android 上因为 StreamingAssets 的读取限制需要改成异步初始化。这也是为什么我前面强调配置要先拷到 persistentDataPath——拷过去之后就是普通文件同步读取没有平台差异。6. 那些文档里不会写、但实际项目一定会遇到的坑前面讲的都是应该怎么做这一节讲讲实际做的时候会怎么翻车。这些都是我在真实项目里踩过或者看别人踩过的每一条背后都有血泪。6.1 编辑器与真机路径不一致导致的灵异现象最常见的坑编辑器里读写的路径和真机上完全不同。Application.persistentDataPath在编辑器里指向的是你电脑上的某个目录在真机上是另一个。如果你在代码里硬编码了某个绝对路径或者用Application.dataPath拼路径编辑器里能跑真机必崩。排查这类问题的标准动作是在读写前后把完整路径打出来。Debug.Log($当前数据目录: {Application.persistentDataPath}); Debug.Log($目标文件路径: {path});把这两行日志放在真机上跑一遍路径对不对一目了然。我养成习惯后这类问题基本五分钟内定位。6.2 中文乱码编码问题的隐蔽性File.WriteAllText默认使用 UTF-8 无 BOM 编码File.ReadAllText默认也是 UTF-8。理论上不会乱码。但如果你的 JSON 文件是用某些编辑器尤其是 Windows 记事本的老版本保存的可能会带上 BOM 头导致JsonUtility.FromJson解析失败。解决办法是显式指定编码using System.Text; // 写入时明确用 UTF-8 无 BOM File.WriteAllText(path, json, new UTF8Encoding(false)); // 读取时也明确 string json File.ReadAllText(path, Encoding.UTF8);new UTF8Encoding(false)里的 false 表示不写 BOM。这个细节在跨平台项目里很重要尤其是需要和外部工具交换 JSON 文件时。6.3 浮点数精度Vector3 序列化后的微小偏差JsonUtility序列化Vector3时会把 float 转成字符串。float 本身精度有限序列化再反序列化后值可能有极小的偏差。对于位置、旋转这类数据通常无所谓但如果你的逻辑里有if (pos targetPos)这种精确比较就可能出问题。正确做法是用容差比较if (Vector3.Distance(pos, targetPos) 0.001f) { // 视为到达 }这个坑不限于 JSON任何涉及浮点数序列化的场景都要注意。我见过有人因为存档里的坐标精度问题导致玩家读档后卡在墙里——就是因为位置比较用了精确相等。6.4 多线程写入的竞态问题Unity 的主线程模型下大部分文件操作都在主线程一般不会有多线程问题。但如果你用了异步任务或者第三方库在后台线程写文件就可能出现两个线程同时写同一个文件的情况导致内容错乱。解决办法是加锁private static readonly object _fileLock new object(); public static void SafeWrite(string path, string content) { lock (_fileLock) { File.WriteAllText(path, content); } }锁的粒度要控制好只锁文件操作本身不要把序列化等耗时操作也包进去否则会阻塞其他线程。6.5 移动端权限与存储空间Android 上写persistentDataPath不需要额外权限这是它作为推荐目录的原因之一。但如果你的应用要写到外部存储比如让用户能用文件管理器看到存档就需要申请权限而且不同 Android 版本的行为差异很大。我的建议是除非产品明确要求否则一律用 persistentDataPath不要碰外部存储。另外移动端存储空间可能不足。写文件前最好检查一下可用空间虽然这个检查不是 100% 可靠但能提前发现明显问题DriveInfo drive new DriveInfo(Path.GetPathRoot(Application.persistentDataPath)); if (drive.AvailableFreeSpace 1024 * 1024) // 小于 1MB { Debug.LogWarning(存储空间不足); }DriveInfo在部分平台可能不支持用之前要测试。这个检查属于锦上添花不是必须的。6.6 打包后 StreamingAssets 的读取差异前面提过 Android 上 StreamingAssets 要用UnityWebRequest但还有一个更隐蔽的坑WebGL 平台上 StreamingAssets 的路径是一个 URL不是文件路径。如果你在 WebGL 项目里用File.ReadAllText读 StreamingAssets必然失败。跨平台项目里读取 StreamingAssets 的正确姿势是统一用UnityWebRequest它在所有平台上都能工作public static IEnumerator ReadStreamingAsset(string fileName, System.Actionstring onDone) { string path Path.Combine(Application.streamingAssetsPath, fileName); using (UnityWebRequest req UnityWebRequest.Get(path)) { yield return req.SendWebRequest(); if (req.result UnityWebRequest.Result.Success) onDone?.Invoke(req.downloadHandler.text); else onDone?.Invoke(null); } }代价是异步调用方要处理回调。但对于需要跨平台的配置读取这是最省心的方案。7. 一套可以直接落地的本地 JSON 管理方案把前面所有内容串起来我给出一套完整的、可以直接抄进项目的方案。它的设计目标是路径统一、读写原子、容错完整、跨平台可用。7.1 整体架构方案分三层路径层LocalPath统一管理所有本地文件的路径屏蔽平台差异。序列化层JsonSerializer封装 JsonUtility 和 Newtonsoft 的切换统一异常处理。业务层SaveSystem/ConfigManager面向具体数据的读写接口包含版本迁移和备份恢复。这样分层的好处是将来要换序列化库、要改存储位置只需要动对应的一层业务代码不受影响。7.2 核心代码骨架using System; using System.IO; using System.Text; using UnityEngine; public static class JsonStorage { private static readonly object _lock new object(); public static bool WriteT(string fileName, T data, bool pretty true) { lock (_lock) { string path LocalPath.GetJsonPath(fileName); string tempPath path .tmp; try { string json JsonUtility.ToJson(data, pretty); File.WriteAllText(tempPath, json, new UTF8Encoding(false)); if (File.Exists(path)) { // 备份旧文件 string backup path .bak; if (File.Exists(backup)) File.Delete(backup); File.Move(path, backup); } File.Move(tempPath, path); return true; } catch (Exception e) { Debug.LogError($写入 {fileName} 失败: {e.Message}); if (File.Exists(tempPath)) { try { File.Delete(tempPath); } catch { } } return false; } } } public static T ReadT(string fileName) where T : new() { lock (_lock) { string path LocalPath.GetJsonPath(fileName); T result TryReadT(path); if (result ! null) return result; // 主文件失败尝试备份 string backup path .bak; result TryReadT(backup); if (result ! null) { Debug.LogWarning(${fileName} 主文件损坏已从备份恢复); File.Copy(backup, path, true); return result; } Debug.LogWarning(${fileName} 不存在或损坏返回默认数据); return new T(); } } private static T TryReadT(string path) where T : new() { if (!File.Exists(path)) return default; try { string json File.ReadAllText(path, Encoding.UTF8); T data JsonUtility.FromJsonT(json); return data; } catch (Exception e) { Debug.LogWarning($读取 {path} 失败: {e.Message}); return default; } } }这套代码把原子写入、备份恢复、编码处理、线程安全都包进去了。业务层调用只需要// 保存 JsonStorage.Write(player_save, playerData); // 读取 PlayerSaveData data JsonStorage.ReadPlayerSaveData(player_save);7.3 使用时的注意事项这套方案有几个使用上的约定违反了就可能出问题T必须是带[Serializable]的类且有无参构造函数new()约束保证了这点。不要用它存超大文件比如几 MB 的日志JSON 序列化大对象性能一般。备份文件会占用额外空间如果存档很大要评估一下。版本迁移逻辑不在这层需要在业务层读取后单独处理。7.4 什么时候该升级方案这套方案适合中小型项目的本地数据管理。如果你的项目出现下面这些需求就该考虑升级了需要加密存档防修改→ 在序列化后加一层加密或者用二进制格式需要云同步 → 本地 JSON 作为缓存加一层同步逻辑数据量极大几十 MB→ 考虑 SQLite 或分片存储需要频繁随机读写 → 考虑数据库而非文件但说实话大部分 Unity 项目根本到不了这个量级。我见过不少团队一上来就上数据库结果维护成本高得离谱实际数据量还没一个 JSON 文件大。先用最简单的方案跑起来遇到瓶颈再升级这是我这些年最深的体会。本地 JSON 这套东西技术难度不高但细节极多。路径、编码、原子性、容错、版本迁移每一个点单独看都不复杂组合起来就是一套需要认真对待的工程实践。把这篇里的代码和思路吃透你在 Unity 本地数据这块基本就不会再翻车了。