模型/视图 教程


每一个UI开发者都应该了解Model/View编程,这篇教程的目标就是对这个主题提供一个容易理解的介绍。

Table, list and tree 窗口部件都是在图形用户界面中常用的组件。这些窗口部件能够通过两种不同的方式访问他们的数据。传统方式是通过窗口部件的内部容器来存储数据。这种方法很直观,然而在一些大型应用中,通常会引起数据同步问题。第二张方法是Model/View编程,用这种方法窗口部件不需要维护内部的数据容器。他们通过一个标准化的接口访问外部数据,因此避免了数据复制。这种方法刚开始看起来可能有些复杂,但是一旦你进一步了解之后,他不仅仅容易掌握,而且会很清晰的发现Model/View编程的很多好处。

treeview.png

在这个过程中,我们将学到一些QT提供的基本技术,例如:

标准窗口部件与Model/View窗口部件的差异性。
表格与视图之间的适配器
预定义的视图
中级主题例如:

Tree 视图
Selection
代理(Delegates)
模型测验的调试

你也会学到如果的新应用程序通过标准窗口组件可以很好工作的时候,换成Model/View编程会不会更容易编写。

这边教程提供了一些例子代码供你编辑并且整合到你自己的项目中。源代码路径:examples/widgets/tutorials/modelview directory。

想要了解更详细的信息,请访问reference documentation

1.简介

模型/视图是在处理数据集合的窗口组件中用来把数据从视图中分离出来的一种技术。标准的窗口组件并没有被设计成数据/视图分离,所以QT4有两种不同类型的窗口组件。这两种类型看起来一样,但是他们与数据交互的方式不同。

标准组件操作的数据是组件的组成部分standardwidget
视图类操作外部数据(模型)view

1.1标准窗口组件

让我们仔细看一个标准的表格部件。一个表格组件是一个用户可以修改的数据元素的二维数组。表格组件能够通过读写表格组件提供的数据元素来集成到程序中。这种方法在大多数应用中都很直观而且很有用,但是当显示和编辑数据库的时候标准组件可能就有问题了。数据的两个副本必须协调:一个在组件外部,一个在组件内部。同步这两个副本的数据是开发者的职责。除了这个,显示和数据的紧密结合使得写单元测试变得更难。

1.2模型/视图

模型/视图使用一个更通用的架构提供了一个解决方案。模型/视图消除了标准组件可能导致的数据一致性问题。模型/视图很容易使用相同数据的多个视图,因为一个模型更够被传递给多个视图。最重要的差异是模型/视图组件并不在表格单元格内存储数据。实际上,他们直接操作你的数据。由于视图类并不你的数据的结构,所有你需要提供一个包装器遵循QAbstractItemModel接口。视图利用这些接口对你的数据中进行读取或者写入。实现了QAbstractItemModel的任何类的实例就是一个模型。一旦视图接收到一个视图的指针,他就会读取并且显示模型的内容然后成为它的编辑器。

1.3模型/视图组件概述

这是一个模型/视图组件与之对应的标准组件的概述。

窗口组件标准窗口组件模型/视图
list_viewQListWidgetQListView
table_viewQTableWidgetQTableView
tree_viewQTreeWidgetQTreeView
column_viewQColumnView
comboxQComboBox既能作为标准组件也能作为视图类来工作

1.4在表单和模型之间使用适配器

在表单和模型之间存在适配器迟早都是有用的。

