ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity数据持久化方案对比:PlayerPrefs、文件序列化、ScriptableObject与SQLite

Unity数据持久化方案对比:PlayerPrefs、文件序列化、ScriptableObject与SQLite

做Unity项目久了,迟早会碰到一件事:玩家关掉游戏再打开,设置还在不在?上次玩到第几关?背包里的道具会不会消失?这些“需要被记住的数据”,对应的其实就是Unity数据持久化的问题。虽然听起来像给存档写文件,但它远不止存几个数字那么简单。这篇内容我会从最基础的选择讲起,把PlayerPrefs、文件序列化、ScriptableObject和数据库这几条路线全部拉出来对比,分享我实际项目里踩过的坑和一些能直接落地的写法。适合刚接触Unity不久、准备做存档系统的同学,也适合做了一段时间但还没系统整理过数据层的开发者。

1. 数据持久化为什么值得提前设计

1.1 游戏状态里,到底哪些数据需要被记住

我在接手项目时,最常见的存档代码就是到处散落的PlayerPrefs.SetInt调用。有人觉得“存数据嘛,随手写一下不就行了”,但数据持久化不是简单的“写入数据”,而是要把游戏状态分门别类,想清楚每一类数据的生命周期。

按我的经验,游戏数据大致可以分成四类:

  • 玩家偏好类:音量、画质、语言、震动开关。这类数据量小,结构简单,丢失了影响也不大,PlayerPrefs完全够用。
  • 游戏进度类:当前关卡、金币数量、已解锁角色、任务进度。这类数据不能丢,丢失一次玩家就会去写差评。
  • 结构化业务数据:背包物品、好友列表、排行榜记录。数据量可能很大,需要索引、查询,还要频繁增删改查。
  • 运行时临时数据:血条当前值、攻击冷却时间、UI面板状态。这些本来就不需要持久化,重启后重新计算就好。

很多人把第四类和第二类混在一起,结果每次保存都写一大堆运行时数据,导致存档又大又慢,还会把一些本不该固定的状态固定下来,后面加功能时很容易出兼容问题。所以第一步不是急着写代码,而是先把数据分类列出来,再决定用哪种持久化手段。

1.2 选型前,先回答四个问题

我不会一上来就推荐某种技术,而是先让需求回答四个问题。四个问题问完,用哪种方案基本就清楚了。

第一,数据结构是“扁平的偏好”还是“嵌套的实体”?扁平结构用PlayerPrefs就够,嵌套结构需要文件序列化或数据库。

第二,读写频率高不高?战斗结算时每秒钟写一次金币,和只在退出游戏时写一次存档,处理方式完全不一样。高频小数据适合内存加日志式落盘,低频完整快照适合整体序列化。

第三,需不需要跨平台?PlayerPrefs在编辑器、PC、移动端的行为细节不完全一样;文件路径必须用Application.persistentDataPath,不能用编辑器里的临时路径去推断。

第四,以后要不要升级存档结构?如果你的游戏打算持续更新,就必须考虑版本字段和迁移逻辑。这个点最容易被忽视,但是到最后坑最大的也是它。

这四个问题里有任何一个回答不清楚,最终存档方案一定会返工。我见过一个项目,刚开始数据量小,全塞在PlayerPrefs里没问题,后来加了背包系统,一次性存了上千件道具,每次保存卡顿一下,最后只能全部迁移到数据库,中间花了两周时间。

1.3 数据层放在架构的哪个位置

数据持久化不是某一页面的功能,它应该是独立的“数据访问层”。最简单的落地方式,就是不要让每个UI界面直接调用PlayerPrefs.SetFloat,而是通过一个SaveManager或者DataManager统一接口读写。

比如设置界面改音量,UI只调SettingsManager.SetVolume(v),界面层不知道数据是存在PlayerPrefs还是文件里。这种封装看起来很基础,但它能保证以后替换存储方式时,不需要改几十个界面。

实际操作中,我会再加一层“内存缓存”。启动时把存档一次性读入内存,运行时只改内存,在关键时机统一写盘。这样既减少了频繁的IO压力,又方便保存时统一处理版本号、校验和等元信息。不要小看这个设计,它会让后面的问题排查容易很多。

