做Unity开发的人迟早都要面对同一个问题:游戏关了,玩家数据怎么留下来?不管是音量设置、金币数量、背包物品还是剧情进度,只要进程一结束,内存里的东西就全没了。想让这些数据在下次启动时原样恢复,就得做数据持久化。今天这篇我想把Unity里常用的数据持久化方案从头到尾捋一遍,包括PlayerPrefs、JSON文件、SQLite数据库,以及围绕它们的封装、加密、版本迁移这些实际工程里躲不开的事。不管是刚写完第一个小Demo、准备做正式存档系统的朋友,还是已经用PlayerPrefs存了一堆数据、想换更可靠方案的老开发,这篇文章应该都能给你一些参考。
我这几年经手的Unity项目,几乎没有哪个是跟存档无关的。而且踩过的坑也不少,比如存档写到一半进程被杀、文件损坏、更新版本后老存档读不出来,这些情况网上教程很少提。所以这篇我不会只讲API怎么用,更多是想分享实际项目里“怎么选方案、怎么写能少踩坑”的工程经验。
1. 先想清楚需求,再谈方案选型
很多人一上来就问“Unity数据持久化用什么”,其实这个问题没法直接回答。存音量设置和存一万条NPC战斗记录,压根不是同一个工程。我见过有人用PlayerPrefs存背包数据,结果后期数据结构一变动就到处爆错;也见过为了存几条设置项,愣是给项目塞了一个数据库的三方库。想要不后悔,动手前得把需求列清楚。
1.1 数据持久化到底在存什么
先给自己的游戏数据分个类,常见的其实就这几种:
- 玩家设置类:音量、画质档位、语言、上次登录时间。特点是数据量极小、结构扁平、改动频率低,丢了也不算大事。
- 游戏进度类:关卡解锁、金币、道具列表、角色属性。一般是一个或几个核心对象,结构可能嵌套但不至于复杂成百上千条记录。
- 结构化业务类:成就列表、任务进度、交易记录、地图上大量动态物件状态。特点是条数多、需要查询和筛选,甚至要做排序、分页。
- 二进制资源类:截图、自定义头像、语音,这类就去写文件好了。
想清楚数据属于哪一类,再去做技术选型就会清晰很多。最怕的是需求还没定就开始敲代码,到后面换方案的成本非常高。
1.2 三条主路线对比
Unity里做得最多的持久化方案其实就是三条:PlayerPrefs、文件序列化、本地数据库。
PlayerPrefs是引擎自带的一个键值存储,用起来最简单。它就像你手边的杂物抽屉,往里面扔点小东西很方便,但你要是想对抽屉里的东西做排序、检索、范围查询,那完全没辙。适合放设置项和少量标记值。
JSON文件是大部分存档系统的首选。把游戏对象整个序列化成文本,写进一个文件里。它像笔记本,内容结构完全由你决定,清晰又直观,调试的时候打开文件就能看明白。适合存游戏进度这类“一个对象就能代表整个存档”的数据。
SQLite是落在本地的关系型数据库,相当于给你的游戏配了一个带检索功能的仓库。它能建表、写SQL、带索引、支持事务,适合数据量大、需要筛选统计的场景,比如战斗回放、成就系统、任务日志。
三条路线的差异,我做了一张表方便对照:
| 维度 | PlayerPrefs | JSON文件 | SQLite |
|---|---|---|---|
| 存储介质 | 平台自带键值存储 | 普通文本文件 | 独立数据库文件 |
| 数据结构 | 扁平的键值对 | 可嵌套的任意对象 | 多张表,字段类型严格 |
| 读写效率 | 极快但不宜频繁大写入 | 整存整取,量大后IO有压力 | 按SQL语句操作,批量高效 |
| 可读性 | 差,本质上是个黑盒 | 极好,随时打开查看 | 可用工具查看,自带查询语言 |
| 依赖程度 | 引擎内置 | 可选JsonUtility或三方库 | 需要导入SQLite插件 |
| 适用数据量 | KB级以下 | MB级以下 | MB级以上都没问题 |
1.3 我做选型时习惯用的判定规则
具体到实际项目,我的判定方法很简单:
- 设置项、小标记、检查点:PlayerPrefs,秒存秒取,不需要文件管理。
- 游戏主存档,就是一个对象能描述完整状态的那种:JSON文件,最通用也最直观。
- 大量结构化数据、需要按条件筛选和统计:SQLite,别硬塞JSON。
- 需要跨设备同步:文件平台逃不掉,JSON加密后配合服务端接口,本地结构只是其中一个环节。
有一个很现实的问题,就是新手容易把所有东西都塞给PlayerPrefs。等你想把背包数据整套迁移到JSON时就会发现,所有的读取逻辑都散落在脚本里,改起来特别痛苦。所以我的建议是:哪怕不知道最终方案,也先封装一层存储接口,后面换实现的时候才不用把项目翻个底朝天。
2. PlayerPrefs入门:别小看这个最简单的方案
PlayerPrefs是Unity里最基础的持久化API,适合用来存音量、画质、语言这种“脏了也不心疼”的数据。但越是简单的API,越容易在细节上翻车。
2.1 PlayerPrefs的本质与平台差异
你不需要关心PlayerPrefs到底存在哪,但有一点必须清楚:它在不同平台上的存储位置和方式完全不同。在Windows上它写进注册表,在macOS上写进plist文件,在Android上是SharedPreferences,在iOS上则是NSUserDefaults。
这意味着两件事:第一,你不能假设玩家卸载游戏后数据一定被清掉,有的平台会留残留;第二,系统清理工具可能在不经意间把数据干掉。所以PlayerPrefs里绝对不能放“不可再生”的贵重数据,比如付费道具、角色等级。这些数据一旦没了,玩家直接找客服,客服还没法还给他,那场面就非常糟糕了。
另外还有一个容易被忽略的问题:PlayerPrefs在WebGL平台下的表现很奇怪,而且不同浏览器刷新策略会导致数据丢失。轻度项目可以接受,但如果你做的是正经上线的WebGL游戏,存档方案建议优先考虑其他方式。
2.2 基础操作玩法
PlayerPrefs的基础操作是SetInt、SetString、SetFloat,读取时我强烈建议永远带上默认值参数。这个默认值不是摆设,玩家第一次启动、或者存档被系统清理之后,读出来的就是默认值,游戏不至于直接报错。
PlayerPrefs.SetInt("musicVolume", 0.7f); PlayerPrefs.SetString("lastLogin", "2025-01-12"); PlayerPrefs.Save();读取就是反向的操作,加上一个三元判断或者default值。还有个细节是HasKey,比如玩家首次启动要有新手引导状态,就可以先查一下键存不存在。
很多人忽略的一个点是Save的调用时机。桌面平台玩家正常退游戏的话引擎会帮你落盘,但移动平台就不一定。如果玩家玩到一半切后台、或者直接被系统杀掉,数据就可能丢。我自己的习惯是监听OnApplicationPause和OnApplicationQuit,在这些回调里统一调Save。
void OnApplicationPause(bool pause) { if (pause) PlayerPrefs.Save(); } void OnApplicationQuit() { PlayerPrefs.Save(); }2.3 封装一套好用的Key管理
PlayerPrefs用多了之后,最大的问题不是API不会用,而是字符串Key到处都是。今天你在设置脚本里写个“volume”,明天你在存档脚本里也写“volume”,改的时候还容易漏。这就是典型的魔法字符串污染。
我的做法是建一个静态配置类,把所有Key集中管理,再配合泛型方法做读写:
public static class PlayerPrefKeys { public const string MusicVolume = "settings_music_volume"; public const string SoundVolume = "settings_sound_volume"; public const string Language = "settings_language"; public const string LastLogin = "progress_last_login"; } public static class PlayerPrefStore { public static int GetInt(string key, int defaultValue) => PlayerPrefs.GetInt(key, defaultValue); public static void SetInt(string key, int value) { PlayerPrefs.SetInt(key, value); PlayerPrefs.Save(); } }写一个简单的封装类,好处是以后想统一加前缀、加日志都方便。但注意不要做得太复杂,PlayerPrefs终究只适合简单数据,没必要为它建一个完整的存档系统框架,过度设计有时候比不设计更累。
2.4 我为什么不建议在PlayerPrefs里存大块数据
有人会把一整段JSON字符串塞进PlayerPrefs,图省事。我试过这么干,最后发现问题不少:一个字符串Set进去就是上千字符,而PlayerPrefs本来是为快速小体积读写设计的;反复Set同一个大字符串,性能开销和磁盘写入频率都比想象中大得多。而且调试特别痛苦,你不能像打开文本文件一样直接看内容。
所以PlayerPrefs的正确打开方式,就是存“短小、扁平、低价值”的数据。一旦发现你要往里面塞一大段结构化内容,就该切换到JSON文件方案了。
3. JSON文件存档:大多数存档系统的通用答案
如果游戏有一个核心存档对象,里面有玩家进度、金币、背包、各类解锁状态,那我建议直接把它序列化成JSON文件。这是Unity项目中我用得最多、也最推荐大多数人采用的方案。
3.1 序列化工具怎么选
Unity里做JSON序列化,可选的东西不少,我说说自己的选择逻辑。
JsonUtility是官方自带的方案,零依赖,性能也够用。但它有几个硬伤:不支持Dictionary,不能序列化属性(property),只能序列化public字段和标记了[Serializable]的私有字段,结构继承上也有诸多限制。小项目简单用用可以,一旦存档里出现字典或者多态,就会非常痛苦。
Newtonsoft Json.NET是目前最成熟的三方库,支持Dictionary、支持属性、支持自定义Converter,功能基本全乎,我新项目只要条件允许都会优先上它。缺点是包体和首次初始化略重,但现代Unity通过程序集裁剪可以控制得不错。
LitJson是老牌库,功能不如Json.NET全,但胜在轻量和稳定,适合只需要存JsonObject格式、不想引入沉重依赖的项目。
另外还有一个很实用但容易被忽略的细节:JsonUtility默认输出是不带缩进的紧凑字符串,调试的时候打开文件全挤在一起,看得人脑仁疼。想看得舒服,可以自己写个格式化函数,或者干脆用Json.NET自带的美化输出。
3.2 存档到底该放哪个目录
Unity里有两个目录经常被人搞混:Application.persistentDataPath和Application.streamingAssetsPath。
- persistentDataPath是系统分配给应用的持久化数据目录,可读写,真正的玩家存档必须放这里。各平台路径不太一样,Windows下通常长这样:C:/Users/用户名/AppData/LocalLow/公司名/产品名,Android下是一块内部沙盒存储,iOS下在应用沙盒的Documents或Library里。
- streamingAssetsPath是只读目录,适合放初始配置、策划配置表这类只读资源。你想往这里写文件,在真机上必然失败。
很多新人的坑就是把存档直接写到程序运行目录下,也就是当前工作目录。这在编辑器里看着正常,但真机上目录往往是只读的。而且平台更新/安装包更新后,程序目录会被覆盖,存档直接消失。所以存档路径的事,说一遍都不够:一律用Application.persistentDataPath。
为了方便调试,我会在编辑器环境加一个菜单项,一键打开当前存档目录:
[MenuItem("Tools/Open Persistent Data Path")] public static void OpenPersistentDataPath() { Application.OpenURL("file://" + Application.persistentDataPath); }这个功能看着小,实际上帮了我大忙。每次测试存档都靠它直接定位文件,省了大量在文件夹里找路径的时间。
3.3 一个能直接用的存档读写封装
接下来放一个我项目里常年在用的存档封装,结构很简单,但胜在皮实:
using System; using System.IO; using System.Text; using UnityEngine; public static class SaveSystem { private static readonly string SaveDirectory = Path.Combine(Application.persistentDataPath, "Saves"); private static readonly UTF8Encoding Utf8NoBom = new UTF8Encoding(false); public static void SaveJson<T>(string fileName, T data) { try { if (!Directory.Exists(SaveDirectory)) Directory.CreateDirectory(SaveDirectory); string json = JsonUtility.ToJson(data, true); string path = Path.Combine(SaveDirectory, fileName); File.WriteAllText(path, json, Utf8NoBom); } catch (Exception e) { Debug.LogError($"[SaveSystem] 保存失败: {e.Message}"); } } public static T LoadJson<T>(string fileName, T defaultValue) where T : class { try { string path = Path.Combine(SaveDirectory, fileName); if (!File.Exists(path)) return defaultValue; string json = File.ReadAllText(path, Utf8NoBom); return JsonUtility.FromJson<T>(json); } catch (Exception e) { Debug.LogError($"[SaveSystem] 读取失败: {e.Message}"); return defaultValue; } } public static bool HasFile(string fileName) => File.Exists(Path.Combine(SaveDirectory, fileName)); }几点值得说的地方:
- Directory.CreateDirectory在目录已存在时不会报错,放心调用。
- **UTF8Encoding(false)**显式指定了不带BOM的UTF-8,避免在某些平台产生BOM头,进而干扰解析。这也是中文乱码问题最常见的兜底。
- 读取和写入都套了try-catch,这是必须的,因为文件损坏、磁盘异常、权限问题都可能让你在真机上崩溃。
- Load失败时返回默认值而不是抛异常,这样游戏还能继续跑,顶多存档丢失。
3.4 存档加密:别用Base64冒充加密,直接用AES
存档文件是个纯文本,玩家用小工具打开就能改。想防止玩家随意篡改,最基础的手段是加密。但这里我劝一句,Base64不是加密,它只是编码,Base64.decode一下照样明文全露,千万别用这个糊弄玩家。
真正能用的是对称加密,Unity里最常见的就是AES。直接贴一个可以跑的示例:
using System; using System.IO; using System.Security.Cryptography; using System.Text; public static class AesEncryption { private static readonly byte[] DefaultKey = Encoding.UTF8.GetBytes("Your32ByteSecretKey1234567890"); public static byte[] Encrypt(string plainText) { using (Aes aes = Aes.Create()) { aes.Key = DefaultKey; aes.GenerateIV(); using (var encryptor = aes.CreateEncryptor()) using (var ms = new MemoryStream()) { ms.Write(aes.IV, 0, aes.IV.Length); using (var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write)) using (var writer = new StreamWriter(cs, Encoding.UTF8)) { writer.Write(plainText); } return ms.ToArray(); } } } public static string Decrypt(byte[] cipherBytes) { using (Aes aes = Aes.Create()) { byte[] iv = new byte[aes.IV.Length]; Array.Copy(cipherBytes, 0, iv, 0, iv.Length); aes.Key = DefaultKey; aes.IV = iv; using (var decryptor = aes.CreateDecryptor()) using (var ms = new MemoryStream( cipherBytes, iv.Length, cipherBytes.Length - iv.Length)) using (var cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read)) using (var reader = new StreamReader(cs, Encoding.UTF8)) { return reader.ReadToEnd(); } } } }我把IV直接拼在了密文头部,这样解密时能自己取出来。密钥不要直接写死在客户端里,这句话要拎出来说:纯客户端加密只能防普通玩家用记事本修改,防不了有心人反编译找密钥。想做到真正的安全需要服务端参与校验,那又是另一个话题了。
不过在项目上的实际建议是:先明文调试,最后上线之前再决定是否加密。我一个项目里掉过头来,一开始就加密,结果因为一个小字段解析问题,所有加密后的存档都看不出来问题,排查了一整天。明文阶段出了错,直接用文本编辑器打开文件就能定位,效率完全不是一个级别。
3.5 防写坏:临时文件加原子替换
写文件最怕什么?写到一半断电、进程被杀、磁盘空间满,导致存档文件不完整。下次启动读文件直接解析失败,玩家心态就崩了。
我的做法是原子写:先写到.tmp临时文件,写完再把旧文件替换掉。如果替换失败,旧文件至少还在。
public static void SaveJsonAtomic<T>(string fileName, T data) { string finalPath = Path.Combine(SaveDirectory, fileName); string tempPath = finalPath + ".tmp"; string json = JsonUtility.ToJson(data, true); File.WriteAllText(tempPath, json, Utf8NoBom); File.Replace(tempPath, finalPath, null); }另外,还可以在Save的时候把旧文件备份成.bak再覆盖,读取时如果主文件损坏,还能尝试从备份恢复。这套组合拳下来,玩家手里的存档基本就稳了。
4. SQLite方案:当数据量大到JSON都吃力时
JSON方案每一个存档文件都是一个完整的对象,看起来很方便,可一旦存档里面塞的是上百个NPC实体、几千条日志,每次读盘都要解析一整坨文本,性能就会变得很难看。这时候就该上SQLite了。
4.1 SQLite适合什么场景
SQLite是一个文件型关系数据库,整个数据库就是一个.db文件,不需要额外安装服务端程序,非常适合移动端离线存储。我用SQLite主要是在这几类场景:
- 成就与统计系统:玩家打过多少场战斗、解锁了多少成就,这些记录需要追加和查询,天然适合表结构。
- 任务系统:任务状态需要频繁变更,按任务ID查询、按状态筛选,写SQL比扫JSON列表舒服得多。
- 地图物件状态:大型房建类游戏,地图上有几百个可交互物件,每个都要存状态,SQLite按坐标或区域查就很香。
- 行为日志:想看玩家在哪关死了几次,这种数据量很大但结构统一,非常适合数据库。
如果你的存档就是“一个RPG角色:名字、等级、金币、背包”,那真没必要上SQLite,JSON一本万利。SQLite适合那种明显有“表”感觉的数据。
4.2 集成sqlite-net的步骤
Unity里用SQLite,我不建议直接用原生的sqlite3.dll去写P/Invoke,太痛了。推荐用sqlite-net这个库,它本质是一个轻量ORM,把常用的增删改查都包成了方法。
集成步骤大概是这样的:
- 导入sqlite-net的插件包,确保里面有对应Android、iOS、Windows各平台的本地库。
- 定义表对应的实体类,用特性标注主键。
- 获取数据库文件路径,创建SQLiteConnection。
- 调CreateTable,然后写增删改查。
代码大概长这样:
public class PlayerEntity { [PrimaryKey] public int Id { get; set; } public string Name { get; set; } public int Level { get; set; } public long Gold { get; set; } } public class GameDatabase { private SQLiteConnection _connection; public GameDatabase() { string path = Path.Combine( Application.persistentDataPath, "GameData.db"); _connection = new SQLiteConnection(path); _connection.CreateTable<PlayerEntity>(); } public void SavePlayer(PlayerEntity player) { _connection.InsertOrReplace(player); } public PlayerEntity GetPlayer(int id) { return _connection.Table<PlayerEntity>() .Where(p => p.Id == id) .FirstOrDefault(); } public List<PlayerEntity> GetTopPlayers(int count) { return _connection.Table<PlayerEntity>() .OrderByDescending(p => p.Level) .Take(count) .ToList(); } }4.3 使用SQLite的三个实际建议
第一,连接不要每帧都开。SQLiteConnection虽然在移动端表现不错,但频繁打开关闭连接还是会有开销。我一般是在游戏启动时创建一个DbManager,始终持有同一个连接,需要时调用方法即可。
第二,批量写入记得用事务。一次性能差百倍,真不是夸张。往表里插一千条数据,一条一条插可能需要好几秒,包进一个事务里基本毫秒级就完成了。
_connection.BeginTransaction(); for (int i = 0; i < logs.Count; i++) { _connection.Insert(logs[i]); } _connection.Commit();第三,查数据用查询而不是遍历到内存。我见过有人把整张表ToList之后再用LINQ去Where,倒也不是不行,但一旦数据量上万,那种写法很容易让人后悔。直接让SQLite在SQL层面过滤,才是数据库该干的活。
5. 版本兼容:存档系统最该设计的一部分
存档文件今天写好了,明天版本更新了,怎么办?我见过太多项目上线之后,新版本读老存档直接抛异常,玩家反馈“更新后进度没了”,那种问题修复起来特别尴尬。你没法让玩家重新打一遍,只能让代码自己去适配旧存档。
5.1 存档结构必须带版本号
做存档的第一天,就要在存档对象里放一个version字段。这几乎是所有正规存档系统的标配。哪怕你觉得现在是内部测试不用管,也得加上,因为版本迭代是必然会发生的。
[Serializable] public class GameSaveData { public int version = 1; public int chapter; public int gold; public List<string> unlockedItems = new List<string>(); }5.2 迁移流程:老版本按老规则处理
读取存档的流程应该是这样的:先读出原始JSON数据,不要直接强转到当前版本的类,而是先取一下version字段,再根据版本做迁移。
public GameSaveData LoadAndMigrate(string filePath) { string json = File.ReadAllText(filePath, Encoding.UTF8); // 先解析出版本号 var raw = JsonUtility.FromJson<RawSaveData>(json); int version = raw.version; // 逐版本迁移到最新 if (version < 2) { // 老存档字段改名、补默认值 json = MigrateV1ToV2(json); } if (version < 3) { // 新版本加了新系统,老玩家没玩过,补默认值 json = MigrateV2ToV3(json); } return JsonUtility.FromJson<GameSaveData>(json); }5.3 字段变更的黄金法则
版本迭代时,改存档结构有几个规则:加字段没问题,删字段要小心,改类型是灾难。
JSON解析时,类里新增一个字段,老存档读进来默认值是0或null,这是安全的。删掉一个字段,反序列化会跳过它,也不崩。但改类型就很危险,比如老字段是string,新版本改成int,反序列化可能抛异常,损失一大片存档。
所以我的建议是:新增功能时,新字段给默认值;实在要改字段含义,宁可新增一个字段也不要在原字段上改类型。同时,每个版本的迁移逻辑要留好测试存档,自己先跑一遍升级流程,别等玩家玩了半个月才发现升级崩。
6. 常见问题与排查技巧实录
最后这部分整理一下我在实际项目里遇到过的、以及很多同行经常被问到的典型问题,每一条都是踩过坑之后的心得。
| 症状 | 根本原因 | 解决办法 |
|---|---|---|
| JsonUtility序列化后Dictionary是空的 | JsonUtility不支持Dictionary | 改成List保存键值对,或直接换Json.NET |
| 存档里的中文变成乱码 | 读写编码不一致 | 显式指定UTF8无BOM编码进行读写 |
| 第一次启动读存档报文件找不到 | 还没生成过存档文件 | 用File.Exists判断,不存在就返回默认数据 |
| 读档时偶发异常 | 存档文件损坏或写了一半 | 读档加try-catch,写档用临时文件原子替换 |
| 玩家卸载重装后数据还在 | PlayerPrefs在部分平台残留 | 接受现实,或换文件存储并加清除按钮 |
| 编辑器里能写、真机报UnauthorizedAccess | 路径写到了只读目录 | 一律使用Application.persistentDataPath |
| 更新版本后老存档废弃 | 字段变更导致反序列化失败 | 提前设计version字段和迁移流程 |
| 打了加密补丁后老存档读不了 | 老存档是明文、新逻辑只走解密 | 读取时按文件头判断,兼容明文和密文两种格式 |
| 每帧写存档,帧率明显下降 | 频繁全量写文件 | 只在关键节点保存,或做延迟批量写 |
再补充两个非常实用的调试技巧。
第一个是读档逻辑里打日志。别小看这条,数据持久化的问题大多数是静默异常,你开着Debug.Log看一遍读档的每一步,很多问题秒变明显。
第二个是给存档加一个“开发者模式”。在编辑器里加个快捷键,一键生成一个特定版本的存档文件,专门用来测试迁移逻辑。没有这个,以后版本兼容就无从谈起。
7. 最后说点个人经验
数据持久化这件事,Unity给的API很简单,但真正把它做好做稳,全靠工程细节。PlayerPrefs、JSON、SQLite没有高低之分,只有合适不合适。我在实际项目里最深的体会是:存什么、存多少、怎么容错,这些设计决定远比某个API的用法更影响后面几个月的开发体验。千万别觉得“反正先存起来,以后再改”,等数据格式铺开后,改造成本会让你很头大。
最后再分享一个小技巧:无论你用哪种方案,保存前把数据备份一份,保存后校验一遍。我宁可多写几行代码,也不愿意看着玩家辛苦打出来的进度凭空消失。这个行业里,玩家进度丢了,比服务器宕机还让人崩溃。希望大家都能绕开我踩过的这些坑,把存档这关稳稳过掉。