Qt: Пишем обобщенную модель для QML ListView

Кому-то материал этой статьи покажется слишком простым, кому-то бесполезным, но я уверен, что новичкам в Qt и QML, которые впервые сталкиваются с необходимостью создания моделей для ListView, это будет полезно как минимум как альтернативное*, быстрое и довольно эффективное решение с точки зрения "цена/качество".


*Как минимум, в свое время ничего подобного мне нагуглить не получилось. Если знаете и можете дополнить — welcome.


О чем шум?


С приходом в Qt языка QML создавать пользовательские интерфейсы стало проще и быстрее… пока не требуется тесное взаимодействие с C++ кодом. Создание экспортируемых C++ классов достаточно хорошо описано в документации и до тех пор пока вы работаете с простыми структурами все действительно достаточно тривиально (ну почти). Основная неприятность появляется, когда нужно "показать" в QML элементы какого-то контейнера, а по-простому — коллекции, и в особенности, когда эти элементы имеют сложную структуру вложенных объектов, а то и другие коллекции.


Интересно?


Предполагается, что вы знакомы с терминологией Qt и такие слова как делегат, роль, контейнер в применении к спискам и списковым компонентам не будут вызывать у вас удивление, как у меня когда-то...


Самый используемый для отображения списковых данных QML компонент — это ListView. Следуя документации Qt есть несколько способов передать в него данные, но подходящий для C++ реализации вариант только один — это создание своей модели через наследование от QAbstractItemModel. Как это сделать на хабре статьи уже были, например эта. И все бы хорошо, но давайте для начала обозначим некоторые факты:


  • В качестве контейнера в Qt чаще всего мы используем QList,
  • Чтобы избежать лишнего копирования обычно мы объекты размещаем в куче.
  • А чтобы упросить управление памятью мы используем какие-то умные указатели. Для Qt вполне неплохо работает "родной" QSharedPointer.
  • Сами элементы контейнера зачастую QObject-ы, т.к. нам нужны экспортируемые в QML свойства (для этого еще можно использовать Q_GADGET, если не требуется эти свойства менять, но там тоже свои "приколы").
  • Элементов у нас часто не так много, скажем, до миллиона (например, какая-нибудь лента новостей, или список файлов в обычном каталоге (npm_modules не в счет :)) Если элементов, которые нужно отобразить, значительно больше, то тут скорее уже надо в первую очередь решать более серьезные проблемы — с UX.

Реализовав несколько таких моделей быстро понимаешь, что количество бойлерплейта в них зашкаливает. Один только проброс названий ролей чего стоит. Да и вообще, зачем, если это все уже есть в Q_OBJECT и Q_GADGET? Быстро приходит на ум, что хотелось бы иметь какой-то шаблонный контейнер, который смог бы все это обобщить: иметь интерфейс листа и при этом возможность выступать как-то в качестве модели для ListView, например ListModel<ModelItem> itemsCollection...


Для чего вообще листу модель?


Лист создает делегаты (рендереры для отдельных элементов) не все сразу, а только те, которые должны быть в данный момент видимы плюс необязательный кеш. При прокрутке листа ушедшие за границы видимости делегаты уничтожаются, а новые — создаются. Теперь давайте добавим в наш список новый элемент. В этом случае ListView должен быть информирован, какой именно индекс был добавлен и если этот индекс находится между индексами, которые в данный момент отображаются, то значит нужно создать новый делегат, проинициализировать его данными и разместить между существующими. При удалении ситуация обратная. Когда мы меняем свойства элементов, сюда добавляются еще сигналы об изменении "ролей" — те данные, которые видны непосредственно в делегате (честно говоря, не знаю, кто придумал так это называть).


Если мы используем "чистые" C++ структуры, то выбора у нас нет: единственный способ как-то экспортировать такие данные — это собственная модель-наследник от QAbstractItemModel. А если у нас элементы Q_OBJECT или Q_GADGET, то они уже и так сами умеют "показывать" свои свойства в QML и дополнительное дублирование ролей, а также "передергивание" модели при изменении таких объектов становится делом очень неудобным и нецелесообразным. А если нужно передать через роль еще и структуру, то задача усложняется еще больше, т.к. в данном случае структура передается размещенной в QVariant со всеми вытекающими.


Передача структурного элемента в QML


Вначале давайте посмотрим, а как вообще можно передать в делегат элемент контейнера со сложной структурой?


Пусть у нас имеется список элементов с такой структурой объектов:


class Person
  + string name
  + string address
  + string phoneNumber

class Employee
  + Person* person
  + string position
  + double salary