2. PlayerPrefs:轻量级偏好存储,别让它扛太重的活

2.1 PlayerPrefs能存什么,原理是什么

PlayerPrefs是Unity内置的键值存储,接口非常简单:SetInt、SetFloat、SetString、GetInt、GetFloat、GetString,另外还有一个HasKey和一个DeleteKey。

它底层在不同平台上的实现是不同的。在编辑器里它会写入注册表或本地配置文件,在移动端会写入应用的沙盒目录。作为开发者,你不必关心它具体存在哪里,只需要记住:它是用来存少量键值对的,不适合存大量数据,也不适合存复杂嵌套结构。

我可以顺手提一个容易踩的坑:PlayerPrefs没有“SetBool”和“GetBool”,通常做法是用SetInt(1/0)或SetString("true"/"false")。我见过有人用字符串存Json,还把整个背包直接塞进PlayerPrefs。字符串存进去是能成功,但读取时要反序列化,而且Unity的PlayerPrefs在Windows上对单个键值的长度和总量是有限制意识的,数据越大越容易出现反直觉的异常。

2.2 一套能直接用的PlayerPrefs封装

我不建议在项目里裸用PlayerPrefs,因为键名容易散落且容易拼错。比较稳的做法是写一个静态帮助类,把键名集中管起来。

public static class PrefsHelper { private const string MasterVolumeKey = "settings_master_volume"; private const string QualityLevelKey = "settings_quality_level"; public static float GetMasterVolume() { return PlayerPrefs.GetFloat(MasterVolumeKey, 1f); } public static void SetMasterVolume(float value) { PlayerPrefs.SetFloat(MasterVolumeKey, Mathf.Clamp01(value)); PlayerPrefs.Save(); } public static int GetQualityLevel() { return PlayerPrefs.GetInt(QualityLevelKey, 2); } public static void SetQualityLevel(int level) { PlayerPrefs.SetInt(QualityLevelKey, level); PlayerPrefs.Save(); } }

这里有一个关键点:PlayerPrefs.SetX之后,最好调用PlayerPrefs.Save()。官方文档里其实提到保存是异步的,退出应用或系统回收进程时,数据可能会没来得及写入。在关键业务节点上主动Save(),是减少数据丢失去的一种有效手段。

另外一个心得是:键名要有统一前缀,比如settings_、gameprogress_。不然项目做大以后,你根本分不清某个键是哪个模块写的。而且不同模块的键如果起了同一个名字,就会出现甲模块把乙模块数据覆盖掉的诡异问题。

2.3 PlayerPrefs的边界和避坑

用PlayerPrefs没问题,但要记得它的边界。首先,不要存图片、列表、复杂对象。哪怕你把对象转成Json再用字符串存进去,也一定会遇到性能问题,尤其是存档频繁写的时候。

其次是键名大小写和空格问题。一般情况下,键名是区分大小写的,我见过有人get_volume和GET_VOLUME混着写,结果设置调了没效果。建议项目里规定所有键名使用全小写加下划线。

还有PlayerPrefs.DeleteAll()这个风险操作。如果你只是想清除某个模块的配置,不要用DeleteAll,否则把所有存档都清掉了。正确做法是DeleteKey("settings_master_volume")然后Save()。

有一个让我印象很深的线上问题:某版本更新后,玩家突然发现游戏设置全部重置了。后来排查发现,是代码里某个地方用PlayerPrefs.DeleteAll()做了“重置功能”,但误触的路径很多,只要网络请求失败就会调用。所以DeleteAll这种危险接口,应当封装成独立的管理类,并且加二次确认,别让普通业务代码随意调用。

3. 文件与序列化:存档的“正规军”

3.1 先搞清楚文件应该写在哪个目录

当你要把存档写成文件时,最忌讳的是把文件放在项目根目录下的Assets/Data或者StreamingAssets里。原因很简单:构建出来后,Assets目录是只读的,就算你能写进去,下次启动可能也被覆盖或丢失了。

