7.4 串行化数据流QDataStream
上节 QTextStream 是针对文本流的处理,QTextStream 中都是人类直接可读的文本形式,但是 QTextStream 有它的局限性,对于
Qt 自己的类对象,QTextStream 仅支持字符相关的 QChar、QString、QByteArray,Qt 还有很多其他类,比如矩形
QRect、像素图 QPixmap、点 QPoint、颜色 QColor 等等,这些都没有规定的字符串形式。
实际程序中会大量用到人类不可读的二进制数据,这些二进制数据的封装和存储、传输,就需要用串行化数据流 QDataStream 实现,对于 Qt
知道的所有数据类型,包括 C++ 基本类型变量和 Qt 自己类对象,都可以使用 QDataStream
自动打包成整体的数据块(将各种类型变量和对象按顺序打包就是串行化,按顺序解包就是反串行化),可以用于网络传输和本地文件读写。
本节先介绍 QDataStream 类的内容,然后编写一个使用 QDataStream 自定义文件格式做输入输出的例子。对于网络数据的封包解包示范,等到
网络传输章节再介绍,目前可以回顾 3.4.3 节的串行化打包例子。本节最后介绍第二个例子,自定义一个结构体,然后让我们自己的结构体支持串行化输入输出。
7.4.1 QDataStream 类
QDataStream 可以支持对输入输出设备的串行化,也可以支持对 QByteArray 字节数组的输入输出,它的构造函数如下:
QDataStream(QIODevice * d)
QDataStream(QByteArray * a, QIODevice::OpenMode mode)
QDataStream(const QByteArray & a)
第一个构造函数是用于支持 I/O 设备,需要在传递指针给 QDataStream 之前把设备打开,比如提前打开文件,然后把文件对象指针传给
QDataStream。
第二个构造函数一般用于支持 QByteArray 的读写,传递字节数组的指针,并且需要指定读写模式,一般是用 QIODevice::ReadWrite。
第三个构造函数参数为字节数组的常量引用,字节数组是只读的,仅作为输入来使用。
除了构造函数,还有如下设置函数改变当前 QDataStream 对象的设备:
void QDataStream::setDevice(QIODevice * d)
QDataStream 使用的是 Qt 自定义的数据打包格式,这种数据打包格式与操作系统、硬件是 100% 无关的,用 QDataStream
打包的数据包,无论是在 Intel + Windows 、还是 Sun SPARC + Solaris、ARM + Andorid
等等,都可以通用,不用管 CPU 字节序。
对于所有 C++ 基本类型变量,QDataStream 提供了一系列的 << 和 >> 运算符重载函数,比如整型的:
QDataStream & QDataStream::operator<<(qint32 i)
QDataStream & QDataStream::operator>>(qint32
& i)
这些都是 QDataStream 的成员函数,还有其他的比如输入输出 bool、qint64、float、double 等等。
对于 C++ 整型数的读写,需要特别注意 long 类型,在 32 位系统,long 类型是 32 bit 长度,而在 64 位系统,long 是 64
bit 长度,因为这种不确定性,所以整型变量都应该转为 Qt
预定义的类型:qint8、quint8、qint16、quint16、qint32、quint32、qint64、quint64,这样能保证变量的长度是类型名字
里指定的位数,无论在什么操作系统都不会出错。
对于所有 Qt 数据封装类的对象,在 QDataStream 类里面查不到对应的运算符重载函数,不是没有,而是太多。在 Qt 帮助文档 Serializing
Qt Data Types 页面可以看到所有可以串行化的类型,Qt 自己类对象的串行化,是通过相关的非成员函数(Related
Non-Members),这些非成员函数可以在各个数据封装类自己的文档页面找到,比如 QColor 类的非成员函数:
QDataStream & operator<<(QDataStream &
stream, const QColor & color)
QDataStream & operator>>(QDataStream &
stream, QColor & color)
对于 QDataStream 流的输入,可以判断当前位置是否到达设备的末尾:
bool QDataStream::atEnd() const
但是 QDataStream 没有 seek() 函数移动游标,也没有游标获取函数 pos() ,这与文本流 QTextStream、文件类 QFile
有很大的区别。在 QDataStream 流里看不到游标,因为它不是按字节或字符读写的,它用 Qt 自家的打包格式(或叫编码方式),只能按变量或类对象顺
序读写,不能随机读写。
串行化,就如它的名字一样,一条道走到黑,输入输出的顺序固定好之后,就不能进行随机位置读写,只能从头按顺序读或从头按顺序写。
除了 << 和 >> 运算符重载函数,QDataStream 提供了另外几个读写函数,可以用于读写自定义的数据块,首先是一对
writeBytes() 和 readBytes() 函数:
QDataStream & QDataStream::writeBytes(const char * s,
uint len) //变量 len 也会写入数据流
QDataStream & QDataStream::readBytes(char *& s,
uint & l) // l 数值是从数据流里读出来的,就是上面的 len
对于字节写入函数,参数里的 s 是要输出的字节缓冲区,len 是写入数据的长度,这里 s 里面针对纯字节数据,不管里面有没有 '\0' ,都写入
数据流。
对于字节读取函数,s 是指针变量的引用,l 是读取的字节数,s 指针不需要程序员分配空间,由 readBytes() 函数自己 new []
一段缓冲区,然后把缓冲区指针赋值给 s;参数 l 是函数的返回变量,是真实读取到的字节数。函数返回之后 s 指针变量指向的缓冲区需程序员手动
delete [] 。
writeBytes() 函数在做串行化时,会先写 quint32 类型的数据长度,然后写入真实的缓冲区数据到数据流。readBytes()
也是先读取字节数组长度,该函数自己 new [] 一片空间,把后面的真实数据读到缓冲区。
writeBytes() 和 readBytes() 函数与字符串 读写运算符重载函数有类似的地方:
QDataStream & QDataStream::operator<<(const
char * s)
QDataStream & QDataStream::operator>>(char
*& s)
在做串行化时,都是先写一个 quint32 类型的长度到数据流,然后写真实数据,读的时候就是先读长度,然后根据长度 new []
缓冲区,把数据读到缓冲区。这两对函数的区别就是 writeBytes() 和 readBytes() 针对字节数组,不管是不是 '\0',都当作普通字
节读写;<< 和 >> 读写字符串时遇到 '\0' 就截止了,并且 '\0' 不会写入到数据流。
QDataStream 还提供了更裸的读写函数,下面这对读写函数是不把字节数组长度变量写入到数据流的,仅仅写入原始的缓冲区数据:
int QDataStream::writeRawData(const char * s, int len)
int QDataStream::readRawData(char * s, int len)
这对函数与之前一对 writeBytes() 和 readBytes()
函数区别有两点:第一,不写数据长度变量到数据流,只读写最原始的数据块;第二,readRawData()
自己不会分配缓冲区,必须由程序员提前分配缓冲区给 s,然后传递给 readRawData() 函数。
QDataStream 与 QTextStream
也有类似的地方,就是流的状态,如果数据流的输入顺序与输出顺序不匹配,那么会出现状态错误,获取流的状态函数为:
Status QDataStream::status() const
状态枚举类型也有四个:
Status 枚举常量 |
数值 |
描述 |
QDataStream::Ok |
0 |
正常操作状态,没出错。 |
QDataStream::ReadPastEnd |
1 |
底层设备已到达末尾,比如文件末尾,无数据可读了。 |
QDataStream::ReadCorruptData |
2 |
读入了腐化数据,典型的就是输入流读取顺序与输出流顺序不一样。 |
QDataStream::WriteFailed |
3 |
无法向底层设备写入数据,比如文件是只读的。 |
在 QDataStream 数据流出现错误状态之后,可以重新设置流的状态:
void QDataStream::setStatus(Status
status) //设置流为参数里执行的状态
void QDataStream::resetStatus()
//重置为原始的 Ok 状态
QDataStream 流的排除处理就比较麻烦了,虽然可以跳过一定的裸字节数:
int QDataStream::skipRawData(int len)
这个函数跳过最原始的裸数据 len 长度的字节,返回值是真实跳过的字节数。这个函数可以与 readRawData()
配合使用,但是在处理流错误时未必有用,因为跳过指定的字节数目之后,流的数据到哪种类型的变量或对象了,难以确定。QDataStream
读取错误时,最好的做法是直接提示出错,后面的不读取,因为串行化数据流默认是不能随机化读写的。
QDataStream 是使用 Qt 自己独有的串行化数据打包方式,它没有任何的格式操作子,因为输入输出格式全是内置的,但 QDataStream
有自己兼容的 Qt 版本号。随着 Qt 版本的更新,QDataStream 打包数据的方式也会更新,因此 QDataStream
有一大堆兼容的版本号,从最早的 Qt_1_0 到最新的 Qt_5_4,版本号枚举常量的命名规则就是 Qt_
大版本号_小版本号 。
如果负责输出的程序与负责输入的程序,使用的 Qt 版本是一致的,就不需要设置版本号。但是如果输出和输入程序的 Qt 版本不一致,比如输出程序
使用的是 Qt 4.8.* ,输入程序使用的是 Qt 5.4.* ,那么就应该向旧版本兼容,在做读取变量或对象之前,设置版本号:
void QDataStream::setVersion(int v)
要兼容 Qt 4.8.* ,那么就把参数设为 QDataStream::Qt_4_8 。
随着 Qt 版本号的更新,C++ 基本类型的变量读写规则是固定的,比如 qint32 就是读写 4 个字节,所以 QDataStream 版本号对
C++ 基本类型变量读写其实没影响。QDataStream 版本号影响的是 Qt 自己类对象的读写规则,比如以后如果新增了一个类
QXXXXData,那么只有新版本的 Qt 中 QDataStream 才能读写这个新类 QXXXXData 对象。
在本小节末尾,我们将 QTextStream 和 QDataStream 简单做一下对比:
对比项 |
QTextStream |
QDataStream |
用途 |
处理文本流,如 QIODevice、FILE句柄、QString、QByteArray,这里的 QByteArray
是作为字符串用途,以 '\0' 为终止符 |
处理二进制数据流,如 QIODevice 、QByteArray ,这里的 QByteArray
作为字节数组,是纯数据,不区分字节数值是否为 0 |
<< 和 >> 涵盖的数据类型 |
C++基本类型和 QChar、QString、QByteArray(字符串) |
C++基本类型和 Qt 库中几乎所有用于表示数据的类 |
流操作子 |
很多格式化操作子,程序员可以控制格式 |
没有操作子,打包格式内定,程序员不能控制 |
流的版本号 |
没有版本号 |
可以设置从 Qt_1_0 到最新的版本号,不同版本号的串行化打包是有区别的 |
流的游标 |
pos() 获取游标,seek() 移动游标,atEnd() 判断末尾,可以随机化读写 |
没有游标,只能用 atEnd() 判断是否到末尾,不能随机化读写,必须按写入时相同的顺序和类型来读取 |
流的工作状态 |
QTextStream::Status 四种状态 |
QDataStream::Status 四种状态 |
从上面表格可以看到,QTextStream 和 QDataStream 除了都有四种工作状态,其他特性都有差异,二者用途不同,决定差异性很大。
7.4.2 自定义文件格式输入输出
我们在 7.3.5
节例子中表格数据,是采用文本形式存储的,本小节我们自定义一个二进制文件格式,专门存储类似下面的数据表格(下面表格是文本的,只是方便大家看,真实的二进制文件我们例
子结尾用 QtCreator 打开看):
#QString qint32 double
小明 18 50.5
小萌 19 55.8
小日 17 60.0
小月 16 55.1
小草 18 58.6
因为用二进制数据格式,就不用注释行了,只需要用 QDataStream 写入指定类型数据。
我们这里自定义文件扩展名设置为 .ds ,一般文件都需要一些文件头的结构体信息,我们这里使用 QDataStream 输出文件信息头,我们定义 .ds
文件结构如下:
文件首先是一个 qint16 变量,数值为 0x4453,就是字母 'D' 和 'S' 的十六进制数,打头的 0x4453 一般称为魔力数字(magic
number),用于标识文件类型;
然后也是一个 qint16 变量,数值为 0x0100,代表文件格式 1.0 版本;
接着是表格数据的总行数计数 nCount ,之后就是每行的数据(不是文本文件不需要写入换行符,这里的“行”只是方便理解数据的排布)。
文件的写入和读取都用 QDataStream 实现,为了向旧版本的 Qt 兼容,我们这里规定文件格式 1.0 版本对应的
QDataStream 都用 Qt_4_8 的版本号。
讲完自定义文件格式,下面我们开始编写示例程序,打开 QtCreator,新建一个 Qt Widgets Application
项目,在新建项目的向导里填写:
①项目名称 dsedit,创建路径 D:\QtProjects\ch07,点击下一步;
②套件选择里面选择全部套件,点击下一步;
③基类选择 QWidget,点击下一步;
④项目管理不修改,点击完成。
我们打开 widget.ui 界面文件,先把主界面窗口的尺寸设置为 480*300,然后按照下图拖入控件:
界面从左边看是两大行控件:
第一大行,标签控件,文本 "姓名",单行编辑器,对象名 lineEditName,标签控件,文本 "年龄",单行编辑器,对象名
lineEditAge,标签控件,文本 "体重",单行编辑器,对象名 lineEditWeight,最右边是按钮控件,对象名
pushButtonAdd,文本为 "添加行" ,第一大行按照水平布局器排布。
第二大行,左边是一个列表控件,对象名默认为 listWidget;右边是一个垂直布局器,在垂直布局里是三个按钮和垂直弹簧条,首先是 "删除行"
按钮,对象名 pushButtonDel,垂直弹簧条,对象名默认为 verticalSpacer,然后是 "保存DS" 按钮,对象名
pushButtonSaveDS,最下面是 "加载DS" 按钮,对象名为 pushButtonLoadDS,第二大行也是按照水平布局器排布。
窗口的主布局器是垂直布局器,点击界面空白位置,不选中任何控件,再点击上面垂直布局按钮就可以为窗口设置主布局器。这个例子界面与 7.3
节第一个例子相比做了调整,因为本节的 DS 文件原本没有,是我们自创的格式,需要手动在示例程序添加各行数据,然后保存为 *.ds 才会生成 DS 文件。
界面和布局就是如上所述,现在我们分四次为四个按钮都添加 clicked() 信号对应的槽函数:
然后为列表控件 listWidget 添加 currentRowChanged(int) 信号对应的槽函数:
添加好槽函数之后,我们保存界面文件,然后关闭界面文件,回到代码编辑模式。
对于头文件 widget.h ,不需要手动添加代码,都是自动生成的,下面仅仅贴出来看看:
#ifndef
WIDGET_H
#define WIDGET_H
#include <QWidget>
namespace Ui {
class Widget;
}
class Widget : public QWidget
{
Q_OBJECT
public:
explicit Widget(QWidget *parent = 0);
~Widget();
private slots:
void on_pushButtonAdd_clicked();
void on_pushButtonDel_clicked();
void on_pushButtonSaveDS_clicked();
void on_pushButtonLoadDS_clicked();
void on_listWidget_currentRowChanged(int currentRow);
private:
Ui::Widget *ui;
};
#endif // WIDGET_H
头文件有五个槽函数,我们需要在 widget.cpp 里面添加功能代码,下面我们打开 widget.cpp ,首先添加头文件包含和构造函数里的代码:
#include
"widget.h"
#include "ui_widget.h"
#include <QDebug>
#include <QMessageBox>
#include <QFileDialog> //文件对话框
#include <QFile> //文件类
#include <QDataStream> //串行化数据流,用于读写 .ds 文件
#include <QTextStream> //用于列表控件显示的文本和数据转换
#include <QListWidgetItem>//用于从列表控件的行提取文本
Widget::Widget(QWidget *parent) :
QWidget(parent),
ui(new Ui::Widget)
{
ui->setupUi(this);
//设置列表控件只能选中一行,简化删除行的操作
ui->listWidget->setSelectionMode( QAbstractItemView::SingleSelection );
}
Widget::~Widget()
{
delete ui;
}
头文件增加了上一节用到过的文件对话框,文件类、文本流等,<QListWidgetItem>
是用于从列表控件的行里提取文本用的,<QDataStream> 是本节的串行化数据流。
构造函数里只增加了一句代码,用于设置列表控件为每次只能选中一行,这样删除行时只需要删除一行,简化操作。
接下来是四个按钮对应的槽函数代码,先看看 "添加行" 按钮对应的槽函数:
void
Widget::on_pushButtonAdd_clicked()
{
QString strName = ui->lineEditName->text().trimmed();
QString strAge = ui->lineEditAge->text().trimmed();
QString strWeight = ui->lineEditWeight->text().trimmed();
//判断三个数据项是否为空
if( strName.isEmpty() || strAge.isEmpty() || strWeight.isEmpty() )
{
QMessageBox::warning(this, tr("添加行"), tr("请填好姓名、年龄、体重三个数据项再添加。"));
return;
}
//计算出年龄和体重
qint32 nAge = strAge.toInt();
double dblWeight = strWeight.toDouble();
//判断范围
if( (nAge<0) || (nAge>600) )
{
QMessageBox::warning(this, tr("添加行"), tr("年龄数值不对,应该 0~600 "));
return;
}
if( dblWeight < 0.1 )
{
QMessageBox::warning(this, tr("添加行"), tr("体重数值不对,至少 0.1kg "));
return;
}
//对于正常数据,添加新行到列表控件
QString strAll;
QTextStream tsLine(&strAll);
tsLine<<strName<<"\t"<<nAge<<"\t"
<<fixed<<qSetRealNumberPrecision(2)<<dblWeight;
ui->listWidget->addItem( strAll );
}
这个槽函数先获取三个单行编辑器的文本,判断文本是否有空串,如果全部非空,再继续后面操作。
对于三个非空字符串,根据年龄、体重的字符串转为数值 nAge、dblWeight ,然后对年龄、体重数值做一下简单判断。
nAge、dblWeight 经过判断之后,我们定义字符串 strAll,用于保存三个数据项生成的文本,我们用文本流 tsLine
将三个数据项按照指定格式输出到 strAll 里面,最后把 strAll 添加到列表控件。
"删除行" 按钮对应的槽函数代码就很简单了:
void
Widget::on_pushButtonDel_clicked()
{
//获取列表控件选中的行
int nCurRow = ui->listWidget->currentRow();
//判断序号是否合法
if( nCurRow < 0)
{
return; //没有选中行
}
//对于正常选中的行,删除掉
ui->listWidget->takeItem( nCurRow );
}
判断当前行号,然后删除列表控件里面的行即可。
接着是 "保存DS" 按钮对应的槽函数,里面有写入 DS 文件格式代码:
void
Widget::on_pushButtonSaveDS_clicked()
{
//总行数
int nCount = ui->listWidget->count();
//判断是否有数据
if(nCount < 1)
{
return; //没数据,不用写
}
//获取要保存的文件名
QString strFileName = QFileDialog::getSaveFileName(
this,
tr("保存为 DS 文件"),
tr("."),
tr("DS files(*.ds);;All files(*)")
);
if( strFileName.isEmpty() )
{
return; //没有保存文件名
}
//有保存文件名,打开该文件供写入
QFile fileOut(strFileName);
if( ! fileOut.open(QIODevice::WriteOnly))
{
QMessageBox::warning(this, tr("无法打开文件"),
tr("无法打开要写入的文件:") + fileOut.errorString() );
return;
}
//现在有输出文件对象,构造输出流
QDataStream dsOut(&fileOut);
//设置流的版本
dsOut.setVersion(QDataStream::Qt_4_8);
//先写文件头,都用 qt 定义的整型变量输出
dsOut<<qint16(0x4453); //'D' 'S'
dsOut<<qint16(0x0100); //1.0版本文件格式
dsOut<<qint32(nCount); //行计数
//每行的三个数据项
QString strCurName;
qint32 nCurAge;
double dblCurWeight;
//从列表控件按行读取数据,然后写入到文件
for(int i=0; i<nCount; i++)
{
QString strLine = ui->listWidget->item(i)->text();
QTextStream tsLine(&strLine);
//从该行文本提取三个数据项
tsLine>>strCurName>>nCurAge>>dblCurWeight;
//写入到文件
dsOut<<strCurName<<nCurAge<<dblCurWeight;
}
//提示写入完毕
QMessageBox::information(this, tr("保存DS文件"), tr("保存为 .ds 文件成功!"));
}
这个槽函数先判断列表控件的数据行数,没数据就不处理。
接着获取要保持的文件名,并判断文件名是否为空。
有保存文件名之后,定义文件对象 fileOut,并以只写模式打开文件。
打开文件成功之后构造串行化输出流 dsOut,设置流的版本号为 QDataStream::Qt_4_8。
接下来是自定文件格式的写入,写入魔力数字 0x4453、版本号 0x0100 和 数据的行数 nCount。
写入文件时,需要注意把整型数都转成 Qt 定义的 qint16、qint32 等类型,明确占用的比特位数。
接着就是每个数据行的写入过程,使用 for 循环取出列表控件每行的文本 strLine ,
然后根据文本 strLine 指针定义文本流 tsLine,从 tsLine 提取三个数据项
strCurName、nCurAge、dblCurWeight。
得到三个数值变量之后,按顺序写入串行化输出流 dsOut 。
注意我们这里说的一行数据只是个概念,没有真的写入换行符,因为二进制文件读写压根不用换行符,"行" 这个字仅仅是方便理解数据的排布而已。
写完所有行的数据之后,弹出消息框,提示保存完毕。
与保存文件函数相反的,就是 "加载DS" 按钮对应的槽函数:
void
Widget::on_pushButtonLoadDS_clicked()
{
//获取要打开的文件名
QString strFileName = QFileDialog::getOpenFileName(
this,
tr("打开DS文件"),
tr("."),
tr("DS files(*.ds);;All files(*)")
);
//判断文件名
if( strFileName.isEmpty() )
{
return;
}
//文件对象
QFile fileIn( strFileName );
//打开
if( ! fileIn.open( QIODevice::ReadOnly ) )
{
//打开失败
QMessageBox::warning(this, tr("打开DS文件"),
tr("打开DS文件失败: ") + fileIn.errorString());
return;
}
//构造输入数据流
QDataStream dsIn(&fileIn);
//读取文件头
qint16 nDS;
qint16 nVersion;
qint32 nCount;
dsIn>>nDS>>nVersion>>nCount;
//判断 nDS 数值
if( 0x4453 != nDS )
{
QMessageBox::warning(this, tr("打开文件"),
tr("指定的文件不是 .ds 文件类型,无法加载。"));
return;
}
//判断版本号目前只有 1.0
if( 0x0100 != nVersion )
{
QMessageBox::warning(this, tr("打开文件"),
tr("指定的 .ds 文件格式版本不是 1.0,暂时不支持。"));
return;
}
else
{
//设置流的版本
dsIn.setVersion( QDataStream::Qt_4_8 );
}
//判断行计数
if( nCount < 1 )
{
QMessageBox::warning(this, tr("打开文件"),
tr("指定的 .ds 文件内数据行计数小于 1,无数据加载。"));
return;
}
//现在行计数也正常
//三个数据项
QString strCurName;
qint32 nCurAge;
double dblCurWeight;
//先清空列表控件的内容
ui->listWidget->clear();
//逐行加载数据到列表控件
for(int i=0; i<nCount; i++)
{
//每行开头判断输入数据流状态
if( dsIn.status() != QDataStream::Ok )
{
qDebug()<<tr("第 %1 行读取前的状态出错:%2").arg(i).arg(dsIn.status());
break; //跳出循环
}
//如果现在的状态正常,就加载当前行的数据项
dsIn>>strCurName>>nCurAge>>dblCurWeight;
//构造字符串
QString strLine = tr("%1\t%2\t%3")
.arg(strCurName)
.arg(nCurAge)
.arg(dblCurWeight, 0, 'f', 2);
//添加到列表控件
ui->listWidget->addItem(strLine);
}
//提示加载完毕
QMessageBox::information(this, tr("加载DS文件"),tr("加载DS文件完成!"));
}
加载文件的过程复杂一些,我们首先获取要打开的文件名,判断文件名是否为空;
然后定义文件对象 fileIn,以只读模式打开文件;
打开文件成功之后,根据 fileIn 指针定义串行化输入流 dsIn。
我们接着还没设置 流的版本号,就先都读取了 nDS、nVersion、nCount 三个整型变量,这是因为 QDataStream 版本号对 C++
基本类型变量的读写没影响,nDS 和 nVersion 各自占 2 字节,nCount 占 4 字节,这三个基本类型变量可以直接读。
然后我们先判断魔力数字 nDS 是否为指定的 0x4453 (即 'D' 'S'),不是这个数值就报错;
接着判断 nVersion 是否为 0x0100 (文件格式版本 1.0),如果不是这个版本就报错,是正确版本就把当前的串行化数据流的版本设置为
QDataStream::Qt_4_8,后续的读取操作就是按 Qt 4.8.* 兼容模式读了。
然后再-判断一下行的计数 nCount,如果行数小于 1 就报错。
对于正常的行数,我们利用 for 循环加载每一行数据,for 循环里面先判断输入流的状态,如果状态出错就停止;
如果状态正常就读取三个变量 strCurName、nCurAge、dblCurWeight,然后根据三个变量构造字符串 strLine
,并添加到列表控件显示该行文本。
加载完成之后,弹出消息框提示加载完毕。
上面代码从串行化数据流读取变量时,如果流的状态出错,就直接停止循环,不再读取后面内容了,这是简单的排错方式,因为文件内容可能损坏了,程序对修正文件错误是
无能为力的。
最后来看看列表控件选中行变化时的信号对应的槽函数:
void
Widget::on_listWidget_currentRowChanged(int currentRow)
{
//判断行号是否合法
if( currentRow < 0 )
{
return;
}
//对于正常的行号
//读取该行文本
QString strLine = ui->listWidget->item(currentRow)->text();
//构造内存字符串文本流
QTextStream tsLine(&strLine);
//三个数据项
QString strName;
int nAge;
double dblWeight;
tsLine>>strName>>nAge>>dblWeight;
//显示到三个单行编辑器
ui->lineEditName->setText(strName);
ui->lineEditAge->setText( tr("%1").arg(nAge) );
ui->lineEditWeight->setText( tr("%1").arg(dblWeight) );
}
当选中行变化时,先判断当前的行号是否合法,行号正常之后,获取该行文本,根据文本构造输入流 tsLine,提取三个数据项
strName、nAge、dblWeight,然后显示到对应的单行编辑器里。
示例代码就是上面那些,我们生成运行例子,然后把本小节开始出的五行数据添加到列表里面:
点击 "保存DS" 按钮,把文件存到影子构建文件夹或项目源码文件夹,文件名为 people.ds ,保存好之后,用 QtCreator 打开
people.ds 文件,可以看到如下内容:
DS 文件打开之后,默认按十六进制模式显示,可以按图上标注的看一下写入后的 DS 文件真实内容,打头的就是 0x4453,然后是版本号
0x0100,接着 qint32 类型的行数,为 5 行。后面还标注了第一行数据的划分 QString、qint32
和 double 类型变量,后面几行的内容是类似的划分。
QDataStream 打包数据时,整型变量默认按照大端字节序排列(就是网络字节序),QDataStream 打包使用的字节序不用操心,也不要改,看看就
行了。
注:小端字节序和大端字节序是网络编程中的基本概念:
● 对于我们常见的 Intel 和 AMD 的主机系统,整型数值比如 0x12345678 ,高位字节 0x12 排在内存地址的高址,低位字节 0x78
排在内存地址的低址,称为小端字节序,这些主机使用的字节序就是主机字节序。
● 而网络传输不是这样的,网络中高位字节 0x12 先传输,低位字节 0x78 后传输,高位字节排在内存低址,因此称为大端字节序,也叫网络字节序。以前
SUN 的主机内部直接使用大端字节序作为主机字节序,只是这类主机差不多绝种了。
7.4.3 让自定义的结构体支持串行化
本小节例子我们自己定义一个 UDP 头部和报文信息的数据结构,然后让这个结构体支持 QDataStream 串行化输入输出,因为 IP 和 TCP
头部比较复杂,这里用 UDP 举例子比较简单。C++ 的结构体和类其实是差不多的,结构体成员变量默认是公有的,而类的成员变量默认私有, class 和
struct 其他的特性都一样,这里用 struct 作例子,如果读者以后让自定义的类支持 QDataStream 也类似地实现就行了。
网上有很多 UDP 协议的介绍文章,我们将 UDP 的头部和信息用如下结构体表示:
//UDP 头部和报文
struct UDPPacker
{
quint16 m_srcPort; //源端口
quint16 m_dstPort; //目的端口
quint16 m_length; //UDP头部和报文总长度
quint16 m_checksum; //校验和(不是强制要求)
QByteArray m_data; //报文内容
//友元声明,一对用于支持QDataStream 输入输出的函数
friend QDataStream & operator<<(QDataStream
& stream, const UDPPacker & udp);
friend QDataStream & operator>>(QDataStream
& stream, UDPPacker & udp);
};
UDP头部里面都是无符号数,按顺序排是两字节的源端口号、两字节的目的端口号、两字节的包长、两字节的校验和,UDP校验和不是强制要求的,我们后面代码就简单
把校验和设置为 0,校验和之后是 UDP 协议承载的报文内容,我们这里用 QByteArray 类型表示,后面代码报文内容都是字符串,用 UTF-8
编码的字符串。
为了让结构体或类支持 QDataStream 串行化输入输出,一般是在结构体或类的声明里添加两句友元声明,如上面运算符 << 和
>> 的重载函数,把函数的第二个参数的类型改成我们的结构体名或类名的引用,如上面示范的两句友元声明。添加声明之后,我们还需要在 .cpp
源代码文件实现这两个重载函数,等会例子中再讲。
我们现在开始学习这个例子,重新打开 QtCreator,新建一个 Qt Widgets Application 项目,在新建项目的向导里填写:
①项目名称 udppack,创建路径 D:\QtProjects\ch07,点击下一步;
②套件选择里面选择全部套件,点击下一步;
③基类选择 QWidget,点击下一步;
④项目管理不修改,点击完成。
我们打开 widget.ui 界面文件,先把主界面窗口的尺寸设置为 480*300,然后按照下图拖入控件:
界面从左边看有三大行控件:
第一行,标签控件,文本 "源头端口",单行编辑器,对象名为 lineEditSrcPort,标签控件,文本 "目的端口",对象名为
lineEditDstPort,第一行的控件按水平布局器排布。
第二行,标签控件,文本 "报文消息",单行编辑器,对象名为 lineEditMsg,第二行也是按水平布局器排布。
第三行,左边是一个列表控件,对象名 listWidget,右边是一个垂直布局器,里面四个按钮和中间的垂直弹簧条,上面第一个按钮,文本为
"添加UDP包",对象名 pushButtonAddUDP,第二个按钮文本为 "删除UDP包",对象名
pushButtonDelUDP,然后是垂直弹簧条,对象名默认为 verticalSpacer,再到第三个按钮,文本为 "保存UDP" ,对象名为
pushButtonSave,第四个按钮的文本为 "加载UDP",对象名 pushButtonLoad 。第三大行也是按水平布局器排布。
主界面是以垂直布局器作为主布局器。
接着我们为分四次为四个按钮都添加 clicked() 信号对应的槽函数:
然后我们为列表控件 listWidget 添加 currentRowChanged(int) 信号对应的槽函数:
添加好上述槽函数之后,保存界面文件,关闭界面文件,回到 QtCreator 代码编辑模式。
我们先在头文件 widget.h 添加自定义结构体的声明代码,完整的 widget.h 代码如下:
#ifndef
WIDGET_H
#define WIDGET_H
#include <QWidget>
//网络协议头一般都用 1 字节对齐
#pragma pack(1)
//UDP 头部和报文
struct UDPPacker
{
quint16 m_srcPort; //源端口
quint16 m_dstPort; //目的端口
quint16 m_length; //UDP头部和报文总长度
quint16 m_checksum; //校验和(不是强制要求)
QByteArray m_data; //报文内容
//友元声明,一对用于支持QDataStream 输入输出的函数
friend QDataStream & operator<<(QDataStream & stream, const UDPPacker & udp);
friend QDataStream & operator>>(QDataStream & stream, UDPPacker & udp);
};
namespace Ui {
class Widget;
}
class Widget : public QWidget
{
Q_OBJECT
public:
explicit Widget(QWidget *parent = 0);
~Widget();
private slots:
void on_pushButtonAddUDP_clicked();
void on_pushButtonDelUDP_clicked();
void on_pushButtonSave_clicked();
void on_pushButtonLoad_clicked();
void on_listWidget_currentRowChanged(int currentRow);
private:
Ui::Widget *ui;
};
#endif // WIDGET_H
在 UDPPacker 结构体声明之前,先加一句 #pragma pack(1) ,告诉编译器对结构体成员变量按照 1
字节对齐,一般网络协议的头部都是按 1 字节对齐,不能用编译器默认的 4 字节对齐。
UDPPacker 的声明,在之前已经看到了,UDP 有 8 字节头部,后面接着数据,我们这个 UDPPacker
里面的成员变量都是主机字节序的,通过友元函数 operator<<() 和 operator>>()
支持串行化输入输出,并且输出后的数据包里面端口数值、长度数值,自动是按网络字节序排布。
顺便解释一下我们这个例子的图形界面,只接受源头端口、目的端口和报文消息的设置,UDPPacker 结构体的成员 m_length
由程序自动填充,所以不提供编辑器修改报文长度,结构体成员 m_checksum 也是自动填充 0,不提供编辑器。
头文件后半截 class Widget 的声明代码全是自动生成的,不需要修改。
接下来我们编辑源文件 widget.cpp,添加需要的功能代码,先看头文件包含和构造函数:
#include
"widget.h"
#include "ui_widget.h"
#include <QDebug>
#include <QMessageBox>
#include <QFile>
#include <QFileDialog>
#include <QDataStream> //串行化数据流
#include <QListWidgetItem> //从列表控件的行提取文本
#include <QIntValidator> //验证端口号范围
Widget::Widget(QWidget *parent) :
QWidget(parent),
ui(new Ui::Widget)
{
ui->setupUi(this);
//设置列表控件一次只能选一行
ui->listWidget->setSelectionMode( QAbstractItemView::SingleSelection );
//新建验证器,限制源端口和目的端口范围
QIntValidator *valSrc = new QIntValidator(0, 65535);
ui->lineEditSrcPort->setValidator( valSrc );
QIntValidator *valDst = new QIntValidator(0, 65535);
ui->lineEditDstPort->setValidator( valDst );
}
Widget::~Widget()
{
delete ui;
}
包含的头文件新加了一个 <QIntValidator> 整数验证器,保证用户输入的端口范围位于 0 到
65535,其他的头文件上个例子都用过,不重复说了。
在构造函数里面,我们先把列表控件设置成一次只能选一行 QAbstractItemView::SingleSelection,简化后面 "删除UDP包"
按钮的操作代码。然后我们 new 了两个整数验证器,范围都是 0 到 65535,把整数验证器设置给源端口和目的端口的单行编辑器。
在编写各个槽函数之前,我们在析构函数后面手动添加两个全局函数,就是 UDPPacker 结构体里面声明的两个友元函数,第一个是
operator<<() 函数,用于串行化输出流:
//将
UDPPacker
对象串行化的全局函数
QDataStream & operator<<(QDataStream & stream, const UDPPacker & udp)
{
//将数据按照规定顺序都写入流中,整型数值会自动转为网络字节序
stream<<udp.m_srcPort;
stream<<udp.m_dstPort;
stream<<udp.m_length;
stream<<udp.m_checksum;
//写入裸的数据报文
stream.writeRawData( udp.m_data.data(), udp.m_data.size() );
//返回数据流对象
return stream;
}
在我们编写输出语句时,比如 dsOut<<udp ,前面的 dsOut 就是上面函数参数里的 stream,udp 就是参数里第二个
udp。
在输出时,我们输出源端口、目的端口、UDP包长度、校验和四个整型变量,QDataStream 自动把主机字节序的整型变量输出为网络字节序。
最后写入的是报文消息 udp.m_data 的裸数据,udp.m_data 裸数据的长度等会可以通过 (udp.m_length - 8)
计算出来的,udp.m_length 就是 UDP 包头 8 字节加报文消息长度,所以没必要把报文消息的长度写入数据流。
在输出函数末尾返回 stream 流对象,这样保证以后可以连环使用输出运算符,比如 dsOut<<udp1<<udp2。
第二个是用于串行化输入流的 operator>>() 函数:
QDataStream
&
operator>>(QDataStream
&
stream, UDPPacker & udp)
{
//按照原来的顺序和类型读取,网络字节序自动转为主机字节序
stream>>udp.m_srcPort;
stream>>udp.m_dstPort;
stream>>udp.m_length;
stream>>udp.m_checksum;
//读取裸的数据报文
//readRawData参数的缓冲区必须由程序员提前分配空间
//计算报文消息的长度
int nMsgLen = udp.m_length - 8; //减去包头 8 字节
char *buff = new char[nMsgLen]; //分配空间
stream.readRawData( buff, nMsgLen ); //把数据读到缓冲区
//把缓冲区设置给 QByteArray 对象,缓冲区自动划归 QByteArray 对象管理,不要手动删除 buff 指针
udp.m_data.setRawData( buff, nMsgLen );
//返回数据流对象
return stream;
}
在做输入时,要与输出函数里面的顺序一样,先读取四个整型变量,即源端口、目的端口和 UDP 包长度、校验和,对于整型变量输入时,QDataStream
自动把输入源的网络字节序整型变量转为主机字节序。
使用 readRawData() 函数时需要注意,参数里的缓冲区需要程序员提前分配好,我们通过 (udp.m_length - 8)
计算出后面消息报文裸数据的长度,然后分配 buff 缓冲区,调用 stream.readRawData() 读取消息报文的数据,最后把缓冲区指针
buff 设置给 udp.m_data。
QByteArray::setRawData() 函数会自动接管缓冲区,不要手动删除 buff
指向的缓冲区。QByteArray::setRawData() 函数不会新增内部缓冲区也不会执行数据拷贝,而是直接占有参数里的 buff 缓冲区,长度为
nMsgLen 。
在输入函数结尾也是返回 stream 流对象,用于连环输入变量。
如果读者以后需要为自己定义的结构体或类添加串行化输入输出支持,可以按上面如法炮制,只要两个运算符函数就行了。
接下来我们来看看几个槽函数的代码,第一个是 "添加UDP包" 按钮对应的槽函数代码:
void
Widget::on_pushButtonAddUDP_clicked()
{
//获取字符串
QString strSrcPort = ui->lineEditSrcPort->text().trimmed();
QString strDstPort = ui->lineEditDstPort->text().trimmed();
QString strMsg = ui->lineEditMsg->text().trimmed();
//判断字符串是否为空
if( strSrcPort.isEmpty() || strDstPort.isEmpty() || strMsg.isEmpty() )
{
QMessageBox::warning(this, tr("添加包"), tr("请先填写两个端口和消息字符串。"));
return;
}
//定义UDP包的结构体
UDPPacker udp;
//把消息转为 UTF-8 编码的字节数组
QByteArray baMsg = strMsg.toUtf8();
//填充结构体
udp.m_srcPort = strSrcPort.toUShort();
udp.m_dstPort = strDstPort.toUShort();
udp.m_length = 8 + baMsg.size(); //8是包头长度
udp.m_checksum = 0 ; //这里填充0,UDP校验和不是强制要求
udp.m_data = baMsg;
//把结构体输出到一个字节数组,打成一个包
QByteArray baAll;
QDataStream dsOut(&baAll, QIODevice::ReadWrite);
dsOut<<udp; //只需要一句,就可以把复杂的结构体填充到流里面
//baAll 是原始字节数组,人为不可读,转为十六进制字符串显示
QString strAll = baAll.toHex();
//添加到列表控件显示
ui->listWidget->addItem( strAll );
}
这个槽函数先获取三个单行编辑器的文本,判断文本是否为空。
对于非空文本,我们定义 UDP 包的结构体对象 udp;
把报文消息 strMsg 转为 UTF-8 编码的字节数组 baMsg ,备用;
然后填充 udp 对象的成员:源端口、目的端口,计算UDP包长度,设置校验和为 0,并把消息字节数组 baMsg 赋值给 udp.m_data。
接下来我们定义一个内存中的输出字节数组 baAll,用于 udp 对象打包成一个整体;
定义 dsOut 输出流对象,对于输出流,应该传递字节数组 baAll 的指针,并标出读写模式;
然后只用一句代码 dsOut<<udp 就把整个结构体对象填充到 baAll 里面了。
这个 baAll 其实就是一个真的 UDP 包,如果把它附加上 MAC 帧头和 IP 包头,就可以向网络中发送。
我们这里没学到网络,只是借 UDP 结构体做示范而已。
baAll 里面是二进制数据,没法直接提供给人类来看,需要把里面的每个字节都转为两个十六进制数的形式来显示,就是调用 baAll.toHex(),得到
strAll 字符串,比如数值 0x8f 会转为两个十六进制字符 "8f" 。
原始数据包变成字符串 strAll 之后,就可以添加 strAll 到列表控件来显示了。
第二个是 "删除UDP包" 按钮对应的槽函数代码:
void
Widget::on_pushButtonDelUDP_clicked()
{
//获取行号
int nCurRow = ui->listWidget->currentRow();
//判断行号是否合法
if( nCurRow < 0 )
{
return;
}
//删除该行的 UDP 包
ui->listWidget->takeItem(nCurRow);
}
这段代码获取列表控件当前行号,如果行号合法才进行删除一行的操作。
第三个是 "保存UDP" 按钮对应的槽函数,我们把列表控件里用户添加的所有行都转为 UDP 包存到文件中:
//把UDP包存到文件
void Widget::on_pushButtonSave_clicked()
{
//获取行计数,并判断是否有数据行
int nCount = ui->listWidget->count();
if(nCount < 1)
{
return;
}
//获取保存文件名
QString strFileName = QFileDialog::getSaveFileName(
this,
tr("保存UDP文件"),
tr("."),
tr("UDP files(*.udp);;All files(*)") );
//判断文件名
if( strFileName.isEmpty() )
{
return; //没文件名不保存
}
//根据文件名定义文件对象
QFile fileOut(strFileName);
//打开
if( ! fileOut.open( QIODevice::WriteOnly ) )
{
QMessageBox::warning(this, tr("保存UDP文件"),
tr("打开要保存的文件失败:") + fileOut.errorString());
return;
}
//构建串行化输出流
QDataStream dsOut( &fileOut );
//不设置流的版本号,用默认的版本号
//先写入一个整型变量,表示包个数
dsOut<<qint32(nCount);
//用 for 循环从列表控件提取包
UDPPacker udpCur;
for(int i=0; i<nCount; i++)
{
//十六进制字符串
QString strHex = ui->listWidget->item(i)->text();
//转为原始数据包
QByteArray baCur = QByteArray::fromHex( strHex.toUtf8() );
//根据字节数组定义输入流
QDataStream dsIn(baCur);
//提取UDP包
dsIn>>udpCur;
//把原始 udp 包写入到文件中
dsOut<<udpCur;
}
//保存完毕
QMessageBox::information(this, tr("保存UDP包"), tr("保存UDP包到文件完毕!"));
}
这个槽函数先获取列表控件内容的行计数,如果没内容就不处理。
列表控件有内容时,获取保存文件名,并判断获取的文件名是否为空。
说明一下,*.udp 文件的格式非常简单,我们先写入一个 UDP 包的计数变量,然后把 UDP 包依次写入文件中即可。
得到文件名之后,先定义文件对象 fileOut 并打开,然后根据文件对象定义串行化输出流 dsOut。
这里简化操作,不设置 dsOut 的版本号,就用默认的值。
我们先把 UDP 包计数 nCount 写入输出流中,然后用 for 循环:
从列表控件提取一行的文本 strHex,把 strHex 转为 UTF-8 编码的临时 QByteArray
,然后用 QByteArray::fromHex() 把十六进制数的字符串转为原始的字节数值,得到 baCur ;
根据原始数值的字节数组 baCur 定义输入流 dsIn;
用一句代码 dsIn>>udpCur 提取出数据包的结构体对象;
输出结构体对象 udpCur 到文件中。
for 循环完成之后,提示用户 UDP 包保存完毕。
这里再说一下 QDataStream 构造函数,把数据输出到一个内存字节数组,那么需要传递字节数组指针,并指明读写的模式,比如:
QDataStream dsOut( &baAll,
QIODevice::ReadWrite);
如果是从一个内存字节数组做输入,那么只需要传递字节数组对象给 QDataStream 构造函数就行了:
QDataStream dsIn( baCur );
读者可以复习一下前面 7.4.1 小节的内容,注意两个构造函数的区别。
我们为结构体添加两个函数 operator>>() 和 operator>>(),QDataStream
流会自动调用这两个函数做输入输出,无论是对内存字节数组的输入输出,还是对文件的输入输出,都可以自动完成对结构体的填充和输出。
第四个是 "加载UDP" 按钮对应的槽函数代码,用于读取上面代码写入的 *.udp 文件:
void
Widget::on_pushButtonLoad_clicked()
{
//获取打开文件名
QString strFileName = QFileDialog::getOpenFileName(
this,
tr("打开UDP文件"),
tr("."),
tr("UDP files(*.udp);;All files(*)") );
//判断文件名
if( strFileName.isEmpty() )
{
return;
}
//定义文件对象
QFile fileIn( strFileName );
//打开
if( ! fileIn.open(QIODevice::ReadOnly) )
{
QMessageBox::warning(this, tr("打开UDP文件"),
tr("打开指定UDP文件失败:") + fileIn.errorString());
return;
}
//定义串行化输入流
QDataStream dsIn(&fileIn);
//获取数据包个数
qint32 nCount;
dsIn>>nCount;
//判断个数
if( nCount < 1 )
{
QMessageBox::warning(this, tr("加载UDP包"), tr("指定UDP文件内数据包计数小于1,无法加载。"));
return;
}
//先清空列表控件内容
ui->listWidget->clear();
//使用 for 循环提取UDP数据包
UDPPacker udpCur;
for( int i=0; i<nCount; i++ )
{
//先判断流的状态,状态不对就结束循环
if( dsIn.status() != QDataStream::Ok )
{
qDebug()<<tr("读取第 %1 个数据包前的状态错误:%2").arg(i).arg(dsIn.status());
break;
}
//提取UDP数据包
dsIn>>udpCur;
//把UDP数据包的内容打包成一个 QByteArray
QByteArray baCur;
QDataStream dsOut(&baCur, QIODevice::ReadWrite);
dsOut<<udpCur;
//把原始字节数组转成十六进制字符串
QString strHex = baCur.toHex();
//添加到列表控件显示
ui->listWidget->addItem( strHex );
}
//提示加载完毕
QMessageBox::information(this, tr("加载UDP包"), tr("加载文件中的UDP包完成!"));
}
这个槽函数先获取要打开的文件名,判断文件名是否为空;
有文件名之后定义文件对象 fileIn,打开该文件;
根据文件对象定义串行化输入流 dsIn;
从输入流读取 UDP 包的计数 nCount ,并判断一下计数是否有 UDP 包;
在加载文件中的 UDP 包之前,先清空列表控件中的内容,准备显示新数据;
利用 for 循环:
先判断输入流的状态,如果状态不对就跳出循环;
然后从文件输入流中逐个提取 UDP 包结构体,填充到 udpCur;
定义内存字节数组 baCur,并根据这个 baCur 定义输出流 dsOut;
把结构体对象 udpCur 输出到 dsOut,就是把结构体对象打包成一个字节数组 baCur;
把原始字节数组 baCur 转为十六进制字符串 strHex ,显示到列表控件;
在 for 循环处理完毕之后,用消息框提示加载完成。
加载过程就是从文件提取各个 UDP 结构体对象,转为一个原始数值的字节数组,再转为人类可读的十六进制字符串显示到列表控件。
最后一个槽函数是关于列表控件的,当列表控件选中的行变化时,我们从当前行字符串中提取 UDP 包,并显示源端口、目的端口和报文消息到三个单行编辑器里面:
void
Widget::on_listWidget_currentRowChanged(int currentRow)
{
//判断行号是否合法
if( currentRow < 0 )
{
return;
}
//提取十六进制字符串
QString strHex = ui->listWidget->item(currentRow)->text();
//转为原始数据包
QByteArray baAll = QByteArray::fromHex( strHex.toUtf8() );
//构造输入流
QDataStream dsIn(baAll);
//定义UDP结构体对象
UDPPacker udp;
dsIn>>udp; //只需要一句,就可以从字节数组提取 UDP 结构体对象
//显示到三个编辑器里面
ui->lineEditSrcPort->setText( tr("%1").arg( udp.m_srcPort ) );
ui->lineEditDstPort->setText( tr("%1").arg( udp.m_dstPort ) );
ui->lineEditMsg->setText( QString::fromUtf8( udp.m_data ) );
}
这个槽函数先判断当前行号是否合法,不合法就返回;
然后取出列表控件当前行的文本,存到 strHex ;
把 strHex 转为 UTF-8 编码的临时 QByteArray,利用 QByteArray::fromHex() 把十六进制字符串转为原始数值的字
节数组 baAll ;
然后根据 baAll 定义输入流 dsIn;
从输入流提取数据填充到 udp 对象;
然后把源端口、目的端口、报文消息显示到各自对应的单行编辑器里面。
例子的代码就是上面那些,本节第二个例子代码与第一个例子代码有些相似,主要是供读者多练手,学习串行化数据流 QDataStream 的用法。
第二个例子生成运行之后,我们可以手动添加几个 UDP 数据包:
"保存UDP" 和 "加载UDP" 等功能,读者可以自行测试一下。
本节的内容就到这,上一节的文本流和本节的串行化数据流最常用,一定要熟练运用。下一节我们介绍其他几个文件读写相关的类,内容相对简单一些。