每个UI开发人员都应该了解ModelView编程,而本教程的目标就是为您提供一个易于理解的关于这个主题的介绍。
表、列表和树小部件是 GUI 中经常使用的组件。这些小部件有两种不同的方式来访问它们的数据。传统的方法涉及到包含存储数据的内部容器的小部件。这种方法非常直观,但是,在许多重要的应用程序中,它会导致数据同步问题。第二种方法是模型/视图编程,其中小部件不维护内部的数据容器。它们通过标准化接口访问外部数据,从而避免了数据重复。乍一看,这似乎很复杂,但一旦深入了解,它不仅容易掌握,而且模型/视图编程的许多好处也会变得更加清晰。
在这个过程中,我们会了解到Qt提供的一些基本技术,例如:
标准部件和模型/视图部件之间的区别
表单和模型之间的适配器
开发简单的模型/视图应用程序
预定义的模型
中间主题,如:
Tree views
Selection
Delegates
Debugging with model test
您还将了解使用模型/视图编程是否可以更容易地编写新应用程序,或者经典的小部件是否也能同样工作。
本教程包含示例代码,供您编辑并集成到项目中。
教程的源代码位于Qt的
Qt5.13.2\Examples\Qt-5.13.2\widgets\tutorials\modelview
Qt5.13.2\Examples\Qt-5.13.2\widgets\itemviews
1、介绍
模型/视图是一种用于在处理数据集的小部件中从视图中分离数据的技术。标准窗口小部件不是为将数据从视图中分离而设计的,这就是Qt有两种不同类型窗口小部件的原因。这两种类型的小部件外观相同,但它们与数据的交互方式不同。
1.1 标准小部件
让我们仔细看看一个标准的表小部件。表小部件是用户可以更改的数据元素的2D数组。通过读写表小部件提供的数据元素,可以将表小部件集成到程序流中。这种方法在许多应用程序中非常直观和有用,但是使用标准表小部件显示和编辑数据库表可能会有问题。必须协调数据的两个副本:一个在小部件外部;一个在小部件内部。开发人员负责同步两个版本。除此之外,表示和数据的紧密耦合使得编写单元测试更加困难。
1.2 模型/视图的改善
模型/视图进一步提供了一个更通用的架构的解决方案。模型/视图消除了标准小部件可能出现的数据一致性问题。模型/视图还使得使用同一数据的多个视图变得更容易,因为一个模型可以传递给多个视图。最重要的区别是模型/视图小部件不将数据存储在表格单元格后面。实际上,它们直接从您的数据进行操作。因为视图类不知道数据的结构,所以需要提供包装器来使数据符合QAbstractItemModel接口。视图使用这个接口来读取和写入数据。实现QAbstractItemModel的类的任何实例都被称为模型。一旦视图接收到模型的指针,它将读取和显示它的内容,并成为它的编辑器。
1.3 模型/视图小部件概述
以下是模型/视图小部件及其相应的标准小部件的概述。
1.4 在表单和模型之间使用适配器
在表单和模型之间有适配器可以派上用场。
我们可以直接在表本身中编辑存储在表中的数据,但是在文本字段中编辑数据要舒服得多。没有直接对应于模型/视图的数据与视图分离的小部件来操作一个值而不是数据集(QLineEdit 、QCheckBox…),因此我们需要一个适配器来将表单连接到数据源。
QDataWidgetMapper是一个很好的解决方案,因为它将表单小部件映射到表行,并且使为数据库表构建表单变得非常容易。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XRDOzZPr-1607992128752)(https://doc.qt.io/qt-5/images/widgetmapper.png)]
另一个适配器示例是QCompleter。Qt有QCompleter,用于在Qt小部件中提供自动补全功能,如QComboBox和(如下所示)QLineEdit。QCompleter使用一个模型作为它的数据源。
2. 一个简单的模型/视图应用程序
如果你想开发一个模型/视图应用程序,你应该从哪里开始?我们建议从一个简单的示例开始,并逐步扩展它。这使得理解体系结构变得容易得多。在调用IDE之前,尝试理解模型/视图架构的细节对许多开发人员来说是不太方便的。从具有演示数据的简单模型/视图应用程序开始要容易得多。试试吧!只需用您自己的数据替换下面示例中的数据。
下面是7个非常简单和独立的应用程序,它们展示了模型/视图编程的不同方面。源代码可以在examples/widgets/tutorials/modelview目录中找到。
2.1 一个只读的表
我们从使用 QTableView 显示数据的应用程序开始。稍后我们将添加编辑功能。
(file source: examples/widgets/tutorials/modelview/1_readonly/main.cpp)
// main.cpp
#include <QApplication>
#include <QTableView>
#include "mymodel.h"
int main(int argc, char *argv[])
{
QApplication a(argc, argv);
QTableView tableView;
MyModel myModel;
tableView.setModel(&myModel);
tableView.show();
return a.exec();
}
我们有常用的main()函数:
下面是有趣的部分:我们创建一个MyModel的实例并使用tableView.setModel(&myModel);将它的指针传递给tableView。
tableView会调用接收到的指针的方法来找出两件事:
应该显示多少行和列。
应该在每个单元格中打印什么内容。
模型需要一些代码来响应这两点。
我们有一个表数据集,所以让我们从QAbstractTableModel开始,因为它比更通用的QAbstractItemModel更容易使用。
// mymodel.h
#include <QAbstractTableModel>
class MyModel : public QAbstractTableModel
{
Q_OBJECT
public:
MyModel(QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
int columnCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
};
行数和列数由MyModel::rowCount()和MyModel::columnCount()提供。当视图必须知道单元格的文本是什么时,它调用方法MyModel::data()。用参数索引指定行和列信息,角色设置为Qt::DisplayRole。其他角色将在下一节中介绍。在我们的示例中,生成了应该显示的数据。在真实的应用程序中,MyModel将有一个名为MyData的成员,它作为所有读写操作的目标。
这个小示例演示了模型的被动性质。模型不知道何时使用它或需要哪些数据。它只是在视图每次请求数据时提供数据。
当模型的数据需要更改时,会发生什么? 视图如何意识到数据已更改并需要再次读取? 模型必须发出信号,表明单元格的范围发生了变化。这将在第2.3节中演示。
2.2 使用角色扩展只读示例
除了控制视图显示什么文本之外,模型还控制文本的外观。稍微改变模型,得到如下结果:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-b9w8Mwxz-1607992128754)(https://doc.qt.io/qt-5/images/readonlytable_role.png)]
实际上,除了data()方法之外,不需要改变任何东西来设置字体、背景颜色、对齐方式和复选框。下面是产生上述结果的data()方法。不同之处在于,这次我们使用参数int role根据其值返回不同的信息。
(file source: examples/widgets/tutorials/modelview/2_formatting/mymodel.cpp)
// mymodel.cpp
QVariant MyModel::data(const QModelIndex &index, int role) const
{
int row = index.row();
int col = index.column();
// generate a log message when this method gets called
qDebug() << QString("row %1, col%2, role %3")
.arg(row).arg(col).arg(role);
switch (role) {
case Qt::DisplayRole:
if (row == 0 && col == 1) return QString("<--left");
if (row == 1 && col == 1) return QString("right-->");
return QString("Row%1, Column%2")
.arg(row + 1)
.arg(col +1);
case Qt::FontRole:
if (row == 0 && col == 0) { //change font only for cell(0,0)
QFont boldFont;
boldFont.setBold(true);
return boldFont;
}
break;
case Qt::BackgroundRole:
if (row == 1 && col == 2) //change background only for cell(1,2)
return QBrush(Qt::red);
break;
case Qt::TextAlignmentRole:
if (row == 1 && col == 1) //change text alignment only for cell(1,1)
return Qt::AlignRight + Qt::AlignVCenter;
break;
case Qt::CheckStateRole:
if (row == 1 && col == 0) //add a checkbox to cell(1,0)
return Qt::Checked;
break;
}
return QVariant();
}
每个格式化属性都将通过对data()方法的单独调用从模型中请求。role参数用于让模型知道被请求的属性:
请参阅Qt名称空间文档以了解关于Qt::ItemDataRole enum功能的更多信息。
现在我们需要确定使用独立模型如何影响应用程序的性能,因此让我们跟踪视图调用data()方法的频率。为了跟踪视图调用模型的频率,我们在data()方法中放置了一条调试语句,该语句将登录到错误输出流上。在我们的小示例中,data()将被调用42次。每次将光标悬停在字段上时,data()将再次被调用——每个单元格调用7次。这就是为什么在调用data()和缓存昂贵的查找操作时,确保数据可用非常重要。
2.3 表格单元格内的时钟
我们仍然有一个只读表,但这次内容每秒都在变化,因为我们显示的是当前时间。
QVariant MyModel::data(const QModelIndex &index, int role) const
{
int row = index.row();
int col = index.column();
if (role == Qt::DisplayRole && row == 0 && col == 0)
return QTime::currentTime().toString();
return QVariant();
}
少了什么东西使时钟滴答作响。我们需要每秒钟告诉视图时间已经改变,需要再次读取它。我们用计时器来做这个。在构造函数中,我们将其间隔设置为1秒,并连接其超时信号。
MyModel::MyModel(QObject *parent)
:QAbstractTableModel(parent)
,timer(new QTimer(this))
{
timer->setInterval(1000);
connect(timer, &QTimer::timeout , this, &MyModel::timerHit);
timer->start();
}
对应的槽如下:
void MyModel::timerHit()
{
//we identify the top left cell
QModelIndex topLeft = createIndex(0,0);
//emit a signal to make the view reread identified data
emit dataChanged(topLeft, topLeft, {Qt::DisplayRole});
}
我们通过发送dataChanged()信号请求视图再次读取左上角单元格中的数据。注意,我们没有显式地将dataChanged()信号连接到视图。当我们调用setModel()时,这将自动发生。
2.4 设置行、列表头
头部内容是通过模型设置的,因此我们要重新实现 headerData()方法
QVariant MyModel::headerData(int section, Qt::Orientation orientation, int role) const
{
if (role == Qt::DisplayRole && orientation == Qt::Horizontal) {
switch (section) {
case 0:
return QString("first");
case 1:
return QString("second");
case 2:
return QString("third");
}
}
return QVariant();
}
注意headerData()也有一个 role 参数,其含义和date() 方法中的role一样
2.5 最小的编辑示例
在本例中,我们将构建一个应用程序,通过重复在表格单元格中输入的值来自动填充窗口标题。为了方便地访问窗口标题,我们将QTableView放在QMainWindow中。
模型决定编辑功能是否可用。为了启用编辑功能,我们只需要修改模型。这是通过重新实现以下虚拟方法来实现的:setData()和flags()。
// mymodel.h
#include <QAbstractTableModel>
#include <QString>
const int COLS= 3;
const int ROWS= 2;
class MyModel : public QAbstractTableModel
{
Q_OBJECT
public:
MyModel(QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
int columnCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
bool setData(const QModelIndex &index, const QVariant &value, int role = Qt::EditRole) override;
Qt::ItemFlags flags(const QModelIndex &index) const override;
private:
QString m_gridData[ROWS][COLS]; //holds text entered into QTableView
signals:
void editCompleted(const QString &);
};
我们使用二维数组QString m_gridData来存储我们的数据。这使得m_gridData成为MyModel的核心。MyModel的其余部分充当一个包装器,并将m_gridData调整为QAbstractItemModel接口。我们还引入了editCompleted()信号,它可以将修改后的文本传输到窗口标题。
bool MyModel::setData(const QModelIndex &index, const QVariant &value, int role)
{
if (role == Qt::EditRole) {
if (!checkIndex(index))
return false;
//save value from editor to member m_gridData
m_gridData[index.row()][index.column()] = value.toString();
//for presentation purposes only: build and emit a joined string
QString result;
for (int row = 0; row < ROWS; row++) {
for (int col= 0; col < COLS; col++)
result += m_gridData[row][col] + ' ';
}
emit editCompleted(result);
return true;
}
return false;
}
每次用户编辑单元格时都会调用setData()。索引参数告诉我们哪个字段已经被编辑,value 提供编辑过程的结果。角色将始终设置为Qt::EditRole,因为单元格只包含文本。如果有一个复选框,并且用户权限设置为允许选中该复选框,那么还将使用角色设置为Qt::CheckStateRole进行调用。
Qt::ItemFlags MyModel::flags(const QModelIndex &index) const
{
return Qt::ItemIsEditable | QAbstractTableModel::flags(index);
}
单元格的各种属性可以使用flags()进行调整。
返回Qt::ItemIsSelectable | Qt::ItemIsEditable | Qt::ItemIsEnabled就足以向编辑器显示可以选择单元格
如果编辑一个单元格所修改的数据多于特定单元格中的数据,则模型必须发出dataChanged()信号,以便读取已更改的数据。
3.中间的话题
3.1 TreeView
您可以将上面的示例转换为具有树视图的应用程序。简单地用QTreeView替换QTableView,这会产生一个读/写树。不需要对模型进行任何更改。树不会有任何层次结构,因为模型本身没有任何层次结构。
QListView、QTableView和QTreeView都使用了一个模型抽象,即一个合并的列表、表和树。这使得在同一个模型中使用多个不同类型的视图类成为可能。
这是我们的示例模型到目前为止的样子:
我们想呈现一个真实的树。为了创建模型,我们已经将数据封装在上面的示例中。这次我们使用QStandardItemModel,它是分层数据的容器,也实现了QAbstractItemModel。要显示树,QStandardItemModel必须用QStandardItems填充,它能够保存项目的所有标准属性,如文本、字体、复选框或笔刷。
![1](E:\Users\Desktop\1.png) // modelview.cpp
#include "mainwindow.h"
#include <QTreeView>
#include <QStandardItemModel>
#include <QStandardItem>
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent)
, treeView(new QTreeView(this))
, standardModel(new QStandardItemModel(this))
{
setCentralWidget(treeView);
QList<QStandardItem *> preparedRow = prepareRow("first", "second", "third");
QStandardItem *item = standardModel->invisibleRootItem();
// adding a row to the invisible root item produces a root element
item->appendRow(preparedRow);
QList<QStandardItem *> secondRow = prepareRow("111", "222", "333");
// adding a row to an item starts a subtree
preparedRow.first()->appendRow(secondRow);
treeView->setModel(standardModel);
treeView->expandAll();
}
我们只需实例化一个QStandardItemModel,并向构造函数中添加两个QStandardItems。然后我们可以创建一个分层的数据结构,因为QStandardItem可以包含其他QStandardItems。节点在视图中被折叠和展开。
3.2处理选定项
我们希望访问所选项目的内容,以便将其输出到窗口标题和层次结构级别中。
#include "mainwindow.h"
#include <QTreeView>
#include <QStandardItemModel>
#include <QItemSelectionModel>
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent)
, treeView(new QTreeView(this))
, standardModel(new QStandardItemModel(this))
{
setCentralWidget(treeView);
QStandardItem *rootNode = standardModel->invisibleRootItem();
//defining a couple of items
QStandardItem *americaItem = new QStandardItem("America");
QStandardItem *mexicoItem = new QStandardItem("Canada");
QStandardItem *usaItem = new QStandardItem("USA");
QStandardItem *bostonItem = new QStandardItem("Boston");
QStandardItem *europeItem = new QStandardItem("Europe");
QStandardItem *italyItem = new QStandardItem("Italy");
QStandardItem *romeItem = new QStandardItem("Rome");
QStandardItem *veronaItem = new QStandardItem("Verona");
//building up the hierarchy
rootNode-> appendRow(americaItem);
rootNode-> appendRow(europeItem);
americaItem-> appendRow(mexicoItem);
americaItem-> appendRow(usaItem);
usaItem-> appendRow(bostonItem);
europeItem-> appendRow(italyItem);
italyItem-> appendRow(romeItem);
italyItem-> appendRow(veronaItem);
//register the model
treeView->setModel(standardModel);
treeView->expandAll();
//selection changes shall trigger a slot
QItemSelectionModel *selectionModel = treeView->selectionModel();
connect(selectionModel, &QItemSelectionModel::selectionChanged,
this, &MainWindow::selectionChangedSlot);
}
视图在单独的选择模型中管理选择,可以使用selectionModel()方法检索该模型。我们检索选择模型,以便将插槽连接到它的selectionChanged()信号。
void MainWindow::selectionChangedSlot(const QItemSelection & /*newSelection*/, const QItemSelection & /*oldSelection*/)
{
//get the text of the selected item
const QModelIndex index = treeView->selectionModel()->currentIndex();
QString selectedText = index.data(Qt::DisplayRole).toString();
//find out the hierarchy level of the selected item
int hierarchyLevel = 1;
QModelIndex seekRoot = index;
while (seekRoot.parent() != QModelIndex()) {
seekRoot = seekRoot.parent();
hierarchyLevel++;
}
QString showString = QString("%1, Level %2").arg(selectedText)
.arg(hierarchyLevel);
setWindowTitle(showString);
}
我们通过调用treeView->selectionModel()->currentIndex()来获得与选择相对应的模型索引,并通过使用模型索引来获得字段的字符串。然后我们只计算项目的层级级别。顶级项没有父项,父()方法将返回一个默认构造的QModelIndex()。这就是为什么我们使用parent()方法迭代到顶层,同时计算迭代期间执行的步骤。
选择模型(如上所示)可以被检索,但是也可以用QAbstractItemView::setSelectionModel进行设置。这就是为什么有3个视图类与同步选择是可能的,因为只有一个选择模型的实例被使用。要在3个视图之间共享选择模型,请使用selectionModel()并使用setSelectionModel()将结果分配给第二个和第三个视图类。
3.3 预定义的模型
使用模型/视图的典型方法是包装特定数据,使其可用于视图类。然而,Qt还为公共底层数据结构提供了预定义的模型。如果可用的数据结构中有一种适合您的应用程序,那么预定义的模型可能是一个不错的选择。
3.4 代理
到目前为止的所有例子中,数据都以文本或单元格中的复选框的形式显示,并被编辑为文本或复选框。提供这些表示和编辑服务的组件称为代理。我们只是刚刚开始使用代理,因为视图使用了默认的代理。但是,假设我们想要有一个不同的编辑器(例如,滑动条或下拉列表),或者假设我们想要将数据表示为图形。让我们看一个叫做星型代理的例子,在这个例子中星型用来显示评级:
视图有一个setItemDelegate()方法,该方法替换默认的代理并安装一个自定义代理。可以通过创建继承自QStyledItemDelegate的类来编写新的代理。为了编写一个显示星号且没有输入功能的代理,我们只需要覆盖2个方法。
// 这个代理不完整,还需相应的编辑器,现在只做了解用,完整用法可参考 Star Delegate Example
class StarDelegate : public QStyledItemDelegate
{
Q_OBJECT
public:
StarDelegate(QWidget *parent = 0);
void paint(QPainter *painter, const QStyleOptionViewItem &option,
const QModelIndex &index) const;
QSize sizeHint(const QStyleOptionViewItem &option,
const QModelIndex &index) const;
};
paint()根据基础数据的内容绘制星星。可以通过调用index.data()来查找数据。代理的sizeHint()方法用于获取每个星星的尺寸,因此单元格将提供足够的高度和宽度来容纳星星。
如果您想在视图类的网格中使用自定义图形表示来显示数据,那么编写自定义代理是正确的选择。如果您想要留下网格,您不应该使用自定义代理,而应该使用自定义视图类。
Qt文档中关于代理的其他引用:
Spin Box Delegate 示例
Star Delegate Example
QAbstractItemDelegate类引用
QSqlRelationalDelegate类引用
QStyledItemDelegate类引用
QItemDelegate类引用
3.5 通过模型测试进行调试
模型的被动特性为程序员提供了新的挑战。模型中的不一致可能导致应用程序崩溃。由于模型受到来自视图的大量调用的影响,因此很难找出哪个调用导致应用程序崩溃,以及哪个操作引入了问题。
Qt Labs提供了一种叫做ModelTest的软件,它可以在程序运行时检查模型。每次更改模型时,ModelTest都会扫描模型并使用assert报告错误。这对于树模型尤其重要,因为它们的层次性为细微的不一致留下了许多可能性。
与视图类不同,ModelTest使用范围外的索引来测试模型。这意味着您的应用程序在使用ModelTest时可能会崩溃,即使它在没有ModelTest的情况下可以完美地运行。因此,在使用ModelTest时,您还需要处理超出范围的所有索引。
4. 额外信息的良好来源
4.1 Books
模型/视图编程在Qt的文档和一些好书中都有相当广泛的介绍。
《C++ GUI Programming with Qt 4》 / Jasmin Blanchette, Mark Summerfield, Prentice Hall,第二版,ISBN 0-13-235416-0。
《The Book of Qt4》,构建Qt应用程序的艺术/ Daniel Molkentin,开放源码出版社,ISBN 1-59327-147-6。翻译自Qt 4, Einfuhrung in die Applikationsentwicklung,开源出版社,ISBN 3-937514-12-0。
《Foundations of Qt Development 》/ Johan Thelin, Apress, ISBN 1-59059-831-8。
《Advanced Qt Programming》/ Mark Summerfield, Prentice Hall, ISBN 0-321-63590-6。这本书超过150页涵盖了模型/视图编程。
4.2 Qt Documentation
Qt 5.0提供了19个模型/视图示例。示例可以在项目视图示例页面上找到。