Pull to refresh

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

Reading time 9 min
Views 23K

Кому-то материал этой статьи покажется слишком простым, кому-то бесполезным, но я уверен, что новичкам в 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 реквесты приветствуются.

Tags:
Hubs:
+25
Comments 6
Comments Comments 6

Articles