news 2026/9/29 5:10:18

Unity数据持久化:PlayerPrefs、JSON、SQLite与云端存档

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Unity数据持久化:PlayerPrefs、JSON、SQLite与云端存档

做 Unity 这几年,被问得最多的一类问题不是渲染,也不是帧率优化,而是「我这存档怎么又没了」。数据持久化这件事,在 Unity 里看起来特别简单——PlayerPrefs 一行代码就能存,JsonUtility 两行就能读写文件——但真正踩过坑的人都知道,它是那种「写起来十分钟、排查起来三天」的活儿。存档在编辑器里好好的,打成包就丢了;安卓上没事,iOS 上一更新就回档;玩家卸载重装,等级清零,差评直接拉满。

这篇笔记我想把 Unity 里数据持久化的几种方式从头到尾捋一遍:PlayerPrefs 的边界在哪、JSON 存档怎么写才安全、什么时候该上二进制和 SQLite、云端同步又该怎么设计。不管你是刚入门在做一个单机小游戏,还是已经带着项目上线、被存档问题折磨过的开发者,都能从里面找到能直接抄的东西。所有代码都是我自己项目里在用的结构,不是那种「看起来很美、跑起来报错」的示例片段。

1. 为什么 Unity 的数据持久化不能「随便存」

1.1 先搞清楚你要存的到底是哪一类数据

很多人一上来就问「Unity 用什么存数据最好」,这个问题本身就没法回答,因为「数据」这个词太笼统了。我在做技术方案的时候,第一步永远是把要落盘的数据先分类,分类分清楚了,方案基本就自己浮出来了。

我一般会分成四类。第一类是配置类数据,比如音量大小、分辨率、语言选择、按键映射、画质档位,特点是字段少、体积小、读写频率高、丢了也不致命,玩家最多重新设一遍。第二类是进度类数据,比如关卡通关情况、角色等级、金币数量、背包物品、任务状态,特点是字段中等、体积中等、写入频率中等,但丢了就是致命的,这是存档里最要命的部分。

第三类是业务类数据,比如订单记录、操作日志、玩家行为埋点、战斗回放,特点是数据量大、追加写入为主、几乎不需要修改历史记录,而且往往需要按条件查询。第四类是缓存类数据,比如下载下来的资源包、远程配置、图片缩略图,特点是可重建、可以随时删掉、不需要精确持久化。

把这四类分清楚之后你会发现,它们对存储方案的要求完全不同。配置类用 PlayerPrefs 就够了,进度类必须是可靠的文件或数据库,业务类基本只有 SQLite 才扛得住,缓存类则要考虑容量上限和淘汰策略。用一个方案通吃所有场景,最后一定是某些地方别扭得不行。

1.2 我选型时盯着的四个指标

在具体比较各方案之前,先说清楚我是拿什么尺子去量的。第一个指标是写入频率,这直接决定了你能不能忍受 IO 开销。PlayerPrefs 每次 Set 都是内存操作,但 Save 会触发一次磁盘刷写;JSON 存档每存一次就是完整写一个文件;SQLite 单条 insert 其实很便宜,但如果每条都单独 commit 事务,那性能会掉到让你怀疑人生。

第二个指标是数据体量。几十个字段和几万条记录,完全是两个世界。前者的瓶颈在序列化,后者的瓶颈在查询和索引。第三个指标是跨平台一致性。Unity 虽然做了大量封装,但各个平台的差异依然存在:安卓的存储权限、iOS 的 iCloud 备份、WebGL 的 IndexedDB 配额、微信小游戏的键值存储接口,每一个都能让你在打包后突然发现「怎么不生效」。第四个指标是安全与可迁移性,也就是存档会不会被玩家随手改掉,以及将来要上云的时候,本地数据结构能不能平滑地映射过去。

这四个指标里,我认为新手最容易忽略的是第三个。编辑器里跑得好好的东西,在移动端翻车的概率其实相当高,因为编辑器用的是 Windows 或 macOS 的文件系统语义,而移动端是沙盒 + 权限 + 系统杀进程的组合拳。

1.3 一张表看清主流方案的分工

与其空谈,不如直接把我常用的一张对照表贴出来。这张表是我自己在项目里反复验证过之后整理的,基本能覆盖九成以上的决策场景。

方案适合数据单次写入开销可读性防篡改跨平台风险
PlayerPrefs配置项、开关、少量进度低(但 Save 有开销)高(明文)极差WebGL/小游戏需注意
JSON 文件存档、配置表、中等结构中(整文件重写)高需自行加校验低
二进制文件存档、体积敏感数据低无中等低
SQLite背包、日志、成就、大量记录低(事务批量)中(需工具查)中等iOS 需注意库链接
云端存储账号数据、跨设备同步取决于网络无好需处理离线

