Pull to refresh

Иерархические модели в Qt

Reading time8 min
Views57K
Продолжаю тему создания моделей с использованием Qt MV. В прошлый раз была критическая статья по поводу того, как делать не надо. Переходим к позитивной части.

Для создания плоских моделей списков и таблиц можно использовать заготовки QAbstractListModel и QAbstractTableModel. Доведение их до готовности не составляет большого труда, поэтому рассматривать их подробно нет необходимости.

Создание же иерархических моделей – более сложная задача. О ней и пойдет речь в этой статье.

В общем случае, Qt позволяет создавать не только древовидные модели, но и модели более сложных конфигураций и размерностей. Например, таблица, в которой каждый элемент является группирующим для подтаблицы. Несмотря на это, в большинстве случаев, когда говорят об иерархических моделях – имеют в виду деревья. Именно процесс создания деревьев я и хочу осветить.

Рисунок: Taблица в таблице

Еще одно вводное замечание касается сути модели, создавать которую я буду в качестве примера. Хотелось выбрать что-то конкретное и универсальное, поэтому я решил создать для примера модель, отображающую файловую систему. Она не страдает полнотой и завершенностью, поэтому вряд ли кто-то захочет использовать ее всерьез (тем более что уже есть QFileSystemModel), но для примера вполне подойдет. В следующей статье, если такая будет, вокруг этой модели я собираюсь построить несколько proxy.

Проектирование

Прежде всего, необходимо определиться со внутренней структурой данных. Здесь я выделил бы 2 основных направления:
  • Модели с хорошо определенной структурой или небольшой вложенностью.
    Примером может являться редактор свойств в Qt Designer. В этом случае всю информацию о положении конкретного элемента модели можно сохранить в самом индексе (QModelIndex), используя internalId. В случае случая редактора свойств в internalId можно сохранить идентификатор группы свойства.
    Другой пример – модель записной книжки. Очевидно, запись «Lol4t0» входит в подгруппу Lo, которая, в свою очередь, входит в группу L. И обратно – в группу Lo входят те, и только те записи, которые начинаются с префикса «Lo». Это я и называю хорошо определенной структурой.

  • Модели без четко определенной структуры. Модель файловой системы относится именно к таким. Зная только название папки Documents, вообще говоря, невозможно определить, в какой папке она находится и какие папки содержит.

Дополнительные сведения об организации данных можно использовать при конструировании, поэтому создание моделей первого типа обычно не вызывает проблем. В статье речь пойдет о моделях второго типа.

Все виды данных, с которыми приходится иметь дело при разработке древовидных моделей, на самом деле похожи. Поэтому не составит труда выделить некоторые общие рекомендации относително внутренней организации данных:
  • Необходимо хранить данные узлов
  • Должна существовать взаимнооднозначная связь между QModelIndex и элементом внутренней структуры данных
  • Каждый узел должен содержать ссылки на родительский узел и дочерние узлы

В случае модели файловой системы, я буду использовать QFileInfo для хранения информации о каждом узле. Кроме того, необходимо будет хранить информацию о дочерних узлах и родительском узле. Дополнительно, понадобится информация о том, производился ли поиск дочерних узлов, или нет.

Бизнес данные (QFileInfo) пришлось обернуть служебной информацией. В большинстве случаев, без этого невозможно обойтись. Если данные предметной области уже поддерживают иерархию, можно использовать ее, однако я еще ни разу не встречался со случаем, когда исходные данные содержали бы всю необходимую информацию.

Получаем следующую внутреннюю структуру данных:
struct FilesystemModel::NodeInfo
{
	QFileInfo fileInfo; // информация об узле
	QVector<NodeInfo> children; // список дочерних узлов
	NodeInfo* parent; // ссылка на родительский узел

	bool mapped; // производился ли поиск дочерних узлов.
};

При создании дерева, я сконструирую список узлов, соответствующий корневым объектам файловых систем, а их дочерние элементы я буду подгружать по мере необходимости:
typedef QVector<NodeInfo> NodeInfoList;
NodeInfoList _nodes; // список корневых узлов файловых систем

В дереве будет несколько столбцов:
enum Columns
{
	RamificationColumn, // столбец, по которому производится ветвление, всегда первый.
                        // Другого варианта не поддерживает QTreeView
	NameColumn = RamificationColumn, // столбец с именем узла
	ModificationDateColumn, // столбец с датой изменения узла
	SizeColumn, // столбец с размером файла
	ColumnCount // число столбцов
};


Минимальная реализация

