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 官方指定的、跨平台可读写的持久化目录。它的实际路径在不同平台上完全不一样:
| 平台 | 实际路径示例 |
|---|---|
| Windows | C:\Users\用户名\AppData\LocalLow\公司名\产品名 |
| Android | /storage/emulated/0/Android/data/包名/files |
| iOS | /var/mobile/Containers/Data/Application/应用ID/Documents |
| macOS | ~/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 List<ItemData> inventory = new List<ItemData>(); 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 List<ItemData> items = new List<ItemData>(); }用的时候:
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 什么时候该放弃 JsonUtility
JsonUtility的短板很明确:不支持 Dictionary、不支持多态、不支持 null 值的精确控制。当你的数据结构出现下面这些情况时,就该考虑 Newtonsoft.Json 了:
- 需要
Dictionary<string, 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.DeserializeObject<PlayerSaveData>(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() { GetWindow<ConfigEditorWindow>("本地配置编辑器"); } private void OnEnable() { configPath = Path.Combine(Application.streamingAssetsPath, "items.json"); if (File.Exists(configPath)) config = JsonUtility.FromJson<ItemDataList>(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.FromJson<PlayerSaveData>(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.FromJson<PlayerSaveData>(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.FromJson<PlayerSaveData>(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.FromJson<ItemDataList>(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.Action<string> 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 Write<T>(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 Read<T>(string fileName) where T : new() { lock (_lock) { string path = LocalPath.GetJsonPath(fileName); T result = TryRead<T>(path); if (result != null) return result; // 主文件失败,尝试备份 string backup = path + ".bak"; result = TryRead<T>(backup); if (result != null) { Debug.LogWarning($"{fileName} 主文件损坏,已从备份恢复"); File.Copy(backup, path, true); return result; } Debug.LogWarning($"{fileName} 不存在或损坏,返回默认数据"); return new T(); } } private static T TryRead<T>(string path) where T : new() { if (!File.Exists(path)) return default; try { string json = File.ReadAllText(path, Encoding.UTF8); T data = JsonUtility.FromJson<T>(json); return data; } catch (Exception e) { Debug.LogWarning($"读取 {path} 失败: {e.Message}"); return default; } } }这套代码把原子写入、备份恢复、编码处理、线程安全都包进去了。业务层调用只需要:
// 保存 JsonStorage.Write("player_save", playerData); // 读取 PlayerSaveData data = JsonStorage.Read<PlayerSaveData>("player_save");7.3 使用时的注意事项
这套方案有几个使用上的约定,违反了就可能出问题:
T必须是带[Serializable]的类,且有无参构造函数(new()约束保证了这点)。- 不要用它存超大文件(比如几 MB 的日志),JSON 序列化大对象性能一般。
- 备份文件会占用额外空间,如果存档很大要评估一下。
- 版本迁移逻辑不在这层,需要在业务层读取后单独处理。
7.4 什么时候该升级方案
这套方案适合中小型项目的本地数据管理。如果你的项目出现下面这些需求,就该考虑升级了:
- 需要加密存档(防修改)→ 在序列化后加一层加密,或者用二进制格式
- 需要云同步 → 本地 JSON 作为缓存,加一层同步逻辑
- 数据量极大(几十 MB)→ 考虑 SQLite 或分片存储
- 需要频繁随机读写 → 考虑数据库而非文件
但说实话,大部分 Unity 项目根本到不了这个量级。我见过不少团队一上来就上数据库,结果维护成本高得离谱,实际数据量还没一个 JSON 文件大。先用最简单的方案跑起来,遇到瓶颈再升级,这是我这些年最深的体会。
本地 JSON 这套东西,技术难度不高,但细节极多。路径、编码、原子性、容错、版本迁移,每一个点单独看都不复杂,组合起来就是一套需要认真对待的工程实践。把这篇里的代码和思路吃透,你在 Unity 本地数据这块基本就不会再翻车了。