看这张表的时候有个细节容易被忽略:「可读性高」这件事,在开发期是优点,在运营期是缺点。JSON 存档方便你调试,也方便玩家用记事本打开把金币改成 999999。所以我在项目里经常做的是「开发期用 JSON,上线前加一层轻量校验或者切二进制」,这个取舍后面会详细讲。

1.4 顺手澄清一个高频误解:ScriptableObject 不是持久化方案

这个坑我见过太多次了。有新人用 ScriptableObject 存运行时数据,在编辑器里测试一切正常——因为编辑器里对 ScriptableObject 的修改确实会被写进 .asset 文件。但打包之后,ScriptableObject 变成了只读资源,运行时改内存里的值没问题,一旦重启就全丢了。

ScriptableObject 的正确定位是配置数据的载体,也就是策划填表、程序读表。运行时状态一定要走真正的持久化通道。如果你确实想在编辑器里用 SO 做「可持久化的调试状态」,那也得自己写编辑器脚本去EditorUtility.SetDirty加AssetDatabase.SaveAssets,而且这套东西在真机上完全没有意义。

注意:区分「编辑器资产」和「运行时数据」是 Unity 持久化的第一课。凡是跟着包一起打进安装包的资源,运行时都改不了、存不住。

2. PlayerPrefs:最顺手也最容易用歪的存储

2.1 PlayerPrefs 到底把数据写在了哪里

很多人用了好几年 PlayerPrefs,却说不出它到底存在哪。其实各平台的落点差别挺大,搞清楚了心里才有底。

Windows 平台写的是注册表,路径大概是HKEY_CURRENT_USER\Software\<公司名>\<产品名>,键名就是你传进去的那个字符串。macOS 写的是~/Library/Preferences/unity.<公司名>.<产品名>.plist。Android 落在应用私有目录/data/data/<包名>/shared_prefs/<包名>.v2.playerprefs.xml,注意这是私有目录,卸载应用就会一起删掉。iOS 落在沙盒的Library/Preferences/<BundleId>.plist。

WebGL 平台比较特殊,它走的是浏览器的 IndexedDB,Unity 会在里面挂一个虚拟文件系统。而微信小游戏这类小游戏平台,Unity 官方的适配方案会把 PlayerPrefs 转发到平台自己的键值存储接口上,所以行为又和 WebGL 不完全一样。

知道了落点,很多问题就有解释了。比如「为什么我在 Windows 上删了游戏重装,设置还在」——因为注册表没被清掉。再比如「为什么安卓上卸载重装设置就没了」——因为它在应用私有目录里。这些都不是 bug,是设计使然。

2.2 三个必须知道的硬限制

第一个限制是类型只有三种:int、float、string。没有 bool,没有 long,没有数组,没有对象。bool 要自己转 0/1,long 要么拆成两个 int 要么转 string,复杂结构只能序列化成字符串再塞进去。这个限制本身不致命,但它会诱导你把不该塞的东西塞进去。

第二个限制是没有事务和原子性。PlayerPrefs.Set 只是改内存,PlayerPrefs.Save 才刷盘。如果在 Save 的过程中进程被杀,理论上可能写坏。更现实的问题是:很多人只在OnApplicationQuit里调用 Save,但在 iOS 和 Android 上,应用被切到后台然后被系统回收时,OnApplicationQuit 不一定会被调用。正确的做法是在OnApplicationPause(true)里也调一次 Save。

private void OnApplicationPause(bool pauseStatus) { if (pauseStatus) { PlayerPrefs.Save(); } } private void OnApplicationFocus(bool hasFocus) { if (!hasFocus) { PlayerPrefs.Save(); } }

第三个限制是完全明文、零防护。安卓上 root 之后直接改 xml,iOS 上备份出来也是明文 plist,PC 上更是打开注册表就能改。所以任何涉及经济系统的数值,比如金币、钻石、等级,都绝对不要只靠 PlayerPrefs。

2.3 什么时候该用它,什么时候碰都别碰

我的判断标准很粗暴:只存「设置」和「标记」,不存「资产」。设置指的是音量、语言、画质、震动开关;标记指的是新手引导看没看过、弹窗今天弹过没有、上次登录的时间戳。这类数据的特点是可重建、丢了大不了重来、数量在几十个以内。

反过来,背包、进度、成就、订单,一律不要用 PlayerPrefs。我见过一个项目把整个背包的 JSON 字符串塞进一个 PlayerPrefs 键里,前期没问题,等玩家背包满了之后,那个字符串涨到几百 KB,每次保存都卡一下,而且一旦超过平台限制就直接写失败,没有任何报错,静默丢档。这个事故的修复成本非常高,因为玩家数据已经坏了。

