首页 Qt 学习之路 2 Qt 学习之路 2(36):二进制文件读写

Qt 学习之路 2(36):二进制文件读写

26 3K

在上一章中,我们介绍了有关QFileQFileInfo两个类的使用。我们提到,QIODevice提供了read()readLine()等基本的操作。同时,Qt 还提供了更高一级的操作:用于二进制的流QDataStream和用于文本流的QTextStream。本节,我们将讲解有关QDataStream的使用以及一些技巧。下一章则是QTextStream的相关内容。

QDataStream提供了基于QIODevice的二进制数据的序列化。数据流是一种二进制流,这种流完全不依赖于底层操作系统、CPU 或者字节顺序(大端或小端)。例如,在安装了 Windows 平台的 PC 上面写入的一个数据流,可以不经过任何处理,直接拿到运行了 Solaris 的 SPARC 机器上读取。由于数据流就是二进制流,因此我们也可以直接读写没有编码的二进制数据,例如图像、视频、音频等。

QDataStream既能够存取 C++ 基本类型,如 int、char、short 等,也可以存取复杂的数据类型,例如自定义的类。实际上,QDataStream对于类的存储,是将复杂的类分割为很多基本单元实现的。

结合QIODeviceQDataStream可以很方便地对文件、网络套接字等进行读写操作。我们从代码开始看起:

QFile file("file.dat");
file.open(QIODevice::WriteOnly);
QDataStream out(&file);
out << QString("the answer is");
out << (qint32)42;

在这段代码中,我们首先打开一个名为 file.dat 的文件(注意,我们为简单起见,并没有检查文件打开是否成功,这在正式程序中是不允许的)。然后,我们将刚刚创建的file对象的指针传递给一个QDataStream实例out。类似于std::cout标准输出流,QDataStream也重载了输出重定向<<运算符。后面的代码就很简单了:将“the answer is”和数字 42 输出到数据流(如果你不明白这句话的意思,这可是宇宙终极问题的答案 ;-P 请自行搜索《银河系漫游指南》)。由于我们的 out 对象建立在file之上,因此相当于将宇宙终极问题的答案写入file

需要指出一点:最好使用 Qt 整型来进行读写,比如程序中的qint32。这保证了在任意平台和任意编译器都能够有相同的行为。

我们通过一个例子来看看 Qt 是如何存储数据的。例如char *字符串,在存储时,会首先存储该字符串包括 \0 结束符的长度(32位整型),然后是字符串的内容以及结束符 \0。在读取时,先以 32 位整型读出整个的长度,然后按照这个长度取出整个字符串的内容。

但是,如果你直接运行这段代码,你会得到一个空白的 file.dat,并没有写入任何数据。这是因为我们的file没有正常关闭。为性能起见,数据只有在文件关闭时才会真正写入。因此,我们必须在最后添加一行代码:

file.close(); // 如果不想关闭文件,可以使用 file.flush();

重新运行一下程序,你就得到宇宙终极问题的答案了。

我们已经获得宇宙终极问题的答案了,下面,我们要将这个答案读取出来:

QFile file("file.dat");
file.open(QIODevice::ReadOnly);
QDataStream in(&file);
QString str;
qint32 a;
in >> str >> a;

这段代码没什么好说的。唯一需要注意的是,你必须按照写入的顺序,将数据读取出来。也就是说,程序数据写入的顺序必须预先定义好。在这个例子中,我们首先写入字符串,然后写入数字,那么就首先读出来的就是字符串,然后才是数字。顺序颠倒的话,程序行为是不确定的,严重时会直接造成程序崩溃。

由于二进制流是纯粹的字节数据,带来的问题是,如果程序不同版本之间按照不同的方式读取(前面说过,Qt 保证读写内容的一致,但是并不能保证不同 Qt 版本之间的一致),数据就会出现错误。因此,我们必须提供一种机制来确保不同版本之间的一致性。通常,我们会使用如下的代码写入:

QFile file("file.dat");
file.open(QIODevice::WriteOnly);
QDataStream out(&file);

// 写入魔术数字和版本
out << (quint32)0xA0B0C0D0;
out << (qint32)123;

out.setVersion(QDataStream::Qt_4_0);

// 写入数据
out << lots_of_interesting_data;

这里,我们增加了两行代码:

out << (quint32)0xA0B0C0D0;

用于写入魔术数字。所谓魔术数字,是二进制输出中经常使用的一种技术。二进制格式是人不可读的,并且通常具有相同的后缀名(比如 dat 之类),因此我们没有办法区分两个二进制文件哪个是合法的。所以,我们定义的二进制格式通常具有一个魔术数字,用于标识文件的合法性。在本例中,我们在文件最开始写入 0xA0B0C0D0,在读取的时候首先检查这个数字是不是 0xA0B0C0D0。如果不是的话,说明这个文件不是可识别格式,因此根本不需要去继续读取。一般二进制文件都会有这么一个魔术数字,例如 Java 的 class 文件的魔术数字就是 0xCAFEBABE,使用二进制查看器就可以查看。魔术数字是一个 32 位的无符号整型,因此我们使用quint32来得到一个平台无关的 32 位无符号整型。

