Skip to content

二进制存储数据

更新: 1/17/2026 字数: 0 字 时长: 0 分钟

二进制数据读写效率更高,体积更小,安全性更高,非常适合作为网络通信中传递数据的媒介。

二进制文件不仅是人眼不可阅读的,如果使用者不清楚这个二进制文件数据是如何存储的,就无法解析这个二进制文件。

数据 <——> 字节

通过BitConverter中的GetBytes方法将各种原始类型转化为字节。

cs
byte[] bytes = BitConverter.GetBytes(256); // [0, 1, 0, 0]

同样通过BitConverter中的ToXxx方法将字节转化为原始数据,额外需要一个startIndex索引参数。

cs
int number = BitConverter.ToInt32(bytes, 0);  // 256

NOTE

基础类型中的decimalstring无法通过该方式转换。

对于字符串,需要采用一种编码方式再转化为字节数组。毋庸置疑,最通用的编码格式是 UTF-8。使用Encoding类来完成这个转换工作:

cs
byte[] bytes = Encoding.UTF8.GetBytes("Explosion!!!");
string s = Encoding.UTF8.GetString(bytes);

文件操作

File

.NET 原生的File类提供了许多有用的文件操作方法:

cs
// 1.判断文件是否存在
bool isValidPath = File.Exists(fileName)

// --- 写入 ---

// 2. 创建文件 
FileStream fs = File.Create(fileName);
// 3. 写入字节数组
byte[] bytes = Encoding.UTF8.GetBytes("Explosion!!!");
File.WriteAllBytes(fileName, bytes);
// 4. 将指定的 string 数组一会会写入文件
string[] strArr = {"Black\nRed", "Explosion!!!","Cartethyia"}
File.WriteAllLines(fileName, strArr);

// --- 读取 ---

// 5. 读取字节数据
bytes = File.ReadAllBytes(Application.dataPath + "/LimBoo's Kin.txt");
// 6. 读取所有行信息
strArr = File.ReadAllLines(Application.dataPath + "/LimBoo's Kin.txt");
// 7. 读取所有文本信息
File.ReadAllText(Application.dataPath + "/LimBoo's Kin.txt");

// --- 删除 ---
// 8. 删除文件(文件需要关闭)
File.Delete(Application.dataPath + "/LimBoo's Kin.txt");

// --- 复制 ---
// 9. 复制文件到目标路径,true 代表强制覆盖(默认 false)
File.Copy(Application.dataPath + "/LimBoo's Kin.txt", targetFileName, true);

// --- 替换 ---
// 10. 用一个文件替换另一个文件
// 可填一个备份路径:替换发生前,旧的目标文件会被改名存放在这里。
File.Replace(sourceFileName, 
	destinationFileName,
	destinationBackupFileName);

// --- 流 ---
// 需要关闭
FileStream fs = File.Open(fileName, 
	FileMode.OpenOrCreate, 
	FileAccess.ReadWrite);

文件流

文件在内存与硬盘文件之间建立了一个数据传输通道。文件流利用缓冲区分批次加载文件,这意味着它会将整个文件放入内存,且可以一步一步地读写数据。适合用于处理大文件,断点续传/随机读写与网络传输。

FileStream只能读写字节 (byte)。

创建一个文件流

通过构造函数:

cs
FileStream fs = new FileStream(fileName,
	FileMode.Create,
	FileAccess.ReadWrite
	);
  • FileMode枚举:
    • CreateNew:创建新文件,如果文件存在,则报错
    • Create:创建文件,如果文件存在,则覆盖
    • Open:打开文件,如果文件不存在,则报错
    • OpenOrCreate:打开文件,如果文件不存在,则创建文件
    • Append:若存在文件,则打开并查找文件尾,或者创建一个新文件
    • Truncate:打开并清空文件内容
  • FileShare参数(可选):
    • None:谢绝共享
    • Read:允许别的程序读取该文件; Write:允许别的程序写入该文件
    • ReadWrite:允许别的程序读写该文件

通过Create方法:

cs
FileStream fs = File.Create(fileName, 2048, FileOptions.None);
  • 参数二:缓存大小
  • 参数三:描述如何创建或覆盖该文件

通过Open方法:

cs
FileStream fs = File.Open(fileName, FileMode.Create);

重要方法

基础方法:

cs
// 获取文本字节长度
int length = fs.Length;
// 是否可读/可写
bool canRead = fs.canRead;
// 将缓冲区立刻写入
fs.Flush();
// 关闭 fs 释放资源,自定调用 Flush() 方法(二者效果相同)
fs.Close();
// fs.Dispose();
// 自动释放资源:
using (FileStream fs = new FileStream("file.txt", FileMode.Open)) { 
	// ...
}