Unity推荐的可写目录是Application.persistentDataPath。在PC上它通常在用户目录下,在Android上它是应用专属沙盒目录,在iOS上也是沙盒目录。这个路径在每次启动时完全相同,适合存存档文件。

实操中我会这样初始化目录:

public static class FileSaveHelper { private static string SaveDirectory => Path.Combine(Application.persistentDataPath, "Saves"); private static string SavePath => Path.Combine(SaveDirectory, "game_save.json"); public static void EnsureDirectory() { if (!Directory.Exists(SaveDirectory)) { Directory.CreateDirectory(SaveDirectory); } } }

不要以为persistentDataPath目录一定存在,第一次运行时它可能还没创建,直接File.WriteAllText会抛异常。我开发时遇到过不止一次,在编辑器里没问题,打Android包后首次写存档就崩溃,原因就是少了CreateDirectory。

3.2 用JsonUtility把存档写到本地

Unity提供了内置的JsonUtility,优点是无需引入第三方库,缺点是不支持Dictionary,只支持[Serializable]标记的类、数组和List。

我一般会定义一个存档类:

[Serializable] public class SaveData { public int version; public int currentLevel; public int coin; public List<string> unlockedItems; }

保存和读取可以这样写:

using System.IO; using System.Text; using UnityEngine; public static class JsonSaveSystem { private static string SavePath => Path.Combine( Path.Combine(Application.persistentDataPath, "Saves"), "save.json"); public static void Save(SaveData data) { string directory = Path.GetDirectoryName(SavePath); if (!Directory.Exists(directory)) { Directory.CreateDirectory(directory); } string json = JsonUtility.ToJson(data, true); File.WriteAllText(SavePath, json, Encoding.UTF8); } public static SaveData Load() { if (!File.Exists(SavePath)) { return null; } string json = File.ReadAllText(SavePath, Encoding.UTF8); return JsonUtility.FromJson<SaveData>(json); } }

这里有个常见误区:很多人省略Encoding.UTF8,直接用默认编码。一旦存档包含中文,在部分平台上会乱码。用Encoding.UTF8读写是最稳妥的。

此外,JsonUtility.ToJson默认不会序列化Dictionary。如果你必须用字典,要么自己把它转换成[Serializable]的键值对列表,要么使用第三方JSON库。使用第三方库时,要注意平台兼容性,别为了一个存档功能把整个包体搞复杂。

3.3 什么时候用XML或二进制

JSON的好处是“人能看懂”,编辑器调试、玩家上传日志、CSV排查都很方便。XML和JSON角色类似,但更啰嗦,适合本来就使用XML配置的成熟项目,或者需要和旧系统对接的场景。

二进制最适合需要防篡改或者追求性能的存档方案。二进制文件人类不可读,文件体积比JSON小,解析速度通常更快。但要注意两点:一是版本升级时,二进制格式一旦调整,老存档基本报废,必须做专门的迁移工具;二是如果只把整个对象用二进制序列化而不加密,玩家依然可以用内存修改器或者反射工具篡改。

我不会为了“显得专业”强行上二进制。如果游戏类型是单机且不太注重反作弊,JSON完全够用。下面是三者的快速对照:

存储方式可读性体积性能适用场景
JSON好中等一般常规存档、可调试配置
XML较好较大一般配置类数据、跨平台交换
二进制差较小高有理型存档、需要加密压缩

如果一定要用二进制,我建议不要直接用内置的BinaryFormatter。它在部分新版本运行时里因为安全性问题被禁用,而且它的二进制版式变化后,跨版本兼容很麻烦。更可控的办法是自己定义字节结构,用BinaryWriter和BinaryReader进行写入和解析。

4. ScriptableObject不是“运行时存档”,但它是配置的利器

4.1 先理解ScriptableObject的持久化边界

ScriptableObject经常被误认为是“一种可以主动持久化数据的对象”,其实它本身是在Unity编辑器里作为资源存在的。你在Inspector里修改SO数据,改动会保存到Assets资源文件里,但那是编辑器层面的事,构建后的游戏运行时,SO是随包体被打出来的资源,本质上是只读的。

