Qt5 学习8 之 model/view、QStringListModel、模型、视图和委托

model/view

有时,我们的系统需要显示大量数据,比如从数据库中读取数据,以自己的方式显示在自己的应用程序的界面中。早期的 Qt 要实现这个功能,需要定义一个组件,在这个组件中保存一个数据对象,比如一个列表。我们对这个列表进行查找、插入等的操作,或者把修改的地方写回,然后刷新组件进行显示。这个思路很简单,也很清晰,但是对于大型程序,这种设计就显得苍白无力。比如,在一个大型系统中,你的数据可能很大,全部存入一个组件的数据对象中,效率会很低,并且这样的设计也很难在不同组件之间共享数据。如果你要几个组件共享一个数据对象,要么你就要用存取函数公开这个数据对象,要么你就必须把这个数据对象放进不同的组件分别进行维护。

Smalltalk 语言发明了一种崭新的实现,用来解决这个问题,这就是著名的 MVC 模型。对这个模型无需多言。MVC 是 Model-View-Controller 的简写,即模型-视图-控制器。在 MVC 中,模型负责获取需要显示的数据,并且存储这些数据的修改。每种数据类型都有它自己对应的模型,但是这些模型提供一个相同的 API,用于隐藏内部实现。视图用于将模型数据显示给用户。对于数量很大的数据,或许只显示一小部分,这样就能很好的提高性能。控制器是模型和视图之间的媒介,将用户的动作解析成对数据的操作,比如查找数据或者修改数据,然后转发给模型执行,最后再将模型中需要被显示的数据直接转发给视图进行显示。MVC 的核心思想是分层,不同的层应用不同的功能。

Qt 4 开始,引入了类似的 model/view 架构来处理数据和面向最终用户的显示之间的关系。当 MVC 的 V 和 C 结合在一起,我们就得到了 model/view 架构。这种架构依然将数据和界面分离,但是框架更为简单。同样,这种架构也允许使用不同界面显示同一数据,也能够在不改变数据的情况下添加新的显示界面。为了处理用户输入,我们还引入了委托(delegate)。引入委托的好处是,我们能够自定义数据项的渲染和编辑。
Qt5 学习8 之 model/view、QStringListModel、模型、视图和委托_第1张图片
如上图所示,模型与数据源进行交互,为框架中其它组件提供接口。这种交互的本质在于数据源的类型以及模型的实现方式。视图从模型获取模型索引,这种索引就是数据项的引用。通过将这个模型索引反向传给模型,视图又可以从数据源获取数据。在标准视图中,委托渲染数据项;在需要编辑数据时,委托使用直接模型索引直接与模型进行交互。

总的来说,model/view 架构将传统的 MV 模型分为三部分:模型、视图和委托。每一个组件都由一个抽象类定义,这个抽象类提供了基本的公共接口以及一些默认实现。模型、视图和委托则使用信号槽进行交互:

来自模型的信号通知视图,其底层维护的数据发生了改变;
来自视图的信号提供了有关用户与界面进行交互的信息;
来自委托的信号在用户编辑数据项时使用,用于告知模型和视图编辑器的状态。

所有的模型都是QAbstractItemModel的子类。这个类定义了供视图和委托访问数据的接口。模型并不存储数据本身。这意味着,你可以将数据存储在一个数据结构中、另外的类中、文件中、数据库中,或者其他你所能想到的东西中。我们将在后面再详细讨论这些内容。

QAbstractItemModel提供的接口足够灵活,足以应付以表格、列表和树的形式显示的数据。但是,如果你需要为列表或者表格设计另外的模型,直接继承QAbstractListModel和QAbstractTableModel类可能更好一些,因为这两个类已经实现了很多通用函数。关于这部分内容,我们也会在后文中详述。

Qt 内置了许多标准模型:

QStringListModel:存储简单的字符串列表。
QStandardItemModel:可以用于树结构的存储,提供了层次数据。
QFileSystemModel:本地系统的文件和目录信息。
QSqlQueryModel、QSqlTableModel和QSqlRelationalTableModel:存取数据库数据。

正如上面所说,如果这些标准模型不能满足你的需要,就必须继承QAbstractItemModel、QAbstractListModel或者QAbstractTableModel,创建自己的模型类。

