logo头像

单枪匹马亦对饮,历经磨难记初心

Qml组件化编程13-树组件的定制

gallery-img

简介

最近遇到一些需求,要在Qt/Qml中开发树结构,并能够导入、导出json格式。

于是我写了一个简易的Demo,并做了一些性能测试。

在这里将源码、实现原理、以及性能测试都记录、分享出来,算是抛砖引玉吧,希望有更多人来讨论、交流。

源码

先放源码

github https://github.com/jaredtao/TreeEdit

访问不了的,可以用gitee码云 https://gitee.com/jaredtao/Tree

效果预览

看一下最终效果

预览

Qml实现的树结构编辑器, 功能包括:

树结构的缩进
节点展开、折叠
添加节点
删除节点
重命名节点
搜索
导入
导出
节点属性编辑(完善中)

原理说明

数据model的实现,使用C++,继承于QAbstractListModel,并实现rowCount、data等方法。

model本身是List结构的,在此基础上,对model数据进行扩展以模拟树结构,例如增加了 “节点深度”、“是否有子节点”等数据段。

view使用Qml Controls 2中的ListView模拟实现(Controls 1 中的TreeView即将被废弃)。

关键代码

model

基本model的声明如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
template <typename T>
class TaoListModel : public QAbstractListModel {
public:
//声明父类
using Super = QAbstractListModel;

TaoListModel(QObject* parent = nullptr);
TaoListModel(const QList<T>& nodeList, QObject* parent = nullptr);

const QList<T>& nodeList() const
{
return m_nodeList;
}
void setNodeList(const QList<T>& nodeList);

int rowCount(const QModelIndex& parent) const override;

QVariant data(const QModelIndex& index, int role) const override;
bool setData(const QModelIndex& index, const QVariant& value, int role) override;


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

Qt::ItemFlags flags(const QModelIndex& index) const override;
Qt::DropActions supportedDropActions() const override;

protected:
QList<T> m_nodeList;
};

其中数据成员使用 QList m_nodeList 存储, 大部分成员函数是对此数据的操作。

Json格式的model声明如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
const static QString cDepthKey = QStringLiteral("TModel_depth");
const static QString cExpendKey = QStringLiteral("TModel_expend");
const static QString cChildrenExpendKey = QStringLiteral("TModel_childrenExpend");
const static QString cHasChildendKey = QStringLiteral("TModel_hasChildren");
const static QString cParentKey = QStringLiteral("TModel_parent");
const static QString cChildrenKey = QStringLiteral("TModel_children");

const static QString cRecursionKey = QStringLiteral("subType");
const static QStringList cFilterKeyList = { cDepthKey, cExpendKey, cChildrenExpendKey, cHasChildendKey, cParentKey, cChildrenKey };
class TaoJsonTreeModel : public TaoListModel<QJsonObject> {
Q_OBJECT
Q_PROPERTY(int count READ count NOTIFY countChanged)
public:
//声明父类
using Super = TaoListModel<QJsonObject>;
//从json文件读入数据
Q_INVOKABLE void loadFromJson(const QString& jsonPath, const QString& recursionKey = cRecursionKey);
//导出到json文件
Q_INVOKABLE bool saveToJson(const QString& jsonPath, bool compact = false) const;
Q_INVOKABLE void clear();
//设置指定节点的数值
Q_INVOKABLE void setNodeValue(int index, const QString &key, const QVariant &value);
//在index添加子节点。刷新父级,返回新项index
Q_INVOKABLE int addNode(int index, const QJsonObject& json);
Q_INVOKABLE int addNode(const QModelIndex& index, const QJsonObject& json)
{
return addNode(index.row(), json);
}
//删除。递归删除所有子级,刷新父级
Q_INVOKABLE void remove(int index);
Q_INVOKABLE void remove(const QModelIndex& index)
{
remove(index.row());
}
Q_INVOKABLE QList<int> search(const QString& key, const QString& value, Qt::CaseSensitivity cs = Qt::CaseInsensitive) const;
//展开子级。只展开一级,不递归
Q_INVOKABLE void expand(int index);
Q_INVOKABLE void expand(const QModelIndex& index)
{
expand(index.row());
}
//折叠子级。递归全部子级。
Q_INVOKABLE void collapse(int index);
Q_INVOKABLE void collapse(const QModelIndex& index)
{
collapse(index.row());
}
//展开到指定项。递归
Q_INVOKABLE void expandTo(int index);
Q_INVOKABLE void expandTo(const QModelIndex& index)
{
expandTo(index.row());
}
//展开全部
Q_INVOKABLE void expandAll();

//折叠全部
Q_INVOKABLE void collapseAll();


int count() const;

Q_INVOKABLE QVariant data(int idx, int role = Qt::DisplayRole) const
{
return Super::data(Super::index(idx), role);
}
signals:
void countChanged();
...
};

TaoJsonTreeModel继承于TaoListModel,并提供大量Q_INVOKABLE函数,以供Qml调用。

view

TreeView的模拟实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
Item {
id: root
readonly property string __depthKey: "TModel_depth"
readonly property string __expendKey: "TModel_expend"
readonly property string __childrenExpendKey: "TModel_childrenExpend"
readonly property string __hasChildendKey: "TModel_hasChildren"

readonly property string __parentKey: "TModel_parent"
readonly property string __childrenKey: "TModel_children"
...
ListView {
id: listView
anchors.fill: parent
currentIndex: -1
delegate: Rectangle {
id: delegateRect
width: listView.width
color: (listView.currentIndex === index || area.hovered) ? config.normalColor : config.darkerColor
// 根据 expaned 判断是否展开,不展开的情况下高度为0
height: model.display[__expendKey] === true ? 35 : 0
// 优化。高度为0时visible为false,不渲染。
visible: height > 0
property alias editable: nameEdit.editable
property alias editItem: nameEdit
TTextInput {
id: nameEdit
anchors.verticalCenter: parent.verticalCenter
//按深度缩进
x: root.basePadding + model.display[__depthKey] * root.subPadding
text: model.display["name"]
height: parent.height
width: parent.width * 0.8 - x
editable: false
onTEditFinished: {
sourceModel.setNodeValue(index, "name", displayText)
}
}
TTransArea {
id: area
height: parent.height
width: parent.width - controlIcon.x
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
onPressed: {
//单击时切换当前选中项
if (listView.currentIndex !== index) {
listView.currentIndex = index;
} else {
listView.currentIndex = -1;
}
}
onTDoubleClicked: {
//双击进入编辑状态
delegateRect.editable = true;
nameEdit.forceActiveFocus()
nameEdit.ensureVisible(0)
}
}
Image {
id: controlIcon
anchors {
verticalCenter: parent.verticalCenter
right: parent.right
rightMargin: 20
}
//有子节点时,显示小图标
visible: model.display[__hasChildendKey]
source: model.display[__childrenExpendKey] ? "qrc:/img/collapse.png" : "qrc:/img/expand.png"
MouseArea {
anchors.fill: parent
onClicked: {
//点击小图标时,切换折叠、展开的状态
if (model.display[__hasChildendKey]) {
if( true === model.display[__childrenExpendKey]) {
collapse(index)
} else {
expand(index)
}
}
}
}
}
}
}
...
}

model层并没有扩展role,而是在data函数的role为display时直接返回json数据,

所以delegate中统一使用model.display[xxx]的方式访问数据。

性能测试

测试环境

CPU: Intel i5-8400 2.8GHz 六核

内存: 16GB

OS: Windows10 1909

Qt: 5.12.6

编译器: msvc 2017 x64

测试框架: QTest

测试方法

数据生成

使用node表示根节点的数量,depth表示每个根节点下面嵌套节点的层数。

例如: node 等于 100, depth 等于10,则数据如下:

预览

顶层有100个节点,每个节点下面再嵌套10层,共计节点 100 + 100 * 10 = 1100.

生成json数据的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
...
//单元测试类
class LoadTest : public QObject {
Q_OBJECT

public:
LoadTest();
~LoadTest();

static void genJson(const QPoint& point);

...
//私有槽函数会被QTest调用
private slots:
//初始化
void initTestCase();
//清理
void cleanupTestCase();
//测试导入
void test_load();
//测试导入前,准备数据
void test_load_data();
//测试导出
void test_save();
//测试导出前,准备数据
void test_save_data();
};
...
//节点最大值
const int nodeMax = 10000;
//嵌套深度最大值
const int depthMax = 100;

void LoadTest::genJson(const QPoint& point)
{
using namespace TaoCommon;
int node = point.x();
int depth = point.y();
QJsonArray arr;
for (int i = 0; i < node; ++i) {
QJsonObject obj;
obj["name"] = QString("node_%1").arg(i);
QVector<QJsonArray> childrenArr = { depth, QJsonArray { QJsonObject {} } };
//最后一个节点,嵌套层级最深的。
childrenArr[depth - 1][0] = QJsonObject { { "name", QString("node_%1_%2").arg(i).arg(depth - 1) } };
//从后往前倒推。
for (int j = depth - 2; j >= 0; --j) {
childrenArr[j][0] = QJsonObject { { cRecursionKey, childrenArr[j + 1] }, { "name", QString("node_%1_%2").arg(i).arg(j) } };
}
obj[cRecursionKey] = childrenArr[0];
arr.append(obj);
}
writeJsonFile(qApp->applicationDirPath() + QString("/%1_%2.json").arg(node).arg(depth), arr);
}

void LoadTest::initTestCase()
{
QList<QPoint> list;
for (int i = 1; i <= nodeMax; i *= 10) {
for (int j = 1; j <= depthMax; j *= 10) {
list.append({ i, j });
}
}
auto result = QtConcurrent::map(list, &LoadTest::genJson);
result.waitForFinished();
}

初始化函数initTestCase中,组织了一个QList,然后使用QtConcurrent::map并发调用genJson函数,生成数据json文件。

node和depth每次扩大10倍。

经过测试,嵌套层数在100以上时,Qt可能会崩溃。要么是QJsonDocument无法解析,要么是Qml挂掉。所以不使用100以上的嵌套级别。

测试过程

QTest十分好用,简单易上手,参考帮助文件即可

例如测试加载的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
void LoadTest::prepareData()
{
//添加两列数据
QTest::addColumn<int>("node");
QTest::addColumn<int>("depth");
//添加行
for (int i = 1; i <= nodeMax; i *= 10) {
for (int j = 1; j <= depthMax; j *= 10) {
QTest::newRow(QString("%1_%2").arg(i).arg(j).toStdString().c_str()) << i << j;
}
}
}
void LoadTest::test_load_data()
{
//准备数据
prepareData();
}
void LoadTest::test_load()
{
using namespace TaoCommon;
//取数据
QFETCH(int, node);
QFETCH(int, depth);
TaoJsonTreeModel model;
//性能测试
QBENCHMARK
{
model.loadFromJson(qApp->applicationDirPath() + QString("/%1_%2.json").arg(node).arg(depth));
}
}

测试结果

预览

一秒内最多可以加载的数据量在十万级别,包括

10000 x 10耗时在 386毫秒,1000 x 100 耗时在671毫秒。

支付宝打赏 微信打赏

赞赏是不耍流氓的鼓励