还有个隐形的坑:PlayerPrefs 的数量没有硬上限,但性能是有拐点的。实测下来,几百个键之后,iOS 上 Save 的耗时会明显上升,因为它是整个 plist 重写。所以别拿它当小型数据库用。

2.4 封装一层才敢在项目里用

裸用 PlayerPrefs 的代码,三个月后自己都看不懂。我习惯做一个静态封装类,把键名集中管理、把类型转换吃掉、把默认值显式声明。

using UnityEngine; public static class Prefs { private const string KEY_VOLUME = "set.volume"; private const string KEY_LANG = "set.lang"; private const string KEY_TUTORIAL = "flag.tutorial_done"; private const string KEY_LAST_LOGIN = "flag.last_login_utc"; private const string KEY_TOTAL_PLAY = "stat.total_play_sec"; public static float Volume { get => PlayerPrefs.GetFloat(KEY_VOLUME, 0.8f); set { PlayerPrefs.SetFloat(KEY_VOLUME, Mathf.Clamp01(value)); } } public static string Language { get => PlayerPrefs.GetString(KEY_LANG, "zh-CN"); set { PlayerPrefs.SetString(KEY_LANG, value); } } public static bool TutorialDone { get => PlayerPrefs.GetInt(KEY_TUTORIAL, 0) == 1; set { PlayerPrefs.SetInt(KEY_TUTORIAL, value ? 1 : 0); } } public static long LastLoginUtcTicks { get => long.TryParse(PlayerPrefs.GetString(KEY_LAST_LOGIN, "0"), out var v) ? v : 0L; set { PlayerPrefs.SetString(KEY_LAST_LOGIN, value.ToString()); } } public static void Flush() { PlayerPrefs.Save(); } }

这么写有几个好处。键名集中在一处,将来要改前缀或者迁移到别的存储,只动这一个文件。默认值写在 getter 里,调用方不用每次都传。类型转换被吃掉,bool 和 long 这些用起来和原生类型一样自然。Flush 显式暴露,方便你在合适时机统一刷盘,而不是到处散落 PlayerPrefs.Save。

提示:键名加命名空间前缀(比如set.、flag.、stat.)这个习惯,能让你在调试时一眼看出某个键属于哪一类,也方便做批量清理。

2.5 在 WebGL 和小游戏平台上要多留个心眼

WebGL 下 PlayerPrefs 的持久化依赖浏览器的 IndexedDB。早期 Unity 版本里,IndexedDB 的刷盘不是实时的,需要主动同步,否则玩家刷新页面就回档。较新的版本已经默认在合适的时机同步了,但如果你在做一个长时间不刷新的网页小游戏,最好还是在关键的保存节点之后,确认数据确实落到了 IndexedDB。

微信小游戏这类平台更要注意,因为它的存储是平台自己的接口,容量和生命周期都由平台控制。我一般的做法是:在小游戏平台上,把所有持久化操作集中到一个适配层里,上层业务代码调用统一接口,底层根据平台是不是小游戏,分别走 PlayerPrefs 或者平台的存储接口。这样将来平台接口变了,你只改适配层。

3. JSON 序列化本地文件:中小项目的万金油

3.1 JsonUtility 的脾气和它的替代方案

Unity 自带的 JsonUtility 最大的优点是「零依赖、快」,最大的缺点是一堆限制。我把它不支持的东西列一下,省得你逐个去试:不支持 Dictionary、不支持顶层数组或 List(必须包一层对象)、不支持属性 property(只认字段)、不支持 null 与多态(继承关系会被拉平)、私有字段必须加 SerializeField、不支持引用循环。

这些限制里,Dictionray 和顶层数组是最常撞的。常见的绕法是:字典用一个 List 存键值对再手动转,顶层列表包一个{ "items": [...] }的壳。

如果项目里 JSON 用得比较多,我建议直接上com.unity.nuget.newtonsoft-json这个官方包。它支持字典、支持多态(配上 TypeNameHandling)、支持属性、支持 LINQ 查询,写起来舒服很多。代价是包体大一点点,以及在 IL2CPP 下要注意 AOT 裁剪导致某些类型反射失败的问题——这个坑后面会讲。

还有几个选项:OdinSerializer 功能很全,适合做复杂对象的序列化,但要付费;System.Text.Json 性能不错,不过在某些 Unity 版本和平台上有兼容性坑,用之前建议先在目标平台跑通。

3.2 存档目录怎么选:persistentDataPath 是唯一正确答案

这个必须说清楚,因为选错目录是新手最常见的翻车原因。Unity 提供的目录里,跟持久化相关的其实就两个:Application.persistentDataPath和Application.streamingAssetsPath。