写入:

cs
fs.Write(byteArr, startIndex, length)

当你要写入一个字符串时,由于字符串长度是不确定的,为了之后的读取,需要记录字符串的长度。通常的作法是先写入字符串的长度,再写入字符串自身。自己完成这个流程很麻烦,我们可以借助已有的轮子BinaryWriterBinaryReader ,例如:

cs
// 传统方法
byte[] bytes = Encdoing.UTF8.GetBytes("Explosion!!!");
int length = bytes.Length
// 写入长度
fs.Write(BitConverter.GetBytes(bytes.Length), 0, 4);
// 写入内容
fs.Write(bytes, 0, bytes.Length);
fs.Flush()

// 更简洁轻松的方法(已有轮子)
using (FileStream fs = new FileStream(path, FileMode.Create)) using (BinaryWriter writer = new BinaryWriter(fs, Encoding.UTF8)) {
	// BinaryWriter 会自动处理字符串长度前缀 
	writer.Write("Explosion!!!"); 
	// 你甚至可以顺便写入其他基础类型,比如 int, byte 
	// writer.Write(100); 
}

读取:

BinaryReaderReadXxx()方法可以读取多种基础类型。如果之前写入了一个字节数组,读取时通常使用reader.ReadBytes(int count),参数count是你要读取的字节数量。

cs
using (FileStream fs = new FileStream(path, FileMode.Open)) 
using (BinaryReader reader = new BinaryReader(fs, Encoding.UTF8)) 
{
	// 指针跳转
	// 参数1: 偏移量;参数2: 从哪里开始算 (此处是文件开头)
	reader.BaseStream.Seek(500, SeekOrigin.Begin);
	
	// 方法 B: 直接改 Position 属性 (效果同上) 
	// reader.BaseStream.Position = 500;

	// ReadString 会自动读取长度前缀,然后读取对应长度的字符 
	string s = reader.ReadString(); 
}

WARNING

BinaryWriterBinaryReader必须成对使用。

文件夹操作

.NET 原生的Directory类提供了许多有用的文件操作方法:

cs
// 1. 断文件夹是否存在
bool isDirValid = Directory.Exists(path);
// 2. 创建文件夹(若文件已存在则直接返回 DirectoryInfo)
DirectoryInfo info = Directory.CreateDirectory(path);
// 3. 删除文件夹
// 参数二:是否删除非空目录
// true:删除整个目录;false:仅当该目录为空时才删除(默认)
Directory.Delete(path);
// 4. 查找文件夹
// 指定路径下所有文件夹名
string[] strs = Directory.GetDirectories(rootPath);
// 指定路径下所有文件名(不含文件夹)
strs = Directory.GetFiles(Application.dataPath);
// 5. 移动文件夹及其内容
// targetPath 已存在时会报错;代码实际实现为重命名
Directory.Move(originPath, targetPath);

// --- DirectoryInfo ---
// 1. 在创建和获取目录时会返回 DirectoryInfo
DirectoryInfo info = Directory.CreateDirectory(path);
dInfo = Directory.GetParent(path);
// 2. 属性
// 全路径
print(dInfo.FullName);
// 文件名
print(dInfo.Name);
// 3. 到所有子文件夹的目录信息
DirectoryInfo[] dInfos = dInfo.GetDirectories();

// --- FileInfo文件信息类 ---
// 通过 DirectoryInfo 得到该文件下的所有文件信息
FileInfo[] fInfos = dInfo.GetFiles();
for (int i = 0; i < fInfos.Length; i++)
{
	print(fInfos[i].Name);  // 文件名
	print(fInfos[i].FullName);  // 路径
	print(fInfos[i].Length);  // 字节长度
	print(fInfos[i].Extension);  // 后缀名
}

二进制序列化

序列化 (Serialization) 就是把内存里复杂的对象,变成一串可以存储或传输的数据。

CAUTION

BinaryFormatter已经被微软官方宣布极度不安全且已弃用 (Obsolete),很多时候你也不必使用[System.Serialize]

建议使用第三方高性能库,如 Protobuf-net 或 MessagePack。其速度极快,生成的文件极小,而且像写 JSON 一样简单(全是自动的)。

使用 Protobuf-net

这是 Google 开发的一种协议,被适配到了 C#。它能把你的类自动变成极小的二进制流。Protobuf 只是一个打包员。它负责把数据打包成字节,把这些字节运到硬盘上还得靠FileStream

但注意,Protobuf 只能读取由自己生成的数据:对于任何不遵守 Protobuf 规则的数据(比如 PNG 图片、MP3 音乐、Excel 文件,或者是别的程序员用 BinaryWriter 随便写的文件),Protobuf 拿它们完全没办法。

Github 页面

定义数据类:

cs
// [ProtoContract] 表示这个类可以被序列化
[ProtoContract]
public class GameSaveData
{
    // [ProtoMember(1)] 代表这个字段在二进制流里的唯一 id
    // 由于 Protobuf 是靠 id 定位数据的,变量可以随意修改,增加
    [ProtoMember(1)]
    public string PlayerName;

    [ProtoMember(2)]
    public int Level;

    [ProtoMember(3)]
    public float Health;

    [ProtoMember(4)]
    public List<int> ItemIds;

    // 没有标签的字段会被忽略
    public string TempStatusString; 
}

序列化和反序列化:

cs
using System.IO;
using ProtoBuf;

public static class SaveSystem
{
    // ==========================================
    // 场景 A: 本地存储 (存硬盘)
    // ==========================================
    
    public static void Save(GameSaveData data, string path)
    {
        using (var file = File.Create(path)) // 管道通向硬盘
        {
            Serializer.Serialize(file, data);
        }
    }

    public static GameSaveData Load(string path)
    {
        if (!File.Exists(path)) return null;
        using (var file = File.OpenRead(path)) // 管道来自硬盘
        {
            return Serializer.Deserialize<GameSaveData>(file);
        }
    }

    // ==========================================
    // 场景 B: 网络传输 (存内存 -> byte[])
    // ==========================================

    /// <summary>
    /// 发送端:把对象变成字节数组 (准备塞给 Socket.Send)
    /// </summary>
    public static byte[] SerializeToNetworkPacket(GameSaveData data)
    {
        // 创建一个内存流(临时的内存缓冲区)
        // MemoryStream 可以接受一个数组
        using (var ms = new MemoryStream()) 
        {
            // 魔法时刻:Protobuf 以为自己在写文件,其实写进了内存
            Serializer.Serialize(ms, data);
            
            // 把内存流里的数据全部倒出来,变成 byte[]
            // 可以声明一个类成员数组,减少此处创建/销毁数组的开销
            return ms.ToArray(); 
        }
    }

    /// <summary>
    /// 接收端:把收到的字节数组还原成对象 (来自 Socket.Receive)
    /// </summary>
    public static GameSaveData DeserializeFromNetworkPacket(byte[] receivedBytes)
    {
        // 把收到的死数据,重新包装成活的流
        using (var ms = new MemoryStream(receivedBytes))
        {
            // 魔法时刻:从内存流里还原对象
            return Serializer.Deserialize<GameSaveData>(ms);
        }
    }
}

使用 BinaryWriter

cs
using System.IO;

public class PlayerData
{
    public string Name;
    public int Level;
    public float Hp;

    // --- 自己实现序列化逻辑 ---
    
    // 把自己写入流
    public void Serialize(BinaryWriter writer)
    {
        writer.Write(Name);
        writer.Write(Level);
        writer.Write(Hp);
    }

    // 从流里读取自己
    public void Deserialize(BinaryReader reader)
    {
        Name = reader.ReadString();
        Level = reader.ReadInt32();
        Hp = reader.ReadSingle();
    }
}

// --- 调用层工具类 ---
public static class PlayerNetworkManager
{
    // ==========================================
    // 场景 A: 本地存储
    // ==========================================
    public static void SaveGame(PlayerData data, string path)
    {
        using (FileStream fs = new FileStream(path, FileMode.Create))
        using (BinaryWriter writer = new BinaryWriter(fs))
        {
            data.Serialize(writer); 
        }
    }

    // ==========================================
    // 场景 B: 网络传输
    // ==========================================

    /// <summary>
    /// 发送端:将 PlayerData 打包成网络包
    /// </summary>
    public static byte[] CreateNetworkPacket(PlayerData data)
    {
        // 1. 创建内存管道
        using (MemoryStream ms = new MemoryStream())
        {
            // 2. 创建 Writer,骑在内存管道上
            using (BinaryWriter writer = new BinaryWriter(ms))
            {
                // 3. 调用 PlayerData 自己的逻辑,让它写进 Writer
                data.Serialize(writer);
                
                // 4. 这个 bytes 就是你要发给服务器的东西
                return ms.ToArray(); 
            }
        }
    }