Конечно, в данном случае для отображения такой структуры ее безболезненно можно было бы сделать плоской, но давайте представим, что данные сложные и мы так сделать не можем.


Итак, создаем наследника от QAbstractListModel (который в свою очередь наследник от QAbstractItemModel). В качестве хранилища берем популярный QList. Но не задаем никакие роли! Вместо этого мы поступим следующим образом:


  • Зарегистрируем наши классы в QMLEngine:

qmlRegisterUncreatableType<Person>( "Personal", 1, 0, "Person", "interface" );
qmlRegisterUncreatableType<Employee>( "Personal", 1, 0, "Employee", "interface" );

и не забыть еще


Q_DECLARE_METATYPE( Person* )
Q_DECLARE_METATYPE( Employee* )

В данном случае я предполагаю, что наши классы — это QObject. Можно долго спорить об эффективности такого подхода, но в реальных задачах экономия на QObject часто оказывается экономией на спичках и несоизмерима с трудозатратами. А если посмотреть вообще на тенденции писать приложения на Electron…
Почему uncreatable — потому что так проще. Мы не собираемся создавать эти объекты в QML, а значит нам не нужен дефолтный конструктор, например. Для нас это просто "интерфейс".


  • Реализуем в модели Q_INVOKABLE метод, который будет нам возвращать указатель на нашу структуру по ее индексу.

Итого, получается что-то такое:


class Personal : public QAbstractListModel {
public:
    // Собственно метод для доступа к элементу
    Q_INVOKABLE Employee* getEmployee( int index );

    // Обязательная реализация QAbstractListModel:
    int rowCount( const QModelIndex& parent ) const override {
        return personal.count();
    }

    // Этот метод не реализован, т.к. у нас нет ролей.
    QVariant data( const QModelIndex& index, int role ) const override {    
        return QVariant();
    }

    // И где-то тут должны быть методы для добавления и удаления элементов 
    // в модель и внутренний QList, а также все необходимые вызовы 
    // beginInsertRows(), endInsertRows() и им подобные.
    // Тут все стандартно, как в документации, никакой магии.

private:
    QList<Employee*> personal;
}

Теперь, с такой моделью, во view мы можем при инстанцировании делегата подставлять в него и далее использовать типизированный объект! Более того, Qt Creator вполне способен при вводе подказывать поля этой структуры, что в свою очередь тоже не может не радовать.


// P.S. Не забыть этот класс тоже зарегистрировать в QMLEngine
Personal {
    id: personalModel
}

ListView {
    model: personalModel
    delegate: Item {
        // index - стандартная доступная роль. но нужно помнить, что доступна она только здесь
        property Employee employee: personalModel.getEmployee(index)
        Text {
            text: employee.person.name
        }
    }
}

Ступень первая: модель индексов


Теперь давайте проанализируем, что у нас получилось. А получилось то, что мы от нашей QAbstractListModel используем только индексы, всю остальную работу делает Q_OBJECT-ы и их мета-свойства. Т.е. мы можем реализовать в общем и целом модель, которая будет работать только с индексами и этого будет достаточно, чтобы ListView знал, что происходит! Получаем такой интерфейс:


class IndicesListModelImpl : public QAbstractListModel {
    Q_OBJECT
    Q_PROPERTY( int count READ count NOTIFY countChanged )

public:
    int count() const;

    // --- QAbstractListModel ---
    int      rowCount( const QModelIndex& parent ) const override;
    QVariant data( const QModelIndex& index, int role ) const override;

protected:
    // Create "count" indices and push them to end
    void push( int count = 1 );

    // Remove "count" indices from the end.
    void pop( int count = 1 );

    // Remove indices at particular place.
    void removeAt( int index, int count = 1 );

    // Insert indices at particular place.
    void insertAt( int index, int count = 1 );

    // Reset model with new indices count
    void reset( int length = 0 );

Q_SIGNALS:
    void countChanged( const int& count );

private:
    int m_count = 0;
};

где в реализации мы просто информируем view о том, что определенные индексы как будто бы изменились, например так:


void IndicesListModelImpl::insertAt( int index, int count ) {
    if ( index < 0 || index > m_length + 1 || count < 1 )
        return;

    int start = index;
    int end = index + count - 1;
    beginInsertRows( QModelIndex(), start, end );
    m_count += count;
    endInsertRows();
    emit countChanged( m_count );
}

Что ж, неплохо, теперь мы можем наследоваться не напрямую от QAbstractListModel, а от нашего импровизированного класса, где есть уже половина необходимой нам логики. А что если… и контейнер обобщить?


Cтупень вторая: добавляем контейнер