После того, как определились со структурой хранения данных, можно приступить к реализации модели.
Если необходимо реализовать иерархическую модель, то ничего не остается, кроме как наследоваться от QAbstractItemModel. Для того чтобы реализовать простейшую модель, необходимо написать реализацию всего пяти функций:
virtual QModelIndex index(int row, int column, const QModelIndex &parent) const;
virtual QModelIndex parent(const QModelIndex &child) const;
virtual int rowCount(const QModelIndex &parent = QModelIndex()) const;
virtual int columnCount(const QModelIndex &parent = QModelIndex()) const;
virtual QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const;

Однако реализация первых двух обычно и составляет 80% проблем, связанных с созданием иерархических моделей. Дело еще и в том, что вызываются они очень часто, поэтому использование в них алгоритмов сложнее O(1), вообще говоря, не желательно.

Я предлагаю хранить указатель на NodeInfo в internalPointer индекса. В большинстве случаев, именно так и поступают. При реализации index ни в коем случае нельзя возвращать несуществующие индексы. При этом не нужно рассчитывать на то, что такой индекс никто не запросит. Для проверки существования индекса есть очень удобная функция hasIndex.
QModelIndex FilesystemModel::index(int row, int column, const QModelIndex &parent) const
{
	if (!hasIndex(row, column, parent)) {
		return QModelIndex();
	}

	if (!parent.isValid()) { // запрашивают индексы корневых узлов
		return createIndex(row, column, const_cast<NodeInfo*>(&_nodes[row]));
	}

	NodeInfo* parentInfo = static_cast<NodeInfo*>(parent.internalPointer());
	return createIndex(row, column, &parentInfo->children[row]);
}

С parent все несколько сложнее. Несмотря на то, что по заданному индексу всегда можно отыскать NodeInfo родительского элемента, для создания индекса родительского элемента необходимо также знать его положение среди «братьев».

Тут есть два варианта – или хранить с каждым узлом информацию о его положении, или определять это положение каждый раз заново. Беда с первым — в том, что при добавлении и удалении узлов все нижележащие узлы придется обновлять. Чего мне очень не хотелось. Поэтому я выбрал второй вариант, несмотря на его вычислительную сложность. В реальной модели я бы придерживался такого выбора до тех пор, пока не смог доказать что это – узкое место.
QModelIndex FilesystemModel::parent(const QModelIndex &child) const
{
	if (!child.isValid()) {
		return QModelIndex();
	}

	NodeInfo* childInfo = static_cast<NodeInfo*>(child.internalPointer());
	NodeInfo* parentInfo = childInfo->parent;
	if (parentInfo != 0) { // parent запрашивается не у корневого элемента
		return createIndex(findRow(parentInfo), RamificationColumn, parentInfo);
	}
	else {
		return QModelIndex();
	}
}

int FilesystemModel::findRow(const NodeInfo *nodeInfo) const
{
	const NodeInfoList& parentInfoChildren = nodeInfo->parent != 0 ? nodeInfo->parent->children: _nodes;
	NodeInfoList::const_iterator position = qFind(parentInfoChildren, *nodeInfo);
	return std::distance(parentInfoChildren.begin(), position);
}

Реализация rowCount и columnCount тривиальна: в первом случае мы всегда можем определить число дочерних узлов из NodeInfo::children::size, а число столбцов фиксировано.
int FilesystemModel::rowCount(const QModelIndex &parent) const
{
	if (!parent.isValid()) {
		return _nodes.size();
	}
	const NodeInfo* parentInfo = static_cast<const NodeInfo*>(parent.internalPointer());
	return parentInfo->children.size();
}

int FilesystemModel::columnCount(const QModelIndex &) const
{
	return ColumnCount;
}

Реализация data тоже не представляет собой ничего сложного, всю необходимую информацию получаем из QFileInfo. Как минимум, необходимо реализовать поддержку ролей Qt::DisplayRole для отображения текста во view и Qt::EditRole, если предусмотрено редактирование. Данные, полученные от модели с ролью Qt::EditRole будут загружены в редактор. Причем, данные, которая модель возвращает при запросе с Qt::DisplayRole и Qt::EditRole могут различаться. Например, будем отображать файлы без расширений, а редактировать — с расширением.
Код функции data
QVariant FilesystemModel::data(const QModelIndex &index, int role) const
{
	if (!index.isValid()) {
		return QVariant();
	}

	const NodeInfo* nodeInfo = static_cast<NodeInfo*>(index.internalPointer());
	const QFileInfo& fileInfo = nodeInfo->fileInfo;

	switch (index.column()) {
	case NameColumn:
		return nameData(fileInfo, role);
	case ModificationDateColumn:
		if (role == Qt::DisplayRole) {
			return fileInfo.lastModified();
		}
		break;
	case SizeColumn:
		if (role == Qt::DisplayRole) {
			return fileInfo.isDir()? QVariant(): fileInfo.size();
		}
		break;
	default:
		break;
	}
	return QVariant();
}