Qt 还提供了一系列预定义好的视图:QListView用于显示列表,QTableView用于显示表格,QTreeView用于显示层次数据。这些类都是QAbstractItemView的子类。这意味着,如果你要创建新的视图类,则可以继承QAbstractItemView。

QAbstractItemDelegate则是所有委托的抽象基类。自 Qt 4.4 依赖,默认的委托实现是QStyledItemDelegate。但是,QStyledItemDelegate和QItemDelegate都可以作为视图的编辑器,二者的区别在于,QStyledItemDelegate使用当前样式进行绘制。在实现自定义委托时,推荐使用QStyledItemDelegate作为基类,或者结合 Qt style sheets。

如果你觉得 model/view 模型过于复杂,或者有很多功能是用不到的,Qt 还有一系列方便使用的类。这些类都是继承自标准的视图类,并且继承了标准模型。这些类并不是为其他类继承而准备的,只是为了使用方便。它们包括QListWidget、QTreeWidget和QTableWidget。这些类远不如视图类灵活,不能使用另外的模型,因此只适用于简单的情形。

QStringListModel

既然是 model/view,我们也会分为两部分:model 和 view。本章我们将介绍 Qt 内置的最简单的一个模型:QStringListModel。接下来,我们再介绍另外的一些内置模型,在此基础上,我们将了解到 Qt 模型的基本架构,以便为最高级的应用——自定义模型——打下坚实的基础。

QStringListModel是最简单的模型类,具备向视图提供字符串数据的能力。QStringListModel是一个可编辑的模型,可以为组件提供一系列字符串作为数据。我们可以将其看作是封装了QStringList的模型。QStringList是一种很常用的数据类型,实际上是一个字符串列表(也就是QList)。既然是列表,它也就是线性的数据结构,因此,QStringListModel很多时候都会作为QListView或者QComboBox这种只有一列的视图组件的数据模型。

下面我们通过一个例子来看看QStringListModel的使用。首先是我们的构造函数:

MyListView::MyListView()
{
    QStringList data;
    data << "Letter A" << "Letter B" << "Letter C";
    model = new QStringListModel(this);
    model->setStringList(data);

    listView = new QListView(this);
    listView->setModel(model);

    QHBoxLayout *btnLayout = new QHBoxLayout;
    QPushButton *insertBtn = new QPushButton(tr("insert"), this);
    connect(insertBtn, SIGNAL(clicked()), this, SLOT(insertData()));
    QPushButton *delBtn = new QPushButton(tr("Delete"), this);
    connect(delBtn, SIGNAL(clicked()), this, SLOT(deleteData()));
    QPushButton *showBtn = new QPushButton(tr("Show"), this);
    connect(showBtn, SIGNAL(clicked()), this, SLOT(showData()));
    btnLayout->addWidget(insertBtn);
    btnLayout->addWidget(delBtn);
    btnLayout->addWidget(showBtn);

    QVBoxLayout *mainLayout = new QVBoxLayout(this);
    mainLayout->addWidget(listView);
    mainLayout->addLayout(btnLayout);
    setLayout(mainLayout);
}

首先,我们创建了一个QStringList对象,向其中插入了几个数据;然后将其作为QStringListModel的底层数据。这样,我们可以理解为,QStringListModel将QStringList包装了起来。剩下来的只是简单的界面代码,这里不再赘述。试运行一下,程序应该是这样的:
Qt5 学习8 之 model/view、QStringListModel、模型、视图和委托_第2张图片

void MyListView::insertData()
{
    bool isOK;
    QString text = QInputDialog::getText(this, "Insert",
                                         "Please input new data:",
                                         QLineEdit::Normal,
                                         "You are inserting new data.",
                                         &isOK);
    if (isOK) {
        int row = listView->currentIndex().row();
        model->insertRows(row, 1);
        QModelIndex index = model->index(row);
        model->setData(index, text);
        listView->setCurrentIndex(index);
        listView->edit(index);
    }
}

首先是insertData()函数。我们使用QInputDialog::getText()函数要求用户输入数据。这是 Qt 的标准对话框,用于获取用户输入的字符串。这部分在前面的章节中已经讲解过。当用户点击了 OK 按钮,我们使用listView->currentIndex()函数,获取QListView当前行。这个函数的返回值是一个QModelIndex类型。我们会在后面的章节详细讲解这个类,现在只要知道这个类保存了三个重要的数据:行索引、列索引以及该数据属于哪一个模型。我们调用其row()函数获得行索引,该返回值是一个 int,也就是当前是第几行。然后我们向模型插入新的一行。insertRows()函数签名如下:

bool insertRows(int row, int count, const QModelIndex &parent = QModelIndex());

该函数会将 count 行插入到模型给定的 row 的位置,新行的数据将会作为 parent 的子元素。如果 row 为 0,新行将被插入到 parent 的所有数据之前,否则将在指定位置的数据之前。如果 parent 没有子元素,则会新插入一个单列数据。函数插入成功返回 true,否则返回 false。我们在这段代码中调用的是insertRows(row, 1)。这是QStringListModel的一个重载。参数 1 说明要插入 1 条数据。记得之前我们已经把 row 设置为当前行,因此,这行语句实际上是在当前的 row 位置插入 count 行,这里的 count 为 1。由于我们没有添加任何数据,实际效果是,我们在 row 位置插入了 1 个空行。然后我们使用 model 的index()函数获取当前行的QModelIndex对象,利用setData()函数把我们用QInputDialog接受的数据设置为当前行数据。接下来,我们使用setCurrentIndex()函数,把当前行设为新插入的一行,并调用edit()函数,使这一行可以被编辑。

以上是我们提供的一种插入数据的方法:首先插入空行,然后选中新插入的空行,设置新的数据。这其实是一种冗余操作,因为currentIndex()已经获取到当前行。在此,我们仅仅是为了介绍这些函数的使用。因此,除去这些冗余,我们可以使用一种更简洁的写法:

void MyListView::insertData()
{
    bool isOK;
    QString text = QInputDialog::getText(this, "Insert",
                                         "Please input new data:",
                                         QLineEdit::Normal,
                                         "You are inserting new data.",
                                         &isOK);
    if (isOK) {
        QModelIndex currIndex = listView->currentIndex();
        model->insertRows(currIndex.row(), 1);
        model->setData(currIndex, text);
        listView->edit(currIndex);
    }
}
void MyListView::deleteData()
{
    if (model->rowCount() > 1) {
        model->removeRows(listView->currentIndex().row(), 1);
    }
}
void MyListView::showData()
{
    QStringList data = model->stringList();
    QString str;
    foreach(QString s, data) {
        str += s + "\n";
    }
    QMessageBox::information(this, "Data", str);
}

于QStringListModel我们简单介绍这些。从这些示例中可以看到,几乎所有操作都是针对模型的,也就是说,我们直接对数据进行操作,当模型检测到数据发生了变化,会立刻通知视图进行刷新。这样,我们就可以把精力集中到对数据的操作上,而不用担心视图的同步显示问题。这正是 model/view 模型所带来的一个便捷之处。

模型

在 model/view 架构中,model 提供一种标准接口,供视图和委托访问数据。在 Qt 中,这个接口由QAbstractItemModel类进行定义。不管底层数据是如何存储的,只要是QAbstractItemModel的子类,都提供一种表格形式的层次结构。视图利用统一的转换来访问模型中的数据。但是,需要提供的是,尽管模型内部是这样组织数据的,但是并不要求也得这样子向用户展示数据。

下面是各种 model 的组织示意图。我们利用此图来理解什么叫“一种表格形式的层次结构”。Qt5 学习8 之 model/view、QStringListModel、模型、视图和委托_第3张图片如上图所示,List Model 虽然是线性的列表,也有一个 Root Item(根节点),之下才是呈线性的一个个数据,而这些数据实际可以看作是一个只有一列的表格,但是它是有层次的,因为有一个根节点。Table Model 就比较容易理解,只是也存在一个根节点。Tree Model 主要面向层次数据,而每一层次都可以都很多列,因此也是一个带有层次的表格。

为了能够使得数据的显示同存储分离,我们引入模型索引(model index)的概念。通过索引,我们可以访问模型的特定元素的特定部分。视图和委托使用索引来请求所需要的数据。由此可以看出,只有模型自己需要知道如何获得数据,模型所管理的数据类型可以使用通用的方式进行定义。索引保存有创建的它的那个模型的指针,这使得同时操作多个模型成为可能。

QAbstractItemModel *model = index.model();

