先说说我为什么想把这几个方案摆在一起聊。做Unity时间久了几乎每个项目都会碰到存档这件事从单机小游戏到带云同步的中型项目都躲不过。数据持久化听起来简单无非是把内存里的对象写到磁盘再读回来但实际踩过的坑远比想象中多PlayerPrefs存复杂结构时各种别扭JsonUtility遇到字典直接摆烂二进制序列化一到IL2CPP打包就翻车移动端上写文件没做原子操作断电后档案直接归零。这篇笔记就是把我这几年在Unity数据持久化上的选型思路、代码实践和一些血泪教训整理出来面向的是已经能写出基本C#脚本、正在做存档功能或者准备重构存档模块的同学。全文围绕Unity、数据持久化两个核心关键词展开不堆名词重点放在每种方案什么时候该用、怎么用、哪里会出问题。1. 数据持久化选型的那些前置思考1.1 存档需求的三层拆解很多人一上来就问Unity用什么存档最好这问题本身就没法答因为最好取决于你要存什么、存多少、存多快、给谁看。我的习惯是把需求拆成三层来看。最底层是设置类数据比如音量、画质档位、语言、上次登录的账号名这类数据的特点是字段少、频率高、单条数据小而且丢了也不太致命顶多让玩家重新调一次。中间层是进度类数据角色等级、关卡进度、背包物品、任务状态这层是玩家最在意的丢了就是要命的事故所以写入必须可靠、必须能容错。最上层是配置类数据比如策划填的关卡表、怪物属性表、技能数值表这些其实是开发期的静态资源不应该混进玩家的存档文件里。把需求分成这三层之后选型就清晰很多。设置类可以直接上PlayerPrefs进度类一般用JSON或二进制序列化到文件配置类走ScriptableObject加Excel导表工具链。如果你把所有东西一股脑塞进一个PlayerPrefs里或者把策划表也序列化进玩家存档那后面维护一定会疼。我见过一个项目把整张技能表存进了PlayerPrefs后来每次改数值都要写迁移代码非常难受。另外一个容易被忽略的维度是数据规模。十个字段和一万条记录方案完全不同。字段少、结构固定直接定义一个Serializable类就够了要存几千上万条带查询需求的数据比如玩家历史对战记录、好友列表缓存那就该考虑SQLite了。规模上去之后线性读写的文件方案会越来越吃力尤其是需要按条件筛选的时候。1.2 一份可以直接抄的选型决策表下面这张表是我自己总结的每次开新项目会对照着过一遍基本能覆盖八成场景。方案适合数据读写速度体积可读性跨平台风险PlayerPrefs少量设置项快小中平台相关低JsonUtility固定结构进度中中高低Newtonsoft.Json复杂结构进度中偏慢中偏大高低自定义二进制大规模进度快小无低ScriptableObject编辑器配置快中高仅编辑器SQLite海量可查询数据中带索引快中中中表格里跨平台风险这一列是我特别在意的。有些方案在编辑器和Windows上跑得好好的一到Android或者小游戏平台就出问题典型代表就是靠反射的序列化方案在IL2CPP下受限靠本地库的SQLite在WebGL下需要额外适配。选型时把目标平台想清楚能省掉后期大量返工。提醒不要因为以后可能用得上就选最重的方案。存档系统越简单越好维护能三行代码搞定的别写三百行。2. PlayerPrefs最顺手也最容易埋雷2.1 PlayerPrefs在底层到底存了什么先搞清楚PlayerPrefs的底层实现很多坑就能提前预判。它不是Unity自己发明的一套存储而是对各个平台原生键值存储的封装。Windows上实际写进了注册表路径大致在HKEY_CURRENT_USER\Software公司名产品名下面macOS写进了plist文件Android底层是SharedPreferences本质是个XMLiOS是NSUserDefaults也是plistWebGL上落在IndexedDB里微信小游戏这类平台则是走平台自己的Storage接口。理解这一点很关键因为它决定了PlayerPrefs的两个特性第一它是全局命名空间不同项目只要公司名和产品名相同键名就会冲突第二各平台的容量限制和清理时机不一样Android上应用被卸载或者清除数据时会一起清掉iOS某些情况下系统可能回收所以它天然不适合当唯一存档。// 写入 PlayerPrefs.SetInt(Settings.Volume, 80); PlayerPrefs.SetString(Settings.Language, zh-CN); PlayerPrefs.Save(); // 不调用这句应用被强杀时可能丢 // 读取第二个参数是默认值 int volume PlayerPrefs.GetInt(Settings.Volume, 100); string lang PlayerPrefs.GetString(Settings.Language, en-US);上面这段里最容易被忽略的是Save()。PlayerPrefs在内存里做了缓存Set系列方法只是改缓存Save才会真正落盘。编辑器里关掉工程会触发自动保存所以很多人本地测不出问题一上真机玩家后台杀进程设置就没了。我的习惯是设置界面点应用时立刻调一次Save而不是等退出时统一写。2.2 三个必须知道的坑第一个坑是键名管理。PlayerPrefs没有命名空间也没有类型约束GetInt去读一个之前用SetString写进去的键不会报错只会拿到默认值。项目一大键名散落在各个脚本里改一个键名或者换一个类型几乎必然出事。我的做法是统一用一个静态类集中管理所有键并在每个键上标注类型。public static class PrefKeys { // 全部键名集中在这里改名只改一处 public const string Volume Settings.Volume; public const string Language Settings.Language; public const string LastAccount Login.LastAccount; }第二个坑是把结构化数据硬塞进去。有人会把背包列表拼成字符串存或者用JsonUtility序列化后当字符串塞进去。前者解析容易出错后者虽然可行但失去了PlayerPrefs轻的意义还多了两次序列化开销。超过五个字段的结构我建议直接换文件方案。第三个坑是WebGL和小游戏平台。WebGL的文件系统是内存虚拟文件系统PlayerPrefs写进去之后要调用PlayerPrefs.Save还不够整个IndexedDB的同步依赖浏览器刷盘时机。如果你在WebGL上做关键存档保险起见还是要走文件加SyncFs的方案后面会讲。2.3 什么时候果断放弃PlayerPrefs我的判断标准很简单只要这份数据丢了会导致玩家进度丢失或者需要客服补偿就不要用PlayerPrefs。它适合存首选项、上次选择、新手引导是否看过这类丢了无所谓的信息。反过来如果一份数据你需要版本迁移、需要加密、需要跨设备同步PlayerPrefs一个都做不到。还有一个性能细节。注册表或者SharedPreferences的写入不是免费的尤其是Windows注册表键值数量多了之后读写会变慢。我做过一个不太严谨的对比在Windows上写入一万个键PlayerPrefs的耗时明显高于写一个JSON文件。所以千万别把PlayerPrefs当数据库用。3. JSON方案JsonUtility与Newtonsoft.Json的取舍3.1 JsonUtility的能力边界JsonUtility是Unity内置的序列化工具优点是零依赖、速度快、和Unity类型体系贴合支持Vector2/3/4、Quaternion、Color、Rect这些内置结构体这对存档来说太方便了坐标直接存不用自己拆成三个float。用法也简单[System.Serializable] public class SaveData { public int version 1; public string playerName; public int level; public Vector3 position; public Listint unlockedItems new Listint(); public ListItemEntry backpack new ListItemEntry(); } [System.Serializable] public class ItemEntry { public int id; public int count; } // 写入 SaveData data new SaveData(); string json JsonUtility.ToJson(data, true); // true表示格式化方便调试 File.WriteAllText(path, json); // 读取 SaveData loaded JsonUtility.FromJsonSaveData(File.ReadAllText(path));它的限制也很明确我列一下都是实际会撞上的。第一不支持Dictionary这是最常见的抱怨。第二不支持属性只序列化public字段{ get; set; }会被忽略。第三不支持null序列化时null字段会被写成默认值反序列化时你也没法区分字段不存在和值为默认。第四不支持顶层数组JsonUtility.ToJson(new int[]{1,2,3})会返回空对象{}必须包一层类。第五不支持多态父类引用指向子类实例序列化后只保留父类字段。字典这个限制有几种绕过方式。一种是把字典转成两个Listkey一个Listvalue一个List读回来再拼另一种是用ISerializationCallbackReceiver在序列化前把字典展开成List反序列化后再合并回去。[System.Serializable] public class SerializableDictTKey, TValue : ISerializationCallbackReceiver { [SerializeField] private ListTKey keys new ListTKey(); [SerializeField] private ListTValue values new ListTValue(); private DictionaryTKey, TValue dict new DictionaryTKey, TValue(); public DictionaryTKey, TValue Data dict; public void OnBeforeSerialize() { keys.Clear(); values.Clear(); foreach (var kv in dict) { keys.Add(kv.Key); values.Add(kv.Value); } } public void OnAfterDeserialize() { dict.Clear(); for (int i 0; i keys.Count; i) dict[keys[i]] values[i]; } }注意OnAfterDeserialize里如果key重复会抛异常做防抖时最好先判断ContainsKey。3.2 Newtonsoft.Json什么时候值得引入当你的数据结构开始出现字典、多态、嵌套层次很深、需要自定义字段名的时候JsonUtility就不够用了。这时候可以引入Newtonsoft.JsonUnity官方包管理器里有com.unity.nuget.newtonsoft-json直接装就行不需要手动丢dll。using Newtonsoft.Json; var settings new JsonSerializerSettings { TypeNameHandling TypeNameHandling.Auto, // 支持多态 Formatting Formatting.Indented, NullValueHandling NullValueHandling.Ignore }; string json JsonConvert.SerializeObject(data, settings); SaveData loaded JsonConvert.DeserializeObjectSaveData(json, settings);它支持Dictionary、支持属性、支持多态、支持自定义Converter几乎是你想要的都有。代价是体积变大、性能变慢、GC分配更多。这里有个坑一定要强调TypeNameHandling会往JSON里写类型全名如果这个字符串来自外部输入并且被反序列化会有安全风险。本地存档自己写自己读问题不大但如果存档文件可能被玩家修改再加载就不要开这个选项或者至少做签名校验。3.3 序列化性能与GC的实测对比我拿一个包含两百个ItemEntry的SaveData做过一轮粗略对比在同一台机器上循环一千次序列化和反序列化。结果是JsonUtility大概快两到三倍GC分配大概只有Newtonsoft的三分之一到一半。数据量越大、嵌套越深差距越明显。所以我的默认策略是能用JsonUtility就用JsonUtility只在真的需要字典或复杂多态时才上Newtonsoft。有些项目为了图省事全局用Newtonsoft结果每次自动存档卡顿半帧查了半天才发现是序列化的问题。如果确实要用Newtonsoft又要控GC可以复用JsonSerializer实例避免每次创建还可以用JsonTextWriter配合StringBuilder减少中间字符串分配。这类优化在自动存档频率高的项目里效果很明显。4. 二进制与ScriptableObject两种极端场景4.1 自定义二进制写入当存档数据量非常大比如沙盒游戏的地形改动、建造类游戏的上万块结构JSON的体积和解析开销就受不了了。这时候二进制是正解。注意我强调的是自定义二进制而不是直接用老旧的BinaryFormatter。BinaryFormatter在Unity新版本里已经被标记为过时而且它有安全问题AOT平台上也经常有坑官方自己都不推荐了。自定义二进制的思路很简单按约定顺序写字段读的时候按同样顺序读回来。Unity的BinaryWriter和BinaryReader就够用。public static class BinarySave { public static void Write(string path, SaveData data) { using var fs new FileStream(path, FileMode.Create, FileAccess.Write); using var bw new BinaryWriter(fs); bw.Write(data.version); bw.Write(data.playerName ?? string.Empty); bw.Write(data.level); bw.Write(data.position.x); bw.Write(data.position.y); bw.Write(data.position.z); bw.Write(data.backpack.Count); foreach (var item in data.backpack) { bw.Write(item.id); bw.Write(item.count); } } public static SaveData Read(string path) { using var fs new FileStream(path, FileMode.Open, FileAccess.Read); using var br new BinaryReader(fs); var data new SaveData(); data.version br.ReadInt32(); data.playerName br.ReadString(); data.level br.ReadInt32(); data.position new Vector3(br.ReadSingle(), br.ReadSingle(), br.ReadSingle()); int count br.ReadInt32(); data.backpack new ListItemEntry(count); for (int i 0; i count; i) data.backpack.Add(new ItemEntry { id br.ReadInt32(), count br.ReadInt32() }); return data; } }好处是体积小、速度快、格式完全可控。坏处也很明显字段顺序必须严格对应改一个字段就要写迁移代码而且完全没有可读性出问题时没法直接打开看。我的做法是在文件头写一个魔数加版本号读的时候先校验版本不匹配就跳到迁移分支。private const uint Magic 0x554E4954; // UNIT private const int CurrentVersion 3;提醒BinaryWriter.Write(string)用的是带长度前缀的UTF-8跨语言读取时要注意长度前缀的编码方式和小端序约定。如果不想手写这么多读写代码可以考虑MessagePack for C#它兼顾了二进制体积和自动化只是会多一个依赖。项目对包体不敏感的话它是手写二进制的很好的替代。4.2 ScriptableObject的定位与常见误用ScriptableObject常被拿来跟持久化混为一谈其实它的定位是编辑器期的资源容器不是运行时存档方案。它最大的价值在于把策划配置从代码里剥离出来让非程序也能改数值还能在Inspector里直接引用其他资源。[CreateAssetMenu(fileName LevelConfig, menuName Config/LevelConfig)] public class LevelConfig : ScriptableObject { public int levelId; public string levelName; public int targetScore; public ListEnemySpawn spawns; }运行时你的配置数据是从打包好的资源里加载的只读。不要把玩家的进度写回ScriptableObject因为在编辑器和打包后行为完全不一样。编辑器里你改了它会永久保存到资源文件打包后这些修改只在内存里重启游戏就没了很多新手在这里栽过跟头。那ScriptableObject能不能用来存档可以但要用另一种方式把存档数据写进一个ScriptableObject实例然后通过AssetDatabase在编辑器下保存或者序列化成JSON再落盘。前者只在编辑器工具里用后者本质上还是JSON方案。所以结论是ScriptableObject适合配置不适合玩家存档。它还有一个妙用是做数据模板。比如你想给玩家一个初始背包可以做一个ScriptableObject存初始物品列表新档时把它克隆成运行时数据。这样策划改初始资源不用动代码。这个模式和前面说的三层需求里的配置层完美契合。5. SQLite当存档变成一张张表5.1 什么时候真的需要数据库判断标准还是数据规模和查询需求。如果满足下面任意一条就该认真考虑SQLite了需要按条件筛选大量记录比如查某个时间段的对战需要频繁增量更新而不是整块重写比如每分钟写一条日志数据关系复杂有明显的一对多、多对多比如玩家、角色、装备三张表。反过来说如果只是存个进度一千个字段以内上SQLite就是杀鸡用牛刀还会引入原生库和平台适配的麻烦。我见过有项目存个音量设置也建表纯粹是过度设计。5.2 sqlite-net的接入步骤Unity里用SQLite最常见的是sqlite-net它是个轻量ORM用特性标注表结构。接入步骤是从仓库取SQLite.cs和SQLiteAsync.cs再根据目标平台放入对应的原生库Windows用dllAndroid用soiOS用静态库。这部分平台差异是SQLite最大的接入成本。using SQLite; [Table(battle_log)] public class BattleLog { [PrimaryKey, AutoIncrement] public int Id { get; set; } [Indexed] public long Timestamp { get; set; } public int LevelId { get; set; } public int Score { get; set; } } public class DbManager { private SQLiteConnection conn; public void Init(string path) { conn new SQLiteConnection(path); conn.CreateTableBattleLog(); } public void AddLog(BattleLog log) conn.Insert(log); public ListBattleLog GetRecent(int count) conn.QueryBattleLog( SELECT * FROM battle_log ORDER BY Timestamp DESC LIMIT ?, count); }[Indexed]这个标记很重要加在时间戳或者外键上查询速度差一个数量级。我实测过一万条记录按时间倒序取十条没索引要遍历全表有索引基本是常数时间。同步接口会阻塞主线程数据量大的时候建议用带Async后缀的异步版本或者在独立线程里操作。数据库文件同样放在Application.persistentDataPath下注意不要和JSON存档混在同一个文件里。注意SQLite对并发写入不友好单进程内多线程写同一个库要加锁或者干脆所有写操作都走一个队列。6. 一套可复用的存档系统落地实录6.1 路径、原子写入与备份不管用哪种序列化方式落盘这一步是共通的也是事故最多的地方。先解决路径问题。唯一正确的存档目录是Application.persistentDataPath它在各平台都指向可读写且不会被系统随便清理的目录。Application.dataPath在移动端是只读的打包后写它会直接失败新手特别容易踩这个。public static string SavePath(string fileName) { return Path.Combine(Application.persistentDataPath, fileName); }各平台实际路径大概是Windows在C:\Users\用户\AppData\LocalLow\公司\产品Android在/storage/emulated/0/Android/data/包名/filesiOS在应用沙盒的Documents目录WebGL是一套基于IndexedDB的虚拟文件系统。这个差异在做云同步或者让玩家手动导出存档时要特别注意路径不能写死。然后是原子写入。直接File.WriteAllText到目标文件有个致命问题如果写到一半断电或者崩溃原文件已经被截断新旧存档全没了。正确做法是先写临时文件写完校验再用File.Replace原子替换。public static void WriteAtomic(string path, string content) { string tmp path .tmp; string bak path .bak; File.WriteAllText(tmp, content); if (File.Exists(path)) File.Replace(tmp, path, bak); // 原子替换同时保留备份 else File.Move(tmp, path); }读取时也要做容错主文件坏了就尝试备份再不行就返回新建档。public static string ReadWithFallback(string path) { try { if (File.Exists(path)) return File.ReadAllText(path); } catch { /* 主文件损坏继续往下 */ } string bak path .bak; if (File.Exists(bak)) return File.ReadAllText(bak); return null; // 调用方决定新建还是报错 }提醒File.Replace在部分Android外置存储或者某些网络文件系统上可能抛异常上线前一定在目标机型和真机上验证。必要时退化成删除旧的再改名的流程虽然理论上有一个极短的窗口期但比整块丢档好。6.2 一个够用的存档管理器把序列化、原子写入、备份和版本迁移串起来就是一个能直接用的存档管理器。我这里给一个精简版用JsonUtility作为序列化后端。public static class SaveManager { public static SaveData Current { get; private set; } private static string FilePath SavePath(save.json); public static void Load() { string json ReadWithFallback(FilePath); if (string.IsNullOrEmpty(json)) { Current CreateNew(); return; } try { Current JsonUtility.FromJsonSaveData(json); Current Migration.Run(Current, json); // 版本迁移 } catch (System.Exception e) { Debug.LogError($存档解析失败: {e.Message}); Current CreateNew(); } } public static void Save() { string json JsonUtility.ToJson(Current, true); WriteAtomic(FilePath, json); } private static SaveData CreateNew() { var d new SaveData(); d.version SaveData.CurrentVersion; return d; } }这段代码看起来简单但每个分支都是踩坑换来的。FromJson抛异常要接住否则一个损坏字节就能让游戏启动就崩迁移要在解析之后立刻做不然旧档字段对不上CreateNew要设置当前版本号否则第一次保存的档会被迁移逻辑误判。存档时机上我一般会在这几个点触发进入关卡时、通关结算后、商店交易后、退出到主菜单时以及应用OnApplicationPause(true)和OnApplicationQuit时兜底。注意OnApplicationQuit在移动端不保证被调用别把唯一一次保存寄托在它身上。6.3 加密与防篡改的务实做法先说一句实话客户端的加密都只能提高门槛不能杜绝破解密钥藏在客户端里总有办法挖出来。所以目标要定得务实——让普通玩家改存档的门槛从记事本打开改数字提高到需要点逆向能力就够了别指望做成绝对安全。在这个前提下我的做法是JSON加AES对称加密再加一个HMAC签名防篡改。签名比加密更重要因为很多作弊是直接改数值签名一验就能发现。public static string Sign(string content, byte[] key) { using var hmac new System.Security.Cryptography.HMACSHA256(key); byte[] hash hmac.ComputeHash(System.Text.Encoding.UTF8.GetBytes(content)); return System.Convert.ToBase64String(hash); }把签名和内容一起写进文件读取时先验签再解析验不过就当损坏处理。密钥别写成一个完整的字符串常量拆成几段在运行时拼接再用简单变换混淆一下。这拦不住有心人但能拦住随手改文件的。注意加密和签名会增加CPU开销自动存档频率高的话要评估一下别让存档卡了帧。6.4 版本迁移怎么写才不痛存档版本迁移是那种不做没事做了救命的功能。核心就一条每次改存档结构都要写一段从旧版本到新版本的转换逻辑并且可以链式执行。public static class Migration { public static SaveData Run(SaveData data, string rawJson) { // 从JSON里单独抠出版本号防止反序列化时字段缺失 var probe JsonUtility.FromJsonVersionProbe(rawJson); int v probe ! null ? probe.version : 1; if (v 2) UpgradeTo2(data); if (v 3) UpgradeTo3(data); data.version SaveData.CurrentVersion; return data; } }这里有个细节值得强调SaveData本身随着版本变化用当前类去反序列化旧档缺失的字段会拿到默认值这通常是安全的但如果字段被删了或者类型变了就可能解析失败。所以我额外定义一个只有version字段的VersionProbe专门用来探版本号保证探版本这一步永远不失败。迁移代码不要图省事全塞在一个大if里按版本拆成独立方法每个方法只管一步。这样以后新增版本只加一个新方法老逻辑不动回归测试范围可控。7. 常见问题与排查技巧实录7.1 存档故障速查表下面这张表是我和同事这些年攒下来的基本覆盖了绝大多数存档报障。现象可能原因排查方向编辑器正常真机丢档没调Save或没落盘检查是否调了Save真机测强杀换设备后进度没了只存了本地确认是否做了云同步存档偶尔损坏非原子写入改成临时文件加Replace改了类结构后读档崩没做版本迁移加VersionProbe和迁移链存档文件越来越大存了不该存的数据检查是否把配置或日志写进去读写卡顿半帧序列化开销大或主线程IO换轻量方案或异步写WebGL上存档不生效虚拟文件系统没刷盘调用SyncFs强制同步这张表里的每一条我都亲身遇到过。最典型的编辑器正常真机丢档几乎每个新手项目上线前都会来一次。还有一个隐蔽的问题是公司名和产品名改了导致persistentDataPath路径变了老玩家的档找不到看着像丢档实际上是路径迁移问题上线后千万别随便改这两个值。7.2 平台差异与实测经验Android上有个特别容易被忽略的点外置存储的写入延迟比内置存储高很多尤其是低端机。如果你的存档写入在主线程同步做可能会卡一下。我的处理是简单数据同步写超过一定体量的数据走协程分帧或者后台线程。iOS上要注意Documents目录会被iCloud备份如果你的存档很大可能会影响审核时对可重建数据的判断。像一些缓存类的文件可以放到Library/Caches下面并且标记不备份。这是个审核经验不是技术问题但确实会卡上线。WebGL和微信小游戏这类平台要单独说。WebGL的文件系统是内存态的写文件后必须等浏览器把数据同步进IndexedDB否则刷新页面可能丢。Unity提供了Application.ExternalCall时代的老做法现在更推荐在写完后调用同步接口。微信小游戏有自己的存储接口容量有上限关键档要控制体积同时考虑分包存储。还有一个和热词里unity 优化 限定数据块大小相关的经验存档不要一次性写一个巨大的块。我遇到过一个项目把几十兆的建造数据塞一个文件结果移动端写入经常超时。后来拆成主档加多个分片只写改动的分片问题就解决了。原则是单文件尽量控制在几百KB以内超了就考虑分片或者上数据库。最后再分享一个排查技巧。存档出问题时我第一反应是把真机上的存档文件拉出来用adb或者直接读沙盒目录用文本编辑器打开看内容是否完整。很多问题看一眼文件就明白了比反复改代码猜要快得多。为此我在开发期会保持JSON可读ToJson的第二个参数传true只有发布版才关掉格式化。这个习惯帮我省下过无数个小时。提醒调试期可以在设置里加一个导出存档的隐藏入口把存档路径和内容展示出来线上出问题时让玩家截图定位效率会高很多。整个选型和使用过程走下来我的核心体会其实就一句话先想清楚数据的重要性分层再决定用多重的方案。设置类用PlayerPrefs进度类用JSON或二进制配置类用ScriptableObject海量可查询数据才上SQLite。落盘环节统一走原子写入加备份再配上版本迁移和签名校验这套组合能扛住绝大多数项目的存档需求。真正难的从来不是选哪个API而是你有没有在写第一行存档代码时就想好断电、崩溃、跨版本、跨设备这些场景该怎么兜底。