QVariant FilesystemModel::nameData(const QFileInfo &fileInfo, int role) const
{
	switch (role) {
	case Qt::EditRole:
		return fileInfo.fileName();
	case Qt::DisplayRole:
		if (fileInfo.isRoot()) {
			return fileInfo.absoluteFilePath();
		}
		else if (fileInfo.isDir()){
			return fileInfo.fileName();
		}
		else {
			return fileInfo.completeBaseName();
		}
	default:
		return QVariant();
	}
	Q_UNREACHABLE();
}

Для того чтобы модель «ожила», осталось заполнить корневые узлы:
void FilesystemModel::fetchRootDirectory()
{
	const QFileInfoList drives = QDir::drives();
	qCopy(drives.begin(), drives.end(), std::back_inserter(_nodes));
}

FilesystemModel::FilesystemModel(QObject *parent) :
	QAbstractItemModel(parent)
{
	fetchRootDirectory();
}


Теперь можно отобразить модель с использованием QTreeView и посмотреть результат.

Однако, что же такое! Корневые элементы невозможно развернуть.
Действительно, ведь данные для них еще не загружены.

Динамическая подгрузка данных

Чтобы реализовать автоматическую подгрузку данных по мере необходимости, в Qt реализовано следующее API:
bool canFetchMore(const QModelIndex &parent) const;
void fetchMore(const QModelIndex &parent);

Первая функция должна возвращать true, когда данные для заданного родительского элемента можно подгрузить, а вторая – собственно подгружать данные.
Здесь пригодится NodeInfo::mapped. Данные можно подгрузить, когда mapped == false.
bool FilesystemModel::canFetchMore(const QModelIndex &parent) const
{
	if (!parent.isValid()) {
		return false;
	}
	const NodeInfo* parentInfo = static_cast<const NodeInfo*>(parent.internalPointer());
	return !parentInfo->mapped;
}

Для подгрузки будем использовать функции, предоставляемые QDir. При этом не забываем использовать beginInsertRows и endInsertRows при изменении числа строк. К сожалению, QTreeView выполняет подгрузку только при попытке развернуть узел, и не пытается подгрузить новые данные при прокрутке списка. Поэтому ничего не остается, как загрузить весь список дочерних узлов целиком. Исправить это поведение можно, разве что, созданием своего компонента отображения.
void FilesystemModel::fetchMore(const QModelIndex &parent)
{
	NodeInfo* parentInfo = static_cast<NodeInfo*>(parent.internalPointer());
	const QFileInfo& fileInfo = parentInfo->fileInfo;
	QDir dir = QDir(fileInfo.absoluteFilePath());
	QFileInfoList children = dir.entryInfoList(QStringList(), QDir::AllEntries | QDir::NoDotAndDotDot, QDir::Name);

	beginInsertRows(parent, 0, children.size() - 1);
	parentInfo->children.reserve(children.size());
	for (const QFileInfo& entry: children) {
		NodeInfo nodeInfo(entry, parentInfo);
		nodeInfo.mapped = !entry.isDir();
		parentInfo->children.push_back(std::move(nodeInfo));
	}
	parentInfo->mapped = true;
	endInsertRows();
}

Делаем подгрузку, запускаем программу, а результата – нет. Корневые узлы все также невозможно развернуть. Все дело в том, что QTreeView использует функцию hasChildren для проверки того, есть ли у узла дочерние элементы, и полагает, что развернуть можно только те узлы, у которых дочерние элементы есть. hasChildren же по умолчанию возвращает true, только когда число строк и число столбцов для родительского узла больше 0.

В данном случае, такое поведение не подходит. Переопределим функцию hasChildren так, чтобы она возвращала true для заданного узла, когда у него точно есть или могут быть (когда mapped ==false) дочерние узлы.

Можно точно определить, пуста ли директория, но это довольно дорогая операция, использовать ли ее – решать вам.
bool FilesystemModel::hasChildren(const QModelIndex &parent) const
{
	if (parent.isValid()) {
		const NodeInfo* parentInfo = static_cast<const NodeInfo*>(parent.internalPointer());
		Q_ASSERT(parentInfo != 0);
		if (!parentInfo->mapped) {
			return true;//QDir(parentInfo->fileInfo.absoluteFilePath()).count() > 0; -- точное определение того, что директория не пуста
		}
	}
	return QAbstractItemModel::hasChildren(parent);
}

Вот теперь модель работает, можно просматривать папки.

Я думаю, на этом можно закончить, для полноты картины можно было бы добавить функцию переименования файлов и папок, создания директорий и отслеживания изменений файловой системы. Но это явно выходит за рамки дозволенного статье на хабр. Чуть более расширенный пример я выложил на GitHub.
Tags:
Hubs:
+22
Comments7

Articles