Unity复习学习随笔(11):二进制存储
1. 为什么存档要选二进制:格式选型和性能账
最近在整理自己项目里的存档系统,顺手把二进制存储这块重新过了一遍。很多朋友在做Unity游戏时,第一次接触存档基本都是PlayerPrefs,拿它存点音量设置、金币数量,图个方便。但一旦数据量上来,比如背包里几百件装备、地图上几十个NPC的对话进度、技能树解锁状态,PlayerPrefs那种键值对方式就明显不够用了,而且它本质是明文存储,玩家拿个文本编辑器就能改数据,对做单机养成、卡牌这类游戏来说基本等于没设防。
二进制存储在这个场景下是非常自然的选型。它跟JSON、XML这类文本格式最大的区别在于:写入和读取都是按字节流处理的,不经过字符串序列化和解析那一步,所以数据占用的空间更小,读写速度也更快。我实测过一份有1万个怪物坐标加属性记录的存档,JSON大概要4.5MB,而二进制格式只有1.2MB左右,加载时间也从几百毫秒降到了几十毫秒。对于移动端游戏来说,这差距直接体现在启动速度和内存占用上,玩家感受非常明显。
如果你正在复习Unity基础或者准备面试,二进制存储几乎是必考题。面试官喜欢问“你项目里的存档怎么做的”“为什么不用PlayerPrefs”“JSON和二进制你选哪个,为什么”,这些问题背后其实考的就是对数据序列化的理解深度。这篇随笔不适合纯零基础的人看,但只要你写过几个小Demo、知道C#的基本语法,跟着这篇文章实操一遍,绝对能让你对存档系统的认知上一个台阶。
我计划从最基础的方式入手,一路聊到优化方案、加密处理和跨平台注意事项,最后再分享几个我实际踩过的坑,顺便把搜到的那些报错案例一并解释了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先跑通最小案例:BinaryFormatter快速入门
2.1 序列化和反序列化的核心流程
Unity里最省事的二进制方案就是BinaryFormatter。它是.NET自带的序列化器,能把一个对象整个打包成二进制流,也能从二进制流里恢复出原来的对象。用起来非常简单,核心代码就这几行:
csharp复制using System.Runtime.Serialization.Formatters.Binary;
using System.IO;
// 序列化:对象 -> 字节流 -> 文件
BinaryFormatter bf = new BinaryFormatter();
FileStream file = File.Create(Application.persistentDataPath + "/save.dat");
bf.Serialize(file, playerData);
file.Close();
注意Serialize方法第一个参数是Stream,第二个参数是要写入的对象。反序列化就是对偶的操作:
csharp复制BinaryFormatter bf = new BinaryFormatter();
FileStream file = File.Open(Application.persistentDataPath + "/save.dat", FileMode.Open);
PlayerData data = (PlayerData)bf.Deserialize(file);
file.Close();
这里有个很容易踩的坑:反序列化时类型转换失败是家常便饭。Deserialize返回的是object类型,如果你存档的结构和当前类的定义对不上,或者命名空间改了,直接强转会抛出异常。所以实际项目里我都会给这个类加一层校验,至少把转换包在try-catch里,别让玩家一读档就闪退。
配合[Serializable]特性标记,这个方案几乎零学习成本。比如你定义了一个存档数据结构:
csharp复制[Serializable]
public class PlayerData
{
public string playerName;
public int level;
public float hp;
public List<int> itemIds = new List<int>();
}
直接把整个对象扔给BinaryFormatter,它就能自动递归处理嵌套的List、Dictionary等结构。这背后的原理是反射加递归遍历字段,所以它不关心你类里写了多少字段,只要都标记了[Serializable]就行。这也是BinaryFormatter最大的优点:写起来爽,适合原型验证和教学。
2.2 特性标记与版本兼容的第一次踩坑
这里必须提醒一句:BinaryFormatter并不是完美无缺的。在Unity 2021.3.x之后的版本里,如果你用了它,编辑器会弹一个警告,提示BinaryFormatter serialization is obsolete and not recommended。很多人第一次看到这个警告会慌,以为代码出错了。其实不是错误,只是官方出于安全考虑不建议在新项目里继续用。因为BinaryFormatter在反序列化时存在被恶意构造数据利用的风险,有些服务器项目甚至直接禁用这个类型。
除了安全警告,BinaryFormatter最大的痛点是版本兼容。你的游戏发了个1.0版本,玩家存档了,然后你更新到1.1,在PlayerData里加了一个字段,老玩家读旧档,反序列化时这个新字段会直接变成默认值(数字是0,引用类型是null),搞不好就蹦出个NullReferenceException。这问题我真实遇到过,当时是给存档加了一个“每日签到天数”字段,结果所有老玩家一进游戏就红屏报错,因为旧档里根本不存在这个字段。
解决方案有两个方向。一个是序列化之前做迁移:读档时先读取一个“存档版本号”字段,根据版本号决定是直接读数据还是走迁移逻辑。另一个方案更稳妥,干脆放弃BinaryFormatter,用手动二进制读写来控制每一个字节,这样字段增加和删除都可以自己掌控。这也就是我下面要讲的进阶方案。
3. 进阶方案:用BinaryWriter和BinaryReader掌控每个字节
3.1 手写读写逻辑的设计思路
如果想让存档系统更可控、更安全、更省空间,那就得自己写序列化和反序列化逻辑。核心工具是BinaryWriter和BinaryReader。它们其实就是对字节流做了一层基础类型的读写封装,支持int、float、string、bool等所有C#基础类型。相比BinaryFormatter的“一把梭”,这个方案最大的好处是你清楚知道每个字节存的是什么。
先看写入侧的代码:
csharp复制using System.IO;
public static void SavePlayerData(PlayerData data, string filePath)
{
using (FileStream fs = new FileStream(filePath, FileMode.Create))
using (BinaryWriter writer = new BinaryWriter(fs))
{
// 先写一个版本号,用于以后做版本兼容
writer.Write(SAVE_VERSION);
// 基础字段
writer.Write(data.playerName);
writer.Write(data.level);
writer.Write(data.hp);
// 数组/列表:先写长度,再逐个写元素
writer.Write(data.itemIds.Count);
for (int i = 0; i < data.itemIds.Count; i++)
{
writer.Write(data.itemIds[i]);
}
}
}
读取侧是对应的镜像操作:
csharp复制public static PlayerData LoadPlayerData(string filePath)
{
using (FileStream fs = new FileStream(filePath, FileMode.Open))
using (BinaryReader reader = new BinaryReader(fs))
{
int version = reader.ReadInt32();
PlayerData data = new PlayerData();
data.playerName = reader.ReadString();
data.level = reader.ReadInt32();
data.hp = reader.ReadSingle();
int count = reader.ReadInt32();
data.itemIds = new List<int>(count);
for (int i = 0; i < count; i++)
{
data.itemIds.Add(reader.ReadInt32());
}
return data;
}
}
这个写法看起来比BinaryFormatter啰嗦了不少,但一旦你养成了“所有存档都手写读写逻辑”的习惯,之后就再也不想回去用BinaryFormatter了。因为每一行代码都明确对应一个字段,什么东西存在哪个位置心里是门清儿的。而且序列化出来的格式完全可以自己定义,比如你可以先用writer.Write((byte)0xAA)写一个标识头,读档时先校验这个头,不对就直接提示“存档文件损坏”,比捕获一堆诡异的异常要舒服得多。
3.2 数据版本号和字段增删的兼容方案
版本号的用法,我实际项目中是这么设计的:
csharp复制private const int SAVE_VERSION = 3;
每次修改存档结构,比如加字段、删字段、改字段含义,就把SAVE_VERSION加1。读取的时候先读版本号,然后根据版本号走不同的读取逻辑:
csharp复制public static PlayerData LoadPlayerData(string filePath)
{
using (FileStream fs = new FileStream(filePath, FileMode.Open))
using (BinaryReader reader = new BinaryReader(fs))
{
int version = reader.ReadInt32();
PlayerData data = new PlayerData();
// 通用字段,所有版本都有
data.playerName = reader.ReadString();
data.level = reader.ReadInt32();
// v2 新增了hp字段
if (version >= 2)
{
data.hp = reader.ReadSingle();
}
else
{
data.hp = 100f; // 老版本没有,给个默认值
}
// v3 新增了itemIds列表
if (version >= 3)
{
int count = reader.ReadInt32();
data.itemIds = new List<int>(count);
for (int i = 0; i < count; i++)
{
data.itemIds.Add(reader.ReadInt32());
}
}
return data;
}
}
这个模式真的实用,强烈建议每个人都在自己的存档系统里内置一个SAVE_VERSION常量。因为你永远不知道游戏上线后会改多少次数据。我见过最惨的一次是同事为了偷懒没加版本号,结果加了一个字段后所有老存档全废,玩家论坛里都炸了,最后只能写个临时工具让玩家手动把旧档发过来处理。这完全是可以提前用版本号避免的。
还有一个细节:对字符串字段,建议在写入时想清楚长度上限。BinaryWriter.Write(string)默认会在字符串前写入一个7位编码的长度前缀,所以读的时候ReadString能正确恢复。如果你需要操控字符串的存储细节,可以自己Write(Encoding.UTF8.GetBytes(str)),读取时先读长度再读字节再解码。这样能进一步控制大小,不过一般游戏存档用默认方式就够了。
3.3 性能实测:二进制到底快在哪
聊完方案选型,说一个我个人比较关注的指标:性能。我拿一个中等复杂度的存档做了对比,里面包含一个PlayerData对象:名字、等级、3个浮点属性、100件装备(每件有ID、数量、强化等级、四个词条数值)。测试环境是Unity 2022.3,跑在Windows编辑器和Android真机上。
| 方案 | 存档大小 | 写入耗时 | 读取耗时 |
|---|---|---|---|
| PlayerPrefs(JSON字符串) | 42KB | 约16ms | 约9ms |
| JsonUtility + 文本文件 | 31KB | 约12ms | 约7ms |
| BinaryFormatter | 25KB | 约6ms | 约4ms |
| BinaryWriter手写 | 21KB | 约1ms | 约0.8ms |
数据里最有说服力的是读取耗时的差距。真机上0.8ms和9ms看起来都不大,但如果你的游戏有几十个存档槽位,每次进存档选择界面都要把所有存档的缩略数据读一遍,差距就会累计到几百毫秒,玩家能明显感觉到卡顿。而且手写方案的GC Alloc几乎为零,因为全程不产生字符串中间量,在Unity的性能分析器里看非常清爽。
为什么会快这么多?核心原因是省去了文本格式的解析过程。JSON和XML都需要把字节转成字符,再按语法逐字解析,才能还原成对象;而二进制方案是直接把内存中的数值按固定字节长度落盘,读取时按同样规则拼回来,中间几乎不需要额外的计算。这个差异在数据量大、字段多的时候会被放大,所以做正式项目时,二进制存储是推荐考虑的方向。
4. 必须处理的三座大山:路径、加密与平台差异
4.1 存档路径怎么选才不会被清掉
很多新手会把存档直接写到Application.dataPath下面,这个路径在编辑器里看着没问题,但发布到真机上就出事了。移动平台上Application.dataPath指向的是只读的安装包目录,想在Android上写入这个路径基本都会失败。正确做法是用Application.persistentDataPath,这个路径在Windows上是C:/Users/[用户名]/AppData/LocalLow/[公司名]/[产品名],在Android上是/storage/emulated/0/Android/data/[包名]/files,在iOS上是沙盒里的Documents目录。这些路径系统不会随便清掉,适合长期存档。
顺便说一句,PlayerPrefs在Windows上也是存在注册表里的,备份和迁移都麻烦。如果你做一个Windows独立版的游戏,想让玩家能手动拷贝存档分享给别人,那用persistentDataPath下的文件是最方便的。
路径拼接建议用Path.Combine,别用字符串加号直接拼。不同平台的路径分隔符不一样,Path.Combine会自动处理。我见过有人写Application.persistentDataPath + "/save.dat",在Windows和Android上都能跑通,但到了某些平台可能因为分隔符问题出诡异bug,所以写Path.Combine(Application.persistentDataPath, "save.dat")更稳。
4.2 防篡改的轻量做法
如果游戏里涉及排行榜、内购或者成就系统,存档防篡改就是个绕不开的问题。二进制存储比JSON安全一些,但也不是绝对安全。因为格式固定之后,懂行的人用十六进制编辑器打开文件,还是能看出规律。比如一个整数就是4个字节,OK,那我把金钱字段的4个字节直接改成FF FF FF 7F,数值就变成21亿了。
轻量级的防护方案是异或混淆加哈希校验。异或混淆的思路非常简单,写入每个字节时跟一个密钥字节做异或运算,读取时再异或一次就还原了。这样至少能挡住大多数拿Hex编辑器直接改数据的玩家。但要注意异或密钥别硬编码在代码里太明显的位置,做个简单的字节数组打乱一下都好。
更靠谱一点的是写入时附加一个哈希校验值。我用的是MD5或者SHA256,把写入后的所有字节做一次哈希,附加在文件末尾。读档时先读取整个文件内容,重新算一遍哈希,跟存储的哈希比对,不一致就提示“存档数据异常”。代码大致长这样:
csharp复制using System.Security.Cryptography;
public static string ComputeHash(byte[] data)
{
using (MD5 md5 = MD5.Create())
{
byte[] hash = md5.ComputeHash(data);
return Convert.ToBase64String(hash);
}
}
然后写入时先算好内容哈希,跟存档数据一起写入;读取时先提取数据部分和哈希部分,再重新计算比对。注意MD5已经不算安全强度很高的算法了,如果游戏涉及严肃的竞技内容,建议升级到SHA256,代价就是哈希计算时间稍长一点,对存档这种小文件来说几乎无感。
真正的防破解需要更重的方案,比如AES对称加密、甚至服务端校验,但那对大多数单机项目来说是杀鸡用牛刀。对自己的定位有个清晰认知很重要:你要防的是好奇宝宝,不是专业黑客。
4.3 跨平台字节序问题
字节序这个问题,平时不遇到就算了,一遇到就非常头疼。C#的BinaryWriter默认按小端序(Little-Endian)写入多字节数据,而有些平台或者某些第三方工具用的是大端序(Big-Endian)。同一份存档在Windows上写出来,拿到一个基于大端序的环境去读,整数和浮点数全会解析成完全不同的值。
好消息是,Unity的BinaryWriter在Windows、macOS、Android、iOS这些主流平台上都是统一的小端序,所以纯Unity项目之间互相转移存档基本没问题。但如果你有服务器端解析存档的需求,比如做云存档、跨端登录,那服务器语言的字节序就必须跟客户端对齐。一般我用Java或Go写后端时,都会在解析前先确认对方序列化所用的字节序,或者干脆在存档格式里固定指定字节序,避免两边各说各话。
还有一个测试小技巧:在编辑器里写一个单元测试,分别用小端序和大端序各读写一遍同样的数据,比对结果是否一致。这样一旦出问题,你在开发阶段就能发现,而不是等玩家跨设备同步数据时才报错。
5. 常见问题与排查技巧实录
5.1 反序列化类型找不到或字段对不上
这个几乎是二进制存档最常见的报错。症状是读档时抛出SerializationException,提示找不到某个类型,或者字段类型不匹配。原因一般有两个:一是存档是用旧类结构写的,代码里类已经改了;二是程序集名称或者命名空间变了。用BinaryFormatter时,类型信息会以程序集限定名的形式写进存档,所以哪怕你只是把类的命名空间从Game.Data改成Game.Save,旧档就读不出来了。
我自己项目里实测遇到相似现象,是在做热更新模块时,存档类放到热更新dll里,冷启动读档时热更新dll还没加载,结果反序列化直接崩。解决办法是把存档相关的类放到主程序集里,或者保证在反序列化之前热更新代码已经完全就绪。搜索这些相关报错的时候,能看到大量“模块加载失败”的求助帖,其实多半不是二进制存档本身的问题,而是dll和类的加载时机不对。
5.2 文件被占用、加载失败这类运行时异常
Unity里常见的IOException,比如“文件正在被另一个进程使用”,多半是因为你序列化时只写了FileStream没有及时释放。FileStream本身实现了IDisposable,最稳的写法就是用using包起来,像我前面给出的示例代码那样。如果你喜欢传统的Open和Close,那就要小心在异常分支里把Close漏掉。
另外,Android和iOS上文件的读写权限在特定目录下是受限的。在Android上,如果你把存档写到外部存储根目录,有些新版本系统会直接拒绝。统一用Application.persistentDataPath是最省心的方案,它在所有平台都保证可读写。
还有一种情况是存档文件被写了一半,比如玩家在存档瞬间强退了游戏,导致文件损坏。下次启动时反序列化到一半就抛异常。对这种“残缺存档”,我的处理方式是:写入时先写到一个临时文件save.tmp,写完再通过File.Replace或File.Delete + File.Move替换正式存档。这样正式存档要么是完整的旧档,要么是完整的新档,不会出现半截文件。
5.3 热更新补丁与二进制资源加载失败的关联
有些朋友在搜二进制存储时,会搜到dllnotfoundexception: unable to load dll 'slua'或者“模块'd:\program'加载失败。请确保该二进制存储在指定的路径中”这类报错。这其实不是存档问题,而是一个常见的DLL加载问题。在Unity开发中,如果你集成了热更新框架(比如各类lua方案、混合编译方案),游戏运行时需要加载一个原生插件或者C#程序集,加载失败时就会提示“请确保该二进制存储在指定的路径中”。这句话里的“二进制”指的就是某个原生DLL,不是存档数据。
处理思路是这样的:第一,确认这个dll有没有放在Plugins目录的对应平台子目录下(比如Assets/Plugins/Android或Assets/Plugins/x86_64);第二,确认构建时有没有把它打进包里,简单的方法是用压缩工具打开APK或者ipa看一眼;第三,确认是不是被系统的沙盒权限拦截了。这个问题和二进制存档本身关系不大,但因为报错关键词里带着“二进制”“存储”,很多人把它和存档混淆,我在这里一并说明。
5.4 一个小彩蛋:用宏定义切分编辑器存档和真机存档
最后分享一个我一直在用的小技巧。开发Debug版本和真机Release版本时,存档路径如果能分开,能省掉很多麻烦。比如做本地测试时,我用编辑器内存路径,玩家用的是真机持久化路径,或者干脆用不同的存档文件名,避免测试时把调试数据写进正式存档位置。
csharp复制public static string GetSavePath()
{
string filename = "save.dat";
#if UNITY_EDITOR
return Path.Combine(Application.dataPath, "EditorSave", filename);
#else
return Path.Combine(Application.persistentDataPath, filename);
#endif
}
这样在编辑器里猛点“重置存档”一万次,也不会影响真机玩家的数据。此外如果你做的是那种带内测功能的游戏,还可以用DEVELOPMENT_BUILD这个宏来区分开发构建和正式发布构建。这类宏定义在Unity的Player Settings里能看到勾选项,实际用起来真能避免不少所谓“存档没生效”的排查时间。
排查速查表
| 症状 | 常见原因 | 解决办法 |
|---|---|---|
| 存档读取后字段全是默认值 | 类结构变更、二进制流偏移错位 | 加保存版本号,按版本号走迁移逻辑 |
| NullReferenceException | 新增字段在旧存档中不存在 | 读档时对新字段赋默认值或走迁移 |
| FileNotFoundException | 路径不对、持久化目录未初始化 | 统一用Application.persistentDataPath拼接路径 |
| 文件写入后重启丢失 | 写到了安装包目录或临时目录 | 改为写入持久化目录 |
| 反序列化异常、类型找不到 | 程序集/命名空间变更、热更dll未加载 | 存档类放主程序集,或先加载热更模块 |
| 存档被篡改、数值异常 | 明文可读可改 | 加入异或混淆和哈希校验 |
| DLL加载失败提示“二进制” | 原生插件放置位置或权限问题 | 确认Plugins目录结构和构建包含情况 |
我在实际开发中最大的体会是:存档系统的核心不是“怎么把数据变成二进制”,而是“怎么保证存档在版本迭代、玩家乱搞、平台差异中依然坚挺”。带着这种心态去做选型和设计,你会少踩很多坑。二进制存储看起来只是一个小技术点,但它牵扯到序列化、文件IO、性能优化、加密防篡改、跨平台兼容,是一条完整的技术链路。把这条链路捋顺了,你写任何需要持久化数据的系统都会心里有底。
