Пользовательский интерфейс в Qt

Модели данных в QML: QAbstractListModel на практике

Введение

Для отображения динамических списков данных в QML (через ListView, GridView, Repeater) недостаточно простого JS-массива, когда данные приходят с сервера, регулярно обновляются или требуют эффективной работы с большими объёмами. QAbstractListModel — базовый класс Qt для реализации собственных моделей данных на C++, предоставляющих данные в QML через стандартный интерфейс модели/представления. В статье построим модель списка задач (todo list) с поддержкой добавления, удаления и изменения статуса элементов.

Концепция

QAbstractListModel требует переопределения как минимум rowCount() (число элементов) и data() (значение конкретной роли для конкретной строки). Роли данных (Qt::UserRole + N) объявляются через roleNames() — это сопоставление числового идентификатора роли со строковым именем, под которым свойство будет доступно в QML-делегате (model.title, model.completed). Любое изменение данных модели должно сопровождаться правильными сигналами (beginInsertRows/endInsertRows, dataChanged и так далее), без них представление в QML не узнает об изменении и не обновится визуально.

Пример кода: Модели данных в QML

// todolistmodel.h
#pragma once
#include <QAbstractListModel>
#include <QString>

struct TodoItem
{
    QString title;
    bool completed;
};

class TodoListModel : public QAbstractListModel
{
    Q_OBJECT
public:
    enum Roles {
        TitleRole = Qt::UserRole + 1,
        CompletedRole
    };

    explicit TodoListModel(QObject *parent = nullptr) : QAbstractListModel(parent) {}

    int rowCount(const QModelIndex &parent = QModelIndex()) const override
    {
        if (parent.isValid()) return 0;
        return m_items.size();
    }

    QVariant data(const QModelIndex &index, int role) const override
    {
        if (!index.isValid() || index.row() >= m_items.size()) return {};

        const TodoItem &item = m_items.at(index.row());
        switch (role) {
            case TitleRole: return item.title;
            case CompletedRole: return item.completed;
        }
        return {};
    }

    QHash<int, QByteArray> roleNames() const override
    {
        return {
            { TitleRole, "title" },
            { CompletedRole, "completed" }
        };
    }

    Q_INVOKABLE void addItem(const QString &title)
    {
        beginInsertRows(QModelIndex(), m_items.size(), m_items.size());
        m_items.append({title, false});
        endInsertRows();
    }

    Q_INVOKABLE void toggleCompleted(int row)
    {
        if (row < 0 || row >= m_items.size()) return;
        m_items[row].completed = !m_items[row].completed;
        const QModelIndex idx = index(row);
        emit dataChanged(idx, idx, {CompletedRole});
    }

    Q_INVOKABLE void removeItem(int row)
    {
        if (row < 0 || row >= m_items.size()) return;
        beginRemoveRows(QModelIndex(), row, row);
        m_items.removeAt(row);
        endRemoveRows();
    }

private:
    QList<TodoItem> m_items;
};
// main.qml
import QtQuick
import QtQuick.Controls
import App.Models 1.0

ApplicationWindow {
    visible: true
    width: 360
    height: 480

    TodoListModel { id: todoModel }

    Component.onCompleted: {
        todoModel.addItem("Изучить QAbstractListModel");
        todoModel.addItem("Написать тесты");
    }

    Column {
        anchors.fill: parent
        anchors.margins: 10

        Row {
            TextField { id: newItemField; placeholderText: "Новая задача" }
            Button {
                text: "Добавить"
                onClicked: {
                    todoModel.addItem(newItemField.text);
                    newItemField.text = "";
                }
            }
        }

        ListView {
            width: parent.width
            height: 350
            model: todoModel
            delegate: Row {
                spacing: 8
                CheckBox {
                    checked: model.completed
                    onClicked: todoModel.toggleCompleted(index)
                }
                Text {
                    text: model.title
                    font.strikeout: model.completed
                }
                Button {
                    text: "✕"
                    onClicked: todoModel.removeItem(index)
                }
            }
        }
    }
}

Пояснения к коду

roleNames() сопоставляет внутренние числовые роли с именами "title"/"completed", которые становятся доступны в QML-делегате через model.title/model.completed для каждой строки. Методы addItem, toggleCompleted, removeItem помечены Q_INVOKABLE, что делает их вызываемыми напрямую из QML — каждый из них правильно оборачивает изменение данных нужными сигналами модели (beginInsertRows/endInsertRows для вставки, beginRemoveRows/endRemoveRows для удаления, dataChanged для изменения существующей строки), что заставляет ListView корректно и эффективно обновить только затронутую часть отображения, а не перерисовывать список целиком.

В QML ListView напрямую привязывается к экземпляру TodoListModel через свойство model, а каждый delegate автоматически получает контекстные свойства model (доступ к ролям текущей строки) и index (порядковый номер строки) без явной передачи.

Подводные камни

  • Изменение данных без beginInsertRows/endInsertRows (или соответствующих парных вызовов). Прямая модификация внутреннего контейнера m_items без уведомления через эти методы оставляет представление в QML несинхронизированным с реальными данными — в лучшем случае список не обновится визуально, в худшем — обращение к несуществующему индексу вызовет краш.
  • Несовпадение диапазона индексов в beginInsertRows(parent, first, last) с реально вставленным количеством элементов. Указание неверного диапазона (например, вставка одного элемента, но указание диапазона на два) приводит к неконсистентному состоянию модели представления, которое может проявляться как пропущенные или дублирующиеся элементы в ListView.
  • Производительность при частых поштучных изменениях большого списка. Множественные отдельные вызовы beginInsertRows/endInsertRows для каждого элемента в цикле менее эффективны, чем один вызов для целого диапазона — при массовой вставке стоит группировать изменения в один вызов с правильным диапазоном, если это возможно.
  • Забытое Q_INVOKABLE или неверная сигнатура метода. Без этого макроса метод недоступен для вызова из QML — попытка вызвать todoModel.removeItem(index) для метода без Q_INVOKABLE тихо завершится ошибкой в QML-консоли о неизвестном методе, что легко принять за опечатку в имени, а не за отсутствие экспорта метода.