Как известно, физики давно пытаются найти Теорию Всего, в рамках которой можно было бы объяснять все известные взаимодействия в природе. Склонность к обобщениям присуща не только физикам, но и математикам, и программистам. Способность меньшим количеством сущностей объяснять и предсказывать большой спектр явлений очень ценна. Для программистов в роли теорий выступают различные API и фреймворки. Некоторые из них решают узкоспециализированные проблемы, а какие-то претендуют на роль универсальных теорий. Примером последних может выступать Qt — универсальный фреймворк, предназначенный, в основном, для разработки GUI.

Далее я расскажу, что мне не нравится в Qt и как его можно сделать ещё более универсальным, мощным и удобным для работы.

Демо-видео (лучше смотреть в HD):



Qt, как и многие другие GUI фреймворки развивался от простого к сложному. Сначала создавались простые виджеты, потом более сложные и составные. Появился Model/View framework, для отображения данных в табличном или древовидном виде. Появился Graphics Items framework для отображения набора графических элементов. Все эти фреймворки имеют различные API и несовместимы друг с другом. По сути у нас есть три независимых и почти не пересекающихся теории в рамках одной большой. Когда мне нужно разработать какой-либо новый визуальный элемент, то я должен выбрать, в каком из трёх фреймворков я собираюсь его использовать и применять соответствующее API. Таким образом я не могу создать элемент, который можно было бы использовать и в качестве отдельного виджета, и внедрить в ячейки таблицы, и использовать в узлах графической сцены.

Qt развивается под лозунгом — Write once, run anythere. Для написания конечных приложений это может быть и правда, но для расширения и кастомизации самой библиотеки это не так.

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

Рассмотрим разные виджеты (чекбокс, таблица, дерево и графическая сцена) и постараемся найти в них что-то общее. Информация в них сгруппирована в ячейки (Items). Чекбокс состоит из одной ячейки, таблица — из рядов и столбцов ячеек, в сцене ячейками являются узлы. Таким образом можно сказать, что все виджеты отображают ячейки, только их количество и расположение в пространстве специфичны для разных типов виджетов. Давайте скажем, что виджет отображает некоторое пространство ячеек (Space). Для простых виджетов пространство ячеек тривиально SpaceItem, и состоит из единственной ячейки. Для таблицы можно придумать SpaceGrid, которое описывает, как ячейки организованы в строки и столбцы. Для графической сцены имеем SpaceScene, где ячейки могут располагаться как угодно.

Что есть общего у всех пространств, что можно выделить в базовый класс?
Пока что, можно выделить две вещи:
  1. Возвращать общий размер пространства (обычно это bounding box всех ячеек)
  2. Возвращать расположение ячейки по её координате ItemID


class Space {
    virtual QSize size() const = 0;
    virtual QRect itemRect(ItemID item) const = 0;
};

Давайте теперь внимательно рассмотрим сами ячейки. Для наглядности будем изучать такую таблицу:



Ячейки тоже имеют некоторую структуру. Например, чекбокс состоит из квадратика с галочкой и текста. В таблице ячейки могут быть очень сложными (содержать текст, картинки, ссылки, как в моём видео-примере). Заметим, что для таблицы у нас, как правило, ячейки в одном столбце имеют одинаковую структуру. Поэтому нам легче описывать не каждую ячейку, а целый набор. Наборы ячеек (Range) могут быть разными, например, все ячейки RangeAll, ячейки из колонки RangeColumn, ячейки из строки RangeRow, ячейки из четных строк RangeOddRow и т.п. Какой же интерфейс можно выделить для базового класса Range? Интерфейс простой и лаконичный — отвечать на вопрос, входит какая-то ячейка в Range или нет:

class Range {
    virtual bool hasItem(ItemID item) const = 0;
};

После того, как мы определились с подмножеством ячеек, нам надо указать, какой тип информации в этих ячейках мы хотим отобразить. За отображение самого маленького и неделимого кусочка информации будет отвечать класс View. Например, ViewCheck умеет отображать значок чекбокса, ViewText — отображает строку текста и т.п.

Пока что базовый класс View должен уметь лишь рисовать информацию в ячейке:

class View {
    virtual void draw(QPainter* painter, ItemID item, QRect rect) const = 0;
};

Возникает вопрос, откуда ViewCheck знает, что ему надо рисовать значок слева в ячейке, а ViewText знает, что ему нужно рисовать текст после значка чекбокса? Для этого заведем ещё один «карликовый» класс Layout. Этот класс умеет размещать View внутри ячейки. Например, LayoutLeft разместит View у левого края ячейки, LayoutRight — у правого, а LayoutClient — займёт всё пространство ячейки. Вот базовый интерфейс:

class Layout {
    virtual void doLayout(ItemID item, View view, QRect& itemRect, QRect& viewRect) const = 0;
};

Функция doLayout изменяет параметры itemRect и viewRect так, что бы расположить view внутри ячейки item. Например, LayoutLeft запрашивает размер, необходимый view для отображения информации в ячейке, и «откусывает» необходимое пространство от itemRect. Как видно, от интерфейса View требуется еще одна функция — size:

class View {
    virtual void draw(QPainter* painter, ItemID item, QRect rect) const = 0;
    virtual QSize size(ItemID item) const = 0;
};

В итоге, чтобы описать что и как мы хотим отображать в ячейках некоторого пространства, нам надо перечислять тройки объектов tuple<Range, View, Layout>. Такую тройку я назвал ItemSchema. Полностью наш класс Space выглядит примерно так:

class Space {
    virtual QSize size() const = 0;
    virtual QRect itemRect(ItemID item) const = 0;

    QVector<ItemSchema> schemas;
};

Вот наглядный пример (подписи немного устарели, но основная идея, думаю, понятна):



Создавая разных наследников классов Range, View и Layout, и комбинируя их различным образом, мы имеем богатые возможности по кастомизации любого пространства ячеек и, таким образом, любого виджета. Например, создав класс ViewRating, который отображает оценку в виде звёздочек, я могу использовать его и как отдельный виджет, и в ячейках таблицы, и в элементах графической сцены.

Данная архитектура располагает к сотрудничеству программистов. Кто-то может написать свой тип пространства ячеек, который укладывает ячейки каким-то специальным образом. Кто-то напишет View, который отображает специфичные данные. И эти программисты могу воспользоваться результатом работы друг друга. Вот не полный список моих реализаций класса View, их легко создавать и использовать (реализация буквально несколько строк кода):
  1. ViewButton — рисует кнопку;
  2. ViewCheck — рисует значок чекбокса;
  3. ViewColor — заливает область определенным цветом;
  4. ViewEnumText — рисует текст из ограниченного списка;
  5. ViewImage, ViewPixmap, ViewStyleStandardPixmap — рисуют изображения;
  6. ViewLink — рисует текстовые ссылки;
  7. ViewAlternateBackground — рисует через-полосицу;
  8. ViewProgressLabel, ViewProgressBox — рисуют прогрессбар или проценты;
  9. ViewRadio — рисует значок радиобаттона;
  10. ViewRating — рисует значки оценки;
  11. ViewSelection — рисует выделенные ячейки;
  12. ViewText — рисует текст;
  13. ViewTextFont — меняет шрифт последующего текста;
  14. ViewVisible — показывает или скрывает другой View;


Идём дальше. Как правило, виджет отображает не всё пространство ячеек, а только видимую часть. Класс Space удобен для описания пространства ячеек, но плох для отрисовки ячеек в некоторой ограниченной видимой области. Давайте определим специальный класс для отображения под-области пространства CacheSpace:

class CacheSpace {
    // reference to items space
    Space space;
    // visible area
    QRect window;
    // draw cached items
    void draw(QPainter* painter) const;
    // visit all cached items
    virtual void visit(Visitor visitor) = 0;
};


Каждый конкретный наследник от CacheSpace (CacheGrid, CacheScene и др.) хранит набор кешированных ячеек CacheItem по-разному (но оптимально для данного типа пространства). Поэтому мы выделим в базовом классе функцию visit, которая посещает все кешированные ячейки. С помощью неё легко реализовать функцию draw — просто нужно посетить все кешированные ячейки и вызвать у них свою функцию draw.

Как понятно из названия, CacheItem хранит всю информацию, нужную для отображения конкретной ячейки:

class CacheItem {
    ItemID item;
    QRect itemRect;
    QVector<CacheView> views;

    void draw(QPainter* painter) const;
};

Здесь функция draw устроена тоже очень просто — в цикле вызвать draw у класса CacheView, который отвечает за отрисовку самого маленького и неделимого кусочка информации внутри ячейки.

class CacheView {
    View view;
    QRect viewRect;

    void draw(QPainter* painter, ItemID item) const;
};


Таким образом, виджету необходимо иметь CacheSpace и с помощью него рисовать содержимое своего пространства ячеек:

class Widget {
    // space of items
    Space space;
    // cache of visible area of space
    CacheSpace cacheSpace;

    void paintEvent(QPaintEvent *event) override;
    void resizeEvent(QResizeEvent *event) override;
};

В обработчике resizeEvent мы меняем видимую область объекта cacheSpace.window, а в обработчике paintEvent — рисуем его содержимое cacheSpace.draw().

Как видно, иерархия объектов CacheSpace->CacheItem->CacheView позволяет нам «видеть» всю визуальную структуру виджета с максимальными подробностями. Мы можем доступиться к любому самому маленькому и неделимому кусочку информации, спускаясь с уровня CacheSpace на уровень отдельной ячейки CacheItem и, далее, внутри ячейки перебирая отдельные CacheView.

Эта возможность, представить любой виджет, как иерархию CacheSpace->CacheItem->CacheView, даёт нам большие возможности по управлению и интроспекции виджета.

Например, мы можем реализовать единый интерфейс доступа к любому нашему виджету из системы автоматического тестирования. Система автоматического тестирования GUI обычно запрашивает необходимую область в виджете и потом воздействует на эту область мышью, имитируя действия пользователя. Мы можем предоставить такой системе самую подробную «карту» областей, на которые можно воздействовать.