接下来一行,

out << (qint32)123;

是标识文件的版本。我们用魔术数字标识文件的类型,从而判断文件是不是合法的。但是,文件的不同版本之间也可能存在差异:我们可能在第一版保存整型,第二版可能保存字符串。为了标识不同的版本,我们只能将版本写入文件。比如,现在我们的版本是 123。

下面一行还是有关版本的:

out.setVersion(QDataStream::Qt_4_0);

上面一句是文件的版本号,但是,Qt 不同版本之间的读取方式可能也不一样。这样,我们就得指定 Qt 按照哪个版本去读。这里,我们指定以 Qt 4.0 格式去读取内容。

当我们这样写入文件之后,我们在读取的时候就需要增加一系列的判断:

QFile file("file.dat");
file.open(QIODevice::ReadOnly);
QDataStream in(&file);

// 检查魔术数字
quint32 magic;
in >> magic;
if (magic != 0xA0B0C0D0) {
    return BAD_FILE_FORMAT;
}

// 检查版本
qint32 version;
in >> version;
if (version < 100) {
    return BAD_FILE_TOO_OLD;
}
if (version > 123) {
    return BAD_FILE_TOO_NEW;
}

if (version <= 110) {
    in.setVersion(QDataStream::Qt_3_2);
} else {
    in.setVersion(QDataStream::Qt_4_0);
}
// 读取数据
in >> lots_of_interesting_data;
if (version >= 120) {
    in >> data_new_in_version_1_2;
}
in >> other_interesting_data;

这段代码就是按照前面的解释进行的。首先读取魔术数字,检查文件是否合法。如果合法,读取文件版本:小于 100 或者大于 123 都是不支持的。如果在支持的版本范围内(100 <= version <= 123),则当是小于等于 110 的时候,按照Qt_3_2的格式读取,否则按照Qt_4_0的格式读取。当设置完这些参数之后,开始读取数据。

至此,我们介绍了有关QDataStream的相关内容。那么,既然QIODevice提供了read()readLine()之类的函数,为什么还要有QDataStream呢?QDataStreamQIODevice有什么区别?区别在于,QDataStream提供流的形式,性能上一般比直接调用原始 API 更好一些。我们通过下面一段代码看看什么是流的形式:

QFile file("file.dat");
file.open(QIODevice::ReadWrite);

QDataStream stream(&file);
QString str = "the answer is 42";
QString strout;

stream << str;
file.flush();
stream >> strout;

在这段代码中,我们首先向文件中写入数据,紧接着把数据读出来。有什么问题吗?运行之后你会发现,strout实际是空的。为什么没有读取出来?我们不是已经添加了file.flush();语句吗?原因并不在于文件有没有写入,而是在于我们使用的是“流”。所谓流,就像水流一样,它的游标会随着输出向后移动。当使用<<操作符输出之后,流的游标已经到了最后,此时你再去读,当然什么也读不到了。所以你需要在输出之后重新把游标设置为 0 的位置才能够继续读取。具体代码片段如下:

stream << str;
stream.device()->seek(0);
stream >> strout;

26 评论

msccreater 2013年2月17日 - 20:43

有好多变量都不知道什么含义例如:lots_of_interesting_data,BAD_FILE_FORMAT,BAD_FILE_TOO_OLD,BAD_FILE_TOO_NEW,data_new_in_version_1_2,data_new_in_version_1_2
如果是自定义的希望可以解释下

回复
豆子 2013年2月18日 - 10:28

lots_of_interesting_data 是一个能够保存大量数据的变量;BAD_FILE_FORMAT,BAD_FILE_TOO_OLD,BAD_FILE_TOO_NEW 是用于返回的宏,第一个是“文件格式错误”,第二个是“文件太旧”,第三个是“文件太新”;data_new_in_version_1_2 是 1.2 版本中的新的数据所要保存到的变量。这些都是用的名字字面含义,所以没有详细说明。抱歉带来不便了哦

回复
j 2013年7月26日 - 13:14

因此我们也可以直接读写没有变吗的二进制数据
变吗->变化?

回复
疯子 2013年8月30日 - 14:18

可能是编码

回复
豆子 2013年9月2日 - 14:29

编码编码~ ;-P

回复
2014年1月14日 - 13:16

用out << (quint32)0xA0B0C0D0;
out << (qint32)123;
是不是不管0xA0B0C0D0这个怎样的值,第一行存进去的数据,始终占quint32这种类型的大小?,应该是的……这样取出来才不会有错……

回复
大灰狼嘎嘎 2014年2月8日 - 11:15

“区别在于,有些 QIODevice支持随机读写,而QDataStream提供的是流的形式,不允许随机读写。”
哪些QIODevice支持随即读写?

回复
豆子 2014年2月11日 - 16:22

你可以调用 QIODevice::isSequential() 函数来查看一个 QIODevice 是不是顺序访问的。典型的 QFile、QBuffer 都是可随机访问;QTcpSocket 和 QProcess 则是顺序访问的,也就是不允许随机读写。

回复
rrrdr 2014年4月21日 - 13:53

