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();