Теперь не стыдно написать шаблонный класс для контейнера. Можно заморочиться и сделать два параметра у шаблона: контейнер и хранимый тип, таким образом позволив использование вообще чего угодно, но я бы не стал и остановился на наиболее часто используемом, в моем случае это QList<QSharedPointer<ItemType>>. QList как наиболее часто используемый в Qt контейнер, а QSharedPointer — чтобы меньше беспокоиться об ownership. (P.S. Кое о чем все же нужно будет побеспокоиться, но об этом позже)


Что ж, поехали. В идеале хочется чтобы наша модель имела такой же интерфейс как и QList и таким образом максимально ему мимикрировала, но пробрасывать все было бы слишком неэффективно, ведь реально нам нужно не так уж и много: только те методы, которые используются для изменения — append, insert, removeAt. Для остального можно просто сделать публичный accessor к внутреннему листу "как есть".


template <class ItemType>
class ListModelImplTemplate : public IndicesListModelImpl {
public:
    void append( const QSharedPointer<ItemType>& item ) {
        storage.append( item );
        IndicesListModelImpl::push();
    }

    void append( const QList<QSharedPointer<ItemType>>& list ) {
        storage.append( list );
        IndicesListModelImpl::push( list.count() );
    }

    void removeAt( int i ) {
        if ( i > length() )
            return;
        storage.removeAt( i );
        IndicesListModelImpl::removeAt( i );
    }

    void insert( int i, const QSharedPointer<ItemType>& item ) {
        storage.insert( i, item );
        IndicesListModelImpl::insertAt( i );
    }

    // --- QList-style comfort ;) ---

    ListModelImplTemplate& operator+=( const QSharedPointer<ItemType>& t ) {
        append( t );
        return *this;
    }

    ListModelImplTemplate& operator<<( const QSharedPointer<ItemType>& t ) {
        append( t );
        return *this;
    }

    ListModelImplTemplate& operator+=( const QList<QSharedPointer<ItemType>>& list ) {
        append( list );
        return *this;
    }

    ListModelImplTemplate& operator<<( const QList<QSharedPointer<ItemType>>& list ) {
        append( list );
        return *this;
    }

    // Internal QList storage accessor. It is restricted to change it directly,
    // since we need to proxy all this calls, but it is possible to use it's
    // iterators and other useful public interfaces.
    const QList<QSharedPointer<ItemType>>& list() const {
        return storage;
    }

    int count() const {
        return storage.count();
    }

protected:
    QList<QSharedPointer<ItemType>> storage;
};

Ступень третья: метод getItem() и генерализация модели


Казалось бы, осталось сделать из этого класса еще один шаблон и потом использовать его в качестве типа для любой коллекции и дело с концом, например так:


class Personal : public QObject {
public:
    ListModel<Employee>* personal;
}

Но есть проблема и третья ступень здесь не зря: Классы QObject, использующие макрос Q_OBJECT, не могут быть шаблонными и при первой же попытке компиляции такого класса MOC вам радостно об этом скажет. Всё, приплыли?


Отнюдь, решение этой проблемы все же есть, хоть и не столь изящное: старый добрый макрос #define! Будем генерировать наш класс динамически сами, там где это необходимо (всяко лучше, чем писать каждый раз бойлерплейт). Благо, нам осталось реализовать всего-то один метод!


#define DECLARE_LIST_MODEL( NAME, ITEM_TYPE ) 
class NAME : ListModelImplTemplate<ITEM_TYPE> { 
    Q_OBJECT
protected: 
    Q_INVOKABLE ITEM_TYPE* item( int i, bool keepOwnership = true ) const {
        if ( i >= 0 && i < storage.length() && storage.length() > 0 ) { 
            auto obj = storage[i].data(); 
            if ( keepOwnership ) 
                QQmlEngine::setObjectOwnership( obj, QQmlEngine::CppOwnership );
            return obj;
        } 
        else {  
            return Q_NULLPTR; 
        } 
    } 
};
Q_DECLARE_METATYPE( NAME* )

Отдельно стоит рассказать про QQmlEngine::setObjectOwnership( obj, QQmlEngine::CppOwnership ); — эта штука нужна для для того, чтобы QMLEngine не вздумалось заняться менеджментом наших объектов. Если мы захотим использовать наш объект в какой то JS функции и поместим его в переменную с локальной областью видимости, то JS Engine без раздумий грохнет его при выходе из этой функции, т.к. у наших QObject отсутствует parent. С другой стороны, parent мы не используем намеренно, т.к. у нас уже есть управление временем жизни объекта с помощью QSharedPointer и нам не нужен еще один механизм.


