二进制文件读写
简介
这是本专栏的最后一篇文章
正如第一篇文章中说的,二进制格式文件比文本格式文件更加高效,那么到底有多高效?高效在哪里?怎么去用?这些问题在本篇都会一一解答,
这篇文章有两个数据持久化的例子,以及一个将JSON文件转成二进制文件的例子。
在上一篇文章使用JSON读取俄罗斯方块方块数据的例子中,文件大小为1007字节,在这篇文章,我们将JSON文本文件转存为特定的二进制格式文件,能够将体积压缩约30倍,最终的二进制文件只有36个字节。
为什么二进制格式比文本格式更高效?
我之前做过一个新闻类的安卓应用,在进行图文传输时,最开始为了图个简单,使用的是BASE64字符串编码,因为只需要将图片转换成字符串,放到HTML5的IMG属性中,通过HTTP直接进行上传和下载。
但是后来发现在刷新操作时加载数据特别慢,最后发现Base64编码的图片体积比原图的还要大(百分之三十三),而且还要耗费时间编码和解码才能在字符串和二进制图片之间进行转换。后来直接用原本的二进制格式进行传输,速度瞬间就上来了。
不管是普通的文本文件、JSON格式的文本文本文件,以及图片,视频,等各种数据,最终在硬盘上的存储格式都是二进制。文本文件和二进制文件的差异就在相互转化的过程中,得从两个方面考虑。
编码和解码
文本格式要进行额外的译码处理,12345这样的数字,转成二进制,首先要进行编码,最终仍然是将单独的五个字符编码的二进制数进行保存的。文本文件不仅有编码格式还有可能有交换格式(比如JSON),交换格式又需要类似的操作进行解析,最终才能在二进制和文本之间进行转换。
二进制数据在读写时也有解析的过程,但是这个过程相对固定,非常高效和简单。
空间
文本格式需要占用额外的空间
某些格式需要使用特定的语法去组织数据以便解析,就比如JSON,而这些特定的字符会占用额外空间,比如果一个数组"arr":[1,2,3,4,5],引号,分号,逗号,中括号,大括号这些全都是语法需要,真正的数据就只是12345。别的都不说,就看整数的差别,65535
,用字符去表示需要5个字节,实际上2字节的无符号整型就能表示。
文本格式空间占用大,操作又多,效率肯定没有二进制高
什么时候用二进制格式去存储数据?
这个问题很难找到一个统一答案,只能说是我自己的认识
音频,视频,图片,模型这些静态的美术资源就不说了,绝大多数情况下都是二进制数据,不需要考虑这个问题。要考虑这个问题的应该是程序中既可以是文本格式又可以是二进制格式的数据
数据持久化
把程序中的变量转化为文件数据进行保存,等程序开始的时候再进行加载,保存和恢复现场,比如游戏中的存档。这种数据持久化的情况就比较适合使用二进制格式。
数值型资源数据
只要是数,不管你存在哪个数据结构里面,使用二进制格式进行存储压缩体积。
要说例子的话,之前的英雄和物品数据,真值表
之所以强调数值型,是因为字符数据不管是用二进制格式还是文本格式来存储,数据占用的空间都是一样的,因为文字必然要用某种编码去表示,而这种编码又需要若干个固定的数值去表示,没有太多可操作空间,有时候存多了反而会适得其反。
最后要知道能用二进制格式存的,用文本格式也能存。还是要根据具体情况进行选择,并不是说程序执行高效就是最好的,对开发者的友好度也同样重要。
说了半天,还不如上手直观。
使用二进制格式读写数据
注意,下面的代码都是关键代码,不是完整的代码,缺少一些库或者前面的函数,如有需要请到专栏第一篇文章免费下载
首先看一个对class
的实例进行了保存和恢复的小例子。
class MyClass{
public:
MyClass() :a(0), b(0), c(0) {};
int a;
float b;
double c;
char chars[512] = "qwertyui";
void Print() { std::cout << a << "\n" << b << "\n" << c << "\n" << chars; };
};
void BinaryIO() {
std::string fileName = "./binary/outfile.bin";
//Write
MyClass cls;
cls.a = 10;
cls.b = 2.1f;
cls.c = 3.2;
std::fstream outfile(fileName, std::fstream::out | std::fstream::binary);
outfile.write(reinterpret_cast<char*>(&cls), sizeof(cls));
outfile.close();
//Read
MyClass cls2;
std::fstream infile(fileName, std::fstream::in | std::fstream::binary);
infile.read(reinterpret_cast<char*>(&cls2), sizeof(cls2));
infile.close();
cls2.Print();
}
输出
文件大小为528字节
大小
- int 4个字节
- float 4个字节
- double 8个字节
- char[512] 512字节
文件的后缀名为.bin
,实际上没有任何意义。
MyClass
的尺寸:512+4+4+8 = 528,文件的大小也是528字节
字符串的空间占用
char[]数组看上去有一点小问题,有用的数据只占用8字节,但是在文件中却依然占用了固定分配的512个字节,存在空间浪费。
所以之前说在存储字符串时并不会带来任何好处,因为存储一个字符串,要么像这里固定分配一定的空间,要么将字符串大小记录下来,所以比起文本格式反而会造成空间的浪费,与其在这里费力去记录字符串,不如直接放到文本文件中。当然,少量的浪费也是可以接受的。
读写函数
和之前文本格式文件读写有两个不同
- 打开方式增加了binary字段
- 写入的数据非字符串,使用reinterpret_cast<char*>将变量类型从
MyClass*
重新定义为char*
。因为read和write的参数是char*类型的变量,reinterpret_cast只是改变了指针的类型,而没有改变任何数据,这些数据会原封不动的被写入文件。
通过reinterpret_cast的转换,这里的变量可以是任何类型,不仅仅能保存类,数字,结构体,数组等任意数据,只要传入其地址或首地址和占用的字节数即可。
这里只是一个简单的小例子,因为类的空间是已知的,而且只存了一个,很多情况下,都需要存储若干个数据,下面探讨一下通用的二进制数据读写方法和二进制文件设计方法。
制定规则将数据写入
先说写,因为读需要这里制定的规则。
在不使用二进制格式的时候,很多规则都是其他人制定的,比如文字编码,各有各的规则,自动就按照各自的规则进行编码和解码,到这里,我们就变成了制定规则的人。
制定文件的规则就像在设计一个协议,数据怎么保存,然后怎么写就怎么读,很多东西不需要记录在文件中,使用固定的格式
保存数组的例子
-
情景:用a(整型)来记录数组b(整型数组)的元素个数。
-
制定协议:文件开头是a,接着就是b数组元素。
-
写入:把a和b按照顺序依次写入。
根据规则进行读取
上面说了怎么写就怎么读
-
知道文件开头是a,所以直接用一个a的类型(整型)的变量去接收
-
又知道a记录了数组b元素个数,这时就可以动态分配b的空间,将剩下的数据全部装入b数组中。
上面例子对应的代码
void BinaryArray() {
std::string fileName = "./binary/array.bin";
//写
int b1[] = { 1,2,3,4,5 };
int a1 = static_cast<int>(sizeof(b1) / sizeof(int));
std::fstream outfile(fileName, std::fstream::out | std::fstream::binary);
outfile.write(reinterpret_cast<char*>(&a1), sizeof(int));
outfile.write(reinterpret_cast<char*>(b1), sizeof(b1));
outfile.close();
//读
int a2;
int* b2;
std::fstream infile(fileName, std::fstream::in | std::fstream::binary);
infile.read(reinterpret_cast<char*>(&a2), sizeof(int));
b2 = new int[a2];
infile.read(reinterpret_cast<char*>(b2), a2*sizeof(int));
infile.close();
//打印数组整体大小
std::cout << sizeof(b1) << "\n";
//打印数组元素
for (int i = 0; i < a2; i++) {
std::cout << b2[i];
}
}
执行结果
字段类型
我们借助上面这个简单的例子,简单说一下这个二进制文件中的字段类型。
二进制文件的数据类型主要是两种
- 静态数据类型字段
- 动态数据类型字段
对应上面的例子
a就是静态数据类型,根据数据类型就能确定长度的字段
b就是动态数据类型,光是知道自己的类型还不够,还需要别的数据进行计数才能确定数组的长度
这里的数据全部都是一次性读取到内存中进行处理,如果需要直接访问二进制文件中的某个位置的数据,可以建立两级索引,这地方就先不写代码举例了。
- 字段索引,两个数据项,索引的地址和类型(类型不是必须的,可以直接固定)。意思就是我们可以先把这个数据的索引的地址记录下来,等需要的,根据地址先找到这个,然后就是记录索引
- 记录索引,目标的首地址,字段个数,记录数据类型。紧挨着字段索引,根据这个就可以像之前一样获取数据
关卡热重载案例
在文章的最后,我们在上一章用RapidJSON库
读取俄罗斯方块方块数据
的例子的基础上进行拓展,实现在专栏第一篇文章中说到的,将JSON文件转换成二进制格式文件。也就是这篇文章开头说的大大降低存储空间的例子
这里的代码不是完整代码,整个专栏的完整项目已经放到了Github,如有需要请到专栏第一篇文章进行下载。
在下面的例子中,直接使用1-bit表示一个方块的两种状态,0表示无,1表示有,所以首先需要两个位操作函数
准备位操作函数
//把某一位设置成0或者1
void SetBit(int& value,int index,bool isT) {
if (index < 0 || index >= 32) { return; }
value = isT ? value | (1 << index) : value & (~(1 << index));
return;
}
//判断某一位是否为1
void GetMask(int value, int index,bool& isT) {
if (index < 0 || index >= 32) { return; }
isT = ((value >> index) & 1) == 1;
return;
}
步骤
接着,这从JSON文件到二进制文件的过程中其实是有三个步骤的
- 读取并解析JSON,将JSON文件数据读取到程序中
- 制定二进制文件规则,并保存数据
- 从二进制文件还原到程序
我定义了一个Level类表示一个关卡,如下
class TetrisLevel {
private:
const char* jsonFileName;
const char* binaryFileName;
//方块的三维数组
int*** blocks;
//方块总数
int count;
public:
//传入JSON文件名和Binary文件名
TetrisLevel(const char* inFileName, const char* outFileName);
//释放数组
~TetrisLevel();
//从JSON文件读取数据
bool LoadJson();
//将数据保存为二进制格式
bool SaveToBin();
//从二进制文件中读取数据
bool LoadBin();
void PrintAllBlocks();
};
构造函数
初始化文件名,并规定,当二进制文件读取失败的时候,读取JSON文件,并创建相应的二进制文件
TetrisLevel(const char* inFileName, const char* outFileName):
count(0),
blocks(nullptr)
{
jsonFileName = inFileName;
binaryFileName = outFileName;
if (!LoadBin()) {
LoadJson();
SaveToBin();
}
}
析构函数
释放三维数组,模拟关卡的释放
~TetrisLevel() {
for (int i = 0; i < count; i++) {
for (int j = 0; j < BLOCK_LEN; j++) {
delete[] blocks[i][j];
}
delete[] blocks[i];
}
delete[] blocks;
}
读取并解析JSON
这地方和上一章的类似。
//从JSON文件读取数据
bool LoadJson() {
//形状数量
int shapeNum;
//一个形状有多少个方块
int* blockNum;
//一个形状的第一个方块下标
int* sumNum;
rapidjson::Document doc = GetDocument(jsonFileName);
if (!doc.IsObject()) {
cout << "Faild to valid JSON";
return false;
}
rapidjson::Value& vBlocks = doc["Blocks"];
shapeNum = vBlocks.Size();
blockNum = new int[shapeNum];
sumNum = new int[shapeNum];
for (int i = 0; i < shapeNum; i++) {
blockNum[i] = 0;
sumNum[i] = 0;
}
for (int i = 0; i < shapeNum; i++) {
//每个形状对应的角度数量
blockNum[i] = vBlocks[i].Size();
sumNum[i] += 0;
count += blockNum[i];
}
//初始化三维数组[13][BLOCK_LEN][BLOCK_LEN]
blocks = new int** [count];
for (int i = 0; i < count; i++) {
blocks[i] = new int* [BLOCK_LEN];
for (int j = 0; j < BLOCK_LEN; j++) {
blocks[i][j] = new int[BLOCK_LEN];
for (int k = 0; k < BLOCK_LEN; k++) {
blocks[i][j][k] = 0;
}
}
}
//将文件中的数组读取到三维数组
int n = 0;
for (int i = 0; i < shapeNum; i++) {
for (int j = 0; j < blockNum[i]; j++) {
rapidjson::Value matrix = vBlocks[i][j].GetArray();
for (int k = 0; k < static_cast<int>(matrix.Size()); k++) {
int row = static_cast<int>(floor(k / BLOCK_LEN));
int col = k % BLOCK_LEN;
blocks[n][row][col] = static_cast<int>(matrix[k].GetInt());
}
n++;
}
}
delete[] blockNum;
delete[] sumNum;
return true;
}
这段代码存在一些小问题,遍历的过程可能略显复杂,有些转换可以省略,C++的强制转换static_cast<float>()
和C的强制转换(float)
混用,虽然效果相同,但不太好
制定二进制文件规则
首先我们确定方块是由4*4的矩阵构成
然后读取JSON文件,从中解析出
- 形状数量
- 形状的方块数量(每个形状对应的角度数量)
- 形状范围(每种形状的第一角度的index)
- 方块数量
这些变量对游戏程序来说都是有用的,但是这里我们只保存方块数量,和方块矩阵数组
文件规则:第一个数字存方块矩阵数组占据多少空间,第二个数字表示方块矩阵总数,接着存入所有方块矩阵元素,通过设置比特位来表示
按照规则写入文件
//将数据保存为二进制格式
bool SaveToBin() {
//将208个bit保存到7个32bit的int变量中(224的空间,最后剩下16个空bit);
int size = count * BLOCK_LEN * BLOCK_LEN;
int arrSize = static_cast<size_t>(ceil((float)size / 32.0f));//ceil向上取整
int* arr = new int[arrSize];
for (int i = 0; i < arrSize; i++) {
arr[i] = 0;
}
//设置bit位
int bitPtr = 0;
for (int i = 0; i < count; i++) {
for (int j = 0; j < BLOCK_LEN; j++) {
for (int k = 0; k < BLOCK_LEN; k++) {
SetBit(arr[static_cast<int>(floor(bitPtr / 32))], (bitPtr++) % 32, blocks[i][j][k]);
}
}
}
fstream outfile(binaryFileName, fstream::out | fstream::binary);
outfile.write(reinterpret_cast<char*>(&arrSize), sizeof(int));
outfile.write(reinterpret_cast<char*>(&count), sizeof(int));
outfile.write(reinterpret_cast<char*>(arr), sizeof(int) * arrSize);
outfile.close();
cout << sizeof(int) * arrSize << "\n";
delete[] arr;
return true;
}
从二进制文件还原到程序
按照之前定义的规则,进行读取。
//从二进制文件中读取数据
bool LoadBin() {
//用了N个32位int型变量保存所有数据(7个)
int arrSize = 0;
//一共有多少个方块(13个)
count = 0;
//用于接收arrSize个32位int
int* intArr = nullptr;
//读取
fstream infile(binaryFileName, fstream::in | fstream::binary);
if (!infile) {
cout << binaryFileName<<" Not Found";
return false;
}
infile.read(reinterpret_cast<char*>(&arrSize), sizeof(int));
infile.read(reinterpret_cast<char*>(&count), sizeof(int));
intArr = new int[arrSize];
for (int i = 0; i < arrSize; i++) {
intArr[i] = 0;
}
infile.read(reinterpret_cast<char*>(intArr), sizeof(int) * arrSize);
infile.close();
//三维数组存储count个BLOCK_LEN*BLOCK_LEN矩阵
blocks = new int** [count];
for (int i = 0; i < count; i++) {
blocks[i] = new int* [BLOCK_LEN];
for (int j = 0; j < BLOCK_LEN; j++) {
blocks[i][j] = new int[BLOCK_LEN];
for (int k = 0; k < BLOCK_LEN; k++) {
blocks[i][j][k] = 0;
}
}
}
//遍历每一个bit,13*4*4=208<32*7=224
bool isT;
int col = 0;
for (int i = 0; i < count * BLOCK_LEN * BLOCK_LEN; i++) {
GetMask(intArr[static_cast<int>(floor((float)i / 32.0f))], i % 32, isT);
int x = static_cast<int>(floor((float)i / 16.0f));
int y = (int)(((float)i / 4.0f)) % BLOCK_LEN;
int z = i % BLOCK_LEN;
blocks[x][y][z] = isT;
}
PrintAllBlocks();
return true;
}
打印当前数组
void PrintAllBlocks() {
//显示读取数据
for (int i = 0; i < count; i++) {
for (int j = 0; j < BLOCK_LEN; j++) {
for (int k = 0; k < BLOCK_LEN; k++) {
cout << blocks[i][j][k] << " ";
}
cout << std::endl;
}
cout << std::endl;
}
}
测试用例
void LevelTest() {
const char* inFileName = "./json/Blocks.json";
const char* outFileName = "./binary/Blocks.bin";
TetrisLevel* level = new TetrisLevel(inFileName, outFileName);
delete level;
level = new TetrisLevel(inFileName, outFileName);
delete level;
}
输出二进制文件
二进制文件显然是不能用文本编辑器打开的,只有使用像Binary Viewer这样的二进制查看软件打开之后才能看到数据,当然写个程序也能看。
从下图就可以清晰的看见数据的排列情况
前32位就是上面代码中的arrSize
,接着的32位就是count
0010 0000 0110 0010
其实是按照 2 1 4 3的字节顺序原地反转读取
0000 0100 0100 0110
的顺序进行读取的,对应的就是第一个方块
0 0 0 0
0 1 0 0
0 1 0 0
0 1 1 0
最后文件的大小就是9个4字节(32位)的整数。共计36字节,之前的JSON文件大小为916字节,约为二进制版本的25倍。如果将前面的两个32位静态字段换成两个8位的无符号整数,最大范围255,最终大小将减小到30字节。这个用32位还是其它多少位,完全就要看业务需要。
这里没有实现版本控制的功能,其实也比较简单,加一个version字段,每次比较JSON文件和二进制文件的version,如果不相等就重新生成,没有必要每次运行都重新生成。
结束
OK,到这里基本上就完整的实现了手动修改JSON数据,将JSON格式的文本数据转换为纯的二进制数据,同时兼顾阅读和性能。实现了数据的热重载,在俄罗斯方块游戏中,程序可以不用重新编译,就能加载新的方块或者地图!
本专栏到这里就结束了,本来还写了从C++开始学习游戏开发的部分,实在没时间,这大四上的我头皮发麻,一个星期6天上课15节课(我没挂科,全是学校安排的课程)。不说了还是学习去了
喜欢的话点个赞,关注一下,后续可能还有一些扩展内容,有问题评论留言或邮箱与我联系,谢谢。