
做过几年Unity项目的人大概率都被同一个问题找上门过游戏好不容易做完一版打包跑到真机上玩家关掉App再打开存档没了、设置重置了、排行榜数据回到解放前。这种时候基本就是数据持久化没做对。Unity数据持久化听起来是个基础话题但真正踩过坑的人都知道它从PlayerPrefs到SQLite中间隔着一整座冰山。这篇文章我不打算照着官方文档念一遍而是把我在实际项目里用过的方案、踩过的坑、以及最终沉淀下来的套路一次性说清楚。这套内容适合谁刚接触Unity没多久、想把存档机制理清的初学者以及已经在用PlayerPrefs存JSON、但总觉得哪里不对劲的进阶开发者都能在里面找到能直接用的东西。我会从方案选型讲起穿插完整可复现的代码最后给一份常见问题排查清单全程都是用过的经验不是网上抄来的概念堆砌。1. 从需求说起到底什么场景才需要数据持久化1.1 持久化的本质与常见误区数据持久化的本质很简单把内存里的数据变成磁盘上的数据让它在进程结束之后依然存在。但这句话翻译到Unity里很多人第一反应就是“用PlayerPrefs啊”这就是最大的误区。PlayerPrefs适合存什么东西音量大小、画质档位、是否看过新手引导、上次登录的时间戳这些轻量级键值对确实是它的主场。但一旦涉及玩家背包、关卡进度、捏脸数据、商店购买记录这些东西结构复杂、体量大、还可能频繁读写再用PlayerPrefs硬扛很快就会撞上性能和可维护性的天花板。我见过不少项目把整个存档序列化成一个大JSON字符串塞进PlayerPrefs结果存档一上百KBAndroid上每次Save都要卡一下。更麻烦的是PlayerPrefs在不同平台上存的底层文件格式完全不一样Windows注册表、macOS的plist、Android的SharedPreferences出了问题你连直接修改存档文件调试都做不到。所以做持久化的第一步不是写代码是先分清你的数据类型。我把项目里的持久化需求分成三类轻量偏好设置、结构化存档数据、需要查询分析的复杂数据。这三类对应的技术选型完全不同混在一起用后面必然后悔。1.2 方案选型对比先想清楚再动手下面这张表是我在实际项目里反复验证过的选型结论直接照着抄就行数据类型推荐方案不建议方案理由音量、画质、语言等设置PlayerPrefsJSON文件量小且零散PlayerPrefs最简单存档进度、背包、角色数据JSON文件 自定义存档管理器PlayerPrefs结构复杂、体量可控JSON可读可调试需防篡改的竞技数据加密二进制文件明文JSON明文一改一个准防君子不防小人关卡配置、物品表ScriptableObjectSQLite编辑器内可视化配置打包即Assets玩家大量动态数据、搜索统计SQLiteJSON文件数据量大、需要条件查询时文件型方案会写死你跨存档共享的全局数据云端同步本地缓存纯本地存储换设备场景下本地方案无解这个表不是绝对的但它反映了一个关键原则持久化方案要跟着数据形态走而不是跟着习惯走。结构简单就用简单方案结构复杂就上重型武器最怕的是项目做到一半发现PlayerPrefs撑不住了再动刀重构存档层那工程量不亚于重写一遍业务逻辑。另外一个很多人忽略的点是存档与配置的区别。ScriptableObject本质上是Unity资源它是跟着项目打包走的改不了玩家本地的值所以它适合做“项目侧数据”不适合做“玩家侧数据”。把配置和存档混为一谈是新手项目最常见的架构混乱根源。2. PlayerPrefs与JsonUtility入门首选组合2.1 PlayerPrefs的正确打开方式PlayerPrefs的入门用法三句话能说完SetInt存整数SetFloat存浮点SetString存字符串取的时候用GetInt、GetFloat、GetString没有这个键时返回你填的默认值。但它背后的行为细节文档上写得并不仔细。第一PlayerPrefs没有直接存布尔值的方法实际是用SetInt存0和1。第二Windows编辑器下数据存在注册表里你直接在Assets目录下翻文件是找不到的。第三点也是我特别想提醒的Android平台上频繁调用PlayerPrefs.Save()是可能触发ANR的尤其是存档量大的时候Save内部是同步写盘调用太频繁会让主线程卡死。所以我的习惯是PlayerPrefs只做“改动极少”的轻量存储并且封装成一个设置管理类不直接在业务代码里到处散落SetString调用。这样以后想换存储后端、想加云端同步都只要改一个文件。public class GameSettings { private const string KeyMusicVolume music_volume; private const string KeyLanguage language; public static float MusicVolume { get PlayerPrefs.GetFloat(KeyMusicVolume, 0.8f); set { PlayerPrefs.SetFloat(KeyMusicVolume, value); PlayerPrefs.Save(); } } public static string Language { get PlayerPrefs.GetString(KeyLanguage, zh-CN); set { PlayerPrefs.SetString(KeyLanguage, value); PlayerPrefs.Save(); } } }2.2 JsonUtility的坑与救法新手第一次接触结构化存档往往先遇到JsonUtility因为它是Unity内置的不引第三方库就能用。这个类的定位很尴尬它比直接拼字符串省事但比起Newtonsoft.Json功能差了一大截。JsonUtility最大的三个坑我一次性列清楚。第一它无法直接序列化Dictionary联网搜解决方案能搜出一堆自定义封装核心思路都是把字典转成List再序列化。第二它只能序列化公有字段或者标记了[SerializeField]的私有字段属性Property是会被忽略的。第三它的多态支持约等于没有基类引用指向子类对象时序列化出来只有基类字段。[Serializable] public class SaveData { public int level; public string playerName; [SerializeField] private float playTime; }上面这段代码里playTime虽然是私有的但因为有[SerializeField]标签JsonUtility会把它一起序列化。这是Unity序列化体系的老规矩跟纯C#的序列化概念不完全一样。2.3 一个可落地的存档管理器雏形搞清楚了JsonUtility的脾气就可以组装一个最简存档管理器了。核心逻辑就三板斧序列化成JSON字符串、写到Application.persistentDataPath下、读取时反序列化回对象。using System.IO; using UnityEngine; public static class SaveManager { private static string SavePath Path.Combine(Application.persistentDataPath, player_save.json); public static void Save(SaveData data) { string json JsonUtility.ToJson(data); File.WriteAllText(SavePath, json); } public static SaveData Load() { if (!File.Exists(SavePath)) { return new SaveData(); } string json File.ReadAllText(SavePath); return JsonUtility.FromJsonSaveData(json); } public static void Delete() { if (File.Exists(SavePath)) { File.Delete(SavePath); } } }这段代码看起来简短但已经足够支撑中小型单机游戏了。我提醒一句Save()和Load()都是同步IO虽然对几十KB的小文件来说耗时几乎无感但如果你做的游戏在切场景时连续调用存档最好加个节流机制比如同一帧内多次Save只执行一次。3. 进阶方案文件存储与外部JSON库3.1 Application.persistentDataPath到底该不该用Unity提供了一堆路径APIApplication.dataPath、Application.streamingAssetsPath、Application.persistentDataPath很多人搞不清楚区别直接乱用。我做个对比你就明白了。dataPath是只读的项目数据目录PC上就是游戏安装目录移动端基本没有写权限只能读。streamingAssetsPath也是只读的但它是专门用来放随包资源的运行时读取方式在每个平台还不一样Android上用UnityWebRequest读PC上直接File.ReadAllText就行。persistentDataPath才是真正留给你的可读写目录同一个App升级之后这个路径不变所以存档必须放这里。persistentDataPath在Windows上通常长这样C:/Users/用户名/AppData/LocalLow/公司名/产品名。注意这个路径依赖PlayerSettings里填的公司名和产品名如果这两项在开发中改过老的存档可能找不到了这是测试阶段最容易踩的隐形坑。3.2 Newtonsoft.Json vs JsonUtility 实战对比项目稍微复杂一点我推荐直接引入Newtonsoft.JsonUnity官方把它做成了官方包包名是com.unity.nuget.newtonsoft-jsonPackageManager里直接搜就能装上。装好之后它的序列化能力比JsonUtility强太多字典原生支持、属性序列化支持、多态通过TypeNameHandling支持、还有丰富的属性标记控制字段名。using Newtonsoft.Json; [Serializable] public class InventoryData { public ListItemStack items new ListItemStack(); [JsonProperty(custom_name)] public string CustomName { get; set; } }那段代码里JsonProperty标记可以让序列化出来的JSON字段名跟C#属性名解耦。比如线上存档协议要求字段叫custom_name你C#代码里叫CustomName以前用JsonUtility只能干瞪眼现在一行标记搞定。代价是什么呢Newtonsoft.Json比JsonUtility慢而且打包后体积会增加。但在绝大多数游戏项目里存档操作的频率低、数据量也没到几MB这点性能差异完全感知不到。我现在的习惯是只要项目里有字典、有需要跨版本兼容的存档、有多态需求就直接Newtonsoft.Json不再纠结。3.3 二进制序列化与加密方案有些项目追求防篡改比如排行榜分数、竞技场段位这些数据直接存明文JSON等于敞开大门让人改。二进制的本质是让数据不可读但专业的破解者照样能逆向。所以我的观点很明确加密和混淆的目的是提高篡改门槛不是做到绝对安全游戏本地数据根本没有绝对安全这回事。比较务实的加密方案有两种。第一种是把JSON字符串加密后再写文件解密后反序列化用AES对称加密密钥存放在C#代码里。第二种是纯二进制格式用BinaryWriter手动写字段数据结构本身就不直观再加上字节级别的混淆。第一种维护成本低适合绝大多数项目。public static string Encrypt(string plainText, string key) { using (Aes aes Aes.Create()) { aes.Key Encoding.UTF8.GetBytes(key); aes.IV new byte[16]; ICryptoTransform encryptor aes.CreateEncryptor(); byte[] plainBytes Encoding.UTF8.GetBytes(plainText); byte[] encrypted encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length); return System.Convert.ToBase64String(encrypted); } }这段AES加密代码在处理256位密钥时密钥必须恰好32字节IV恰好16字节这是新手最容易翻车的地方。我在项目里一般会把密钥做哈希处理后取字节确保长度稳定而不是直接拿字符串当Key。另外我要提醒密钥放C#代码里用IL2CPP打包后还能增加一点逆向成本用Mono打包就基本等于明文了。4. 大型项目的选择ScriptableObject与SQLite4.1 ScriptableObject不是存档而是配表神器我看到很多Unity教学视频把ScriptableObject归进数据持久化的体系里这个说法容易把人带沟里。ScriptableObject本身是Unity资源它随项目打包、编辑期内可以创建、运行时能被引用但它不能主动修改Assets目录下的文件。所以它更适合做配置数据比如武器属性表、任务流程表、商店商品列表。真正的进阶用法是把ScriptableObject当作数据容器运行时把它的字段拷进一个普通类去存档。这样既能享受编辑器里的可视化配置又能把运行时数据跟配置数据解耦。[CreateAssetMenu(fileName ItemConfig, menuName Config/ItemConfig)] public class ItemConfig : ScriptableObject { public string itemId; public string displayName; public int maxStack; }这段代码里的ItemConfig就是一个配置项。假设玩家把一个物品放进背包运行时存档数据不应该直接引用ScriptableObject而是把itemId、数量、耐久这些字段写入存档对象。等下次读档时再用itemId从资源库里把ItemConfig查出来渲染。这样存档里存的永远是普通数据结构不会因为资源被修改而读错配置。4.2 SQLite接入与数据表设计如果游戏数据量大、查询需求复杂比如聊天记录、战报、道具流水JSON文件方案会写到你怀疑人生从头遍历整个文件、按条件过滤、排序、分页全得自己手写。这种时候上SQLite才是正解。Unity里接入SQLite的方式有很多我推荐直接用sqlite3库通过P/Invoke调用或者用现成的第三方封装。核心是记住这些数据别用PlayerPrefs也别用JSON整体读写用SQL语句按条件查询。数据表设计上我建议存档元信息单独建表业务数据按模块分表表里统一带版本号字段方便以后做数据迁移。CREATE TABLE player_save ( save_id INTEGER PRIMARY KEY AUTOINCREMENT, version INTEGER NOT NULL, created_at TEXT NOT NULL, level INTEGER, gold INTEGER );这段建表语句是存档模块的例子。版本号字段尤其重要因为线上玩家拿到了新版本App旧存档格式跟新代码对不上没有版本号你根本没法判断要不要做迁移。关于迁移我实践中遇到最麻烦的问题是玩家在版本A创建存档直接升级到版本C这种跨版本兼容必须靠链式迁移也就是A到B、B到C各写一个迁移脚本按顺序执行保证任何版本进来的存档都能升到最新。5. 常见问题与排查技巧实录5.1 存档丢失与路径问题做Unity开发时遇到的第一个存档问题基本都是“我明明存了重开怎么没了”。这个问题的原因九成出在路径上编辑器下你写的可能是dataPathAndroid真机上dataPath根本没写入权限写操作不报错但实际失败了。排查路径问题的方法很简单在游戏里搞一个调试界面直接把Application.persistentDataPath打出来用文件管理器去看这个目录下到底有没有文件。有些移动端平台需要特殊权限配置比如国产Android ROM对App私有目录的管理策略不太一样但这种属于平台适配问题需要针对具体机型单独测。还有一个我踩过的坑是写入过程中的进程被杀。比如玩家点存档按钮后立刻杀进程File.WriteAllText还没写完文件就只剩一半内容。下次读档时JSON解析直接崩游戏启动就白屏。解决办法是写临时文件再加原子替换先写save.json.tmp写完再File.Move换成正式文件名。5.2 序列化异常与版本兼容存档结构不是一成不变的加了新功能就要给存档加字段。麻烦在于旧存档里没有这个字段反序列化时如果字段类型对不上就直接异常。Newtonsoft.Json对缺失字段的处理是保留默认值JsonUtility则同样宽容但两边对“字段类型变化”的处理都算不上友好。我的解决套路是两层保险。第一层是存档里带版本号第二层是在反序列化之后补一个Migrate方法按版本号逐级升级。旧版本存档读进来后先补齐缺失字段的默认值再存一次盘确保下次读取时已经是新格式。另外一个高频坑是字段改名。比如你发现原来的playerName拼写不对改成了playerDisplayName新版代码读旧存档旧字段就丢了。应对方法是在序列化库层面做字段映射Newtonsoft.Json的[JsonProperty]设置旧字段名或者干脆在迁移脚本里手动复制旧字段到新字段。5.3 性能与IO阻塞问题最后说性能。很多新手把存档逻辑放在Update里每帧判断“数据变了就Save”这是灾难性的设计。Android上每帧同步写文件用不了几分钟UI就会开始卡顿严重时直接ANR。正确做法是脏标记加节流。数据变化时只置一个布尔标志存档执行器每5秒或者每30秒检查一次标志需要存档才写盘。涉及大量数据时还可以考虑异步写法用StreamWriter异步写入避免阻塞主线程。不过要注意Unity主线程之外的C# API访问Unity对象是不行的你只能在子线程里处理纯数据和文件IO回到主线程再做反序列化结果的应用。提示移动端省电也是个隐性需求。频繁小文件写入会频繁唤醒存储芯片玩家的掉电速度肉眼可见。控制存档频率不只是性能问题也是续航问题。6. 我的一些后续扩展想法这套方案梳理完我其实还想延伸一句Unity数据持久化看起来是技术选型的事本质上是数据建模的事。你手里数据长什么样决定了该走PlayerPrefs、JSON文件还是SQLite。我在项目里养成的一个习惯是每次新增一个存档字段前先问自己一句这个数据是玩家偏好还是业务流程产物答案不同存放位置完全不同。如果你现在的项目还在用PlayerPrefs硬存一堆JSON字符串我建议你照着文章里的SaveManager雏形重构成文件存储成本很低但收益立竿见影。要是项目已经复杂到配置文件都上百个了那就赶紧规划一下SQLite或者云存档的路线别等玩家数据出了事故再回头补课。按照我个人的经验存档模块的返工成本往往是所有系统里最高的因为它的改动会波及每一个读写数据的模块。