streamingAssetsPath 是只读的。它在打包时被塞进安装包里,PC 上还是个能访问的文件夹,但安卓上它在 apk 里面,iOS 上也在包内,你根本写不进去。所以它只能放「只读的初始配置」,不能放存档。很多人不知道这一点,在编辑器里测试时往 StreamingAssets 写文件成功,打包后就开始报 IOException,然后一脸茫然。

persistentDataPath 才是可读写、且在应用更新时不会被清掉的那个目录。它在各平台的落点大致是:Windows 在C:\Users\<用户名>\AppData\LocalLow\<公司名>\<产品名>,macOS 在~/Library/Application Support/<公司名>/<产品名>,Android 在/storage/emulated/0/Android/data/<包名>/files,iOS 在沙盒的Documents目录下。

这里有个平台差异要特别注意:安卓上的 persistentDataPath 位于外部存储,在某些定制系统上,如果外部存储不可用或者被清理软件盯上,可能会出问题。以及从 Android 11 开始,分区存储让外部存储的访问规则变复杂了,虽然 Unity 用的是应用专属目录所以影响不大,但如果你要往用户的公共目录导存档,那就得走系统的文件选择器。

拼路径的时候,永远用Path.Combine,永远不要手写斜杠。因为 Windows 用反斜杠,其他平台用正斜杠,手写的话迟早要出问题。

public static class SavePath { public static string Root { get { var dir = Path.Combine(Application.persistentDataPath, "Save"); if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); return dir; } } public static string ForSlot(int slot) => Path.Combine(Root, $"slot_{slot}.json"); public static string BackupForSlot(int slot) => Path.Combine(Root, $"slot_{slot}.bak"); }

3.3 一个能上线的存档管理器:原子写入才是关键

直接File.WriteAllText写存档,最大的风险是写到一半进程挂了,文件就废了。玩家看到的后果是「存档损坏,进不去游戏」,这比丢档还难受,因为连回退的机会都没有。

正确做法是原子写入:先写临时文件,写完之后用File.Replace把临时文件替换成正式文件,同时保留旧的作为备份。这样无论在哪一步崩溃,正式存档要么是旧的完整版本,要么是新的完整版本,不会出现半截文件。

using System; using System.IO; using UnityEngine; [Serializable] public class SaveData { public int version = 1; public string playerName = "Player"; public int level = 1; public long exp = 0; public long coins = 0; public long lastSaveUtcTicks = 0; } public static class SaveSystem { private const int CurrentVersion = 1; public static bool Save(int slot, SaveData data) { data.version = CurrentVersion; data.lastSaveUtcTicks = DateTime.UtcNow.Ticks; var finalPath = SavePath.ForSlot(slot); var tempPath = finalPath + ".tmp"; var backupPath = SavePath.BackupForSlot(slot); try { string json = JsonUtility.ToJson(data, true); File.WriteAllText(tempPath, json); if (File.Exists(finalPath)) { File.Replace(tempPath, finalPath, backupPath); } else { File.Move(tempPath, finalPath); } return true; } catch (Exception e) { Debug.LogError($"[SaveSystem] 存档写入失败 slot={slot}: {e}"); TryDelete(tempPath); return false; } } public static SaveData Load(int slot) { var path = SavePath.ForSlot(slot); var backup = SavePath.BackupForSlot(slot); var data = TryLoadFrom(path); if (data == null) { Debug.LogWarning("[SaveSystem] 主存档读取失败,尝试备份"); data = TryLoadFrom(backup); } if (data == null) { Debug.LogWarning("[SaveSystem] 备份也不可用,返回新档"); return new SaveData(); } return Upgrade(data); } private static SaveData TryLoadFrom(string path) { try { if (!File.Exists(path)) return null; var json = File.ReadAllText(path); if (string.IsNullOrWhiteSpace(json)) return null; return JsonUtility.FromJson<SaveData>(json); } catch (Exception e) { Debug.LogError($"[SaveSystem] 读取失败 {path}: {e}"); return null; } } private static SaveData Upgrade(SaveData data) { if (data.version < 1) { // 举例:早期版本没有 coins 字段,补默认值 data.coins = 0; data.version = 1; } return data; } private static void TryDelete(string path) { try { if (File.Exists(path)) File.Delete(path); } catch { } } }

这段代码里有几个点值得单独说。File.Replace的第三个参数是备份路径,它会在替换成功之后把原文件挪到备份位置,等于是免费的版本回滚能力。读取失败时先试备份再返回新档,这个降级链能把绝大部分「存档损坏」的投诉挡掉。Upgrade 函数是版本迁移的入口,每次加字段都在这里补一段,保证老玩家的档能读进来。

注意:File.Replace在部分平台上对目标文件不存在的情况会抛异常,所以第一次保存时要走File.Move分支。这个细节我踩过一次,测试环境是新档所以没暴露,线上老玩家覆盖安装时才炸出来。

3.4 存档防篡改:三层防线,别指望一层搞定

客户端存档的防篡改,本质上是个提高成本的活儿,不是做到绝对安全的活儿。因为代码和密钥都在玩家机器上,理论上都能逆出来。但只要你把门槛抬到「改档比正常玩还累」,绝大多数人就不会去动了。

我的做法是三层。第一层是结构混淆,把字段名改成a、b、c这种无意义的名字,或者把数值统一加一个偏移量存储。这一步只能挡住用记事本随便看看的人。第二层是完整性校验,对整个 JSON 串算一个 HMAC,把结果一起存进去。这里的坑是密钥硬编码在客户端,会被提取,所以更稳的做法是密钥分段拼接、运行时组装,增加静态分析的难度。

using System.Security.Cryptography; using System.Text; public static class Integrity { private static readonly byte[] Salt = { 0x51, 0x37, 0x9A, 0x2C, 0x88, 0x41, 0x0D, 0x6B, 0x2F, 0x94, 0xC1, 0x03, 0x7E, 0x55, 0xA8, 0x10 }; public static string Compute(string payload) { using (var hmac = new HMACSHA256(Salt)) { var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload)); var sb = new StringBuilder(hash.Length * 2); foreach (var b in hash) sb.Append(b.ToString("x2")); return sb.ToString(); } } public static bool Verify(string payload, string expected) => !string.IsNullOrEmpty(expected) && CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(Compute(payload)), Encoding.UTF8.GetBytes(expected)); }

