本文是《用 Qt 实现电子白板》的其中一节,建议全章阅读。
在电子白板中,一个页面(ResourcePage)包含多个资源(比如图片、文字、视频等),资源有先后顺序,决定其对于控件的展示层级关系。
我们希望对资源的管理是数据驱动的,业务层只能操作数据,也就是只能调用 ResourcePage 的方法,至于控件的创建、销毁、层级改变都是内部自动管理的。
在 Qt 中,实现数据驱动界面,有一个工具,即 QAbstractItemModel 。虽然界面管理也是要自己实现,完全可以自定义数据驱动模型,但是使用 QAbstractItemModel 可以方便地与 Qt 其他组件对接,所以我们还是决定数据、界面都对接到这个工具上。
数据模型
QAbstractItemModel 是一个相当复杂的类,它里面既支持普通的一维(List)数据,也支持描述二维(Grid)数据,还能描述树形(Tree)数据。在最复杂的场景下,它是一个以 Tree 为基础,每个 Tree 节点又是一个 Row 的数据模型。
在我们的场景中 (ResourcePage),只需要作为最简单的 List 使用,所以 Tree 只有一个根节点,根节点的 Grid 只有一列数据。
class ResourcePage : public QAbstractItemModel
{
Q_OBJECT
private:
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
int columnCount(const QModelIndex &parent = QModelIndex()) const override;
QModelIndex index(int row, int column, const QModelIndex &parent = QModelIndex()) const override;
QModelIndex parent(const QModelIndex &child) const override;
};
实现 QAbstractItemModel,最主要的是需要实现下面这些方法:
- rowCount,columnCount 方法
这个比较简单,rowCount 返回资源的个数,columnCount 返回固定值 1,参数 parent 表示所属的树节点,显然在这里没有作用。
int ResourcePage::rowCount(const QModelIndex &parent) const
{
(void) parent;
return resources_.size();
}
int ResourcePage::columnCount(const QModelIndex &parent) const
{
(void) parent;
return 1;
}
- parent 方法
方法 parent 返回一个节点的父节点,是因为只有一个虚拟 Tree 根节点,所以返回一个无效 QModelIndex。
QModelIndex ResourcePage::parent(const QModelIndex &child) const
{
(void) child;
return QModelIndex();
}
- index 方法
方法 index 用于构造一个 QModelIndex 对象,用来唯一表示一个节点,并且与实际的数据关联。后续很多操作可以直接在该 QModelIndex 对象上进行。
QModelIndex ResourcePage::index(int row, int column, const QModelIndex &parent) const
{
(void) parent;
return createIndex(row, column, resources_[row]);
}
- data 方法
方法 data 也是一种谜之操作,一个节点上的数据可以拆分为多个角色(字段),Qt 定义了好多基础的角色,但是在 ResourcePage 上不关心角色,容易角色都返回相同的数据。
QVariant ResourcePage::data(const QModelIndex &index, int role) const
{
(void)role;
return QVariant::fromValue(resources_[index.row()]);
}
除了继承 QAbstractItemModel,更建议继承实现 QAbstractItemListModel,这样只要实现 rowCount 和 data 这两个方法就可以了。
修改数据
上面我们实现了一个静态的数据模型,但是要动态增加、删除、修改资源,上面的实现还不够。
动态数据模式,需要我们主动通知数据的变化。下面从增加、删除、移动节点三个方面来看一下怎么通知。
- 增加资源
增加资源的操作需要在 beginInsertRows、endInsertRows 之间执行。虽然 startIndex 与我们的资源数组下标一致,但是 endIndex 其实是最后一个新增节点的下标,所以别忘了减 1。
void ResourcePage::insertResource(int index, QList<ResourceView *> ress)
{
beginInsertRows(QModelIndex(), index, index + ress.size() - 1);
for (auto r : ress) {
resources_.insert(index++, r);
r->setParent(this);
}
endInsertRows();
}
- 删除资源
删除资源的操作需要在 beginRemoveRows、endRemoveRows 之间执行。
void ResourcePage::removeResource(int index, QList<ResourceView *> ress)
{
beginRemoveRows(QModelIndex(), index, index + ress.size() - 1);
for (auto r : ress) {
resources_.removeAt(index);
r->setParent(nullptr);
}
endRemoveRows();
}
- 移动资源
移动资源的操作需要在 beginMoveRows、endMoveRows 之间执行。相对前面两个操作,这个的实现比较费脑筋。很多人都会困惑,这几个下标,到底要怎么计算?
首先要分向前移动还是向后移动两种情况,另外 ItemModel 的移动下标与 QList 的 move 方法的下标的意义并不一致。所以唯一的建议就仔细阅读官方文档:
QAbstractItemModel::beginMoveRows 和 QList::move
void ResourcePage::moveResource(int pos1, int count, int newPos)
{
int pos = pos1 + count;
int newPos2 = newPos > pos2 ? newPos + 1 : newPos; // ItemModel diffs from QList
beginMoveRows(QModelIndex(), pos1, pos2, QModelIndex(), newPos2);
while (pos2 >= pos1) {
resources_.move(pos1, newPos);
--pos2;
}
endMoveRows();
}
界面绑定数据
有了数据为基础,接下来就是看界面怎么实现了。
与 ResourcePage 对应的 UI 模块是 PageCanvas。PageCanvas 派生与 QQGraphicsItem,管理一个页面的所有控件(每个资源对应一个控件)实例。控件也是派生于 QQGraphicsItem,作为子 Item 存在于 PageCanvas 中。
借着数据绑定的任务,先说明一下 QQGraphicsItem 子节点管理的一些方法:
首先是增加子节点,QQGraphicsItem 并没有 addChild 或者 insertChild 之类的方法,而是通过修改 child 的 parentItem 来实现的:
child->setParentItem(parent)
设置 parent 会从原来的 parent 中删除自己(如果有的话),然后加入到新的 parent 子节点列表的最后。
然后是删除子节点,还是调用 setParent,设置 parent 为 null。
而移动子节点的位置,同样要从 child 的角度来做,通过 stackBefore 方法完成,下面的方法移动下标从 start 到 end 的所有节点,移动到下标 dest:
void PageCanvas::resourceMoved(QModelIndex const &parent, int start, int end,
QModelIndex const &destination, int dest)
{
(void) parent;
(void) destination;
if (dest < start) {
ControlView * dest = childItems()[dest];
while (start <= end) {
ControlView * item = childItems()[start];
item->stackBefore(dest);
++start;
}
dest->update();
} else if (dest > end) {
ControlView * first = childItems()[start];
while (++end < dest) {
ControlView * item = childItems()[end];
item->stackBefore(first);
}
first->update();
}
}
PageCanvas 与数据的动态绑定主要是通过信号连接来实现。QAbstractItemModel 提供数据一系列变化信号:
void PageCanvas::switchPage(ResourcePage * page)
{
if (page_ != nullptr) {
page_->disconnect(this);
for (int i = page_->resources().size() - 1; i >= 0; --i) {
removeResource(i);
}
}
page_ = page;
if (page_ != nullptr) {
for (int i = 0; i < page_->resources().size(); ++i)
insertResource(i);
QObject::connect(page_, &ResourcePage::rowsInserted,
this, &PageCanvas::resourceInserted);
QObject::connect(page_, &ResourcePage::rowsRemoved,
this, &PageCanvas::resourceRemoved);
QObject::connect(page_, &ResourcePage::rowsMoved,
this, &PageCanvas::resourceMoved);
}
}
在绑定信号之前,我们先同步已有的数据(for 循环 insertResource),这是常见的数据同步操作。
数据角色
上面的例子并没有涉及到具体的数据(角色),也没有数据的更新。为了完整性考虑,接下来补充一些与数据角色相关知识。
下面是一个学生 Model 的数据实现:
enum Roles {
StudentRole = Qt::UserRole + 1,
IdRole,
NameRole,
NumberRole,
SexRole,
IconRole,
};
QVariant StudentModel::data(const QModelIndex &index, int role) const
{
Student * s = student(index.row());
switch (role) {
case Qt::DisplayRole:
case StudentRole:
return QVariant::fromValue(s);
case IdRole:
return QVariant::fromValue(s->id());
case NameRole:
return s->name();
case NumberRole:
return s->number();
case SexRole:
return QVariant::fromValue(s->sex());
case IconRole:
return QVariant::fromValue(s->icon());
}
return QVariant();
}
这里自定义了一些 Role,data 方法为每种 Role 返回不同的值。
如果要在 Qml 使用这些数据角色,最好的方法是为每个角色起个名字,具体做法是实现 roleNames 方法:
QHash<int, QByteArray> StudentModel::roleNames() const
{
static QHash<int, QByteArray> roles;
if (roles.empty()) {
roles = QAbstractItemModel::roleNames();
roles[StudentRole] = "student";
roles[IdRole] = "stuId";
roles[NameRole] = "name";
roles[NumberRole] = "number";
roles[SexRole] = "sex";
roles[IconRole] = "icon";
}
return roles;
}
这样在 Qml 就可以很方便是使用这些数据了:
ListView {
id: listView
model: viewModel.studentListModel
Text {
id: name
text: name
}
Text {
id: number
text: numer
}
}
当列表的某一项数据有变化时,使用 dataChanged 方法通知外部,通知可以附加角色列表,表示哪些角色有变化,以便减少不必要的更新,提高处理效率。
int n1 = 9; // row 9
dataChanged(index(n1, 0), index(n1, 0), {NameRole, NumberRole});