
做完一个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 SettingsAndroid/storage/emulated/0/Android/data/包名/files应用卸载即清除iOS应用沙盒的 Documents 目录会被iCloud备份存档过大要标记排除WebGLIndexedDB 虚拟文件系统/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存档走JsonUtilitypersistentDataPath配置走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不存在时返回0GetString(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 的四条硬限制第一不支持字典。Dictionarystring, int序列化会得到一个空对象{}反序列化回来是空字典且不报错。这是最阴险的一条因为它不抛异常你只会发现数据莫名其妙没了。第二不支持顶层数组和List。JsonUtility.ToJson(new Listint{1,2,3})返回的是{}因为JsonUtility只能序列化带[Serializable]的类或结构体。解决办法是包一层[Serializable] public class Wrapper { public Listint items; }。第三不序列化属性Property。只有public字段或者带[SerializeField]的private字段才会被处理。如果你用的是自动属性public int Level { get; set; }存出来永远是0。这个坑我在一个重构项目里踩过把字段改成属性之后存档直接全空排查了半天。第四多态和null处理弱。基类引用指向子类实例时只会序列化基类字段null的引用类型字段在反序列化后可能变成默认构造的实例。3.3 原子写入避免断电写坏存档直接File.WriteAllText有一个致命问题写文件过程中断电或者进程被杀会得到一个半截的、损坏的存档。而存档一旦损坏玩家几百小时的进度就没了这是最严重的线上事故之一。解决方案叫原子写入先写临时文件写完确认成功再把临时文件重命名覆盖正式文件。重命名在绝大多数文件系统上是原子操作要么成功要么完全没发生。public static class SaveIO { public static bool WriteT(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 ReadT(string fileName) where T : class, new() { string path Path.Combine(Application.persistentDataPath, fileName); T result TryReadT(path); if (result ! null) return result; result TryReadT(path .bak); if (result ! null) return result; return new T(); // 全新玩家 } private static T TryReadT(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.FromJsonT(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 Liststring unlockedLevels new Liststring(); public EquipSave equip new EquipSave(); public const int CurrentVersion 3; }字段一定要给初始值尤其是List。JsonUtility在反序列化时如果JSON里没有这个字段会保留字段的初始值如果初始值是null后面用的时候就会NullReferenceException。所以new Liststring()这种初始化是必须的不是可选的习惯。迁移逻辑放在读取之后、使用之前public static SaveData LoadAndMigrate() { var data SaveIO.ReadSaveData(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默认就能处理Dictionarystring, ItemData不需要额外配置var save new SaveData { inventory new Dictionarystring, int { { potion_hp, 3 }, { sword_01, 1 } }, lastLogin DateTime.UtcNow }; string json JsonConvert.SerializeObject(save, Formatting.Indented); var back JsonConvert.DeserializeObjectSaveData(json);多态稍微麻烦一点需要开TypeNameHandlingvar 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 fullnameAssembly-CSharp type fullnameGame.SaveData preserveall/ type fullnameGame.QuestData preserveall/ /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 Liststring(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.LoadAssetAtPathMonsterConfig(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.CreateTableItemRow(); } 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.soiOS上系统自带。在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 Dictionaryint, ActionSaveData 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存了所有东西等项目做到第三个版本改造成本会让你想把整个模块推倒重写。