简单树模型示例

简单树模型示例展示了如何使用 Qt 的标准视图类来创建分层模型。

Qt 的模型/视图架构为视图处理数据源中的信息提供了一种标准方法,它使用数据的抽象模型来简化和规范访问数据的方式。简单的模型将数据表示为一个项目表,并允许视图通过基于索引的系统访问这些数据。更一般地说,模型可用于以树形结构的形式表示数据,允许每个项作为子项表的父项。

在尝试实现树状模型之前,值得考虑的是数据是由外部来源提供,还是将在模型本身中维护。在本例中,我们将实现一个内部结构来保存数据,而不是讨论如何从外部来源打包数据。

设计和概念

我们用来表示数据结构的数据结构是由TreeItem 对象构建的树形结构。每个TreeItem 代表树形视图中的一个项目,并包含几列数据。

简单的树形模型结构

模型内部使用TreeItem 对象存储数据,这些对象以指针式树形结构链接在一起。一般来说,每个TreeItem 都有一个父项,并且可以有多个子项。但是,树形结构中的根项没有父项,也不会被模型外部引用。

每个TreeItem 都包含有关其在树结构中位置的信息;它可以返回其父项及其行号。有了这些随时可用的信息,就可以更容易地实现模型。

由于树形视图中的每个项目通常都包含几列数据(本例中是标题和摘要),因此在每个项目中存储这些信息是很自然的。为简单起见,我们将使用QVariant 对象列表来存储项中每一列的数据。

使用基于指针的树形结构意味着,在将模型索引传递给视图时,我们可以在索引中记录相应项的地址(参见QAbstractItemModel::createIndex()) 并在以后使用QModelIndex::internalPointer() 进行检索。这使得编写模型变得更容易,并确保所有引用同一项目的模型索引具有相同的内部数据指针。

有了适当的数据结构,我们就可以创建一个树状模型,只需少量额外代码就可以向其他组件提供模型索引和数据。

TreeItem 类定义

TreeItem 类的定义如下:

class TreeItem
{
public:
    explicit TreeItem(QVariantList data, TreeItem *parentItem = nullptr);

    void appendChild(std::unique_ptr<TreeItem> &&child);

    TreeItem *child(int row);
    int childCount() const;
    int columnCount() const;
    QVariant data(int column) const;
    int row() const;
    TreeItem *parentItem();

private:
    std::vector<std::unique_ptr<TreeItem>> m_childItems;
    QVariantList m_itemData;
    TreeItem *m_parentItem;
};

该类是一个基本的 C++ 类。它不继承于QObject ,也不提供信号和槽。该类用于保存 QVariants 列表,其中包含列数据及其在树结构中的位置信息。函数提供以下功能:

  • appendChildItem() 用于在首次构建模型时添加数据,在正常使用时不使用。
  • child()childCount() 函数允许模型获取任何子项的信息。
  • 与项目相关的列数信息由columnCount() 提供,每一列的数据可通过 data() 函数获取。
  • row()parent() 函数用于获取项的行号和父项。

父项和列数据存储在parentItemitemData 私有成员变量中。childItems 变量包含指向项自身子项的指针列表。

TreeItem 类的实现

构造函数仅用于记录项的父项和与各列相关的数据。

TreeItem::TreeItem(QVariantList data, TreeItem *parent)
    : m_itemData(std::move(data)), m_parentItem(parent)
{}

属于此项目的每个子项目的指针将以 std::unique_ptr 的形式存储在childItems 私有成员变量中。调用类的析构函数时,子项将被自动删除,以确保其内存被重复使用:

由于每个子项都是在模型最初填充数据时构建的,因此添加子项的函数非常简单:

void TreeItem::appendChild(std::unique_ptr<TreeItem> &&child)
{
    m_childItems.push_back(std::move(child));
}

