ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

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 平台比较特殊它走的是浏览器的 IndexedDBUnity 会在里面挂一个虚拟文件系统。而微信小游戏这类小游戏平台Unity 官方的适配方案会把 PlayerPrefs 转发到平台自己的键值存储接口上所以行为又和 WebGL 不完全一样。知道了落点很多问题就有解释了。比如「为什么我在 Windows 上删了游戏重装设置还在」——因为注册表没被清掉。再比如「为什么安卓上卸载重装设置就没了」——因为它在应用私有目录里。这些都不是 bug是设计使然。2.2 三个必须知道的硬限制第一个限制是类型只有三种int、float、string。没有 bool没有 long没有数组没有对象。bool 要自己转 0/1long 要么拆成两个 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 之后直接改 xmliOS 上备份出来也是明文 plistPC 上更是打开注册表就能改。所以任何涉及经济系统的数值比如金币、钻石、等级都绝对不要只靠 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/包名/filesiOS 在沙盒的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.FromJsonSaveData(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(ListItemRecord 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每条一次 fsyncSQLite 事务批量约 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.xmlWebGL 刷新后回档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、能一键清档、能模拟版本迁移、能显示所有持久化文件的大小和时间戳。这个东西写起来大概半天但它省下的排查时间一年下来至少是几十个小时。踩过几次「存档莫名其妙没了」的坑之后你会发现有一个能随时看到持久化状态的面板比任何日志都管用。
返回列表