Другой пример — анимации, которые представлены в видео-примере. Мы можем не только смотреть, из чего состоит наш виджет, но и воздействовать на его составные части. Для примера, можно менять расположения любых объектов в иерархии (CacheSpace->CacheItem->CacheView) во времени или отрисовывать их с полупрозрачностью. Таким образом, можно собирать целую библиотеку анимаций, которые могут быть применены на любой виджет и на любое пространство ячеек.

В итоге, хочу еще раз перечислить, в каких направлениях можно кастомизировать данную библиотеку:
  1. Space — можно создавать свои типы пространства ячеек
  2. CacheSpace — можно создавать новые типы отображения пространств, например, реализовать CacheSpaceCourusel — отображать список ячеек в виде карусельки
  3. View — создавать новые виды визуализаций для ячеек
  4. Animation — создавать новые анимации


Данная заметка является продолжением предыдущих двух: здесь и здесь. Проект qt-items является реализацией идей из этих заметок.

Идей и задач по дальнейшему развитию еще много, так что оставайтесь на связи.

Комментарии (13)


  1. RPG18
    14.04.2015 11:01
    +2

    Впечатляет, но в Qt есть еще один фреймворк это QtQuick. Я стараюсь использовать QtQuick везде, где нужно сильно кастомезированное UI.


    1. lexxmark Автор
      14.04.2015 12:02

      Да, в последнее время стало легко смешивать десктопные виджеты и QML-ные.
      Я решал задачи для инженерных приложений, там большие объемы данных нужно отображать, так что производительность — необходимое условие.

      Подскажите, содержимое QML контролов можно выводить на печать или экспортировать как изображение в файл?


      1. Gorthauer87
        14.04.2015 12:21

        Теоретически да, это же обычные узлы в графе сцены, но вот добраться до них может быть не очень тривиально.


        1. lexxmark Автор
          14.04.2015 12:33

          Да… похоже мульён записей обрабатывать QML будет тяжело.

          Еще в QML есть одна проблема (или достоинство) JS — слабо-типизированный язык — компилятор почти не следит за корректностью типов.
          Эта же проблема есть в Model/View framework — все данные идут через QVariant — полностью обезличивая их на этапе компиляции.


          1. Gorthauer87
            14.04.2015 20:48

            Мне кажется мильен записей никогда не должна рисоваться. А обработать видимое по зубам qml


            1. lexxmark Автор
              14.04.2015 21:00

              Да, само собой рисовать надо только видимые данные.
              Обработка данных — это обычно сортировка или фильтрация, где перебираются все данные.

              Гонять туда-сюда данные через QVariant и JS — это пока не для инженерных приложений, которыми я занимаюсь.

              А вот оживить приложение анимациями используя стандартные Qt items widgets почти невозможно. По крайней мере я не знаю как.


              1. dion
                15.04.2015 10:42

                Сортировка и фильтрация — это должна модель делать, т.е. C++, а не QML (который View).


              1. RPG18
                15.04.2015 11:21

                Для этих целей есть QAbstractProxyModel.
                В инженерных задачах обычно QAbstractItemModel является обёрткой вокруг структур данных/объектов из предметной области.
                Если прокси-модель знает об этих структурах данных, то можно обойтись без перегонки данных через QVariant.
                Если производительности не хватает, то можно в прокси-модель засунуть OpenMP


                1. lexxmark Автор
                  15.04.2015 13:05

                  Да, всё верно.

                  Жалко только, что QAbstractProxyModel слишком «абстрактный».
                  Если он предназначен для пересортировки и фильтрации исходной модели, почему бы не дать опциональную возможность запоминать перестановку строк и их видимость.

                  А так, приходится эти маппинги из исходной модели в изменённую каждый раз реализовывать заново (ну или делать свой базовый класс).


      1. zsilas
        15.04.2015 05:13

        Можно и даже удобно. У всех элементов унаследованных от Item (QQuickItem) есть метод bool grabToImage(callback, targetSize).


        1. lexxmark Автор
          15.04.2015 12:48

          Не знал о таком методе, спасибо за информацию.


  1. appsforlife
    15.04.2015 03:44

    А можно на основе этой технологии сделать что-то типа iOS-евых UICollectionView, где виджеты создаются на лету по требованию? Или для длинного списка в памяти будут все равно будут висеть вьюхи для каждого элемента?

    как работает UICollectionView
    В iOS список реализован следующим образом: программист определяет «виджет», который будет использован в качестве элемента списка. Потом программист сообщает виджету списка сколько в нем элементов, а дальше список просит у программиста предоставить ему виджет под таким-то номером (запрашиваются те, что видны в данный момент). Программист, прежде чем создавать новый виджет, спрашивает у списка — а не завалялось ли у него в данный момент невидимых виджетов, которые ушли за пределы видимой области? Если такие есть, то вместо создания нового виджета, программист просто перенастраивает старый.

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


    1. dion
      15.04.2015 10:45

      QML-ный ListView так и делает внутри. Т.е. создает делегатов сколько нужно чтобы влезло в экран (плюс скролл).