我们在编写程序的时候,经常需要保存自己的程序产生的一些数据,然后在下次程序运行的时候,读取出来,这就涉及到文件的读写操作。
一般情况下,我们搜索C#文件读写,经常是这样的一段代码:
FileStream fs = new FileStream("C:\\test.dat", FileMode.Open, FileAccess.Read);
byte[] data = new byte[fs.Length];
fs.Read(data, 0, fs.Length);
fs.Close();
上面的这段代码就是经典的文件读取代码,代码没错,但是很多时候,这段代码并不能满足我们的需要,因为这段代码,是将文件整个的读取到一个byte数组里。
我们想有格式的读取文件,怎么办?
比如有个兄弟给了我这么一个需求:将一个float数组写入文件,然后再将文件读到到一个float数组。
这就涉及到自定义文件格式的读写了。
当然也有人会抬杠说可以保存为xml文件格式,可以保存为json格式,可以保存为文本格式,没错,这些都是解决方案,但是我这个兄弟还有个要求,用二进制而不是文本格式保存,因为他不想让用户直接看到里面保存的内容。
所以,以上的方案就不适合了。而且,我本质上,是希望通过这个简单的例子,让大家融会贯通,能够设计出自己的更复杂的文件格式。
好了,现在让我们来设计一下文件格式。
需求很明确,也很简单,文件就是保存一个float数组,我们可以将文件分为三部分:
- 文件标识区。
- 数组长度区。
- 数组数据区。
至于文件扩展名,自己定义一个,只要不和常见的文件扩展名相同就可以了,比如本文,我们用.dat作为自己定义的文件的扩展名。
文件标识区
这个是自己来定义的,也就是用来识别是不是自己的文件格式,有很多文件格式都会有自己的文件标识区,比如exe文件格式,早期的dos系统的可执行程序,文件头部有个MZ标识。
这里我用我的V号作为我的文件标识:jidi0827,用utf8编码的话,占8个字节。
数组长度区
数组长度区就是告诉我们,文件里保存的这个float数组的长度。虽然说每个float都是占4个字节,是定长的,但是我们还是浪费4个字节来存储数组的长度,用途等下再说。
数组数据区
这个区域存储的就是float数组的具体数据。一个接一个的float数组依次存放,每个float占4个字节。
写入文件
至此,我们的文件格式已经定义好了,是时候写入数据了。在写入数据之前,我们看一下FileStream有哪些写入函数:
1、public override void Write(byte[] array, int offset, int count);
2、public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken);
3、public override void WriteByte(byte value);
有这么三个写入函数,第一个是写入一个byte数组,第二个函数是异步写入,我们这里不做讨论,第三个是写入一个byte类型的数据。
所以,我们用第一个函数,而且要将数据转换成byte数组写入,无论是字符串string,还是原始类型,比如int, float这些,都要先转换成byte数组,然后调用这个函数写入到文件中。
首先我们写入文件标识区,也就是“jidi0827”这串文本,先把这串文本转换成byte数组,然后写入这个数组到文件
byte[] bytes = System.Text.Encoding.UTF8.GetBytes("jidi0827");
fs.Write(bytes, 0, bytes.Length);
然后写入数组长度区,我们用int类型,4个字节来表示这个长度。
int length = 10;
byte[] bytes = BitConverter.GetBytes(length) ;
fs.Write(bytes, 0, bytes.Length);
最后一步,逐个写入float数组里的数据,结束。
for (int i = 0; i < dataArr.Length; i++)
{
byte[] data = BitConverter.GetBytes(dataArr[i]);
fs.Write(data, 0, data.Length);
}
文件写入完毕。
读取文件
现在,我们看看如何读取这个文件。在此之前,我们先来了解一下FileStream有关读取的几个函数:
1、public override int Read(byte[] array, int offset, int count);
2、public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken);
3、public override int ReadByte();
第一个函数是将数据读入到一个byte数组中,返回值是实际读取的字节数,第二个函数是异步读取数据,不在本文的讨论范围中,第三个函数是读取一个byte数据。
所以,我们一般都是使用第一个函数进行文件读取,读取数据到byte数组,然后再将这个byte数组转换成我们需要的类型,比如string ,int,float等等。
根据文件格式,首先,我们要读取文件的标识区,看看这个文件是不是我们定义的文件格式,如果不是,那么我们就识别不了,不继续读取了。文件读取要比文件写入要麻烦一些,因为我们要做更多的判断。
在刚才我们写入文件标识区的时候,我们已经知道了文件标识区"jidi0827"这串文本,占8个字节,所以,我们要先判断文件长度是否有8个字节,如果没有,那肯定不是我们的文件格式:
if (fs.Length < 8) return;
然后定义一个长度为8的byte数组,将文件标识区读入到这个数组
byte[] bytes = new byte[8];
int readBytes = fs.Read(bytes, 0, 8);
if (readBytes < 8) return;
然后将这个数组转换成文本,并和"jidi0827"比较是否相同
string str = System.Text.Encoding.UTF8.GetString(bytes);
if (!str.Equals("jidi0827")) return;
然后,我们在读取数组的长度,并且计算一下如果是这个数组长度,那么文件大小应该是多少个字节,确保我们能读入这么多字节。
byte[] bytes = new byte[4];
int readBytes = fs.Read(bytes, 0, 4);
if (readBytes < 4) return;
int length = BitConverter.ToInt32(bytes, 0);
如果文件大小,小于我们计算的文件大小,说明文件要么被破坏了,要么就是一个巧合,这不是我们定义的文件格式。这个就是数组长度区的作用,能够确保有足够的数据给我们读入,不会引发异常。
int fileSize = 8 + length + 4length; //文件尺寸=文件标识区长度 +4个字节的数组长度区 + 4数组长度
if (fs.Length < fileSize) return;
最后就简单了,依次读入float数据,然后显示出来,和之前写入的数据做对比。
// 逐一读取浮点数组数据
byte[] data = new byte[4];
float[] dataArr = new float[length];
for (int i = 0; i < length; i++)
{
fs.Read(data, 0, 4);
dataArr[i] = BitConverter.ToSingle(data, 0);
}