Итого, получаем такую картинку:


  1. Базовую реализацию QAbstractListModelIndicesListModelImpl — для манипуляции с индексами, чтобы ListView реагировал
  2. Честный шаблонный класс-обертку над стандартным контейнером, задача которого обеспечивать редактирование этого контейнера и вызов методов вышестоящего IndicesListModelImpl
  3. Сгенерированный класс — наследник всего этого "добра", который предоставляет единственный метод для доступа к элементам из QML.


Заключение


Пользоваться полученным решением очень просто: там где нам необходимо экспортировать в QML некоторую коллекцию объектов, тут же создаем нужную модель и тут же ее используем. Например, у нас имеется некоторый класс-провайдер (а в терминологии Qt — Backend), одно из свойств которого должно предоставлять список неких DataItem:


// Создаем нашу модельку
DECLARE_LIST_MODEL( ListModel_DataItem, DataItem )

class Provider : public QObject {
    Q_OBJECT
    Q_PROPERTY( ListModel_DataItem* itemsModel READ itemsModel NOTIFY changed )

public:
    explicit Provider( QObject* parent = Q_NULLPTR );

    ListModel_DataItem* itemsModel() {
         return &m_itemsModel;
    };

    Q_INVOKABLE void addItem() {
        m_itemsModel << QSharedPointer<DataItem>( new DataItem );
    }

Q_SIGNALS:
    void changed();

private:
    ListModel_DataItem  m_itemsModel;
};

И конечно же, со всем этим вместе: с шаблоном и полным кодом примера использования можно взять и ознакомиться на гитхаб.


Любые дополнения, комментарии и pull реквесты приветствуются.

  • +25
  • 5,8k
  • 6
Поделиться публикацией
Похожие публикации
Ой, у вас баннер убежал!

