1. 入门
我们已经认识了流(Stream),它其实就是用来顺序读写数据的抽象。我们用过FileStream来按字节级别访问文件,也用过StreamReader和StreamWriter来方便地处理文本数据(其实底层还是用FileStream,只是帮我们搞定了编码问题)。
但如果我们想在文件里存的不只是文本,而是强类型的数据,比如整数(int)、浮点数(double、float)、布尔值(bool)、日期(DateTime)甚至自定义结构体怎么办?当然可以全转成字符串用StreamWriter写进去,读的时候再解析回来。但这样有几个大坑:
- 存储效率低:数字12345如果按文本写进去要5个字节(字符),但二进制的int只要4个字节。数据量大时差别就很明显了。
- 性能问题:数字和字符串之间来回转,CPU要多做不少事。
- 数据精度:浮点数转成文本再转回来,可能会因为四舍五入丢精度。
- 解析麻烦:手动拆文本字符串来还原各种类型(比如"123,45 TRUE 2024-06-21")代码又长又容易出错。
为了解决这些问题,C#有专门的BinaryReader和BinaryWriter类。这俩其实是适配器,能套在任何Stream(最常用的还是FileStream)上,给你一堆方便的方法,直接以C#基本类型的二进制格式读写数据。它们会自动帮你把字节和具体类型互相转换,让你操作结构化二进制文件变得超简单。
核心思想:BinaryReader和BinaryWriter不是独立的流,它们是用来增强已有Stream的功能,让你能直接操作C#类型而不是原始字节。
2. 用BinaryWriter写数据
BinaryWriter有一堆Write()方法,每种C#基本类型都能用。你调用这些方法时,BinaryWriter会把值转成二进制字节,然后写进底层的Stream里。
例子:保存游戏设置
假设我们要保存游戏设置:音量(浮点数)、当前玩家等级(整数)、音乐是否开启(布尔值)、选择的难度(字符串)。
string filePath = "settings.bin";
// 1. 创建FileStream用于写入
using FileStream fs = new FileStream(filePath, FileMode.Create, FileAccess.Write);
// 2. 在FileStream上套BinaryWriter,指定字符串编码(如果有字符串)
using BinaryWriter writer = new BinaryWriter(fs, Encoding.UTF8);
// 3. 写入各种类型的数据
writer.Write(0.75f); // float(4字节)
writer.Write(15); // int(4字节)
writer.Write(true); // bool(1字节)
writer.Write("Easy"); // string(长度前缀+字节)
Console.WriteLine($"设置已保存到 '{filePath}'。");
例子解析:
- 我们用FileStream和FileMode.Create,这会新建文件或覆盖已有的。
- 然后用BinaryWriter包一层,传进去fs。注意,BinaryWriter默认会在Dispose()(被using块自动调用)时顺便关闭底层流(fs)。
- writer.Write()方法很直观:Write(float)、Write(int)、Write(bool)、Write(string)。它们自己知道每种类型要写多少字节、怎么写。
- 对于字符串,BinaryWriter会自动在字符串字节前加长度前缀。这样BinaryReader就能准确知道要读多少字节还原字符串。
- 你要是用文本编辑器打开settings.bin,看到的都是“乱码”,因为它是二进制文件。想看内容得用HEX编辑器。
3. 用BinaryReader读数据
BinaryReader有一堆ReadXxx()方法(比如ReadInt32()、ReadBoolean()、ReadString()),会从底层Stream里读对应数量的字节,然后转成你要的C#类型。
例子:加载游戏设置
现在我们来从settings.bin文件里读回刚才保存的设置。
string filePath = "settings.bin";
// 1. 创建FileStream用于读取
using FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read);
// 2. 在FileStream上套BinaryReader,编码要和写入时一样
using BinaryReader reader = new BinaryReader(fs, Encoding.UTF8);
// 3. 按照写入时的顺序读取数据
float volume = reader.ReadSingle(); // float
int level = reader.ReadInt32(); // int
bool isMusicOn = reader.ReadBoolean(); // bool
string difficulty = reader.ReadString(); // string
Console.WriteLine($"设置已从 '{filePath}' 加载:");
Console.WriteLine($"- 音量: {volume:P0}"); // 格式化为百分比(用字符串格式化)
Console.WriteLine($"- 玩家等级: {level}");
Console.WriteLine($"- 音乐开启: {isMusicOn}");
Console.WriteLine($"- 难度: {difficulty}");
例子解析:
- 用FileStream和FileMode.Open打开文件读数据。
- 用BinaryReader包一层fs,编码要和写入时一样。
- 超重要:调用reader.ReadXxx()方法的顺序必须和写入时一模一样。比如你写进去的是int,读的时候却用ReadString(),要么报EndOfStreamException(如果string更长),要么读出来就是乱七八糟的数据。
- ReadXxx()方法会自动读对数量的字节并转成你要的类型。ReadString()会用BinaryWriter写入时的长度前缀,知道要读多少字节还原字符串。
4. 重要细节和最佳实践
顺序要严格一致:
这是最重要的原则。BinaryReader和BinaryWriter不会存类型元数据;它们只知道每种基本类型占多少字节。你得保证顺序完全一致。
资源管理(using):
和.NET里大多数操作系统资源(比如文件、网络连接)相关的类一样,BinaryReader和BinaryWriter都实现了IDisposable接口。所以一定要用using块包起来——这样就算出错也会自动调用Dispose(),帮你防止资源泄漏,文件也会被正确关闭。
顺便说一句,BinaryWriter和BinaryReader默认也会帮你调用底层流(比如FileStream)的Dispose(),所以底层流也会自动关闭。
using FileStream fs = new FileStream("data.bin", FileMode.OpenOrCreate);
using BinaryWriter writer = new BinaryWriter(fs);
// ... 操作
字符串编码:
用BinaryWriter.Write(string)写字符串、用BinaryReader.ReadString()读字符串时,一定要在构造函数里指定同样的编码(比如Encoding.UTF8)。否则遇到非ASCII字符就可能出问题。
异常处理:
文件IO操作随时可能被外部因素打断(文件不存在、没权限、磁盘满了)。一定要用try-catch块包住FileStream和BinaryReader/BinaryWriter的代码,保证健壮性。
BaseStream和位置:
你可以通过BaseStream属性访问底层流(比如reader.BaseStream或writer.BaseStream)。这很有用,比如你想知道当前流的位置(BaseStream.Position)或者想跳转到文件的某个地方(BaseStream.Seek())。
// BaseStream.Position的用法例子
using FileStream fs = new FileStream("data.bin", FileMode.OpenOrCreate);
using BinaryWriter writer = new BinaryWriter(fs);
writer.Write(123);
Console.WriteLine($"流中的当前位置: {writer.BaseStream.Position}"); // 会输出4(int的大小)
writer.Write("Hello");
Console.WriteLine($"流中的当前位置: {writer.BaseStream.Position}"); // 会输出4 + (1+5) = 10
⚠️ Write(string)方法会先写字符串长度(7位整数),再写字符串字节。所以实际大小不一定等于1+字符串长度。
GO TO FULL VERSION