做完一个demo,关掉编辑器重新跑一遍,金币、关卡进度、音量设置全回到了初始值——这个场景几乎每个Unity开发者都经历过。数据持久化看起来是"存个文件"这么简单的事,但真正上手之后你会发现,选PlayerPrefs还是JSON、存到哪个目录、打包之后为什么读不到、WebGL上数据为什么第二天就没了、玩家改一改存档文件就把道具刷满了……每一个问题背后都对应着不同的技术选择。这篇笔记我把这几年在项目里用过的几种Unity数据持久化方式完整梳理一遍,从最简单的键值对到SQLite数据库,从单机存档到结构化配置,把每种方案的适用边界、实际写法和踩过的坑都摊开讲清楚。无论你是在做小体量单机、移动端网游还是Web小游戏,都能在这里找到对得上号的那一档方案。
1. 存档需求先分类,再谈选型
很多人在搜索引擎里搜"Unity数据持久化",得到的第一条答案永远是PlayerPrefs,然后照抄一遍发现能做,就再也不换了。问题在于,PlayerPrefs能跑通不代表它适合你的场景。选型之前,我习惯先把要存的东西按生命周期和数据形态分成三类,分完之后方案基本就自动浮出来了。
1.1 三类数据的生命周期完全不同
第一类是玩家设置。音量、画质档位、语言、按键映射、是否看过新手引导,这类数据的特点是小、扁平、key-value结构、读写频率低,而且丢失的代价很低——大不了让玩家重设一次。它们最适合PlayerPrefs。
第二类是游戏存档。角色等级、背包物品、任务进度、地图探索状态、经济数值。特点是结构嵌套、体积从几KB到几MB不等、需要版本兼容、可能被玩家修改,读写往往发生在关键节点(进关卡、退出游戏)。这类数据用JSON文件或者数据库更合适。
第三类是配置数据。怪物属性表、道具表、关卡参数,它们的特点是只读,上线之后基本不改,改也是策划改完重新打包。这类东西在Unity里通常用ScriptableObject、CSV或者JSON放在StreamingAssets/Resources里,跟"持久化"其实是两码事——但由于都能"存数据",新手经常把它们混在一起,导致出现"打包后修改配置不生效"这种哭笑不得的问题。
把这三类分开之后,你会意识到一个常见的错误做法:把整个存档结构塞进PlayerPrefs的几十个key里,用下划线拼层级,比如save_slot1_player_level。这种写法在前两周能跑,第三周策划加了一个嵌套的装备词条系统,你就得开始写循环拼字符串了。
1.2 可写路径:persistentDataPath 的真实面目
在动手写文件之前,必须先搞清楚写到哪里。Unity给了一个Application.persistentDataPath,它的价值在于跨平台且可写,而且卸载应用之前数据不会消失。但它在每个平台上的真面目是不一样的:
| 平台 | persistentDataPath 实际位置 | 备注 |
|---|---|---|
| Windows 编辑器/独立版 | C:/Users/<用户>/AppData/LocalLow/<公司名>/<产品名> | 公司名和产品名来自Player Settings |
| Android | /storage/emulated/0/Android/data/<包名>/files | 应用卸载即清除 |
| iOS | 应用沙盒的 Documents 目录 | 会被iCloud备份,存档过大要标记排除 |
| WebGL | IndexedDB 虚拟文件系统/idbfs/<hash> | 存浏览器里,清缓存就没了 |
| 主机平台 | 各自的存档分区 | 通常还要走平台SDK的保存接口 |
这里有个特别容易踩的坑:路径里含有公司名和产品名。我见过一个项目,上线前把Product Name从"GameDemo"改成正式名字,结果所有老玩家的存档全部"消失"——因为路径变了,代码去新路径找文件,自然是空。解决方式是把存档文件名固定,或者一开始就把产品名定死,别在临近上线时改。
另一个坑是路径分隔符。Application.persistentDataPath返回的字符串在不同平台末尾不一定带斜杠,所以拼路径一定要用Path.Combine,不要手写+ "/" +。
1.3 一张选型对照表
把常见方案放在一起对比,判断起来会快很多:
| 方案 | 适合的数据 | 体积上限 | 可读性 | 跨平台坑 |
|---|---|---|---|---|
| PlayerPrefs | 设置项、简单标记 | 建议<100KB | 差 | WebGL落盘时机 |
| JsonUtility+文件 | 结构化存档 | 几MB | 好 | 无 |
| Newtonsoft.Json | 复杂结构、字典、多态 | 几MB | 好 | AOT裁剪 |
| 自定义二进制 | 大存档、防手改 | 十几MB | 差 | 字节序、版本 |
| ScriptableObject | 只读配置 | 视情况 | 编辑器内好 | 打包后不可写 |
| SQLite | 大量结构化记录 | 几十MB+ | 中 | IL2CPP链接 |
我的默认起手式是:设置走PlayerPrefs,存档走JsonUtility+persistentDataPath,配置走ScriptableObject。只有当某个维度真的顶到边界了,才升级到Newtonsoft、二进制或者SQLite。下面挨个展开。
2. PlayerPrefs 的边界:容量、落盘与封装
PlayerPrefs的API只有十来行,所有教程都五分钟讲完,但它真正容易出事的地方在实现细节上,而不是用法上。
2.1 它到底把数据写到了哪里
PlayerPrefs在Windows上写注册表HKCU\Software\<公司名>\<产品名>,在macOS写plist,在Android写SharedPreferences的XML,在iOS写plist,在WebGL写IndexedDB里的一条记录。理解这一点的重要性在于:它不是文件,所以你不能用读文件的方式去备份它,也不能用文件校验的方式去验证它有没有写成功。
这也带来一个隐性的麻烦:在Windows上调试时,如果你想"清空存档重新测一遍",删掉AppData里的文件是没用的,得去删注册表项,或者在代码里调PlayerPrefs.DeleteAll()。我自己习惯在开发期加一个快捷键,按下去就DeleteAll()然后重载场景,能省下大量来回翻注册表的时间。
2.2 写入时机与批量保存的正确姿势
PlayerPrefs.SetInt只改内存,真正落盘发生在三个时刻:调用PlayerPrefs.Save()、应用正常退出、或者平台自己决定flush的时机。这就意味着,如果游戏在后台被系统杀掉(移动端非常常见),没Save的数据直接丢了。
正确的做法是:把一个存档周期内的所有写入集中起来,最后调一次Save()。频繁调用Save()在移动端会有明显的卡顿,因为它涉及一次同步IO。我实测过Android上一个约40个key的存档,每次Save()耗时在2~8毫秒,如果放在每帧调用的逻辑里,帧率必掉。
public static class Settings { private static bool _dirty; public static void SetVolume(float v) { PlayerPrefs.SetFloat("volume", v); _dirty = true; } public static void SetQuality(int level) { PlayerPrefs.SetInt("quality", level); _dirty = true; } // 在切场景、暂停、退出时统一调用 public static void Flush() { if (!_dirty) return; PlayerPrefs.Save(); _dirty = false; } }用_dirty标记而不是每次Save,是我踩过坑之后加上的:有一次做完设置界面,每个滑条的回调里都写了Save,结果玩家疯狂拖滑条的时候帧率掉到20多。加上脏标记之后,滑条再怎么拖,落盘只发生一次。
2.3 一层薄封装,把默认值和类型校验补齐
PlayerPrefs原生API有个小毛病:GetInt(key)在key不存在时返回0,GetString(key)返回空字符串。这意味代码里到处散落着魔法默认值。我的做法是写一层极薄的封装,把所有key集中在一个地方管理,顺便把默认值也定义好。
public static class Prefs { private const string K_Volume = "set.volume"; private const string K_Quality = "set.quality"; private const string K_Lang = "set.lang"; public static float Volume { get => PlayerPrefs.GetFloat(K_Volume, 0.8f); set { PlayerPrefs.SetFloat(K_Volume, Mathf.Clamp01(value)); } } public static int Quality { get => PlayerPrefs.GetInt(K_Quality, QualitySettings.GetQualityLevel()); set { PlayerPrefs.SetInt(K_Quality, value); } } }注意key加了set.前缀。这不是强迫症,而是为了将来做"一键重置设置"时能按前缀批量清理,也避免跟其他模块的key撞名。另外Quality的getter用了当前的QualitySettings作为默认值,这样新玩家第一次进游戏时读到的是Unity当前档位,而不是硬编码的某个数字。
2.4 WebGL 与小游戏平台上的落盘差异
WebGL是我见过最多人翻车的地方。Unity在WebGL上把文件系统映射到IndexedDB,但IndexedDB的写入是异步的,而PlayerPrefs的落盘在某些版本里并不会立刻同步。如果你的页面被直接关掉(玩家点浏览器叉),最后几次写入有可能丢失。
在小游戏这类平台上,情况类似:数据存在平台提供的文件系统里,需要主动调用同步接口把内存里的数据刷到磁盘。经验做法是:跟平台存档相关的写入,不要等到"退出游戏"那个回调(那个回调在很多平台上根本不保证被调用),而是在关键节点主动触发——过完一关、关闭设置面板、切场景。
// 存档后立即强制同步,别指望退出回调 public static void SaveAndSync() { PlayerPrefs.Save(); #if UNITY_WEBGL && !UNITY_EDITOR // 小游戏平台需要主动触发文件写入,具体接口名依平台转换插件而定 WebGLFileSync.SyncFiles(); #endif }还有一个隐蔽的问题:WebGL上Application.persistentDataPath返回的是一个虚拟路径/idbfs/<hash>,而<hash>取决于你的应用URL。如果你换域名发布,Hash变了,存档就读不到了。做运营活动换地址的时候要留意这点。
3. JsonUtility + 文件:多数项目的存档主力
搞定了设置项,接下来是真正的存档。我的默认选择一直是Unity自带的JsonUtility配合Application.persistentDataPath,理由不复杂:不需要引入任何第三方包,体积为零,性能足够,而且生成的文件是人类可读的,出问题时可以直接打开看。
3.1 默认选它不是因为最好,而是因为最省事
选JsonUtility的核心理由是调试成本低。存档系统出问题的时候,最怕的就是"存进去的东西读出来不对",而二进制存档会让你陷入对着十六进制发呆的窘境。JSON文件可以直接用记事本打开,一眼看出字段是不是null、数组长度对不对、数值有没有溢出。
另外它是Unity官方API,不存在"第三方库作者弃坑"的风险,也不会因为IL2CPP的反射裁剪而突然在真机上崩掉——System.Text.Json和一些依赖反射的库在IL2CPP下就需要额外的link.xml配置,JsonUtility没有这个负担。
代价也很明确:功能弱。下面这些限制必须提前知道,否则一定会在项目中期翻车。
3.2 JsonUtility 的四条硬限制
第一,不支持字典。Dictionary<string, int>序列化会得到一个空对象{},反序列化回来是空字典,且不报错。这是最阴险的一条,因为它不抛异常,你只会发现数据莫名其妙没了。
第二,不支持顶层数组和List。JsonUtility.ToJson(new List<int>{1,2,3})返回的是{},因为JsonUtility只能序列化带[Serializable]的类或结构体。解决办法是包一层:[Serializable] public class Wrapper { public List<int> items; }。
第三,不序列化属性(Property)。只有public字段或者带[SerializeField]的private字段才会被处理。如果你用的是自动属性public int Level { get; set; },存出来永远是0。这个坑我在一个重构项目里踩过,把字段改成属性之后存档直接全空,排查了半天。
第四,多态和null处理弱。基类引用指向子类实例时,只会序列化基类字段;null的引用类型字段在反序列化后可能变成默认构造的实例。
3.3 原子写入:避免断电写坏存档
直接File.WriteAllText有一个致命问题:写文件过程中断电或者进程被杀,会得到一个半截的、损坏的存档。而存档一旦损坏,玩家几百小时的进度就没了,这是最严重的线上事故之一。
解决方案叫"原子写入":先写临时文件,写完确认成功,再把临时文件重命名覆盖正式文件。重命名在绝大多数文件系统上是原子操作,要么成功要么完全没发生。
public static class SaveIO { public static bool Write<T>(string fileName, T data) { string path = Path.Combine(Application.persistentDataPath, fileName); string tmp = path + ".tmp"; try { string json = JsonUtility.ToJson(data, true); var utf8NoBom = new UTF8Encoding(false); File.WriteAllText(tmp, json, utf8NoBom); if (File.Exists(path)) { string bak = path + ".bak"; if (File.Exists(bak)) File.Delete(bak); File.Replace(tmp, path, bak); // 部分平台不支持Replace } else { File.Move(tmp, path); } return true; } catch (Exception e) { Debug.LogError($"写存档失败: {fileName}\n{e}"); return false; } } }这里有几个细节值得说。第一,UTF8Encoding(false)是为了不写BOM头,某些平台的JSON解析器看到BOM会直接报错。第二,File.Replace会保留一份.bak备份,如果读取正式文件时解析失败,可以自动回退到备份。第三,File.Replace在部分平台(尤其是某些移动端文件系统和WebGL)不支持,所以要try-catch住,退回File.Move。
读取侧对应地做三级回退:正式文件 → 备份文件 → 返回默认存档。
public static T Read<T>(string fileName) where T : class, new() { string path = Path.Combine(Application.persistentDataPath, fileName); T result = TryRead<T>(path); if (result != null) return result; result = TryRead<T>(path + ".bak"); if (result != null) return result; return new T(); // 全新玩家 } private static T TryRead<T>(string path) where T : class { try { if (!File.Exists(path)) return null; string json = File.ReadAllText(path, Encoding.UTF8); if (string.IsNullOrWhiteSpace(json)) return null; return JsonUtility.FromJson<T>(json); } catch { return null; } }注意:读取时把整个try-catch包住并不是偷懒,而是因为存档文件来自外部环境,可能被玩家手动改坏、被第三方文件管理器截断。任何解析异常都不应该让游戏崩在启动界面上。
3.4 存档结构里的版本号
存档结构一定会变。第一版只有金币和等级,第二版加了装备词条,第三版改了任务的数据结构。如果存档里没有版本号,你就没法知道读到的是哪一版的数据,迁移逻辑无从写起。
我的习惯是每个存档类里都放一个ver字段,而且写在最前面:
[Serializable] public class SaveData { public int ver = CurrentVersion; // 当前是第3版 public string playerName; public int level; public int gold; public List<string> unlockedLevels = new List<string>(); public EquipSave equip = new EquipSave(); public const int CurrentVersion = 3; }字段一定要给初始值,尤其是List。JsonUtility在反序列化时,如果JSON里没有这个字段,会保留字段的初始值;如果初始值是null,后面用的时候就会NullReferenceException。所以new List<string>()这种初始化是必须的,不是可选的习惯。
迁移逻辑放在读取之后、使用之前:
public static SaveData LoadAndMigrate() { var data = SaveIO.Read<SaveData>("save.json"); if (data.ver < 2) { // v1 -> v2:原来没有关卡解锁列表,根据等级推断 data.unlockedLevels = InferUnlockedFromLevel(data.level); data.ver = 2; } if (data.ver < 3) { // v2 -> v3:装备从单个字符串改成结构体 data.equip = new EquipSave { weaponId = data.legacyWeaponId }; data.ver = 3; } return data; }迁移段的写法有个原则:只写向前迁移,不做向后兼容。也就是说,旧版本数据升级到最新版之后就立即重写存档;不需要让新代码去支持老结构。这样迁移代码量会随着版本增长而线性增加,而不是指数增加。
4. Newtonsoft.Json:复杂结构的解法与代价
当存档里出现字典、多态、嵌套很深的配置、或者需要跟服务端交换JSON时,JsonUtility就不够用了。这时候我会换Newtonsoft.Json,也就是俗称的Json.NET。
4.1 三种引入方式的取舍
第一种是Unity官方提供的com.unity.nuget.newtonsoft-json包,在Package Manager里通过Git URL或者名称添加。这是我最推荐的方式,版本统一、有官方维护、在IL2CPP下有配套的AOT配置。
第二种是Asset Store上的Json.NET插件,老项目里常见,但版本往往偏旧。
第三种是直接下载DLL丢进Plugins目录。这种方式最灵活也最危险——我遇到过DLL跟项目里另一个库依赖了不同版本的Json.NET,导致运行时MissingMethodException,排查了一整个下午。
体积方面,引入Json.NET大约增加几百KB的包体,在移动端是可以接受的量级;但如果你的目标是极小包体的小游戏,就得掂量一下,或者用libraries里更轻量的方案。
4.2 字典、多态、DateTime 的写法
Json.NET默认就能处理Dictionary<string, ItemData>,不需要额外配置:
var save = new SaveData { inventory = new Dictionary<string, int> { { "potion_hp", 3 }, { "sword_01", 1 } }, lastLogin = DateTime.UtcNow }; string json = JsonConvert.SerializeObject(save, Formatting.Indented); var back = JsonConvert.DeserializeObject<SaveData>(json);多态稍微麻烦一点,需要开TypeNameHandling:
var settings = new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.Auto, Formatting = Formatting.Indented }; string json = JsonConvert.SerializeObject(questList, settings);注意:
TypeNameHandling会在JSON里写入$type字段记录程序集和类型名。如果这份JSON来自不受信任的外部输入,反序列化时可能被构造成任意类型,存在安全风险。单机存档场景问题不大,但涉及网络传输时,建议改成自定义Converter,只序列化你允许的类型白名单。
DateTime的坑在于时区和格式。Json.NET默认序列化成ISO 8601带时区的字符串,JsonUtility根本不支持DateTime(它会被当成一个空对象)。如果存档里有时间戳,我一般直接存long类型的Unix时间戳,跨平台跨库都不会出问题。
4.3 AOT 下的裁剪与反射风险
IL2CPP加上"Managed Stripping Level"设为Medium或High时,代码裁剪可能把你用反射访问的类裁掉,表现是编辑器里一切正常,真机上反序列化得到null或者抛异常。
解决办法是给相关类型加[Preserve],或者在工程里放一个link.xml显式保留:
<linker> <assembly fullname="Assembly-CSharp"> <type fullname="Game.SaveData" preserve="all"/> <type fullname="Game.QuestData" preserve="all"/> </assembly> </linker>我自己养成的习惯是:每次修改了存档相关的类结构,都要打一次真机包验证读写。只在编辑器里测存档,是最容易漏问题的做法。
5. 自定义二进制:把体积和门槛一起处理
JSON很好用,但它有两个天花板:体积和防手改。一旦存档里有几万条记录(比如大地图的探索状态、大量实体的坐标),JSON的文本体积会膨胀到几十MB,读写耗时也会上去。
5.1 BinaryWriter 的最小可用写法
自定义二进制不需要什么框架,BinaryWriter和BinaryReader就能搞定:
public static void WriteBinary(string path, SaveData d) { using var fs = new FileStream(path, FileMode.Create, FileAccess.Write); using var bw = new BinaryWriter(fs, Encoding.UTF8); bw.Write(SaveData.CurrentVersion); // 版本号写在最前 bw.Write(d.playerName ?? string.Empty); bw.Write(d.level); bw.Write(d.gold); bw.Write(d.unlockedLevels.Count); foreach (var lv in d.unlockedLevels) bw.Write(lv); // 大量实体数据,用定长结构体批量写更划算 bw.Write(d.entities.Count); foreach (var e in d.entities) { bw.Write(e.id); bw.Write(e.x); bw.Write(e.y); bw.Write(e.state); } } public static SaveData ReadBinary(string path) { using var fs = new FileStream(path, FileMode.Open, FileAccess.Read); using var br = new BinaryReader(fs, Encoding.UTF8); int ver = br.ReadInt32(); var d = new SaveData { ver = ver }; d.playerName = br.ReadString(); d.level = br.ReadInt32(); d.gold = br.ReadInt32(); int lvCount = br.ReadInt32(); d.unlockedLevels = new List<string>(lvCount); for (int i = 0; i < lvCount; i++) d.unlockedLevels.Add(br.ReadString()); // ... return d; }关键点是读写顺序必须严格一致。一旦顺序错位,读出来的就是一堆乱码,而且不会报错。所以我在写这类代码时,会把读取和写入函数放在同一个文件里,上下紧挨着,改的时候强迫自己两边同时改。
另外,版本号必须写在第一个字节。这样即使后面结构全变了,你也能先读出前4个字节判断版本,再决定用哪套解析逻辑。
5.2 什么时候真的值得上二进制
我的判断门槛是这样的:JSON存档体积超过2MB、或者存档读写耗时超过100毫秒、或者需要频繁保存(比如每隔10秒自动存一次大地图状态)。三者满足其一,就值得换成二进制。
体积收益大概在3到10倍之间——取决于数据的类型。浮点数在JSON里可能是123.456789这样的11个字符,二进制只要4个字节,差3倍;而整数如果数值小,文本可能只要1到2个字符,二进制反而更费。所以二进制对浮点密集数据的收益最大。
5.3 字节序与版本对齐的坑
BinaryWriter默认使用小端序,这在所有主流平台(Windows、Android、iOS、ARM、x86)上都是一致的,所以跨平台基本不用担心字节序问题。但如果你要跟服务端交换二进制数据,就得确认服务端用的是不是小端。
另一个坑是读的时候如果文件被截断(下载不完整、磁盘满),BinaryReader会抛EndOfStreamException。所以整个读取过程要包在try-catch里,失败就回退默认存档,不要让它冒到上层。
6. ScriptableObject 与配置表:别把它当存档容器
这是我见过的、新手最常犯的一个错误:用ScriptableObject来做运行时存档容器。编辑器里跑得好好的,打包之后数据不保存了。原因很简单:ScriptableObject的运行时修改只在编辑器里会被持久化到.asset文件;在打包后的运行时,对它的修改只存在于内存,退出就没了,而且游戏过程中它也不会自动写回磁盘。
6.1 编辑器能写、打包后不能写
这个特性导致一个迷惑现象:开发阶段你用SO存了一些测试数据,退出编辑器重进发现数据还在,于是认为"它能持久化";打包发给测试,测试反馈"数据不保存",你一头雾水。
正确的定位是:ScriptableObject是配置的载体,不是存档的载体。它是设计期的资产,打包之后应该视为只读。
6.2 配置与存档的分工
我现在的做法是把两者彻底分开:
| 维度 | ScriptableObject | 运行时存档 |
|---|---|---|
| 谁写 | 策划/开发者,编辑器内 | 玩家行为,运行时 |
| 何时写 | 打包前 | 游戏过程中 |
| 存放位置 | Assets目录,进AB包 | persistentDataPath |
| 可变性 | 打包后只读 | 随时可变 |
| 典型内容 | 怪物属性、道具表 | 等级、金币、进度 |
如果策划需要在运行时热更配置,那不叫配置,那是"服务端下发的数据",应该走JSON下载+缓存的路子,而不是改SO。
6.3 SO 导出 JSON 的编辑器工具
用SO的好处是策划编辑体验好,但有时代码又想统一按JSON读(比如把配置打包给服务端校验)。这时候可以写一个编辑器脚本,把所有SO导出成JSON:
[MenuItem("Tools/导出配置到JSON")] public static void ExportConfigs() { string outDir = Path.Combine(Application.dataPath, "../ConfigJson"); Directory.CreateDirectory(outDir); var guids = AssetDatabase.FindAssets("t:MonsterConfig"); foreach (var guid in guids) { var path = AssetDatabase.GUIDToAssetPath(guid); var cfg = AssetDatabase.LoadAssetAtPath<MonsterConfig>(path); string json = JsonUtility.ToJson(cfg, true); File.WriteAllText(Path.Combine(outDir, cfg.name + ".json"), json); } AssetDatabase.Refresh(); }这样策划改SO、程序读JSON,两边不用互相迁就。导出的JSON还能直接丢进版本管理做diff,比看二进制asset文件舒服太多。
7. SQLite:数据量大到一定程度才划算
当你的数据是大量同构记录时,比如玩家背包里有几千件物品、开放世界里几千个NPC的状态、或者日志系统要记录每次战斗,文件方案的短板就出现了:改一条数据要重写整个文件。
7.1 接入与建表
Unity上常用的方案是sqlite-net(不是微软的System.Data.SQLite,后者在移动端支持不好)。它提供轻量ORM,用特性标注表结构:
using SQLite; [Table("inventory")] public class ItemRow { [PrimaryKey, AutoIncrement] public int Id { get; set; } [Indexed] public string ItemId { get; set; } public int Count { get; set; } public long AcquiredAt { get; set; } } public class GameDb { private SQLiteConnection _db; public void Open() { string path = Path.Combine(Application.persistentDataPath, "game.db"); _db = new SQLiteConnection(path, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create); _db.CreateTable<ItemRow>(); } public void AddItem(string itemId, int count) { _db.InsertOrReplace(new ItemRow { ItemId = itemId, Count = count, AcquiredAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds() }); } }InsertOrReplace配合PrimaryKey会自动处理已存在的记录,比手写update逻辑省事。[Indexed]用在经常作为查询条件的字段上,几千条记录的情况下,没索引的查询可能要几十毫秒,加了索引降到亚毫秒。
7.2 事务带来的性能差异
这是SQLite最容易被忽略的性能点。默认每次Insert都是一次独立事务,涉及一次磁盘同步。批量插入1000条如果不包事务,可能要几秒钟;包在事务里,通常只要几十毫秒。
_db.RunInTransaction(() => { foreach (var row in rows) _db.Insert(row); });我实测过一个场景:批量写入3000条战斗日志,不包事务耗时约2.3秒,包事务后约90毫秒,差距在20倍以上。所以只要是一次写多条,一律包事务,这是我的硬性习惯。
7.3 移动端 IL2CPP 的坑
sqlite-net底层依赖原生SQLite库,Android上是libsqlite3.so,iOS上系统自带。在IL2CPP下有两个常见问题:
一是裁剪导致ORM反射失效。sqlite-net靠反射读特性来建表,Managed Stripping可能把属性的setter裁掉。解决办法同样是link.xml里保留数据模型类。
二是多线程访问。SQLiteConnection不是线程安全的。如果存档在后台线程写、主线程读,会随机报database is locked。我的处理方式是所有DB操作都回到主线程执行,或者用SQLiteConnectionPool/SQLiteAsyncConnection显式管理并发。对于存档这种低频操作,主线程完全够用,没必要引入复杂度。
8. 防篡改、加密与云存档的现实取舍
单机游戏的存档一定可以被改,区别只是改起来费不费劲。这里要把心态摆正:加密的目标不是"无法破解",而是"破解成本高于收益"。
8.1 简单的异或混淆就够了?
对于纯单机、没有排行榜也没有内购的游戏,其实不需要做任何加密。玩家想改自己的存档是自己的自由,改坏了也是自己的损失。真正需要防的是影响其他玩家的场景:有排行榜、有联机、有内购经济系统。
一旦涉及这些,正确做法不是"把存档加密",而是把关键数据放到服务端。客户端只保留表现层需要的数据,金币余额、排行榜分数、道具数量都由服务端权威管理。本地加密只是给中间过程加一点摩擦力。
8.2 校验和的低成本做法
如果确实需要校验,一个够用的方案是:存档内容 + 一个密钥,算出哈希附在文件末尾,读取时校验。
private const string Salt = "your-project-specific-salt"; private static string ComputeChecksum(string payload) { using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(Salt)); var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload)); return Convert.ToBase64String(hash); } public static void SaveWithChecksum(SaveData d, string path) { string payload = JsonUtility.ToJson(d); string final = payload + "\n#CHECKSUM#" + ComputeChecksum(payload); File.WriteAllText(path, final, new UTF8Encoding(false)); }需要清楚的是,密钥写在客户端代码里,反编译之后一定能找到。所以这层校验只能挡住用记事本改存档的玩家,挡不住有工具的人。别把它当成安全机制,把它当成防止误操作和低级修改的护栏。
8.3 云存档的冲突合并
一旦挂了云存档,必然遇到一个问题:两台设备同时玩,进度怎么合?常见策略有三种:
最后写入胜:比对lastSaveTime,用时间戳晚的那份覆盖。实现最简单,但会丢进度——玩家在手机上打了一关,在平板上打了两关,最后只剩平板的两关。
按槽位分:每个设备一个槽,进游戏时让玩家选。适合存档体量大的游戏,但体验割裂。
字段级合并:给每个字段/每条记录带独立的更新时间,逐字段取新。实现复杂,但体验最好。我一般对"可累加的值"(金币、经验)取最大值或求和,对"状态值"(关卡进度、装备)取时间戳较新的,对"集合型"(已解锁列表)取并集。
public static SaveData Merge(SaveData local, SaveData remote) { return new SaveData { gold = Mathf.Max(local.gold, remote.gold), level = Mathf.Max(local.level, remote.level), unlockedLevels = local.unlockedLevels.Union(remote.unlockedLevels).ToList(), equip = local.equipTime >= remote.equipTime ? local.equip : remote.equip, ver = SaveData.CurrentVersion }; }合并策略一定要在上线前定好并写进文档,否则运营期出现"我的道具没了"的客诉,你只能靠猜。
9. 把存档系统封装成可替换的模块
前面讲的是单点方案,最后说工程化。项目做到中期一定会遇到"换个存储方式"的需求,如果存档逻辑散落在几十个脚本里,那就是灾难。
9.1 ISaveProvider 抽象
我习惯把存储介质抽象成一个接口,上层业务只跟接口打交道:
public interface ISaveProvider { bool Exists(string slot); string Load(string slot); void Save(string slot, string payload); void Delete(string slot); } public class FileSaveProvider : ISaveProvider { /* 走persistentDataPath */ } public class PrefsSaveProvider : ISaveProvider { /* 小数据走PlayerPrefs */ } public class MemorySaveProvider : ISaveProvider { /* 单元测试用 */ }业务层只构造一个SaveData对象,交给Provider序列化。这样带来两个直接好处:一是写单元测试时可以注入MemorySaveProvider,不碰磁盘;二是WebGL上如果发现文件方案有问题,可以临时切到Prefs方案,只改一行初始化代码。
9.2 迁移链的集中管理
前面提到的版本迁移逻辑,不要散在各处。我把它集中成一个字典:版本号 → 迁移函数。
private static readonly Dictionary<int, Action<SaveData>> Migrations = new() { { 1, d => { d.unlockedLevels = InferFromLevel(d.level); } }, { 2, d => { d.equip = new EquipSave { weaponId = d.legacyWeaponId }; } }, }; public static void Migrate(SaveData d) { while (d.ver < SaveData.CurrentVersion) { if (Migrations.TryGetValue(d.ver, out var step)) step(d); d.ver++; } }这样加一个新版本只需要往字典里加一条,不用改主流程,也不容易漏。
9.3 自动存档的时机与节流
最后聊自动存档。移动端游戏被系统杀掉的概率很高,只靠"退出时保存"基本等于不保存。我的做法是监听几个关键节点,并且做节流:
- 切换场景之前(
SceneManager.sceneUnloaded或自己封装的转场方法里) - 应用失去焦点(
OnApplicationPause(true)、OnApplicationFocus(false)) - 玩家完成一次重要操作(通关、购买、任务提交)
- 定时兜底,间隔不低于60秒
节流很重要。如果每完成一次小操作都存一次,Android上频繁的同步IO会产生明显的掉帧感。我的实现是设一个最短间隔(比如5秒),间隔内只标脏,不真写;同时保证"重要节点"可以强制跳过节流立刻写。
private static float _lastSaveTime; public static void RequestSave(bool force = false) { if (!force && Time.realtimeSinceStartup - _lastSaveTime < 5f) { _pending = true; return; } DoSave(); _lastSaveTime = Time.realtimeSinceStartup; _pending = false; } private void Update() { // 兜底:脏数据超过2秒没写就补写 if (_pending && Time.realtimeSinceStartup - _lastSaveTime > 2f) RequestSave(true); }这套组合跑下来,Android上从后台切回来数据丢失的反馈基本消失了,而且没有出现玩家能感知的卡顿。
我个人在实际项目中的体会是:数据持久化这件事,难点从来不在"怎么写文件",而在"什么时候写、写到哪、出错了怎么办、版本变了怎么迁"。把这四个问题在动手之前想清楚,代码量其实很少,后面也几乎不会再返工。反过来,如果一开始就随手用PlayerPrefs存了所有东西,等项目做到第三个版本,改造成本会让你想把整个模块推倒重写。