虽然我们可以通过表格自身直接修改表格内的数据,但是在通过表单的文本域来修改数据看起来更加友好。
没有直接的模型/视图对应物来对操作单个数据而不是数据集合的组件进行数据/视图分离。((QLineEdit, QCheckBox …),因此我们需要一个适配器把表单和数据源进行连接。

QDataWidgetMapper是一个非常好的解决方案,因为它把表单组件与表格映射,并且很容易构建数据库表格的表单。

widget_mapper

适配器的另一个例子是QCompleter。 QCompleter在Qt窗口组件中提供自动补全,例如QComboBox 和在下边展示的QLineEdit。 QCompleter使用一个模型作为它的数据源。

qcompleter

2一个简单的模型/视图应用程序

如果你想要开发一个模型/视图应用程序,你应该从哪开始呢?我们建议用一个简单的例子来开始并一步一步的扩展它。这样会非常容易理解模型/视图的架构。据证明在使用IDE之前就尝试理解模型/视图架构对很多程序员来说都是不太实用的。实际上,以一个拥有演示数据简单的模型/试图例子来开始相对更容易。简单的用你自己的数据替换例子中的数据。

下面7个简单但是独立的应用程序展示了模型/视图变成的不同方面。源代码可以在路径:examples/widgets/tutorials/modelview找到。

2.1一个只读表格

我们一个使用 QTableView来显示数据的应用程序来开始。我们之后会添加编辑功能。

// main.cpp
#include <QtWidgets/QApplication>
#include <QtWidgets/QTableView>
#include "mymodel.h"

int main(int argc, char *argv[])
{
    QApplication a(argc, argv);
    QTableView tableView;
    MyModel myModel(0);
    tableView.setModel( &myModel );
    tableView.show();
    return a.exec();
}

我们有main()函数。
有趣的部分是:我们创建一个MyModel实例并且用 tableView.setModel(&myModel);传递一个它的指针给tableview。taleview会通过调用它接收到的指针的两个方法来找出两个要素。

应该显示多少行和列
显示在每个单元格中的数据是什么

模型需要一些代码来反馈这些要素。
我们有一个表格数据集合,因此我们以 QAbstractTableModel开始,因为它比更通用的QAbstractItemModel更容易使用。

// mymodel.h
#include <QAbstractTableModel>

class MyModel : public QAbstractTableModel
{
    Q_OBJECT
public:
    MyModel(QObject *parent);
    int rowCount(const QModelIndex &parent = QModelIndex()) const Q_DECL_OVERRIDE ;
    int columnCount(const QModelIndex &parent = QModelIndex()) const Q_DECL_OVERRIDE;
    QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const Q_DECL_OVERRIDE;
};

QAbstractTableModel要求实现三个虚函数。

// mymodel.cpp
#include "mymodel.h"

MyModel::MyModel(QObject *parent)
    :QAbstractTableModel(parent)
{
}

int MyModel::rowCount(const QModelIndex & /*parent*/) const
{
   return 2;
}

int MyModel::columnCount(const QModelIndex & /*parent*/) const
{
    return 3;
}

QVariant MyModel::data(const QModelIndex &index, int role) const
{
    if (role == Qt::DisplayRole)
    {
       return QString("Row%1, Column%2")
                   .arg(index.row() + 1)
                   .arg(index.column() +1);
    }
    return QVariant();
}

MyModel::rowCount() 和 MyModel::columnCount()提供了行数和列数。当视图需要知道单元格的内容时,就会调用 MyModel::data()方法。参数index表明了行和列信息,role设置为Qt::DisplayRole。其他的rules会出现在下一节中。在我们例子中,被显示的数据是直接构造出来的。在一个真正的应用程序中,MyModel应该有一个叫做MyData的成员函数,这个成员函数作为读写操作的目标。

这个小例子证明了模型的被动特性。模型并不知到什么时候使用和哪些数据被需要。它每次都是当视图请求的时候简单的提供数据。
当需要改变模型数据的时候会发生什么呢?视图如何得知数据改变了呢,需要再一次做读操作吗?模型会释放一个信号表面哪些单元格改变了。在2.3节会进行证明。

2.1通过Roles来扩展只读的例子

除了控制视图显示什么文本,模型也控制文本的样式。当我们轻微改变模型时,我们得到了下面这样的结果:

readonlytable_role

事实上,除了data()方法需要被改变用来设置字体,背景颜色,对齐方式和一个checkbox外,其他都不需要改变。下面代码是产生上述结果的data()方法。与之前不同的是这次我们依赖int role参数的不同来返回不同的信息。

// 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);
        break;
    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)
        {
            QBrush redBackground(Qt::red);
            return redBackground;
        }
        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;
        }
    }
    return QVariant();
}

模型调用data()方法时,每一个格式化属性都会被请求。role参数的作用就是让模型知道哪一个属性被请求了。

enum Qt::ItemDataRole描述类型
Qt::DisplayRole文本QString
Qt::FontRole字体QFont
BackgroundRole单元格背景刷QBrush
Qt::TextAlignmentRole文本对齐enum Qt::AlignmentFlag
Qt::CheckStateRole通过返回QVariant()添加checkboxes,设置 checkboxes 为 Qt::Checked或 Qt::Uncheckedenum Qt::ItemDataRole

通过QT命名空间文档来了解更多关于 Qt::ItemDataRole枚举的信息。

现在我们需要判断怎么用一个分离的模型对象来影响应用程序的执行,因此让我们来追踪视图多久调用一次data()方法。为了追踪视图多久调用一个模型,我们放一些对错误进行日志输出的测试语句在data()方法中。在我们的小例子中,data()会被调用42次。每一个你在字段上悬停光标时,data()都会被每一个单元格调用7次。这就说明了当data()被调用和昂贵的检查操作被高速缓存时,要确保数据为可用的为什么是如此重要的。

2.3一个时钟显示在表格的单元格内

clock

我们依然采用一个只读表格,但是这一次表格内容每秒改变一次,因为我们显示的是当前时间。

QVariant MyModel::data(const QModelIndex &index, int role) const
{
    int row = index.row();
    int col = index.column();

    if (role == Qt::DisplayRole)
    {
        if (row == 0 && col == 0)
        {
            return QTime::currentTime().toString();
        }
    }
    return QVariant();
}

让时钟滴答的条件被忽略了。我们需要告诉视图时间每秒改变一次,并且需要再次被读取一次。我们通过定时器来实现。在构造函数中,我们设置时间间隔问1秒,并且连接它的超时信号。

MyModel::MyModel(QObject *parent)
    :QAbstractTableModel(parent)
{
//    selectedCell = 0;
    timer = new QTimer(this);
    timer->setInterval(1000);
    connect(timer, SIGNAL(timeout()) , this, SLOT(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);
}

我们通过发射一个dataChanged()信号来请求视图再一次从左上角的单元格中读取数据。注意我们并没有明确的把dataChanged()信号与视图连接起来。当我们调用setModel()时,这会自动发生的。

2.4为行和列设置表头

表头可以通过一个视图类的方法tableView->verticalHeader()->hide();实现隐藏。

header

表头内容通过模型来设置,因此我们需要重新实现headerData()方法。

QVariant MyModel::headerData(int section, Qt::Orientation orientation, int role) const
{
    if (role == Qt::DisplayRole)
    {
        if (orientation == Qt::Horizontal) {
            switch (section)
            {
            case 0:
                return QString("first");
            case 1:
                return QString("second");
            case 2:
                return QString("third");
            }
        }
    }
    return QVariant();
}

注意headerData()方法有一个与MyModel::data()中含义一样的int 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);
    int rowCount(const QModelIndex &parent = QModelIndex()) const Q_DECL_OVERRIDE ;
    int columnCount(const QModelIndex &parent = QModelIndex()) const Q_DECL_OVERRIDE;
    QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const Q_DECL_OVERRIDE;
    bool setData(const QModelIndex & index, const QVariant & value, int role = Qt::EditRole) Q_DECL_OVERRIDE;
    Qt::ItemFlags flags(const QModelIndex & index) const Q_DECL_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)
    {
        //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;
}

每当用户修改单元格时,setData()都会被调用。index参数告诉我们哪一个字段被修改,value参数则提供了修改的结果。因为我们的单元格只包含了文本字段,所以role参数将总是被设置为 Qt::EditRole。如果包含一个复选框,并且用户权限被设置为允许复选框被选中,setData()将被调用伴随着role参数为Qt::CheckStateRole。

Qt::ItemFlags MyModel::flags(const QModelIndex &index) const
{
    return Qt::ItemIsEditable | QAbstractTableModel::flags(index);
}

单元格的属性可以通过flags()函数来设置。
显示一个单元格可被选中的编辑器,设置 Qt::ItemIsSelectable | Qt::ItemIsEditable | Qt::ItemIsEnabled已经足够了。

If editing one cell modifies more data than the data in that particular cell, the model must emit a dataChanged() signal in order for the data that has been changed to be read.

3.中级主题

3.1树状视图

你可以把上面的例子转变为一个使用QTreeView的应用程序。简单的把QTableView替换为QTreeView,就会产生一个包含读写操作的树状视图。模型对象不需要做任何改动。由于模型本身没有任何层级,所以树状视图也没有任何层级。

dummy_tree

QListView, QTableView 和 QTreeView都使用了一个抽象模型,这个抽象模型是列表,表格和树的融合。这使得通过相同的模型构建不同类型的视图称为可能。

list_table_tree

下图是我们之前例子中的模型样子:

example_model

我们想要展示一个真实的树结构。在上述的例子中我们通过包装数据来构造模型。这一次我们使用QStandardItemModel,它是一个层级数据容器,它也是QAbstractItemModel的一个实现。为了显示一个树形,QStandardItemModel必须用 QStandardItems来填充。QStandardItems拥有所有部件的标准属性比如文本,字体,复选框和背景刷。

tree_2_with_algorithm

// modelview.cpp
#include <QTreeView>
#include <QStandardItemModel>
#include <QStandardItem>
#include "mainwindow.h"

const int ROWS = 2;
const int COLUMNS = 3;

MainWindow::MainWindow(QWidget *parent)
    : QMainWindow(parent)
{
    treeView = new QTreeView(this);
    setCentralWidget(treeView);
    standardModel = new QStandardItemModel ;

    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();
}

QList<QStandardItem *> MainWindow::prepareRow(const QString &first,
                                                const QString &second,
                                                const QString &third)
{
    QList<QStandardItem *> rowItems;
    rowItems << new QStandardItem(first);
    rowItems << new QStandardItem(second);
    rowItems << new QStandardItem(third);
    return rowItems;
}

我们简单的实例化了一个 QStandardItemModel并且在构造器里添加了一组QStandardItems。我们可以构造一个层级数据结构,因为QStandardItem可以拥有其他的QStandardItems。在视图中的节点可以被拆散或扩展。

3.2选择功能

我们想读取一个已选中条款的内容,与层级对应的值一起输出到窗口标题栏上。

selection2

让我们创建几个条款:

#include <QTreeView>
#include <QStandardItemModel>
#include <QItemSelectionModel>
#include "mainwindow.h"

MainWindow::MainWindow(QWidget *parent)
    : QMainWindow(parent)
{
    treeView = new QTreeView(this);
    setCentralWidget(treeView);
    standardModel = new QStandardItemModel ;
    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, SIGNAL(selectionChanged (const QItemSelection &, const QItemSelection &)),
            this, SLOT(selectionChangedSlot(const QItemSelection &, const QItemSelection &)));
}

利用视图来管理一个选择模型中的所有选择。当前的选择模型可以通过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()来获取当前选中条款的模型索引,然后我们通过模型索引来获取字段的文本。然后我们计算选中条款的层级等级。顶级的条款没有父条款,并且parent()方法返回一个默认构造 QModelIndex()。这就是我们为什么使用parent()方法来迭代直到顶级,与此同时计算迭代中的步数。

上述展示的选择模型不仅可以被获取到,也可以通过QAbstractItemView::setSelectionModel来设置。由于只有一个选择模型的实例被使用,所以这也就解释了为什么不同的视图类可以同步选择(selections)。为了在三个不同的视图类中共享选择模型,需要调用selectionModel()获取选择模型,然后把这个选择模型通过setSelectionModel()赋值给另外两个视图类。

3.3预定义的模型

使用模型/视图的典型方式就是包装特有数据,使之在视图类中可用。然而,QT也提供了一些常见的预定义模型。如果提供的这些数据结构中有适合你的项目的,预定义模型就是一个好的选择。

QStringListModelStores a list of strings
QStandardItemModelStores arbitrary hierarchical items
QFileSystemModel QDirModelEncapsulate the local file system
QSqlQueryModelEncapsulate an SQL result set
QSqlTableModelEncapsulates an SQL table
QSqlRelationalTableModelEncapsulates an SQL table with foreign keys
QSortFilterProxyModelSorts and/or filters another model

3.4委托

在上述所有的例子中,单元格中的数据都是以文本或者复选框的形式显示和编辑的。提供这些展示和编辑服务的组件就叫做委托。我们现在才开始讲到委托,是因为视图通常使用的是默认委托。但是想象一下,我们想拥有一个不同的编辑器(例如:滑动条,或者下拉列表)或者我们想以图形的方式展示数据。让我们看一个叫做Star Delegate的例子。在这个例子中,星号被用来标识等级。

startdelegate

视图有一个setItemDelegate()方法,替换默认的委托,然后安装用户自定义的委托。一个新的委托可以通过创建一个继承于 QStyledItemDelegate的类来实现。为了实现一个显示星号并且没有编辑功能的委托,我们只需要重载两个函数即可。

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() 方法被用来获取每颗星的大小,因此单元格需要提供足够的高度和宽度来适应星号。

如果你想要在视图类的网格中以自定义的图形展示你的数据时,编写自定义的委托是正确的选择。如果你想忽略这些网格,你不需要使用自定义的委托而需要的是自定义的视图类。

3.5用ModelTest来调试

模型的被动特性给程序员带来了新的挑战。模型中的不一致性可能会导致应用程序崩溃。因为模型被大量的视图类的函数调用触发,所以很难找到哪一个函数调用导致程序崩溃,以及哪一个操作造成了这个问题。

QT labs提供了叫做ModelTest的软件,当你的程序在运行时,这个软件会检查模型。每一次模型改变的时候,ModelTest都会扫描这个模型并且通过assert来报告错误。对于树形视图尤其重要,因为它们的层级特性遗留了很多不一致性的问题产生的可能性。

不像视图类一样,ModelTest使用有效范围之外的索引值来测试模型。这就意味着你的应用程序在不用ModelTest时能够完美运行,但是在用ModelTest时可能会导致崩溃。因此当你用ModelTest时你需要处理所有有效范围之外的索引值。

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