模型索引提供了所需要的信息的临时索引,可以用于通过模型取回或者修改数据。由于模型随时可能重新组织其内部的结构,因此模型索引很可能变成不可用的,此时,就不应该保存这些数据。如果你需要长期有效的数据片段,必须创建持久索引。持久索引保证其引用的数据及时更新。临时索引(也就是通常使用的索引)由QModelIndex类提供,持久索引则是QPersistentModelIndex类。

为了定位模型中的数据,我们需要三个属性:行号、列号以及父索引。下面我们对其一一进行解释。

我们前面介绍过模型的基本形式:数据以二维表的形式进行存储。此时,一个数据可以由行号和列号进行定位。注意,我们仅仅是使用“二维表”这个名词,并不意味着模型内部真的是以二维数组的形式进行存储;所谓“行号”“列号”,也仅仅是为方便描述这种对应关系,并不真的是有行列之分。通过指定行号和列号,我们可以定位一个元素项,取出其信息。此时,我们获得的是一个索引对象(回忆一下,通过索引我们可以获取具体信息):

QModelIndex index = model->index(row, column, ...);

模型提供了一个简单的接口,用于列表以及表格这种非层次视图的数据获取。不过,正如上面的代码暗示的那样,实际接口并不是那么简单。我们可以通过文档查看这个函数的原型:

QModelIndex QAbstractItemModel::index(int row,
                                      int column,
                                      const QModelIndex &parent=QModelIndex()) const

Qt5 学习8 之 model/view、QStringListModel、模型、视图和委托_第4张图片
图中,A 和 C 都是模型中的顶级项:
QModelIndex indexA = model->index(0, 0, QModelIndex());
QModelIndex indexC = model->index(2, 1, QModelIndex());

A 还有自己的子项。那么,我们就应该使用下面的代码获取 B 的索引:

QModelIndex indexB = model->index(1, 0, indexA);

由此我们看到,如果只有行号和列号两个参数,B 的行号是 1,列号是 0,这同与 A 同级的行号是 1,列号是 0 的项相同,所以我们通过 parent 属性区别开来。

以上我们讨论了有关索引的定位。现在我们来看看模型的另外一个部分:数据角色。模型可以针对不同的组件(或者组件的不同部分,比如按钮的提示以及显示的文本等)提供不同的数据。例如,Qt::DisplayRole用于视图的文本显示。通常来说,数据项包含一系列不同的数据角色,这些角色定义在Qt::ItemDataRole枚举中。

我们可以通过指定索引以及角色来获得模型所提供的数据:

QVariant value = model->data(index, role);

通过为每一个角色提供恰当的数据,模型可以告诉视图和委托如何向用户显示内容。不同类型的视图可以选择忽略自己不需要的数据。当然,我们也可以添加我们所需要的额外数据。

总结一下:

模型使用索引来提供给视图和委托有关数据项的位置的信息,这样做的好处是,模型之外的对象无需知道底层的数据存储方式;
数据项通过行号、列号以及父项三个坐标进行定位;
模型索引由模型在其它组件(视图和委托)请求时才会被创建;
如果使用index()函数请求获得一个父项的可用索引,该索引会指向模型中这个父项下面的数据项。这个索引指向该项的一个子项;如果使用index()函数请求获得一个父项的不可用索引,该索引指向模型的最顶级项;
角色用于区分数据项的不同类型的数据。

下面回到前面我们曾经见过的模型QFileSystemModel,看看如何从模型获取数据。

QFileSystemModel *model = new QFileSystemModel;
QModelIndex parentIndex = model->index(QDir::currentPath());
int numRows = model->rowCount(parentIndex);

在这个例子中,我们创建了QFileSystemModel的实例,使用QFileSystemModel重载的index()获取索引,然后使用rowCount()函数计算当前目录下有多少数据项(也就是行数)。前面一章中迷迷糊糊的代码,现在已经相当清楚了。

为简单起见,下面我们只关心模型第一列。我们遍历所有数据,取得第一列索引:

for (int row = 0; row < numRows; ++row) {
    QModelIndex index = model->index(row, 0, parentIndex);

我们使用index()函数,第一个参数是每一行行号,第二个参数是 0,也就是第一列,第三个参数是 parentIndex,也就是当前目录作为父项。我们可以使用模型的data()函数获取每一项的数据。注意,该函数返回值是QVariant,实际是一个字符串,因此我们直接转换成QString:

    QString text = model->data(index, Qt::DisplayRole).toString();
    // 使用 text 数据
}