请问QDataStream的读写机制,它是将整个文件从头到尾全读进内存还是按依据游标来按需读取?

回复
豆子 2014年4月22日 - 09:44

这个没有仔细研究。不过 QDataStream 是流的形式,个人感觉应该是按照需要的类型读取的。

回复
rrrdr 2014年4月22日 - 16:17

感谢答复

回复
Wangzhe 2014年8月20日 - 18:37

关于最后“QDataStream提供的是流的形式,不允许随机读写”的问题,感觉楼主说的不对呀。不支持随机读写的设备,是不能用seek函数重定位游标,而不只是游标只能往后。”随机读写”就是可以使用seek函数将游标移动到文件的任意位置的意思吧?这个QDataStream不是支持的嘛。
最后的代码里stream >> strout没有数据是因为游标在文件末尾,后面没有数据可以读入。如果本来文件有足够多数据,而且使用stream前先将其定位到文件开始位置,那么第一次的stream <> strout仍然可以读出文件数据来。

回复
豆子 2014年8月20日 - 21:36

多谢指出。文中的说法的确有误,已经改正。正确的说法应该是:随机访问、顺序访问是文件本身的性质,与如何读取无关。普通文件一般是随机访问的,某些设备文件、Socket 文件则是顺序访问的。

回复
动感超人 2014年11月14日 - 23:43

想问下楼主如果输出的二进制文件被人为篡改了,那么下次读入这个二进制文件时有什么办法知道是否被篡改了呢?

回复
豆子 2014年11月18日 - 10:46

这个没有直接的办法,你可以记录下文件更新时间,或者更严格一些,保存下文件的 MD5,然后进行比对。

回复
动感超人 2014年11月18日 - 22:03

如果把类对象输出到二进制文件后,稍微修改了这个二进制文件的几个字节,那么程序再次读取这个二进制文件的时候程序会直接崩溃。明明是文件错误了,但是呈现给用户的是程序可能因为BUG崩溃了。

回复
豆子 2014年11月19日 - 10:27

这个就需要用 MD5 之类的散列进行检测,如果发现 MD5 值不符合之前保存的结果,则直接告知用户文件损坏

回复
吴柄谊 2016年5月7日 - 14:56

豆子老师,您好。二进制文件读取出来后,想将它显示应该用哪个函数??如果是用十六进制呢?

回复
sat 2017年2月23日 - 22:39

魔术方法和一般的数貌似没什么区别,先写123再写(qint32)0xA0B0C0D0都能读出来啊。

回复
蜗牛也是牛 2017年4月3日 - 19:20

豆子老师最后的一段,真的学到不少,感谢。

回复
打不死的黄妖精 2019年5月17日 - 16:47

豆哥,这两句话是不是前后矛盾啊?“如果不想关闭文件,可以使用 file.flush();” 、“为什么没有读取出来?我们不是已经添加了file.flush();语句吗?原因并不在于文件有没有写入,而是在于我们使用的是“流”。”你前面说为性能起见,数据只有在文件关闭时才会真正写入,那么要写入则不需要使用 file.flush(),你最后一段程序使用了该语句不就是没有关闭文件吗?如你所说文件都没有关闭又怎么能读写数据?

回复
豆子 2019年5月21日 - 13:41

将数据写入文件有两种方法:close() 和 flush();前者是关闭文件并写入,后者是不关闭文件并写入。流式读取的话,只会读取游标之后的内容。所以在调用了 flush() 之后,数据已经被写入文件,但游标现在在文件末尾,所以读取不到刚刚写入的内容。之所以说“为性能起见,数据只有在文件关闭时才会真正写入,那么要写入则不需要使用 file.flush()”,意思是,既可以调用 close() 在关闭时写入,此时就不需要 flush(),但有些时候比如数据非常重要或数据量太大,不能等到关闭时再写入或者要分段写入,就可以使用 flush() 强制写入,不要等到关闭时一次性写入。至于最后的没有关闭文件,因为只是示例程序,所以没有调用 close()。正式使用时要注意关闭的。

回复
天意 2019年11月5日 - 17:08

学习了

回复
TingQT 2019年11月12日 - 09:35

豆子老师,一个类怎么序列化为一个xml文档啊?字段要一个一个out么?有没有类似.Net的xmlserilizer这种

回复
豆子 2019年11月14日 - 09:10

貌似没有找到 Qt 官方的方法,那么就只能试试第三方库或者自己实现了

回复
麻婆豆腐 2020年11月14日 - 14:55

你好,可以请教一下二进制文件读取的时候为什么我读取完魔术数字和版本号的时候,此时用atEnd()函数来判断就已经是输出1了呢,感觉很奇怪,如果我是想要输出出来所有的数据是不是用atEnd()这个函数呢

回复

回复 TingQT 取消回复

关于我

devbean

devbean

豆子,生于山东,定居南京。毕业于山东大学软件工程专业。软件工程师,主要关注于 Qt、Angular 等界面技术。

主题 Salodad 由 PenciDesign 提供 | 静态文件存储由又拍云存储提供 | 苏ICP备13027999号-2