只要给定一个合适的行号,每个项都能返回其任何子项。例如,在上图中,标有字母 "A "的项对应根项的子项row = 0 ,"B "项是 "A "项的子项row = 1 ,"C "项是根项的子项row = 1

child() 函数返回与项目的子项目列表中指定行号相对应的子项目:

TreeItem *TreeItem::child(int row)
{
    return row >= 0 && row < childCount() ? m_childItems.at(row).get() : nullptr;
}

持有的子项数量可通过childCount() 查找:

int TreeItem::childCount() const
{
    return int(m_childItems.size());
}

TreeModel 使用此函数确定给定父项的存在行数。

row() 函数报告项目在其父项目列表中的位置:

int TreeItem::row() const
{
    if (m_parentItem == nullptr)
        return 0;
    const auto it = std::find_if(m_parentItem->m_childItems.cbegin(), m_parentItem->m_childItems.cend(),
                                 [this](const std::unique_ptr<TreeItem> &treeItem) {
                                     return treeItem.get() == this;
                                 });

    if (it != m_parentItem->m_childItems.cend())
        return std::distance(m_parentItem->m_childItems.cbegin(), it);
    Q_ASSERT(false); // should not happen
    return -1;
}

请注意,虽然根项(没有父项)会被自动分配一个 0 的行号,但模型从未使用过这一信息。

项目中数据的列数可通过columnCount() 函数返回。

int TreeItem::columnCount() const
{
    return int(m_itemData.count());
}

列数据由data() 函数返回。我们使用QList::value() 方便函数检查边界,并在违反边界时返回默认构造的QVariant

QVariant TreeItem::data(int column) const
{
    return m_itemData.value(column);
}

通过parent() 可以找到项的父项:

TreeItem *TreeItem::parentItem()
{
    return m_parentItem;
}

请注意,由于模型中的根项没有父项,因此在这种情况下该函数将返回 0。我们需要确保在实现TreeModel::parent() 函数时,模型能正确处理这种情况。

TreeModel 类定义

TreeModel 类定义如下:

class TreeModel : public QAbstractItemModel
{
    Q_OBJECT

public:
    Q_DISABLE_COPY_MOVE(TreeModel)

    explicit TreeModel(const QString &data, QObject *parent = nullptr);
    ~TreeModel() override;

    QVariant data(const QModelIndex &index, int role) const override;
    Qt::ItemFlags flags(const QModelIndex &index) const override;
    QVariant headerData(int section, Qt::Orientation orientation,
                        int role = Qt::DisplayRole) const override;
    QModelIndex index(int row, int column,
                      const QModelIndex &parent = {}) const override;
    QModelIndex parent(const QModelIndex &index) const override;
    int rowCount(const QModelIndex &parent = {}) const override;
    int columnCount(const QModelIndex &parent = {}) const override;

private:
    static void setupModelData(const QList<QStringView> &lines, TreeItem *parent);

    std::unique_ptr<TreeItem> rootItem;
};

该类与QAbstractItemModel 的大多数其他提供只读模型的子类类似。只有构造函数和setupModelData() 函数的形式是该模型所特有的。此外,我们还提供了一个析构函数,以便在模型销毁时进行清理。

TreeModel 类的实现

为简单起见,该模型不允许编辑其数据。因此,构造函数需要一个参数,其中包含模型将与视图和委托共享的数据:

TreeModel::TreeModel(const QString &data, QObject *parent)
    : QAbstractItemModel(parent)
    , rootItem(std::make_unique<TreeItem>(QVariantList{tr("Title"), tr("Summary")}))
{
    setupModelData(QStringView{data}.split(u'\n'), rootItem.get());
}

构造函数为模型创建一个根项。为方便起见,该项只包含垂直标题数据。我们还用它来引用包含模型数据的内部数据结构,并用它来表示模型中顶层项的假想父项。根项使用 std::unique_ptr 进行管理,以确保在删除模型时删除整个项树。

模型的内部数据结构由setupModelData() 函数填充项。我们将在本文档末尾单独讨论该函数。

