本篇文章让你能够在阅读完之后,掌握Qt的模型视图框架的大致使用方法。
问题引入
在我们开发较小的软件的时候,我们可能不会注意到模型视图框架的作用。
因为我们的同一份的数据可能只会在同一个窗口中显示,不会存在数据在一个窗口中更新,然后另一个窗口没更新的情况。可能也存在两个窗口之间同一份数据的统一显示问题,由于比较简单,我们可能就通过自定义信号和槽来通知这种更新的方式来同步了两个窗口的数据。
但是,一旦软件的功能变得复杂起来,你可能就会遇到这样的问题:同一份数据,可能需要在软件中7,8个地方以不同的方式呈现给用户,并且还会给不同的视图配备不同的操作手段。这时候,我们就不可能在某个视图发生变化的时候,手动通知其他视图相应地发生变化,那太麻烦了,每多一个视图,原来的视图就需要通知这个新的视图,这违背了设计模式中的“开闭原则”。
Model-View-Controller架构就能够很好地处理这个问题。
View本身不应该包含业务逻辑,Model需要有对数据进行CRUD的能力,而Controller接收用户的输入,调用模型处理数据,然后选择视图来显示结果。
Qt中参考了这个MVC架构,搞出来一个Model/View + Delegate的模型视图架构。这里Delegate能够对单个数据记录的编辑和显示做出定义,它可以让用户直观地使用简单Widget来修改某部分数据,并且控制单份数据在View中的显示方式。
你可能会疑惑为什么Controller没有了?我猜测这是因为Qt中的信号与槽机制取代了一部分Controller功能的缘故。
若我们想要使用Qt开发数据交互复杂的软件,那么你绝对需要使用到模型视图框架。在本篇文章中,我想要讨论以下问题:
-
如何为自己所开发软件的业务数据定义Model。
-
如何通过自定义Delegate来自定义数据Item的绘制方式。
-
View和Delegate提供的编辑器无法对复杂的数据Item进行编辑操作,我们应该如何解决这个问题。
自定义Model
使用Model的方法
Qt中有实现几个Model,但都不太可能直接运用到自己写的软件中。这里我举一个使用Model的例子,来看看Model是如何使用的:
int main(int argc, char *argv[])
{
QApplication a(argc, argv);
QSplitter* splitter = new QSplitter(Qt::Horizontal);
QFileSystemModel* model = new QFileSystemModel;
model->setRootPath(QDir::currentPath());
QTreeView* tree = new QTreeView(splitter);
tree->setModel(model);
tree->setRootIndex(model->index(QDir::currentPath()));
QListView* list = new QListView(splitter);
list->setModel(model);
list->setRootIndex(model->index(QDir::currentPath()));
splitter->show();
return a.exec();
}
效果如下:
这是Qt文档所给的例子,我们以Tree和List的视图来展示同一份数据。如何显示是后面需要关心的事情,如何实现自己的Model,将其交给视图进行呈现?
我们观察上面例子可以发现,视图设置Model的方法为:
tree->setModel(model);
list->setModel(model);
这里的setModel()
接收一个QAbstractItemModel*
。我们可以通过子类化QAbstractItemModel
来实现自定义的Model。
但通常来说,我们不会直接子类化QAbstractItemModel
,如果我们的Model中的数据是列表,那么可以子类化QAbstractListModel
;如果Model中的数据是Table,那么可以子类化QAbstractTableModel
会更加方便(指重写的工作量更小)。
若Model中的数据是树形的,那么实现可能比较复杂,首先你需要将你的数据(比如一个结构体),用QStandardItem
封装起来,因为单纯的数据可能并不存在树形结构,而QStandardItem
让你重写的方法实际上就提供了这样的树形结构;接着子类化QStandardItemModel
。
简单数据的自定义Model
这里我以一个简单的例子来说明如何自定义Model:
// StringListModel.h
class StringListModel : public QAbstractListModel
{
Q_OBJECT
public:
StringListModel(const QStringList& list, QObject* parent = nullptr)
: QAbstractListModel(parent)
, m_list(list)
{
}
int rowCount(const QModelIndex &parent = QModelIndex()) const
{
Q_UNUSED(parent); // 树形结构才会使用到parent
return m_list.size();
}
QVariant data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() >= rowCount() || index.row() < 0)
return {};
switch(role) {
case Qt::DisplayRole: { return m_list[index.row()]; }
default: {return {};}
}
return {};
}
private:
QStringList m_list;
};
// main.cpp
int main(int argc, char *argv[])
{
QApplication a(argc, argv);
QSplitter* splitter = new QSplitter(Qt::Horizontal);
QStringList str_list;
str_list << "One" << "Two" << "Three" << "Four";
StringListModel* model = new StringListModel(str_list);
QTreeView* tree = new QTreeView(splitter);
tree->setModel(model);
QListView* list = new QListView(splitter);
list->setModel(model);
splitter->show();
return a.exec();
}
需要重写的方法只有两个rowCount()
和data()
。将其接入View中,显示效果如下:
这里解释一下data()
的role
参数的作用:role
实际上就是为了避免定义太多重复的接口而定义的。为什么这么说呢?比如,View
可能需要使用到model->data()
来获取一个叫DisplayText()
的数据,它可能需要调用model->data_DisplayText()
来获取。以此类推还有EditText()
,ToolTipText()
...将近十多个接口,这样实现Model的人就会很头疼。于是他们抽象出来一个Qt::ItemDataRole
,需要什么数据就自己往data(role)
中传role来获取。Qt在文档中定义了标准的Qt::ItemDataRole
所需要返回的数据。
这里我们再返回一个Role数据,你会发现视图相应地会在某些场合下显示这些数据,比如Qt::ToolTipRole
会在鼠标长久停留在某个数据Item不动时显示:
switch(role) {
case Qt::DisplayRole: { return m_list[index.row()]; }
case Qt::ToolTipRole: { return tr("ToolTip"); }
default: {return {};}
}
复杂数据类型的自定义Model
只有Qt::ItemDataRole
中定义的Role会被View所使用。Qt::ItemDataRole
告诉View需要什么类型的数据,可以利用Qt::ItemDataRole
中的某个值到Model的data方法中取。但实际上它并没有解决一个问题:“开发者如何从data()
方法中取数据呢?”。我们假设Model中的数据变得复杂起来,它变成了:
struct Record
{
int id,
QString name
};
或许我们可以使用Qt::DisplayRole
获取到name
,但是Qt::ItemDataRole
实际上并没有规定可以返回int
的Role。而我们也不能随意修改Qt::ItemDataRole
应该返回的数据的类型,比如Qt::DisplayRole
本应返回QString
类型的数据,但你修改成了int
那么视图显示就会出现问题。
那么这种情况下,我们应该怎么办呢?Qt::ItemDataRole
中有一个枚举非常有趣:
-
Qt::UserRole = 0x100
,应用程序以特定目的使用的使用的第一个Role。
这里面包含两层信息,Qt::UserRole
我们可以随意定义其返回值的类型;“第一个”表示它后面的所有值我们都可以进行自定义,这也就是data()
中的role是int类型而非Qt::ItemDataRole
的原因,你可以自定义一个枚举来从data()
中获取Record中的值:
enum RecordDataRole
{
Id = 0x101,
Name,
}
下面我们完成这样一个程序:点击某个数据Item的时候,使用QMessageBox显示该Record的id和name:
// RecordListModel.h
struct Record
{
enum RecordDataRole
{
Id = 0x101,
Name,
};
int id;
QString name;
// 用来在Model中获取成员数据
QVariant data(int role) const
{
switch (role) {
case Id: { return id; }
case Name: { return name; }
default: { return {}; }
}
return {};
}
};
class RecordListModel : public QAbstractListModel
{
Q_OBJECT
public:
RecordListModel(const QVector<Record>& recordList, QObject *parent = nullptr)
: QAbstractListModel{parent}
, m_record_list(recordList)
{
}
int rowCount(const QModelIndex &parent = QModelIndex()) const
{
Q_UNUSED(parent);
return m_record_list.size();
}
QVariant data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() >= rowCount() || index.row() < 0)
return {};
switch (role) {
case Qt::DisplayRole: { return m_record_list[index.row()].name; }
default: { return m_record_list[index.row()].data(role); }
}
return {};
}
private:
QVector<Record> m_record_list;
};
// main.cpp
int main(int argc, char *argv[])
{
QApplication a(argc, argv);
QSplitter* splitter = new QSplitter(Qt::Horizontal);
QVector<Record> record_list;
record_list.push_back({.id = 1, .name = "Student1 say: Hello World!"});
record_list.push_back({.id = 2, .name = "Student2 say: Hello Qt!"});
record_list.push_back({.id = 3, .name = "Student3 say: Hello C++!"});
record_list.push_back({.id = 4, .name = "Student4 say: Hello MVC!"});
RecordListModel* model = new RecordListModel(record_list);
QListView* list = new QListView(splitter);
list->setModel(model);
auto show_record = [list](const QModelIndex& index) {
// 这里是关键,使用index来访问model中对应的数据。
int id = index.data(Record::Id).toInt();
QString name = index.data(Record::Name).toString();
QMessageBox::information(list, "Record", QString("id=%1,name=%2").arg(id).arg(name));
};
QObject::connect(list, &QListView::doubleClicked, list, show_record);
splitter->show();
return a.exec();
}
在双击某个数据Item之后,界面的效果如下:
QModelIndex
你可以发现,我们在访问Model内部数据时并没有使用到model
,而是直接使用了index.data()
。之前并没有介绍QModelIndex
这个东西,这里遇到了就讲一下。你可能会在编写树形Model才需要真正了解其中的一些含义:
QModelIndex中有三个关键的变量:row
,column
和internalPointer
。
-
我们知道的是QModelIndex指向一个数据,而
internalPointer
就是指向这个数据的变量。但若是仅仅指向数据,那是不足以形成树形结构的。这些数据中肯定还包含了一些变量,比如指向parent的指针和所有子节点的指针。
由图来表示可能比较易于理解:
注意,普通的QModelIndex是有可能因为Model发生改变而失效的。比如我们可能往列表头插入新数据,那么原来的index的row就不对了,但是该QModelIndex又还能用。因此QModelIndex最好是拿到即用,而不要用变量保存下来下次再用,因为你不知道下次使用该index是否还是指向原来的数据。
编辑Model中的数据
如果我想要在某个View中编辑Model中的数据,然后将这个编辑反馈到引用该Model的所有视图中。Qt的模型视图框架如何做到这一点?每当修改完成,将修改的范围通过dataChanged()
信号发送给所有View,View依据这个范围看看自己需不需要进行更新。原理就那么简单。
我们不能够通过index.model()->setData()
的方式来修改数据,因为index.model()
是const方法,不能够调用model->setData()
这个非const方法。
因此,想要修改Model,你必须拿到Model。这里我们完成一个小程序,每当我们双击一次Item,Record.id
就加1,然后更新视图:
初始的效果是这样,对某个Item双击之后,就会更新Model中的Id,最终发送dataChanged()
使得所有视图做出反馈:
最终的效果就是:
看起来有点糊,但是具体的功能确实是达成了。你可以使用以下代码在自己的机器上运行复现:
// RecordListModel.h
struct Record
{
enum RecordDataRole
{
Id = 0x101,
Name,
};
int id;
QString name;
QVariant data(int role) const
{
switch (role) {
case Id: { return id; }
case Name: { return name; }
default: { return {}; }
}
return {};
}
bool setData(const QVariant& value, int role)
{
switch (role) {
case Id:{
int old_id = id;
int new_id = value.toInt();
id = new_id; // 这里一般而言会成功,但不成功的话我们依旧是需要回滚数据
if (id == new_id) {
return true;
}
else {
id = old_id;
return false;
}
}
} // end of switch
return false;
}
};
class RecordListModel : public QAbstractListModel
{
Q_OBJECT
public:
RecordListModel(const QVector<Record>& recordList, QObject *parent = nullptr)
: QAbstractListModel{parent}
, m_record_list(recordList)
{
}
int rowCount(const QModelIndex &parent = QModelIndex()) const
{
Q_UNUSED(parent);
return m_record_list.size();
}
QVariant data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() >= rowCount() || index.row() < 0)
return {};
switch (role) {
case Qt::DisplayRole: { return QString("%1%2").arg(m_record_list[index.row()].name).arg(m_record_list[index.row()].id); }
default: { return m_record_list[index.row()].data(role); }
}
return {};
}
bool setData(const QModelIndex &index, const QVariant &value, int role)
{
if (!index.isValid() || index.row() >= rowCount() || index.row() < 0)
return false;
switch(role) {
default: {
bool ret = m_record_list[index.row()].setData(value, role);
// 成功修改发送该信号能让其余视图更新
// 修改失败时发送该消息能够让做出修改的视图回滚
emit dataChanged(index, index);
return ret;
}
} // end of switch
return false;
}
private:
QVector<Record> m_record_list;
};
// main.cpp
int main(int argc, char *argv[])
{
QApplication a(argc, argv);
QSplitter* splitter = new QSplitter(Qt::Horizontal);
QVector<Record> record_list;
record_list.push_back({.id = 1, .name = "Student"});
record_list.push_back({.id = 2, .name = "Employee"});
record_list.push_back({.id = 3, .name = "Worker"});
record_list.push_back({.id = 4, .name = "Leader"});
RecordListModel* model = new RecordListModel(record_list);
QListView* list1 = new QListView(splitter);
list1->setModel(model);
QListView* list2 = new QListView(splitter);
list2->setModel(model);
QListView* list3 = new QListView(splitter);
list3->setModel(model);
auto show_record = [=](const QModelIndex& index) {
// 每当双击Item,我们就将其id加上某个值,这里设置成+1
int old_id = index.data(Record::Id).toInt();
model->setData(index, old_id + 1, Record::Id);
};
QObject::connect(list1, &QListView::doubleClicked, show_record);
QObject::connect(list2, &QListView::doubleClicked, show_record);
QObject::connect(list3, &QListView::doubleClicked, show_record);
splitter->show();
return a.exec();
}
接收用户的输入编辑Model
不难发现,上面的编辑逻辑是写死在代码中的,用户无法决定一个Item的ID是多少,只能是按照双击ID+1的方式来进行编辑。
那用户有没有办法双击之后,自己来决定ID的值是多少呢?这就需要使用到Delegate了。准确来说是使用Delegate来获取编辑器(实际上就是一个Widget)。
请注意,如果你想要某个数据Item可以在Delegate中获取编辑器,你需要重写QAbstractItemModel::flags()
,让该数据Item具有编辑Flag:
Qt::ItemFlags flags(const QModelIndex &index) const
{
if (!index.isValid())
return Qt::NoItemFlags;
return QAbstractListModel::flags(index) | Qt::ItemIsEditable;
}
由于默认的Delegate能修改的Role是Qt::ItemDataRole
的数据,自然不可能改到我们自定义的Record::Id
,所以提供自定义的Delegate很有必要。或者说,只要你使用自定义的Model,那就需要做好自定义Delegate的准备。
自定义Delegate的编辑器
按照以下方式子类化QStyledItemDelegate
:
// RecordDelegate.h
class RecordDelegate : public QStyledItemDelegate
{
Q_OBJECT
public:
RecordDelegate(QObject* parent = nullptr)
: QStyledItemDelegate{parent}
{
}
QWidget* createEditor(QWidget *parent,
const QStyleOptionViewItem &option,
const QModelIndex &index) const override
{
Q_UNUSED(option);
Q_UNUSED(index);
// 构造editor
// 这里需要编辑ID,就使用QSpinBox
QSpinBox* editor = new QSpinBox(parent);
editor->setFrame(false);
editor->setMinimum(1);
editor->setMaximum(INT_MAX);
return editor;
}
void setEditorData(QWidget *editor,
const QModelIndex &index) const override
{
// 需要向editor中填初始数据
QSpinBox* spin_box = dynamic_cast<QSpinBox*>(editor);
if (spin_box)
{
spin_box->setValue(index.data(Record::Id).toInt());
}
}
void updateEditorGeometry(QWidget *editor,
const QStyleOptionViewItem &option,
const QModelIndex &index) const override
{
Q_UNUSED(index);
// 让editor出现在正确的位置
editor->setGeometry(option.rect);
}
void setModelData(QWidget *editor,
QAbstractItemModel *model,
const QModelIndex &index) const
{
// 编辑完成之后向Model写入数据
QSpinBox* spin_box = dynamic_cast<QSpinBox*>(editor);
if (!spin_box)
return;
int new_id = spin_box->value();
model->setData(index, new_id, Record::Id);
}
};
然后,我们在main中这样使用它:
// main.cpp
int main(int argc, char* argv[])
{
// 在上一节的基础上加上
...
RecordDelegate* delegate = new RecordDelegate;
list1->setItemDelegate(delegate);
list2->setItemDelegate(delegate);
list3->setItemDelegate(delegate);
splitter.show();
return a.exec();
}
并删除图中这段内容:
双击View中的一个Item之后,你就能够以下图的方式编辑ID:
有时候我们使用视图显示数据,是为了方便对该数据进行编辑操作。因此,你可能想要这样使用视图:
🤓👆,为单个Item设置一个按键然后我们一按这个按键就可以让Model做出反馈。想法很美好,但是现实却是Item的绘制是发生在Delegate的paint()
方法中的,我们只能够在这个paint()中自己绘制按键和数据,然后自己检测用户按下的位置来达成按钮的功能。这意味着如果你想实现上面那种在view的item中嵌入多个按键的效果,你需要自己定义按键的绘制逻辑和事件处理逻辑,这会非常耗费精力。
我这里可以提供一种替代方案,最终的功能相近,但用户体验可能不好,却胜在实现简单且可以重复利用视图。
在大多数情况下Model中的Item数据都是一致的,就可以将这些按钮放在同一个widget中,若某个Item被选中,我们就可以将其信息投射到右边的widget中显示出来,并启动其中的按钮,让用户可以点击按钮操作数据。有的时候editor界面实在没办法在View给的有限空间中绘制也可以使用这样的方法。
这里我完成了一个示例:
代码示例我直接上传到Gitee中了,感兴趣的可以去自行查看:项目首页 - Qt模型视图架构使用示例 - GitCode