    /// <summary>
    /// 接收端:解析网络包
    /// </summary>
    public static PlayerData ParseNetworkPacket(byte[] packet)
    {
        PlayerData data = new PlayerData();

        // 1. 把网络包(byte[]) 变成流
        using (MemoryStream ms = new MemoryStream(packet))
        using (BinaryReader reader = new BinaryReader(ms))
        {
            // 2. 让对象从流里读取自己
            data.Deserialize(reader);
        }

        return data;
    }
}

加密

目前理论上除了 One-Time Pad 算法以外,是没有完全安全的算法的。但基于现实考虑,由于 One-Time Pad 算法需要和文件同样大小的密钥,除了非常需要安全性的场合,我们是不可能去使用它的。

简而言之,加密是为了使破解的成本 > 破解的收益。

常见的加密算法:

  • AES (对称加密):目前全球通用的标准(美军严选)。速度很快,破解极难
  • 简单异或

哈希算法用于验证完整性:

  • HMAC(带密钥的哈希)
  • SHA-256
  • 别用 MD4 和 SHA1

XOR (异或混淆) 做法:

网络数据包,为了速度,非关键的数值,只需简单“防君子不防小人”。CPU 做异或的速度非常快,开销非常小:

cs
// 密钥
private static readonly byte[] Key = Encoding.UTF8.GetBytes("SecretKey");

// 加密和解密是同一个方法:调用一次是加密,再调用一次就是解密。
public static byte[] Process(byte[] data)
{
	byte[] result = new byte[data.Length];
	
	for (int i = 0; i < data.Length; i++)
	{
		// 每一个字节都和密钥的对应字节做异或运算
		result[i] ^= Key[i % Key.Length];
	}
	
	return result;
}

ASE:

  • 初始向量 (IV) 的作用是让同样的明文在不同时候加密出不同的密文,就像随机数的 seed
  • 初始向量必须是16字节,其可以是公开的
  • 明文 -> 明文和 IV 异或 -> key 加密
cs
// 密钥:必须是 16, 24 或 32
// 实际发布时,最好通过简单算法运行时生成
private static readonly byte[] Key = Encoding.UTF8.GetBytes("12345678901234567890123456789012");

/// 加密:自动生成随机 IV,并拼接到结果的最前面
/// 结构:[IV (16 bytes)] + [加密数据]
public static byte[] Encrypt(byte[] data)
{
	using (Aes aes = Aes.Create())
	{
		aes.Key = Key;
		// 1. 让 AES 帮我们生成一个随机的 IV
		aes.GenerateIV(); 

		using (MemoryStream ms = new MemoryStream())
		{
			// 2. 【核心步骤】先把这个随机 IV 明文写入流的最开头
			ms.Write(aes.IV, 0, aes.IV.Length);

			// 3. 然后开始加密实际数据
			using (ICryptoTransform encryptor = aes.CreateEncryptor(aes.Key, aes.IV))
			using (CryptoStream cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write))
			{
				cs.Write(data, 0, data.Length);
			}
			
			return ms.ToArray();
		}
	}
}

/// 解密:自动从数据流的最前面提取 IV
public static byte[] Decrypt(byte[] encryptedDataWithIv)
{
	using (Aes aes = Aes.Create())
	{
		aes.Key = Key;

		using (MemoryStream ms = new MemoryStream(encryptedDataWithIv))
		{
			// 1. 准备一个 16 字节的数组来存 IV
			byte[] ivBuffer = new byte[16];
			
			// 2. 【核心步骤】先从流里读出前 16 个字节
			// 如果读不够16字节,说明数据损坏了
			int bytesRead = ms.Read(ivBuffer, 0, 16);
			if (bytesRead < 16) throw new Exception("数据不完整,丢失 IV");

			// 3. 把读出来的 IV 设置给 AES
			aes.IV = ivBuffer;

			// 4. 用这个 IV 解密剩下的部分
			using (ICryptoTransform decryptor = aes.CreateDecryptor(aes.Key, aes.IV))
			using (CryptoStream cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read))
			using (MemoryStream resultMs = new MemoryStream())
			{
				cs.CopyTo(resultMs);
				return resultMs.ToArray();
			}
		}
	}
}

什么时候加密

  • 严格加密
    • 机密:用户的真实隐私、财产、凭证
    • 重要游戏内容:经济系统、进度、成就
  • 轻量加密
    • 代码逻辑
    • 美术资源
  • 无关紧要
    • 游戏设置:音量大小、分辨率、语言设置
    • UI 文本/日志文件:你自己总得读吧