上面的代码片段显示了从模型获取数据的一些有用的函数:

模型的数目信息可以通过rowCount()和columnCount()获得。这些函数需要制定父项;
索引用于访问模型中的数据。我们需要利用行号、列号以及父项三个参数来获得该索引;
当我们使用QModelIndex()创建一个空索引使用时,我们获得的就是模型中最顶级项;
数据项包含了不同角色的数据。为获取特定角色的数据,必须指定这个角色。

视图和委托

前面我们介绍了模型的概念。下面则是另外一个基本元素:视图。在 model/view 架构中,视图是数据从模型到最终用户的途径。数据通过视图向用户进行显示。此时,这种显示方式不必须同模型的存储结构相一致。实际上,很多情况下,数据的显示同底层数据的存储是完全不同的。

我们使用QAbstractItemModel提供标准的模型接口,使用 QAbstractItemView提供标准的视图接口,而结合这两者,就可以将数据同表现层分离,在视图中利用前面所说的模型索引。视图管理来自模型的数据的布局:既可以直接渲染数据本身,也可以通过委托渲染和编辑数据。

视图不仅仅用于展示数据,还用于在数据项之间的导航以及数据项的选择。另外,视图也需要支持很多基本的用户界面的特性,例如右键菜单以及拖放。视图可以提供数据编辑功能,也可以将这种编辑功能交由某个委托完成。视图可以脱离模型创建,但是在其进行显示之前,必须存在一个模型。也就是说,视图的显示是完全基于模型的,这是不能脱离模型存在的。对于用户的选择,多个视图可以相互独立,也可以进行共享。

某些视图,例如QTableView和QTreeView,不仅显示数据,还会显示列头或者表头。这些是由QHeaderView视图类提供的。在《QFileSystemModel》一章的最后,我们曾经提到过这个问题。表头通常访问视图所包含的同一模型。它们使用QAbstractItemModel::headerData()函数从模型中获取数据,然后将其以标签 label 的形式显示出来。我们可以通过继承QHeaderView类,实现某些更特殊的功能。

正如前面的章节介绍的,我们通常会为视图提供一个模型。拿前面我们曾经见过的一个例子来看:

QStringList data;
data << "0" << "1" << "2";
model = new QStringListModel(this);
model->setStringList(data);

listView = new QListView(this);
listView->setModel(model);

QPushButton *btnShow = new QPushButton(tr("Show Model"), this);
connect(btnShow, SIGNAL(clicked()),
        this, SLOT(showModel()));
QHBoxLayout *buttonLayout = new QHBoxLayout;
buttonLayout->addWidget(btnShow);

QVBoxLayout *layout = new QVBoxLayout;
layout->addWidget(listView);
layout->addLayout(buttonLayout);
setLayout(layout);

运行一下程序,这个界面十分简单:

Qt5 学习8 之 model/view、QStringListModel、模型、视图和委托_第5张图片

跟我们前面的演示几乎一模一样。现在我们有一个问题:如果我们双击某一行,列表会允许我们进行编辑。但是,我们没办法控制用户只能输入数字——当然,我们可以在提交数据时进行检测,这也是一种办法,不过,更友好的方法是,根本不允许用户输入非法字符。为了达到这一目的,我们使用了委托。下面,我们增加一个委托:

class SpinBoxDelegate : public QStyledItemDelegate
{
    Q_OBJECT
public:
    SpinBoxDelegate(QObject *parent = 0) : QStyledItemDelegate(parent) {}

    QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option,
                          const QModelIndex &index) const;

    void setEditorData(QWidget *editor, const QModelIndex &index) const;
    void setModelData(QWidget *editor, QAbstractItemModel *model,
                      const QModelIndex &index) const;

    void updateEditorGeometry(QWidget *editor,
                              const QStyleOptionViewItem &option,
                              const QModelIndex &index) const;
};

