Unity复习学习随笔(11):二进制存储
最近项目中要做一个完整的存档系统,一开始图省事直接用了JsonUtility存JSON文本,等到数据量上来之后问题就来了:存档动不动就几MB起步,读写明显卡顿,而且玩家随便改两下JSON就能把存档改坏。被逼着把存档方案整体换成了二进制存储,才彻底解决这几个痛点。这篇随笔就系统地整理一下我在Unity里做二进制存储的经验,从最基础的BinaryWriter用法到存档版本控制、压缩加密的完整方案,希望能帮同样被存档问题困扰的朋友少走弯路。
先说明一下适用人群:这篇文章主要面向已经能在Unity里正常写C#脚本、用过PlayerPrefs或者JsonUtility做过简单存档的开发者。如果你完全没接触过Unity,建议先把基础脚本和生命周期搞清楚再看。文章里的代码基于Unity 2021.3 LTS,用.Net Standard 2.1 / .Net Framework 4.x API Profile都能跑,不同版本差别不大。
1. 为什么存档方案最终选二进制而不是JSON或XML
先把结论放在前面:二进制存储不是银弹,但它在存档场景下有三个不可替代的优势,体积小、速度快、不易篡改。这三个特点戳中的恰好是游戏存档最核心的需求。
1.1 数据体积差距实测
之前的JSON存档里存了背包物品、任务进度、NPC好感度、地图探索标记这些,大概2000条左右的记录,JSON文件量出来是3.6MB。同样数据量换成二进制存下来只有420KB左右,压缩比大约8.5比1。
体积差距来自两个方面。第一是JSON的文本结构开销,每个字段名都要写成字符串,键值对的花括号、冒号、逗号、引号全是字节。比如:
json复制{"itemId":10086,"count":5,"isEquipped":false}
这一段纯文本就要40多个字节。到了二进制里,int固定4字节,bool固定1字节,一条记录只需要9个字节,差距就是这么拉开的。第二维度是类型表达方式不同,文本格式把所有数字都转成ASCII字符,一个int值10086在JSON里占5个字节,二进制里就是个固定的4字节,数值越大文本格式的亏损越多。
1.2 读写速度与GC压力对比
用过JsonUtility或者Newtonsoft.Json的人应该都有感受,反序列化一个大JSON的时候,Unity的Profiler里GC Alloc那个数字飙升得非常快。原因是文本解析过程中要生成大量临时字符串,而C#里字符串是不可变的,每次拼接、截取、转换都在产生新的堆分配。
二进制的读取路径就短得多:直接按字节数读进缓冲区,用BitConverter或者BinaryReader按预定义格式解析,中间基本不产生字符串对象。我自己测过一组数据,同样那个3.6MB的JSON存档,JsonUtility加载耗时约130ms,GC Alloc大概18MB;二进制版本加载耗时约20ms,GC Alloc只有不到1MB。在低端Android机上这个差距还会进一步拉大,体验差异就是进入游戏时卡一下还是秒开的区别。
1.3 防篡改是刚需,不只是防作弊
玩家拿记事本打开JSON存档,把"gold":1000改成"gold":99999,然后整个存档的数值体系就崩了。二进制起码不会让人一眼看懂改哪里,配合一点简单的校验和解密,就能拦住绝大多数手改存档的情况。这里说的防篡改不是游戏安全级别的对抗,而是防止意外损坏,真正的反作弊还需要服务端校验,这里不展开。
所以我的结论很明确:单机游戏、存档数据量超过几百条、需要频繁读写存档的场景,二进制的收益完全值得那点手动序列化的成本。如果你的项目只是存几个设置项,PlayerPrefs完全够用,别强行上二进制给自己加工作量。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. BinaryWriter和BinaryReader的底层逻辑与基础用法
动手写代码之前,先理解一下这两个类在底层做了什么。BinaryWriter本质上是一个字节序列的"写入器",你的int、float、string这些高层类型,通过它转换成一串字节然后写进底层流(Stream)里。BinaryReader则是对称的逆向过程:从流里的某个位置开始,按指定格式把字节解析回高层类型。
这里的关键点在于:二进制存储的本质是你自己定义了一套字节布局规则,读写必须严格遵守同一套规则。这也是它和JSON、XML之间最大的思维差异——文本格式有现成的解析器替你处理布局,二进制格式中布局就是你的代码本身。
2.1 最基础的写入与读取流程
先看一个最简单的完整例子,写一个玩家基础数据进文件再读出来:
csharp复制using System.IO;
using System.Text;
using UnityEngine;
public class BinarySaveDemo : MonoBehaviour
{
[System.Serializable]
public class PlayerData
{
public int level;
public float hp;
public bool isMale;
public string playerName;
}
// 保存
public void SavePlayer(PlayerData data, string path)
{
// FileStream的FileMode.Create表示不存在就创建,存在就覆盖
using (FileStream fs = new FileStream(path, FileMode.Create))
using (BinaryWriter writer = new BinaryWriter(fs, Encoding.UTF8))
{
writer.Write(data.level); // 4字节
writer.Write(data.hp); // 4字节
writer.Write(data.isMale); // 1字节
writer.Write(data.playerName); // 变长,前缀+内容
}
}
// 读取
public PlayerData LoadPlayer(string path)
{
if (!File.Exists(path)) return null;
using (FileStream fs = new FileStream(path, FileMode.Open))
using (BinaryReader reader = new BinaryReader(fs, Encoding.UTF8))
{
PlayerData data = new PlayerData();
data.level = reader.ReadInt32();
data.hp = reader.ReadSingle();
data.isMale = reader.ReadBoolean();
data.playerName = reader.ReadString();
return data;
}
}
}
using语句块在这里是必须的,它保证FileStream和BinaryWriter的Dispose被正确调用,文件句柄被及时释放。Windows上如果你忘了释放句柄,下一次尝试覆盖写入同一个文件时就会抛IOException: The process cannot access the file...,因为文件正在被占用。这个坑在编辑器里反复进入Play Mode时尤其容易遇到。
Encoding.UTF8参数指定了字符串写入文件时的编码方式,不传的话默认是UTF-8。虽然文档里说默认就是UTF-8,我依然建议显式传一遍,因为这个参数在跟他人协作、老项目继承时最容易产生分歧,显式写清楚能避免一部分莫名其妙的乱码问题。
2.2 各种数据类型的字节占用对照
设计存档格式的时候脑子里始终要有这张表,每个字段占多少字节直接决定你怎么安排读写顺序:
| C#类型 | 二进制占用字节 | 对应BinaryWriter.Write方法 | 对应BinaryReader方法 | 备注 |
|---|---|---|---|---|
| bool | 1 | Write(bool) | ReadBoolean() | 0或1 |
| byte | 1 | Write(byte) | ReadByte() | 无符号 |
| sbyte | 1 | Write(sbyte) | ReadSByte() | 有符号 |
| short | 2 | Write(short) | ReadInt16() | 有符号16位 |
| ushort | 2 | Write(ushort) | ReadUInt16() | 无符号16位 |
| int | 4 | Write(int) | ReadInt32() | 有符号32位 |
| uint | 4 | Write(uint) | ReadUInt32() | 无符号32位 |
| long | 8 | Write(long) | ReadInt64() | 有符号64位 |
| float | 4 | Write(float) | ReadSingle() | IEEE 754 |
| double | 8 | Write(double) | ReadDouble() | IEEE 754 |
| char | 2 | Write(char) | ReadChar() | UTF-16编码 |
| string | 变长 | Write(string) | ReadString() | 7-bit长度前缀+UTF-8字节 |
注意string的规则:BinaryWriter.Write(string)会先写一个7-bit编码的长度前缀,再写UTF-8编码的字符串内容。7-bit编码是一种变长整数表示法,小于128的字符数只占1字节,所以短字符串的额外开销很小。读取的时候ReadString()会先读这个长度前缀,再读对应长度的字节。这意味着你不需要手动记录字符串长度,但代价是如果你试图用一个FileStream配合BinaryReader去读取一个不是用BinaryWriter写出来的字符串,ReadString()的解析就会错位,导致整个读取流程崩溃。
2.3 数组和List的写入模式
数组和列表没有内置的Write方法,惯例做法是先写元素个数,再逐个写元素。这样读取时先知道有多少个元素,才能决定循环几次:
csharp复制// 写入List<int>
writer.Write(ids.Count);
for (int i = 0; i < ids.Count; i++)
{
writer.Write(ids[i]);
}
// 读取List<int>
int count = reader.ReadInt32();
List<int> ids = new List<int>(count);
for (int i = 0; i < count; i++)
{
ids.Add(reader.ReadInt32());
}
这段代码里saveload两侧的count字段就是你整个数据流里的"结构标记"。一旦你在写入侧忘了写count,或者在读取侧读的字段顺序对不上,后面全乱。这种错误编译器不会报错,运行时不一定会抛异常,但读出来的数据全是垃圾值,这是二进制存储最典型的翻车方式:写入和读取规则不一致。
3. 从手动字段序列化到带版本号的完整存档结构
基础读写会了之后,直接往项目里用还有一个问题:游戏版本更新之后,存档结构大概率会变。你今天写了level和hp两个字段,下个版本加了exp字段,老玩家的存档就废了。做存档系统第一件事就是设计带版本号的存档结构,这比任何花哨功能都优先。
3.1 存档层级结构设计
我的惯例是把存档分成三个层级:文件头、元数据区、游戏数据区。
文件头固定写在文件最前面,包含一个魔数(Magic Number)和主版本号。魔数用来快速校验"这到底是不是我们的存档文件",避免程序去解析一个完全无关的文件。比如我可以规定所有存档文件的前两个字节固定是'S'和'V',读取时先检查这两个字节,对不上就直接放弃解析,避免后续读取错位。
元数据区存放存档时间戳、游戏版本号、存档等级这类信息。游戏数据区才是真正的游戏状态数据。分层的好处是以后做存档界面显示"存档时间""游戏版本"时,只需要读文件头加元数据,不用加载整个存档。
3.2 给BinaryWriter封装统一的写入上下文
手写大量writer.Write(xxx)的时候,很容易漏字段或者顺序写错。我的做法是封装一个专门的SaveFileWriter类,把版本校验、魔数校验、时间戳这些都收敛在几个方法里,业务侧只关心字段:
csharp复制public class SaveFileWriter
{
private BinaryWriter writer;
public SaveFileWriter(Stream stream)
{
writer = new BinaryWriter(stream, Encoding.UTF8, true);
}
public void WriteHeader(int version)
{
writer.Write('S');
writer.Write('V');
writer.Write(version);
}
public void WriteTimestamp(long unixTime) => writer.Write(unixTime);
public void Write<T>(T value) where T : struct
{
// 利用BinaryWriter的重载
if (value is int i) writer.Write(i);
else if (value is float f) writer.Write(f);
else if (value is bool b) writer.Write(b);
// ... 按需扩展
}
public void Dispose()
{
writer?.Dispose();
}
}
BinaryWriter构造函数的第三个参数leaveOpen设为true,表示释放BinaryWriter时不关闭底层流,这样我可以分别控制生命周期,在处理MemoryStream这种中间层时特别有用。
3.3 版本兼容的读取策略
读取侧是版本兼容的重点。我一般把读取逻辑写成这样:先读版本号,然后根据版本号走不同的解析分支,同一个字段在不同版本中有不同的处理逻辑,用switch分发而不是硬编码一套读取顺序:
csharp复制public GameData Load(string path)
{
using (FileStream fs = new FileStream(path, FileMode.Open))
using (BinaryReader reader = new BinaryReader(fs, Encoding.UTF8))
{
char magic1 = reader.ReadChar();
char magic2 = reader.ReadChar();
if (magic1 != 'S' || magic2 != 'V')
{
Debug.LogError("不是一个有效的存档文件");
return null;
}
int version = reader.ReadInt32();
long timestamp = reader.ReadInt64();
GameData data = new GameData();
if (version >= 1)
{
data.level = reader.ReadInt32();
data.hp = reader.ReadSingle();
}
if (version >= 2)
{
// 2.0版本新增经验值字段
data.exp = reader.ReadInt32();
}
if (version >= 3)
{
data.skillIds = ReadIntList(reader);
}
return data;
}
}
读老版本存档的时候,if (version >= x) 这种写法保证老版本缺少的字段会被跳过,读不到就采用默认值。配合一个UpgradeToLatest刷新逻辑,就能把老存档平滑升级到新版本,而不需要玩家重开档。
这里有一个小技巧:无论当前要写的是哪个版本的数据,一定要按最新的结构写。版本号的意义是让读取方能按版本选择合适的解析路径,输出端永远输出最新格式。否则存出来的文件本身就带着旧结构,版本升级逻辑会越来越乱。
3.4 用固定字节数处理Vector3等Unity类型
Unity的Vector3包含x、y、z三个float,直接遍历序列化需要自己处理。二进制的处理其实很直白,写三个float:
csharp复制public static class BinaryExtensions
{
public static void WriteVector3(this BinaryWriter writer, Vector3 v)
{
writer.Write(v.x);
writer.Write(v.y);
writer.Write(v.z);
}
public static Vector3 ReadVector3(this BinaryReader reader)
{
return new Vector3(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle());
}
}
Quaternion同理,x、y、z、w四个float。但是存储旋转的时候有个特殊考量:如果只是记录朝向,用欧拉角三个float就够了,比四元数省一个float。但如果要做插值计算,必须用四元数。这里属于功能需求层面决定数据格式的典型案例,格式设计不是纯粹的性能问题,要先想清楚数据会被怎么用。
4. 推荐用MemoryStream做中间层的理由与实操
文件写多了以后你会发现,频繁直接操作FileStream有个隐形问题:每次Write都是一次系统调用,IO太频繁会让性能打折扣,特别是在移动平台和机械硬盘上。解决方案非常成熟:把数据先写进内存里的MemoryStream,全部写完之后一次性刷到文件。
4.1 MemoryStream + BinaryWriter的标准组合
csharp复制public void SaveData(GameData data, string filePath)
{
using (MemoryStream ms = new MemoryStream())
{
// 第三个参数leaveOpen=true:释放writer时保留ms可读
using (BinaryWriter writer = new BinaryWriter(ms, Encoding.UTF8, true))
{
writer.Write('S');
writer.Write('V');
writer.Write(CURRENT_VERSION);
writer.Write(DateTimeOffset.UtcNow.ToUnixTimeSeconds());
WriteGameData(data, writer);
writer.Flush(); // 确保所有字节都进入ms
}
byte[] bytes = ms.ToArray();
File.WriteAllBytes(filePath, bytes);
}
}
为什么要先写内存再落盘?两个原因。第一,MemoryStream.ToArray()能够拿到完整的字节数组,之后可以做压缩、加密、加校验和这些后处理操作,而直接写FileStream意味着这些后处理就没机会了。第二,如果你的保存流程中任何一步抛异常,内存方案不会留下一个写到一半的坏文件;等你完成所有处理之后再一次性写入,要么成功要么不写,原子性更好。当然严格意义上的原子写需要写临时文件再替换,这个后面说。
4.2 用Buffer.BlockCopy处理大数组
如果你要存一个上千元素甚至上万元素的int数组,用BinaryWriter逐元素Write会有性能损耗。这种情况下可以直接从数组的内存区域拷出字节:
csharp复制public void WriteIntArray(BinaryWriter writer, int[] arr)
{
writer.Write(arr.Length);
// int是4字节,直接把数组内存块转成字节数组
byte[] byteArray = new byte[arr.Length * sizeof(int)];
Buffer.BlockCopy(arr, 0, byteArray, 0, byteArray.Length);
writer.Write(byteArray);
}
public int[] ReadIntArray(BinaryReader reader)
{
int len = reader.ReadInt32();
byte[] byteArray = reader.ReadBytes(len * sizeof(int));
int[] arr = new int[len];
Buffer.BlockCopy(byteArray, 0, arr, 0, byteArray.Length);
return arr;
}
Buffer.BlockCopy是快速的内存拷贝,底层走的是非托管内存复制,比在C#层逐元素赋值快得多。但要特别注意它按字节偏移计算,第二个参数源偏移量和第四个参数目标偏移量都是字节数,不是元素的个数。写错偏移结果是数据错乱,调试起来相当折磨人。实测下来,元素个数超过几百个时用Buffer.BlockCopy就有明显收益,小数组不要用,维护成本大于收益。
4.3 原子写入:先写临时文件再替换
游戏存档最可怕的不是存不上,而是存到一半崩溃,然后玩家打开游戏发现存档损坏。为了规避这个问题,我采用"临时文件+替换"的方式:
csharp复制public void AtomicSave(byte[] data, string filePath)
{
string tmpPath = filePath + ".tmp";
File.WriteAllBytes(tmpPath, data);
// 重要:确保数据真的落盘,而不是停留在操作系统缓存里
using (FileStream fs = new FileStream(tmpPath, FileMode.Open, FileAccess.Read))
{
fs.Flush(flushToDisk: true);
}
// 旧文件存在则先删掉再替换
if (File.Exists(filePath))
{
File.Delete(filePath);
}
File.Move(tmpPath, filePath);
}
这个流程的核心是:先写一个.tmp临时文件,确认写完之后用它覆盖正式存档文件。玩家如果在写入临时文件的过程中崩溃,正式存档还是完整无损的旧版本,最多丢失最后一次操作,不会出现"既不是新档也不是旧档"的中间态。实测这一套下来,存档的安全性提升非常明显。
5. 二进制存储的加密、校验与压缩扩展
基础功能通了之后,接下来是三个进阶需求:防止玩家改存档、防止存档损坏、减小存档体积。这三个问题各自的解题思路不同,但它们在二进制存储场景下可以被优雅地组合在一起。
5.1 校验和:CRC32或MD5
在文件末尾或者文件头里存一个校验值,加载时先算一遍数据区域的校验值,对不上就直接拒绝加载,提示玩家存档损坏。这种机制的主要价值是防止数据意外损坏,比如磁盘坏道、写入中断、杀毒软件误操作。
CRC32实现简单,性能也够,Unity里自己写一个或者引入现成的库都行。但我更推荐直接用MD5,因为System.Security.Cryptography.MD5是.Net内置的,不需要额外依赖:
csharp复制public static string ComputeMD5(byte[] data)
{
using (System.Security.Cryptography.MD5 md5 = System.Security.Cryptography.MD5.Create())
{
byte[] hash = md5.ComputeHash(data);
StringBuilder sb = new StringBuilder(hash.Length * 2);
for (int i = 0; i < hash.Length; i++)
{
sb.Append(hash[i].ToString("x2"));
}
return sb.ToString();
}
}
存的时候在文件末尾追加32个字符的MD5字符串,读的时候先读数据区算MD5,再跟文件末尾的MD5比对。这里注意MD5只用于完整性校验,不用于安全性,它的碰撞问题在存档完整性场景下完全无所谓。
5.2 加密:对称加密选AES,别自己发明算法
如果存档里有金币数量、道具ID这类玩家想改的数值,光是二进制加校验还不够,因为校验和本身也存在于文件中,懂行的人可以连校验值一起改。这时候需要真正的加密。
我的选择是AES对称加密。AES的安全性经过了大规模验证,而且System.Security.Cryptography.Aes也是.Net内置的。关键点在于密钥管理:客户端里硬编码密钥意味着所有玩家手上都有同样的密钥,所以这只能防君子不防小人。如果你的游戏对存档安全有硬性要求(比如竞技类游戏),必须走服务端校验,单靠客户端加密再强也推不倒逆向工程。
一个可用的AES加解密封装大概是这样的:
csharp复制public static byte[] Encrypt(byte[] plainBytes, byte[] key, byte[] iv)
{
using (Aes aes = Aes.Create())
{
aes.Key = key;
aes.IV = iv;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
using (ICryptoTransform encryptor = aes.CreateEncryptor())
using (MemoryStream ms = new MemoryStream())
using (CryptoStream cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write))
{
cs.Write(plainBytes, 0, plainBytes.Length);
cs.FlushFinalBlock();
return ms.ToArray();
}
}
}
这个过程比较耗CPU,所以一般只在保存的时候做一次、加载的时候做一次,不需要对存档里的每个字段单独加密。把加密、压缩、校验这三个环节的组合顺序理清楚:先序列化成字节数组 -> 压缩 -> 加密 -> 追加校验和 -> 落盘。读取时反着来:读文件 -> 校验和验证 -> 解密 -> 解压 -> 反序列化。
5.3 压缩:GZip够用,LZ4追求极致性能
二进制格式本身已经比文本小很多,但如果你存了好几个MB的关卡数据、地图数据,再压一层仍然有意义。Unity内置的System.IO.Compression.GZipStream开箱即用,压缩率不错但是慢。如果你对读写速度更敏感,可以考虑引入LZ4算法,它在解压速度上比GZip快一个量级,适合存档频繁读写的场景。
我个人在大部分单机项目里直接用GZip就够了,理由很简单:GZip是内置库零依赖,项目部署简单。只有当Profiler里明确看到存档解压耗时超过我预期的时候,才会考虑换LZ4。这里不建议为了"可能出现的性能问题"过早引入依赖,存档的热路径就保存/加载两处,GZip的性能瓶颈在大多数游戏里根本感知不到。
6. 实际操作中遇到的坑与排查链路分享
最后这部分分享几个我在项目里真实碰到过的问题,每个都花了不少时间排查,写出来帮大家省点时间。
6.1 端序问题:为什么某个Android机型上读出来的float是天文数字
有一阵子我们遇到一个诡异bug:在iOS上存档一切正常,在部分Android机型上读档之后,角色的血量数值完全错乱。排查链路是这样的:先怀疑是读写顺序问题,仔细核查发现两侧代码完全对称;再怀疑是Unity版本差异,排除掉;最后问题定位在二进制流的字节序上。
C#的BinaryWriter默认使用当前平台的字节序,也就是大端还是小端取决于运行平台。x86/x64和绝大多数ARM处理器都是小端,但有些特殊环境或者你如果用了跨语言读取(比如C++写、C#读),就会碰到端序不匹配导致的数据错乱。
解决方案很简单:不要依赖默认,固定成小端。BinaryWriter没有直接提供字节序参数,所以需要自己包装一下,或者用MemoryStream手动做字节反转,比较省事的方式是统一的字节序转换类:
csharp复制public static byte[] ToBytes(int value)
{
byte[] bytes = BitConverter.GetBytes(value);
if (!BitConverter.IsLittleEndian)
{
Array.Reverse(bytes);
}
return bytes;
}
这是个噩梦级别的bug,因为数据看起来"读出来了",但数值完全不对。我的建议是:从设计的第一天就确定全项目统一小端序,并且写一个自检工具,在编辑器菜单里跑一遍写入再读出的完整闭环测试,跑不通就报警。
6.2 字符串变长编码导致的长度前缀错位
还有一次遇到的问题在字符串上。BinaryWriter.Write(string)先写7-bit变长编码的长度前缀,这个编码规则对小于128的长度只占1字节。但我自己写了一个"更高效"的字符串写入方案,直接写一个int长度再写UTF-8字节,问题就来了:新版本用int长度,老版本用7-bit长度,两者对不上,老存档读出来之后整个字段全部错位。
排查链路:
- 先确认发生位置,打日志发现是字符串后面的第一个int字段就读错了。
- 猜测是字符串长度解析的问题,写了一个测试脚本,用
BinaryWriter写"abc"再自己读,发现没问题。 - 再试旧版本存档文件,发现读取结果跟预期不一致。
- 最终定位到新旧版本字符串写入规则不一致。
这个教训的核心是:存档格式一旦发布,就是永恒的兼容性负担。任何结构上的调整都必须考虑老存档的读取路径,而不是默认"反正我们还在开发期,不用管兼容"。开发期我也建议从一开始就跑版本兼容测试,不然到项目后期老存档越积越多,修起来成本呈指数上升。
6.3 大端小端、编码、混淆三合一的自检方法
排坑排多了之后,我养成一个习惯:每个存档系统都会额外写一个自检代码,直接在编辑器里跑一遍"写入-读出-比对"的往返测试,任何改动之后先跑这个再提交。流程如下:
- 构造一个覆盖所有字段类型的测试数据,包括最小值和最大值,比如
int.MaxValue、float.Epsilon、超长字符串、空字符串、空数组。 - 走一遍完整保存流程。
- 走一遍完整加载流程。
- 逐字段比对,任何不一致立即报错。
这个自检方法帮我拦下了至少三次因为改结构引入的回归bug。它的价值不在于多复杂,而在于每次改完序列化代码后能自动验证,不用手动去试。
7. 从手动序列化到考虑Unity官方序列化框架
如果项目规模再大一些,手写字段的方式会变得冗长。Unity里有ISerializationCallbackReceiver可以用来自定义序列化行为,也有SerializeField配合JsonUtility的老路子,但二进制领域Unity并没有官方的一体化框架。第三方方案中有MessagePack、Protobuf、FlatBuffers这些高性能序列化库,它们本质上也是二进制格式,只是把"定义字节布局"这件事自动化了。
我目前的做法是:小项目、存档字段数量在几十个以内,直接手动序列化,清晰可控;字段数量膨胀到几百个的时候,上MessagePack或者Protobuf会更省心,生成的代码天然保证读写一致。不过引入第三方库意味着要处理IL2CPP的AOT问题,特别是MessagePack在iOS上需要预先生成代码,这个部署成本需要项目取舍。
二进制存储的核心思路是不变的:明确字节布局、保证读写一致、版本兼容、完整性校验。框架只是把这个过程自动化,底层原理还是这篇随笔讲的这些。
回头再看整个踩坑过程,我最大的体会是:二进制存储本身不难,难的是提前想到格式升级、跨平台、文件损坏这些后续问题。把这几个点在设计初期就纳入考量,后面能省非常多的事。希望这篇随笔对你有帮助,如果你在项目里也遇到过二进制存档的奇葩问题,欢迎一起交流。