第三层是数值合理性校验,也就是在加载存档的时候检查数值有没有离谱。比如等级不超过当前版本上限、金币的增长速度不超过理论上限、背包里的物品 ID 在当前配置表里存在。这一层最容易被忽略,但它其实是最有效的一层,因为它不需要任何密码学知识,纯业务逻辑就能拦住绝大部分改档。

关键在于,关键数值一定要有服务端权威。金币、钻石、付费道具这些,客户端只做缓存和展示,真值在服务器上。客户端被改了,下次同步就被覆盖回去了。这个思路和后面讲云端持久化是一脉相承的。

4. 二进制与 SQLite:数据量上来之后的必然选择

4.1 BinaryFormatter 为什么不能用

如果你在网上搜 Unity 二进制序列化,大概率会看到BinaryFormatter。我的建议很直接:新项目一律不要用。原因有三条。

第一是安全问题。BinaryFormatter 的反序列化可以触发任意类型的构造,这是被业界反复确认的高危点,微软官方已经把它标记为过时并计划移除。第二是平台兼容问题,它在 IL2CPP 下的某些类型上会失败,尤其是在 AOT 编译环境下,因为没有运行时 JIT 来生成必要的代码。第三是版本脆弱,它是按类型和字段顺序强绑定的,你改一下类的结构,老档可能直接读不出来。

Unity 从 2021 版本开始逐步限制它,2022 之后基本不建议使用。所以这条路直接跳过。

4.2 手写二进制:省体积的正确姿势

如果你对存档体积很敏感,比如做的是一个下载量很大的休闲游戏,或者需要在网络上传输存档,那就值得手写二进制。用BinaryWriter和BinaryReader就够了,不需要任何第三方库。