正如前面所说,委托就是供视图实现某种高级的编辑功能。不同于经典的 Model-View-Controller(MVC)模式,model/view 没有将用户交互部分完全分离。一般地,视图将数据向用户进行展示并且处理通用的输入。但是,对于某些特殊要求(比如这里的要求必须输入数字),则交予委托完成。这些组件提供输入功能,同时也能渲染某些特殊数据项。委托的接口由QAbstractItemDelegate定义。在这个类中,委托通过paint()和sizeHint()两个函数渲染用户内容(也就是说,你必须自己将渲染器绘制出来)。为使用方便,从 4.4 开始,Qt 提供了另外的基于组件的子类:QItemDelegate和QStyledItemDelegate。默认的委托是QStyledItemDelegate。二者的区别在于绘制和向视图提供编辑器的方式。QStyledItemDelegate使用当前样式绘制,并且能够使用 Qt Style Sheet(我们会在后面的章节对 QSS 进行介绍),因此我们推荐在自定义委托时,使用QStyledItemDelegate作为基类。不过,除非自定义委托需要自己进行绘制,否则,二者的代码其实是一样的。

继承QStyledItemDelegate需要实现以下几个函数:

createEditor():返回一个组件。该组件会被作为用户编辑数据时所使用的编辑器,从模型中接受数据,返回用户修改的数据。
setEditorData():提供上述组件在显示时所需要的默认值。
updateEditorGeometry():确保上述组件作为编辑器时能够完整地显示出来。
setModelData():返回给模型用户修改过的数据。

下面依次看看各函数的实现:

QWidget *SpinBoxDelegate::createEditor(QWidget *parent,
                                       const QStyleOptionViewItem & /* option */,
                                       const QModelIndex & /* index */) const
{
    QSpinBox *editor = new QSpinBox(parent);
    editor->setMinimum(0);
    editor->setMaximum(100);
    return editor;
}

在createEditor()函数中,parent 参数会作为新的编辑器的父组件。

void SpinBoxDelegate::setEditorData(QWidget *editor,
                                    const QModelIndex &index) const
{
    int value = index.model()->data(index, Qt::EditRole).toInt();
    QSpinBox *spinBox = static_cast(editor);
    spinBox->setValue(value);
}

setEditorData()函数从模型中获取需要编辑的数据(具有Qt::EditRole角色)。由于我们知道它就是一个整型,因此可以放心地调用toInt()函数。editor 就是所生成的编辑器实例,我们将其强制转换成QSpinBox实例,设置其数据作为默认值。

void SpinBoxDelegate::setModelData(QWidget *editor,
                                   QAbstractItemModel *model,
                                   const QModelIndex &index) const
{
    QSpinBox *spinBox = static_cast(editor);
    spinBox->interpretText();
    int value = spinBox->value();
    model->setData(index, value, Qt::EditRole);
}

在用户编辑完数据后,委托会调用setModelData()函数将新的数据保存到模型中。因此,在这里我们首先获取QSpinBox实例,得到用户输入值,然后设置到模型相应的位置。标准的QStyledItemDelegate类会在完成编辑时发出closeEditor()信号,视图会保证编辑器已经关闭,但是并不会销毁,因此需要另外对内存进行管理。由于我们的处理很简单,无需发出closeEditor()信号,但是在复杂的实现中,记得可以在这里发出这个信号。针对数据的任何操作都必须提交给QAbstractItemModel,这使得委托独立于特定的视图。当然,在真实应用中,我们需要检测用户的输入是否合法,是否能够存入模型。

void SpinBoxDelegate::updateEditorGeometry(QWidget *editor,
                                           const QStyleOptionViewItem &option,
                                           const QModelIndex &index) const
{
    editor->setGeometry(option.rect);
}

最后,由于我们的编辑器只有一个数字输入框,所以只是简单将这个输入框的大小设置为单元格的大小(由option.rect提供)。如果是复杂的编辑器,我们需要根据单元格参数(由option提供)、数据(由index提供)结合编辑器(由editor提供)计算编辑器的显示位置和大小。

现在,我们的委托已经编写完毕。接下来需要将这个委托设置为QListView所使用的委托:

listView->setItemDelegate(new SpinBoxDelegate(listView));

值得注意的是,new 操作符并不会真的创建编辑器实例。相反,只有在真正需要时,Qt 才会生成一个编辑器实例。这保证了程序运行时的性能。

然后我们运行下程序:

Qt5 学习8 之 model/view、QStringListModel、模型、视图和委托_第6张图片

你可能感兴趣的:(Qt5)