析构函数确保在销毁模型时删除根项及其所有子项。由于根项存储在 unique_ptr 中,因此该操作会自动完成。

TreeModel::~TreeModel() = default;

由于我们无法在模型构建和设置后向其添加数据,因此这简化了管理内部项树的方式。

模型必须实现index() 函数,以便在访问数据时为视图和委托提供索引。当其他组件通过其行和列编号及其父模型索引被引用时,索引就会被创建。如果指定了一个无效的模型索引作为父索引,则应由模型返回一个与模型中顶层项相对应的索引。

当我们得到一个模型索引时,首先会检查它是否有效。如果无效,我们会假定正在引用的是顶层项;否则,我们会使用internalPointer() 函数从模型索引中获取数据指针,并用它来引用TreeItem 对象。请注意,我们构建的所有模型索引都将包含一个指向现有TreeItem 的指针,因此我们可以保证,我们收到的任何有效模型索引都将包含一个有效的数据指针。

QModelIndex TreeModel::index(int row, int column, const QModelIndex &parent) const
{
    if (!hasIndex(row, column, parent))
        return {};

    TreeItem *parentItem = parent.isValid()
        ? static_cast<TreeItem*>(parent.internalPointer())
        : rootItem.get();

    if (auto *childItem = parentItem->child(row))
        return createIndex(row, column, childItem);
    return {};
}

由于该函数的行和列参数指向相应父项的子项,因此我们使用TreeItem::child() 函数获取该项目。createIndex() 函数用于创建要返回的模型索引。我们指定行和列的编号,以及指向项目本身的指针。该模型索引可用于以后获取项的数据。

TreeItem 对象的定义方式使parent() 函数的编写变得简单:

QModelIndex TreeModel::parent(const QModelIndex &index) const
{
    if (!index.isValid())
        return {};

    auto *childItem = static_cast<TreeItem*>(index.internalPointer());
    TreeItem *parentItem = childItem->parentItem();

    return parentItem != rootItem.get()
        ? createIndex(parentItem->row(), 0, parentItem) : QModelIndex{};
}

我们只需确保永远不返回与根项目对应的模型索引。为了与index() 函数的实现方式保持一致,我们将为模型中任何顶层项的父项返回一个无效的模型索引。

在创建要返回的模型索引时,我们必须指定父项在其父项中的行号和列号。通过TreeItem::row() 函数,我们可以很容易地找到行号,但我们按照惯例指定 0 作为父项的列号。使用createIndex() 创建模型索引的方法与index() 函数相同。

rowCount() 函数只简单地返回与给定模型索引相对应的TreeItem 的子项数,如果指定的索引无效,则返回顶层项数:

int TreeModel::rowCount(const QModelIndex &parent) const
{
    if (parent.column() > 0)
        return 0;

    const TreeItem *parentItem = parent.isValid()
        ? static_cast<const TreeItem*>(parent.internalPointer())
        : rootItem.get();

    return parentItem->childCount();
}

由于每个项都管理自己的列数据,因此columnCount() 函数必须调用项自身的columnCount() 函数,以确定给定模型索引有多少列。与rowCount() 函数一样,如果指定了无效的模型索引,返回的列数将由根项决定:

int TreeModel::columnCount(const QModelIndex &parent) const
{
    if (parent.isValid())
        return static_cast<TreeItem*>(parent.internalPointer())->columnCount();
    return rootItem->columnCount();
}

数据通过data() 从模型中获取。由于项管理自己的列,因此我们需要使用列编号来通过TreeItem::data() 函数检索数据:

QVariant TreeModel::data(const QModelIndex &index, int role) const
{
    if (!index.isValid() || role != Qt::DisplayRole)
        return {};

    const auto *item = static_cast<const TreeItem*>(index.internalPointer());
    return item->data(index.column());
}

请注意,在此实现中,我们只支持DisplayRole ,对于无效的模型索引,我们也会返回无效的QVariant 对象。