using System.IO; public static class BinarySave { public static byte[] Serialize(SaveData d) { using (var ms = new MemoryStream(64)) using (var w = new BinaryWriter(ms)) { w.Write((byte)1); // 版本号占 1 字节 w.Write(d.level); w.Write(d.exp); w.Write(d.coins); w.Write(d.lastSaveUtcTicks); w.Write(d.playerName ?? string.Empty); w.Flush(); return ms.ToArray(); } } public static SaveData Deserialize(byte[] raw) { var d = new SaveData(); using (var ms = new MemoryStream(raw)) using (var r = new BinaryReader(ms)) { byte ver = r.ReadByte(); d.level = r.ReadInt32(); d.exp = r.ReadInt64(); d.coins = r.ReadInt64(); d.lastSaveUtcTicks = r.ReadInt64(); d.playerName = r.ReadString(); d.version = ver; } return d; } }

同样的数据,JSON 大概两三百字节,二进制能压到六七十字节,差距在四倍左右。但代价是强耦合:字段顺序就是协议,加字段只能在末尾追加,删字段会直接毁掉兼容性。所以我在二进制方案里一定会保留一个版本号字节,读的时候先看版本,走不同的解析分支。

还有一点要注意:BinaryReader.ReadString用的是带长度前缀的 UTF-8,如果你要存大量文本,这个开销比想象中大。文本多的话,考虑先压缩再写。

4.3 SQLite 在 Unity 里怎么落地

一旦你的数据从「一个存档对象」变成「一堆需要增删改查的记录」,SQLite 就该出场了。典型的场景是背包、邮件、成就、任务列表、战斗日志、本地排行榜缓存。

Unity 里主流有两条路。一条是Mono.Data.Sqlite,它是随 Mono 一起的老方案,在 PC 上能用,但在移动端要手动带上原生的 sqlite 动态库,配置麻烦。另一条是sqlite-net这个轻量 ORM,配合SQLitePCLRaw提供的原生库,在 Unity 里集成度更好,写起来也舒服得多。

集成的时候有几个细节必须注意。iOS 平台需要确保原生库被打进 Xcode 工程,否则运行时会报找不到 symbol。IL2CPP 下要保留实体类的字段不被裁剪,一般在类的上方加[Preserve],或者维护一个link.xml白名单,否则反射映射字段时会拿到空值。安卓上数据库文件要放在 persistentDataPath 下面,不要放 StreamingAssets,原因和前面说的一样。

表设计上,我的习惯是主键自增 + 业务唯一索引。比如背包表:

using SQLite; using UnityEngine; [Preserve] public class ItemRecord { [PrimaryKey, AutoIncrement] public int Id { get; set; } [Indexed] public long ItemId { get; set; } [Indexed] public int SlotIndex { get; set; } public int Count { get; set; } public long AcquireUtcTicks { get; set; } }

ItemId和SlotIndex都加了索引,因为查询基本都是「按物品 ID 找」和「按格子位置找」。索引不是越多越好,每个索引都会让写入变慢、让数据库变大,所以只在你真正会用来当查询条件的字段上加。

写入的时候,批量操作一定要包在事务里。这是 SQLite 性能的分水岭:

public void BatchAdd(List<ItemRecord> items) { _db.RunInTransaction(() => { foreach (var it in items) { _db.Insert(it); } }); }

我实测过,一千条记录如果每条单独 insert,在安卓中端机上大概要两秒多;包进一个事务之后能降到一百毫秒以内,差了一个数量级。原因很简单,SQLite 默认每条语句都做一次 fsync,事务把这个开销合并了。

4.4 几种方案的实际性能对比

下面这张表是我在一个中端安卓机上做的粗略测试结果,数据规模是一千条结构相同的记录(每条包含一个 long、两个 int、一个字符串)。数字只做量级参考,具体项目差异会很大。

方案写入耗时读取耗时存档体积备注
PlayerPrefs(拼串)不适用不适用大数量多了后性能急剧下降
JSON 单文件约 30 ms约 25 ms约 180 KB每次全量重写
二进制单文件约 12 ms约 8 ms约 45 KB体积优势明显
SQLite 单条插入约 2100 ms约 60 ms(带索引)约 120 KB每条一次 fsync
SQLite 事务批量约 90 ms约 60 ms(带索引)约 120 KB推荐做法

这张表里最值得关注的是最后两行。同样的 SQLite,有没有事务差了二十多倍。所以如果你发现自己用 SQLite 反而更慢,先检查是不是没用事务。

4.5 什么数据适合进 SQLite

我的判断标准是「是否需要按条件查询」和「是否持续追加」。背包和邮件天然是前者,日志和埋点是后者。反过来,一个只有几十个字段的全局存档,塞进 SQLite 反而麻烦,因为你还得建表、写映射、管连接。这种就老老实实用 JSON 或者二进制。

还有一个容易被忽略的场景:本地缓存的远端数据。比如从服务器拉的排行榜、活动列表、商品配置,这些数据量可能很大、需要按条件筛选、而且过期就可以整表删掉重建。这类数据用 SQLite 存一份本地副本,能大幅减少首屏等待时间。

5. 云端持久化:账号体系下的持久化设计

5.1 什么时候必须上云

本地持久化能解决单机场景,但有几类需求它天生解决不了。第一是多设备同步,玩家在手机上玩到 30 级,换平板想接着玩,本地存档帮不上忙。第二是防作弊,只要有本地真值,就有改档的空间。第三是重装恢复,卸载重装之后本地数据全没了,如果账号体系里没有云端存档,玩家会觉得「我的钱白花了」。

这三条里,我认为对留存影响最大的是第三条。很多小团队为了省事不做云端存档,结果就是每次版本更新、每次玩家换手机,都要流失一批人。而做云端存档的成本,其实比想象中低——一个简单的键值存储接口加一个版本号字段就能起步。

5.2 本地优先 + 云端兜底:冲突怎么合

我自己用得最顺手的是「本地优先 + 云端兜底 + 时间戳裁决」这套结构。具体流程是:游戏启动时先读本地存档,保证秒进;同时异步请求云端存档;如果云端版本比本地新,就走合并或者覆盖;如果没有网络,就用本地,把「待同步」标记置上,等下次联网再补。

这里有个关键点:时间戳一定要用服务器时间,绝对不要相信本机时间。改系统时间就能改存档版本号,这是最廉价的一种作弊方式。所以每次登录或者同步的时候,从服务端拿一次权威时间,本地只做相对计时。

冲突合并策略上,我一般分两类处理。可以字段级合并的(比如设置的开关、关卡的完成状态)走字段级,取并集,谁完成了算谁完成。不能合并的(比如金币这种会被消耗的数值)就必须整体覆盖,以服务器为准。这两类一定要在数据结构设计阶段就区分清楚,否则后期改起来非常痛苦。

5.3 离线队列与幂等设计

弱网和断网是常态,所以客户端要有一个操作日志队列。玩家每次消耗金币、获得道具,都往队列里追加一条操作记录,带上一个客户端生成的唯一 ID。联网之后按顺序提交,服务端处理完返回结果,客户端把已确认的记录删掉。

这里最重要的是幂等。因为网络超时的场景下,你根本不知道服务端到底处理成功没有,重试是必然的。如果服务端不按操作 ID 去重,网络抖动一次,玩家就多领一份奖励,这在经济系统里是灾难。所以服务端一定要维护一张已处理操作 ID 的表,并且对同一个 ID 的重复请求直接返回上次的结果。

我这边的经验是,离线时间越长,合并逻辑越容易出问题。所以我会加一个上限:离线超过一定时长(比如 7 天)的队列,在同步时做一次全量对账,用服务端的权威数据覆盖本地,而不是逐条重放。虽然玩家可能会少拿一点离线收益,但比数据错乱好得多。

6. 常见问题排查与避坑清单

6.1 问题速查表

这张表是我自己整理的,基本覆盖了这几年遇到的高频故障。

现象可能原因排查手段
编辑器正常,打包后存档丢往 StreamingAssets 写文件打印 persistentDataPath 确认目录
iOS 上更新后回档存档在 Cache 目录被系统清理确认路径是 Documents 而非 Library/Caches
安卓上偶发读档失败外部存储挂载异常或权限问题加 try-catch 并降级到备份档
反序列化后字段全默认值类没有 [Serializable] 或字段是属性检查字段可见性与 SerializeField
玩家反馈改档无效服务端覆盖了本地值这是预期行为,需要同步提示文案
IL2CPP 下读取报错反射类型被代码裁剪加 [Preserve] 或 link.xml
WebGL 刷新后回档IndexedDB 未同步关键节点主动触发同步
小游戏平台上 PlayerPrefs 不生效平台存储接口未适配检查适配层与平台 SDK 版本

这张表里,「反序列化后字段全默认值」这一条我要单独强调。它太隐蔽了,因为不报错、不抛异常,就是单纯地读出来全是初始值。最常见的原因是用自动属性而不是字段,或者类忘了加[Serializable]。JsonUtility 在遇到不认识的成员时会静默跳过,所以一定要在开发期用真实数据跑通一遍「存—读—比对」,而不是只看有没有报错。

6.2 存档丢失的几类典型原因,我按频率排序

排第一的是路径用错。前面说过 StreamingAssets 的坑,还有一种是手写路径字符串,在 Windows 上用的是反斜杠,到了安卓就找不到文件。解决方案只有一个:一律用Application.persistentDataPath加Path.Combine。

排第二的是没有降级机制。只有一个存档文件,一旦损坏就彻底完蛋。加上备份轮转和完整性校验,能把这类问题的严重程度降低一个档次。成本很低,收益极高,我一直不理解为什么很多项目不做。

排第三的是写入时机不对。只在 OnApplicationQuit 里保存,在移动端等于没保存。正确的做法是:关键节点(过关、交易、升级)立即保存,切后台时兜底保存一次。保存频率和性能之间要平衡,我的经验是单次保存控制在 10 毫秒以内,玩家基本无感。

排第四的是版本迁移没做。加了新字段、改了数据结构,老档读进来直接报错或者字段错位。前面提到的Upgrade函数就是干这个的,每次改结构都要在那里加一段,并写一个老版本的样本文件做回归测试。

排第五的是多线程访问冲突。这个比较少见,但一旦出现就很难查。多个线程同时读写同一个文件,或者 SQLite 连接被多个线程共用,都会导致偶发的数据损坏。原则是:文件操作和数据库操作都放在主线程,或者用一个专门的 IO 线程串行处理。

6.3 关于加密,我的一点实际体会

很多人在做存档保护的时候,第一反应是「我要加密,而且要强加密」。但实际项目里,过度加密的维护成本往往大于它带来的收益。AES 全量加密存档,会导致你没法用文本工具排查线上问题,一旦加密密钥在某个版本里被改坏了,所有老玩家的档都读不出来。

我自己更倾向于「轻校验 + 数据混淆 + 服务端权威」的组合。校验负责发现异常,混淆负责提高门槛,服务端负责最终的权威性。三者配合,既能挡住绝大部分改档,又不会把调试和迁移搞得太痛苦。

如果确实需要强加密(比如涉及付费内容),那也应该只加密敏感字段,而不是整个存档。这样即使密钥出问题,玩家的进度和设置也不受影响。

6.4 一个容易被忽略的小技巧:给存档加「自愈」

这是我踩过坑之后加上的。做法是:加载存档之后,对关键字段做一次范围校验,发现越界就自动裁剪到合理范围,并打一条日志上报。比如金币数量是负数就归零,等级超过上限就截断到上限,背包里有不存在的物品 ID 就删掉。

这样做有两个好处。一是玩家不会因为一个数值异常就卡死在加载界面;二是你能通过日志统计到有多少玩家触发了自愈,如果比例异常高,说明有人在改档,或者你的代码有溢出 bug。这个机制的成本非常低,但在我做过的项目里,它至少救过两次线上事故。

7. 我是怎么把这些方案组合起来的

说了这么多方案,最后讲讲我在真实项目里的组合方式。配置类数据走 PlayerPrefs,因为读写频繁、体积小、丢了不致命。核心存档走 JSON + 原子写入 + 备份轮转 + HMAC 校验,兼顾可读性、可靠性和安全性,开发期调试方便,上线后也够用。背包、邮件、日志这类记录型数据走 SQLite,用事务批量写入,在真正需要查询的字段上加索引。涉及经济系统的数值走服务端权威,客户端只做展示和缓存,用离线队列做弱网兼容。

这套组合的好处是每一层职责清晰,出问题的时候能快速定位到是哪一层。比如「金币对不上」这种问题,直接去看服务端流水;「背包丢东西」去看 SQLite 那层;「设置没保存」去看 PlayerPrefs 的刷盘时机。

还有一条经验值得单独说:从项目第一天就设计好存档结构,比后期重构便宜十倍。我见过太多项目前期用 PlayerPrefs 胡乱存,等到中期发现要加背包和云同步,被迫做一次大迁移,那个工作量和风险比一开始就规划好要大得多。哪怕你只是一个单机小游戏,也建议把存档结构、版本号、保存时机这三样东西在开工前定下来。

最后分享一个我一直在用的调试习惯:在开发构建里做一个隐藏的存档面板,能一键导出当前存档到可读的 JSON、能一键清档、能模拟版本迁移、能显示所有持久化文件的大小和时间戳。这个东西写起来大概半天,但它省下的排查时间,一年下来至少是几十个小时。踩过几次「存档莫名其妙没了」的坑之后,你会发现有一个能随时看到持久化状态的面板,比任何日志都管用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 5:09:59

Cadence Allegro PCB光绘输出顺序与Gerber避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 5:09:54

VisualVM实战:从OOM报警到内存快照根因定位全过程

上周二晚上十一点半&#xff0c;监控电话把我吵醒&#xff1a;mrds65批次服务走完一轮跑批后&#xff0c;内存曲线冲过堆上限&#xff0c;JVM直接抛了OutOfMemoryError: Java heap space。我当时打开 VisualVM 2.2&#xff0c;从进程里导出一份内存快照&#xff0c;花了半个多小…

作者头像 李华
网站建设 2026/9/29 5:07:12

Matlab贝叶斯分类实战:朴素贝叶斯与判别式分析完整指南

简介&#xff1a;一套基于 Matlab 编写的贝叶斯分类完整实现&#xff0c;配备图形用户界面&#xff0c;适合机器学习初学者、算法研究人员以及需要快速完成特征分类任务的工程人员使用&#xff1b;内容涵盖朴素贝叶斯分类器核心代码&#xff0c;覆盖数据预处理、训练集与测试集…

作者头像 李华
网站建设 2026/9/29 5:06:55

n球入m盒的8种组合模型与业务建模指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 5:06:28

Cursor与Cline接入高性价比大模型API实战指南

全栈开发这两年最大的变化&#xff0c;不是框架又出了什么新轮子&#xff0c;而是写代码这件事本身的流程被重写了。以前我们纠结的是用 Vite 还是 Webpack、用 Prisma 还是 Drizzle&#xff0c;现在更多人纠结的是&#xff1a;编辑器里那个 AI 助手到底接哪个模型、走哪条 API…

作者头像 李华
网站建设 2026/9/29 5:04:29

SAP-QM质检操作深度解析:从检验批到使用决策的全链路设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华