Шрифт
Qt: Пишем обобщенную модель для QML ListView
Play

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

📎📎📎📎📎📎📎📎📎📎