我用一句话总结:ScriptableObject是数据的“设计期容器”,不是“运行期存档”。如果你在运行时调用someSo.SetDirty()并写入数据,在编辑器里也许能生效,但在打包后运行,数据不会写进本地文件,下一条启动就没了。

4.2 用SO做存档默认值和配置中心

虽然SO不能承担存档,但它非常适合做“配置中心”和“默认值仓库”。我经常这样用:

public class GameConfig : ScriptableObject { public int startLevel; public int initCoin; public float respawnInterval; }

然后把这个SO资产拖到GameSettings脚本里。启动时,如果本地没有存档,就根据GameConfig生成初识存档;如果本地有存档,就让配置只作为后备兜底值。这样策划改初始参数时,只需要改Prefab/资源,不需要动代码和存档逻辑。

这种做法的好处是存档数据结构和初始化代码分离。否则每次调整初始金币,开发都要重新生成存档,或者写一堆初始化常量在代码里,时间久了根本不知道哪个是当前有效的。

4.3 为什么不能把存档直接塞进SO

我踩过一次很实在的坑:早期一个项目为了省事,把玩家金币直接放在SO里,然后通过编辑器把数值调整得很高,以为“保存了”。结果打包后测试,金币每次启动都重置成编辑器里那个值。原因和目标清晰:SO是随包体资源,不是本地存档。

后来我把它改为:SO只存初始值;运行时数据保存在SaveData类中,通过JSON落盘。SO负责回答问题“初始值是多少”,存档文件负责回答问题“这个玩家现在有多少”。两者分工明确以后,再也没有出现过“数据被重置”的困惑。

如果确实需要用SO来做“运行时工具箱”,可以在启动时new一个运行时SO实例,或者从Resources/Addressables加载后只读取,但不要修改它。

5. 数据量再大一点:Unity里的SQLite数据库方案

5.1 什么时候才需要上数据库

很多开发者对数据库又爱又怕。爱是因为它强大,怕是因为上手复杂度高。但实际项目中,不是所有数据都适合用文件存Json。

当你遇到下面这些场景时,就应该认真考虑SQLite:

  • 数据条目很多,例如几千条本地任务、几百个NPC状态。
  • 需要按条件查询,例如“找出所有等级大于10的任务”或“按时间倒序取最近20条消息”。
  • 存在多个实体之间有关系的结构,例如玩家、背包、邮件系统。
  • 需要频繁的局部更新,而不是每次保存整个存档。

我做过一个以本地玩法为主的项目,一开始用Json存档,然后背包、邮件、任务三套数据互相引用,每次保存要序列化一整个根对象,启动加载要把全部数据塞回内存,后来不得不迁到SQLite。迁移过程其实没有想象中可怕,但前期没有规划,确实浪费了不少时间。

5.2 在Unity中使用SQLite的完整流程

要在Unity里用SQLite,本质是把SQLite的C绑定库和C#接口塞进项目。通常需要包含Mono.Data.Sqlite.dll,以及对应平台的SQLite原生库。使用时要注意按平台划分插件目录,比如Android用Assets/Plugins/Android,iOS用Assets/Plugins/iOS。

自己从零集成的成本比较高,也可以选择市面上已经整理好的SQLite插件,但要注意版本匹配和许可证问题。

初始化连接大致这样:

using Mono.Data.Sqlite; using System.Data; public class DatabaseAccessor { private IDbConnection connection; public void Open(string dbPath) { connection = new SqliteConnection("Data Source=" + dbPath); connection.Open(); } public void Close() { if (connection != null) { connection.Close(); } } }

连接字符串里Data Source指向数据库文件路径。如果项目发布后数据库文件不存在,需要从StreamingAssets把预置的空数据库复制到persistentDataPath,再打开连接。这一步很多人会忘,结果就是在编辑器正常,真机上数据库打开失败。

5.3 参数化、事务和线程,一个都不能少

SQLite虽然比Json重,但直接拼SQL很危险。千万不要这样写:

string sql = "SELECT * FROM player WHERE name = '" + playerName + "'";

