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

发布时间:2026/10/18 6:27:41
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 Liststring 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.FromJsonSaveData(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/AndroidiOS用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]。字段是privateJsonUtility只序列化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。这个决定越早做后面的返工就越少。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询