Ну. И что?
Реклама
Комментарии 6
  • –1
    Когда-то для удобства (было куча списков, которые нужно было (де)сериализовать), накидал себе макрос ниже. Используется так (Car — любой класс, который нужно хранить в списке)
    class MyClass : public QObject
    {
        Q_OBJECT
        QML_LIST_PROPERTY(MyClass, Car, car);
     
    public:
        void foo() {
            append_car(new Car());
            qDebug() << carsCount();
            auto car_at_0 = car(0);
            auto allCars = get_cars();
            clear_cars();
            qDebug() << carsCount();
        }
    };


    Немного бардак из-за смеси camel case/snake case, можно в принципе всё snake case-ом сделать. Исходники с ещё парой очень пригодившихся мне вещей можно взять тут.
    Остальные методы требуются для взаимодействия с QML. Тут документация, собственно, на QQmlListProperty.
    Макрос:
    #define QML_LIST_PROPERTY(classname, type, name) \
            protected: \
            Q_PROPERTY(QQmlListProperty< type > name ## s \
                               READ name ## s \
                               NOTIFY name ## sChanged) \
            public: \
                    QQmlListProperty< type > name ## s() { \
                            return QQmlListProperty< type >(this, this, \
                                             & classname :: append_ ## name, \
                                             & classname :: name ## sCount, \
                                             & classname :: name, \
                                             & classname :: clear_ ## name ## s); \
                    } \
                    void append_ ## name ( type * item) { \
                            m_ ## name ## s . append( item ); \
                            emit name ## sChanged(); \
                    } \
                    int name ## sCount() const { return m_ ## name ## s . count(); } \
                    type * name(int index) const { return m_ ## name ## s . at( index ); } \
                    void clear_ ## name ## s () { \
                            while( ! m_ ## name ## s . isEmpty()) { \
                                    auto tmp = m_ ## name ## s . takeLast(); \
                                    if(tmp->parent() == this) \
                                            delete tmp; \
                            } \
                            emit name ## sChanged(); \
                    } \
                    inline QList< type *>& get_ ## name ## s() { \
                            return m_ ## name ## s; \
                    } \
            Q_SIGNALS: \
                    void name ## sChanged(); \
            protected: \
                    static void append_ ## name(QQmlListProperty< type >* list, type* item) { \
                            reinterpret_cast< classname* >(list->data)->append_ ## name (item); \
                    } \
                    static int name ## sCount(QQmlListProperty<type>* list) { \
                            return reinterpret_cast< classname * >(list->data)->name ## sCount(); \
                    } \
                    static type * name(QQmlListProperty<type>* list, int index) { \
                            return reinterpret_cast< classname * >(list->data)-> name (index); \
                    } \
                    static void clear_ ## name ## s(QQmlListProperty<type>* list) { \
                            reinterpret_cast< classname* >(list->data)->clear_ ## name ## s(); \
                    } \
                    \
                    QList< type *> m_ ## name ## s
    • +2
      Все хорошо, но! QQmlListProperty немного не для этого задумывался, он скорее для обратной задачи: когда нужно иметь возможность добавлять в c++ модель элементы из кода в QML. Как нетрудно заметить в вашем коде, у вас на любые изменения только один сигнал: sChanged(), который не отражает характер изменений никак. Очевидно, что view при любом таком изменении (добавление или удаление элементов) остается два пути: 1) как-то самому хранить предыдущее состояние модели, которая у него была и сравнивать его с тем, что получилось после сигнала — что, конечно, полная дичь, но теоретически… и 2) сбрасывать полностью все делегаты и рисовать весь лист по новой (как минимум ту его часть, которая видна), что тоже не очень эффективно. При этом всем еще скорее всего будет сбрасываться позиция прокрутки…

      Для задачи (де)сериализации объектов api у меня тоже есть достаточно простое и изящное решение, в том числе для листов ;) Если первая статья вкатит, то поделюсь опытом )
      • 0
        Делитесь) Чую, можно было бы всё сделать гораздо красивее, чем у меня
    • +1
      «С приходом в Qt языка QML создавать пользовательские интерфейсы стало проще и быстрее… пока не требуется тесное взаимодействие с C++ кодом»… и это оказалось одной из причин, по которой мой выбор был сделан в пользу QWidget, когда начал писать свое приложение. К тому же я считаю, что реализация интерфейсов на «стандартных» элементах все же удобнее на QWidget. Qml выглядит привлекательным для всяких модных приложений в не профессиональной сфере: медиаплееры, социальные сети и т.п. А за статью спасибо — интересное решение.
      • 0
        Большое спасибо, за статью. Есть, чем дополнить.
        Рекомендую изучить презентацию: Efficient models for QML with QSyncable
        Один из первых подобных проектов — QQmlObjectListModel — прошел лед и пламень, и остался на плаву. Ben Lau (о его проектах в презентации) развил идею чуть дальше.

        Теперь к критике: IMHO, многие утверждения здесь носят спорный характер.
        Экономия на QObject — экономия на спичках при условии, что само приложение или размер контейнера малы. Если Вам довелось писать что-то вроде email-клиента — отказ от QObject-а дает весомое снижение ресурсоемкости. У Дениса Кормалева есть об этом в одном из выступлений.
        Ваша аргументация о том, что у Electron ситуация еще хуже — совсем не аргумент.
        Модель нужна ListView для того, чтобы поддержать анимирование операций над элементами (transitions). А сама QAbstractListModel как раз позволяет экспортировать НЕ Qt-типы в QML без дополнительных регистраций.
        Если у вас есть QList и QObject-ы, но не нужен визуальный обвес ввиде красивых исчезновений элемента при удалении, вы можете прокинуть сам QList. Да, с «приколами», но тем не менее — это штатное средство без макросов, без абстракций.
        О качестве QList и его дальнейшем существовании в Qt 6 одно время велись споры среди самих разработчиков Qt. Кроме того, при условии работы с другой STL-библиотекой, генерирующей данные, на выходе у вас будет STL-ный же контейнер. QList хорош, когда не надо запариваться.
        Для получения данных элемента в статье используется самописный метод ::item()(шаг третий), в то же самое время использование штатного QAbstractItemModel::data() c явным «преобразованием» QObject* в QVariant. На моей практике, штатный метод, используемый для формирования объектов model\modelData в делегате, бывает куууда полезней.

        Сам по себе подход, описанный в этой статье — не панацея, а решение конкретных проблем, например, описанных в этой статье: много полей — грустно таскать каждое из модели — значения отдать QVariant-ом с Q_GADGET. Это проблема.
        Тот факт, что приходится переписывать многа букав — проблема при условии, что вы прототипируете, или делаете MVP или тому подобное. Из невероятного: у вас много однотипных Qt-only моделек, которые использоваться будут также тривиально. Шаг влево — и вам придется написать костыль для того, чтобы сохранить использование вашей абстракции; шаг вправо — и вы сильно рискуете потерять контроль над жизнью объектов в QML (в качестве примера, советую поэкспериментировать с ListModel, QObject* в QML и gc() — все не так гладко).

        Такие наработки хороши: они оптимизируют на 70% время работы с модельками. Одна беда: они применимы в 5% случаев, которые составляют 0,01% от числа всех проблем отдельно взятого приложения. (цифры условные)
        • 0
          Благодарю за дополнение! Презенташку пролистал — действительно очень похожее решение, почему-то не попалось мне раньше. На хабре так вообще голяк (а может я просто гуглить не умею? :)). На выходных гляну повнимательнее.

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое