[Из песочницы] 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
…
Для чего вообще листу модель?
Лист создает делегаты (рендереры для отдельных элементов) не все сразу, а только те, которые должны быть в данный момент видимы плюс необязательный кеш. При прокрутке листа ушедшие за границы видимости делегаты уничтожаются, а новые — создаются. Теперь давайте добавим в наш список новый элемент. В этом случае 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( "Personal", 1, 0, "Person", "interface" );
qmlRegisterUncreatableType( "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 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 + count > m_count + 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
. QList
как наиболее часто используемый в Qt контейнер, а QSharedPointer
— чтобы меньше беспокоиться об ownership. (P.S. Кое о чем все же нужно будет побеспокоиться, но об этом позже)
Что ж, поехали. В идеале хочется чтобы наша модель имела такой же интерфейс как и QList
и таким образом максимально ему мимикрировала, но пробрасывать все было бы слишком неэффективно, ведь реально нам нужно не так уж и много: только те методы, которые используются для изменения — append, insert, removeAt. Для остального можно просто сделать публичный accessor к внутреннему листу «как есть».
template
class ListModelImplTemplate : public IndicesListModelImpl {
public:
void append( const QSharedPointer& item ) {
storage.append( item );
IndicesListModelImpl::push();
}
void append( const QList>& 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& item ) {
storage.insert( i, item );
IndicesListModelImpl::insertAt( i );
}
// --- QList-style comfort ;) ---
ListModelImplTemplate& operator+=( const QSharedPointer& t ) {
append( t );
return *this;
}
ListModelImplTemplate& operator<<( const QSharedPointer& t ) {
append( t );
return *this;
}
ListModelImplTemplate& operator+=( const QList>& list ) {
append( list );
return *this;
}
ListModelImplTemplate& operator<<( const QList>& 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>& list() const {
return storage;
}
int count() const {
return storage.count();
}
protected:
QList> storage;
};
Ступень третья: метод getItem () и генерализация модели
Казалось бы, осталось сделать из этого класса еще один шаблон и потом использовать его в качестве типа для любой коллекции и дело с концом, например так:
class Personal : public QObject {
public:
ListModel* personal;
}
Но есть проблема и третья ступень здесь не зря: Классы QObject, использующие макрос Q_OBJECT, не могут быть шаблонными и при первой же попытке компиляции такого класса MOC вам радостно об этом скажет. Всё, приплыли?
Отнюдь, решение этой проблемы все же есть, хоть и не столь изящное: старый добрый макрос #define! Будем генерировать наш класс динамически сами, там где это необходимо (всяко лучше, чем писать каждый раз бойлерплейт). Благо, нам осталось реализовать всего-то один метод!
#define DECLARE_LIST_MODEL( NAME, ITEM_TYPE )
class NAME : ListModelImplTemplate {
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
и нам не нужен еще один механизм.
Итого, получаем такую картинку:
- Базовую реализацию
QAbstractListModel
—IndicesListModelImpl
— для манипуляции с индексами, чтобыListView
реагировал - Честный шаблонный класс-обертку над стандартным контейнером, задача которого обеспечивать редактирование этого контейнера и вызов методов вышестоящего
IndicesListModelImpl
- Сгенерированный класс — наследник всего этого «добра», который предоставляет единственный метод для доступа к элементам из 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( new DataItem );
}
Q_SIGNALS:
void changed();
private:
ListModel_DataItem m_itemsModel;
};
И конечно же, со всем этим вместе: с шаблоном и полным кодом примера использования можно взять и ознакомиться на гитхаб.
Любые дополнения, комментарии и pull реквесты приветствуются.