我们使用flags() 函数来确保视图知道模型是只读的:

Qt::ItemFlags TreeModel::flags(const QModelIndex &index) const
{
    return index.isValid()
        ? QAbstractItemModel::flags(index) : Qt::ItemFlags(Qt::NoItemFlags);
}

headerData() 函数会返回我们方便地存储在根项目中的数据:

QVariant TreeModel::headerData(int section, Qt::Orientation orientation,
                               int role) const
{
    return orientation == Qt::Horizontal && role == Qt::DisplayRole
        ? rootItem->data(section) : QVariant{};
}

这些信息可以通过不同的方式提供:在构造函数中指定,或者硬编码到headerData() 函数中。

在模型中设置数据

我们使用setupModelData() 函数来设置模型中的初始数据。该函数解析文本文件,提取模型中使用的文本字符串,并创建记录数据和整体模型结构的 item 对象。当然,这个函数的工作方式对这个模型来说是非常特殊的。我们将对其行为作如下描述,更多信息请读者参阅示例代码本身。

我们从以下格式的文本文件开始:

Getting Started                         How to familiarize yourself with Qt Widgets Designer
    Launching Designer                  Running the Qt Widgets Designer application
    The User Interface                  How to interact with Qt Widgets Designer
    ...
Connection Editing Mode                 Connecting widgets together with signals and slots
    Connecting Objects                  Making connections in Qt Widgets Designer
    Editing Connections                 Changing existing connections

我们按照以下两条规则处理文本文件:

  • 对于每一行中的每一对字符串,在树形结构中创建一个项(或节点),并将每个字符串放在项中的一列数据中。
  • 当某一行的第一个字符串相对于前一行的第一个字符串缩进时,将该项目作为前一个项目的子项。

为确保模型正常运行,只需创建TreeItem 的实例,并提供正确的数据和父项即可。

测试模型

正确实施一个项目模型可能具有挑战性。类QAbstractItemModelTester Qt Test模块中的类检查模型的一致性,如模型索引的创建和父子关系。

您只需向类构造函数传递一个模型实例即可测试您的模型,例如作为 Qt 单元测试的一部分:

TestSimpleTreeModel :publicQObject
{Q_OBJECTprivate slots:voidtestTreeModel(); };voidTestSimpleTreeModel::testTreeModel() { constexprautofileName= ":/default.txt"_L1    QFilefile(fileName); QVERIFY2(file.open(QIODevice: :只读 QIODevice::Text)             qPrintable(fileName + " cannot be opened: "_L1 + file.errorString()));
    树模型QString::fromUtf8(file.readAll()));    QAbstractItemModelTestertester(&model); } QTEST_APPLESS_MAIN(TestSimpleTreeModel)#include "test.moc"

要创建可使用ctest 可执行文件运行的测试,需要使用add_test()

# Unit Test

include(CTest)

qt_add_executable(simpletreemodel_tester
    test.cpp
    treeitem.cpp treeitem.h
    treemodel.cpp treemodel.h)

target_link_libraries(simpletreemodel_tester PRIVATE
    Qt6::Core
    Qt6::Test
)

if(ANDROID)
    target_link_libraries(simpletreemodel_tester PRIVATE
        Qt6::Gui
    )
endif()

qt_add_resources(simpletreemodel_tester "simpletreemodel_tester"
    PREFIX
        "/"
    FILES
        ${simpletreemodel_resource_files}
)

add_test(NAME simpletreemodel_tester
         COMMAND simpletreemodel_tester)

示例项目 @ code.qt.io

© 2025 The Qt Company Ltd. Documentation contributions included herein are the copyrights of their respective owners. The documentation provided herein is licensed under the terms of the GNU Free Documentation License version 1.3 as published by the Free Software Foundation. Qt and respective logos are trademarks of The Qt Company Ltd. in Finland and/or other countries worldwide. All other trademarks are property of their respective owners.