简单树模型示例

简单树模型示例展示了如何使用Qt标准视图类来实现层次模型。

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

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

设计和概念

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

简单树模型结构

数据在模型内部使用对象TreeItem并以基于指针的树结构链接存储。一般来说,每个TreeItem都有一个父项目,并且可以有多个子项目。然而,树结构的根项目没有父项目,并且永远不会在模型之外被引用。

每个TreeItem包含了有关其在树结构中位置的信息;它可以返回其父项目及其行号。这种信息的方便可用使实现模型更容易。

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

使用基于指针的树结构意味着,在将模型索引传递到视图时,我们可以在索引中记录相应项的地址(参见QAbstractItemModel::createIndex())并通过使用方法qiitemindex.hqmodelindex#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)
{}

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

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

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

请注意,由于模型中的根项将没有父项,此函数将返回零。我们需要确保在实现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的规则。与index()函数中一样,我们使用createIndex()创建模型索引。

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()函数在模型中设置初始数据。此函数解析文本文件,提取字符串用于模型,并创建记录数据和整体模型结构的项对象。当然,该函数以对该模型非常具体的方式工作。我们提供以下对其行为的描述,并将读者引导至示例代码本身以获取更多信息。

文本文件格式如下

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

我们按以下两个规则处理文本文件

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

为了确保模型正常工作,只需创建具有正确数据和父项的TreeItem实例即可。

测试模型

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

您可以通过将模型实例传递给类构造函数来测试您的模型,例如作为Qt单元测试的一部分。

class TestSimpleTreeModel : public QObject
{
    Q_OBJECT

private slots:
    void testTreeModel();
};

void TestSimpleTreeModel::testTreeModel()
{
    constexpr auto fileName = ":/default.txt"_L1;
    QFile file(fileName);
    QVERIFY2(file.open(QIODevice::ReadOnly | QIODevice::Text),
             qPrintable(fileName + " cannot be opened: "_L1 + file.errorString()));
    TreeModel model(QString::fromUtf8(file.readAll()));

    QAbstractItemModelTester tester(&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"
    PREFIX
        "/"
    FILES
        ${simpletreemodel_resource_files}
)

add_test(NAME simpletreemodel_tester
         COMMAND simpletreemodel_tester)

示例项目 @ code.qt.io

© 2024 Qt公司有限公司。本文档中的文档贡献是各自所有者的版权。本文档是根据自由软件基金会发布的GNU自由文档许可证第1.3版的条款许可的。Qt及其相应的标志是芬兰及/或其他国家/地区的Qt公司有限公司的商标。所有其他商标均为各自所有者的财产。