这会导致SQL注入,本地游戏虽然看起来影响不大,但一旦玩家输入特殊字符,可能破坏数据库,甚至让游戏崩溃。正确做法是使用参数:

IDbCommand cmd = connection.CreateCommand(); cmd.CommandText = "SELECT * FROM player WHERE name = @name"; IDbDataParameter param = cmd.CreateParameter(); param.ParameterName = "@name"; param.Value = playerName; cmd.Parameters.Add(param);

批量写入时,用事务包裹速度会提升很多。不要在逐条插入时反复提交,而是先BeginTransaction,最后Commit。

线程方面,Unity主线程和数据库操作不是天然绑定的。SQLite在非主线程执行时要注意连接对象的使用方式,而且Unity的Mono类库不一定所有环境都能在线程里安全调用。我习惯把数据库操作封装成一个DatabaseService,在单独线程执行,但回调回主线程时必须用UnityMainThreadDispatcher之类的机制,避免直接操作Unity和UI。

6. 常见问题与排查技巧实录

6.1 PlayerPrefs突然读不到数据

我遇到最多的存档问题就是“为什么PlayerPrefs读不到之前存的值”。排查步骤一般是:

  • 检查是不是换了个平台运行,比如编辑器存的数据,真机读不到。
  • 检查是不是使用了DeleteAll或者键名拼写不一致。
  • 检查是否在写入后直接强制杀进程,没能触发Save。
  • 检查是否重新安装应用,卸载会清除应用沙盒数据。

这里有一个我自己的调试习惯:在开发版本里加一个存档调试页面,展示当前PlayerPrefs所有键值和文件目录下的所有文件。这会让排查效率提高很多,因为很多问题不是逻辑错了,而是数据根本没落在你期望的位置。

6.2 JSON中文乱码或字段丢失

中文乱码的根因基本都是编码问题。读取和写入时保持一致用Encoding.UTF8就不会乱码。如果发现存档打开是中文正常,但游戏读取后某些字符串变成问号,那就是写入时用了默认File.WriteAllText(path, json),没有指定编码导致的。

字段丢失的常见原因有几个:

  • 类型没有标记[Serializable]。
  • 字段是private,JsonUtility只序列化public字段,除非加[SerializeField]。
  • 字段是Dictionary、Tuple、object等非内置类型。
  • 构造函数有参数但缺少无参构造函数,反序列化时只能创建空白对象。

建议每次保存前在编辑器里做个“回读测试”:写入一条数据,立刻读取并打印关键字段,确认稳定。

6.3 目录不存在导致写文件失败

这个问题在前面已经强调过,再补充一个典型场景:如果存档目录路径很长或者包含非法字符,在PC上可能会报ArgumentException,在移动端可能会报UnauthorizedAccessException。路径最好只使用数字、字母和较短的分段,不要塞入时间戳雨长椒文。

还有一点,移动端优先选择Application.persistentDataPath。不要用Application.temporaryCachePath来存重要存档,因为系统可能随时清理该目录。

6.4 存档版本升级与旧版本兼容

做单机游戏最怕的更新就是存档格式变了。我的习惯是存档类里永远带一个version字段:

[Serializable] public class SaveData { public int version = 1; // 其他字段 }

读取时先读version,随后根据版本做迁移。比如从版本1升级到版本2,新增了lastLoginTime字段,旧存档里没有,就填一个默认时间。如果版本跨度更大,做逐步迁移比一步到位可靠。

迁移逻辑建议单独放一个SaveDataMigrator类,不要在读取代码里加一堆if (version == 1)。否则版本多了以后,代码会变得不可维护,测试也不方便。

最后,我想特别提醒一件事:数据持久化看起来只是“存和读”,但它在实际开发中牵扯到数据结构设计、多平台路径差异、版本兼容、性能开销和安全防护,每一个方向都能展开很多细节。如果只让我给一条建议,那就是在写第一行存档代码之前,先花半小时把数据结构画出来,再决定用PlayerPrefs、文件、SO还是SQLite。这个决定越早做,后面的返工就越少。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进