Оптимизация GUI на Qt

Kate

Administrator
Команда форума
Как правило, при создании desktop-приложений на платформе Qt не возникает проблем, связанных с медленностью работы GUI. Qt – платформа достаточно надежная, неплохо вылизанная по всем параметрам, в том числе и по скорости работы. Однако всё же иногда бывают ситуации, когда из-за обилия виджетов графический интерфейс немного притормаживает, и это печально). В этой статье я приведу один частный пример простого графического интерфейса и покажу, как за два шага можно сначала ускорить его в 11 раз, а потом и в целых 34 раза. Вдобавок к этому, я постараюсь немного осветить механизм принятия решения для таких оптимизационных задач, постараюсь показать направление мыслей для правильного решения. Поехали!

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

Итак, задачу в студию!

Нам надо нарисовать в две колонки список параметров: имя и значение. Параметров много. Они могут быть сгруппированы в группы с общим заголовком. Таким образом, у нас будет виджет с небольшой шириной, но с большой высотой. Его, конечно, стоит поместить в QScrollArea. Собственно, вот этот виджет:

6a8d9d82333719c394b728d3bf54124d.png

Код виджета был написан когда-то давно, быстро и просто, и он содержал в себе намек на будущее расширение функциональности, то есть, говоря по-честному, некоторую функциональную избыточность. Вот этот код.

Файл FormLayoutWgt.h
#pragma once

#include <QScrollArea>
#include <QFormLayout>

class FormLayoutWgt : public QScrollArea
{
Q_OBJECT
public:
FormLayoutWgt(QWidget* parent = 0);
virtual ~FormLayoutWgt();

typedef QList< QPair<QString, QWidget*> > WidgetList;

void setContents(const WidgetList& widgetList);

QSize sizeHint() const;

public slots:
void clear();

private:
QFormLayout* _pLayout;
};
Файл FormLayoutWgt.cpp
#include "FormLayoutWgt.h"

FormLayoutWgt::FormLayoutWgt(QWidget* parent)
: QScrollArea(parent)
{
QWidget* pWidget = new QWidget;
setWidget(pWidget);
setWidgetResizable(true);

_pLayout = new QFormLayout;
_pLayout->setLabelAlignment(Qt::AlignLeft);
_pLayout->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow);
pWidget->setLayout(_pLayout);
}

FormLayoutWgt::~FormLayoutWgt()
{
clear();
}

void FormLayoutWgt::clear()
{
if (_pLayout != 0)
{
QLayoutItem* item;
for (int i = 0; i < _pLayout->rowCount(); i++)
{
item = _pLayout->itemAt(i, QFormLayout::LabelRole);
if (item != 0) delete item->widget();

item = _pLayout->itemAt(i, QFormLayout::FieldRole);
if (item != 0) delete item->widget();
}
int count = _pLayout->rowCount();
for (int i = 0; i < count; i++)
_pLayout->removeRow(0);
}
}

void FormLayoutWgt::setContents(const WidgetList& widgetList)
{
for (int i = 0; i < widgetList.size(); i++)
{
if (widgetList.at(i).first.isEmpty())
_pLayout->addRow(widgetList.at(i).second);
else
_pLayout->addRow(widgetList.at(i).first, widgetList.at(i).second);
}
}

QSize FormLayoutWgt::sizeHint() const
{
return QSize(270, 200);
}
А вот примерный сценарий использования такого виджета.
Файл MainWindow.h
#pragma once

#include "FormLayoutWgt.h"

class MainWindow : public QWidget
{
Q_OBJECT

public:
MainWindow(QWidget *parent = Q_NULLPTR);

public slots:
void fill();

private:
FormLayoutWgt::WidgetList generateContents() const;
FormLayoutWgt* _flw;
};
Файл MainWindow.cpp
#include "MainWindow.h"

#include <QVBoxLayout>
#include <QPushButton>

MainWindow::MainWindow(QWidget *parent)
: QWidget(parent)
{
QVBoxLayout* layout = new QVBoxLayout();
setLayout(layout);

_flw = new FormLayoutWgt();

QPushButton* fill = new QPushButton("Fill");
QPushButton* clear = new QPushButton("Clear");

layout->addWidget(fill);
layout->addWidget(clear);
layout->addWidget(_flw);

connect(clear, SIGNAL(released()), _flw, SLOT(clear()));
connect(fill, SIGNAL(released()), SLOT(fill()));
}

FormLayoutWgt::WidgetList MainWindow::generateContents() const
{
FormLayoutWgt::WidgetList widgetList;

for (int i = 0; i < 300; i++)
{
widgetList << qMakePair(QString(), new QLabel(QString("<H3>Group%1</H3>").arg(i + 1)));
widgetList << qMakePair(QString("Field1"), new QLabel("Value1"));
widgetList << qMakePair(QString("Field2"), new QLabel("Value2 long long long long"));
widgetList << qMakePair(QString("Field3\n"), new QLabel("Value3 \n two rows"));
widgetList << qMakePair(QString(), new QLabel("==========================="));
}

return widgetList;
}

void MainWindow::fill()
{
auto content = generateContents();
_flw->setContents(content);
}
Получили вот такое тестовое мини-приложение, которое и хотим ускорить:
7d2885d83b6d068a38fe767b4589a535.png

Код виджета был написан когда-то давно, когда число параметров было небольшое – до нескольких десятков. И он исправно и быстро работал, но до тех пор, пока число параметров не возросло до нескольких сотен (или даже тысяч). GUI стало визуально притормаживать. Общее впечатление пользователя от мгновенно работающего приложения стало немного смазываться. И, в принципе, не стоит винить в этом старый код, ведь он писался для априори более простой задачи.
Итак, перед нами замаячила задача оптимизации. Как ее решать? Для начала, конечно, найти слабое место. И вот тут первая проблема: профилировщик нам тут особо не поможет. Казалось бы, что создание и добавление виджетов – и есть то слабое место. То есть вот эта часть кода:
_flw->setContents(content);
Но нет, измерение времени показывает тут какие-то жалкие 7 мс. А своими глазами мы видим, что GUI замирает почти на секунду. То есть события (мыши, клавиатуры) перестают обрабатываться на это время. И да, профилировщик показывает, что именно в цикле обработки событий и идут те самые непонятные вычисления.
В чем же здесь дело? Дело в самом Qt. А именно, в сложных алгоритмах ядра работы с виджетами. Дело в том, что, вызывая какую-либо команду в Qt, связанную с виджетами, Вы не можете никогда надеяться на то, что эта команда будет исполнена прямо сейчас. Часто она просто ставится в очередь задач. А исполнение задач из этой очереди может происходить как в отдельном потоке, так и в цикле обработки событий в основном потоке. Это, на самом деле, очень хорошая особенность Qt, благодаря которой мы получаем в целом очень быстрый GUI. Так, вызывая функцию QLabel::setText тысячу раз подряд с одним и тем же параметром, мы не получаем тысячекратного замедления, а получаем лишь однократную перерисовку QLabel с последним поданным значением параметра.
Конкретно в нашей задаче эта особенность Qt повлияла лишь на то, что стало сложнее понимать, как замерить наши тормоза. Что ж, придется немного поиграть с бубном. Для этого добавим таймер, запустим его после добавления всех данных на лэйаут, а остановим при первом вызове события resizeEvent.
void FormLayoutWgt::setContents(const WidgetList& widgetList)
{
for (int i = 0; i < widgetList.size(); i++)
{
if (widgetList.at(i).first.isEmpty())
_pLayout->addRow(widgetList.at(i).second);
else
_pLayout->addRow(widgetList.at(i).first, widgetList.at(i).second);
}
_timer.start();
}

void FormLayoutWgt::resizeEvent(QResizeEvent* event)
{
if (_timer.isValid())
{
qDebug() << "gui time = " << _timer.elapsed();
_timer.invalidate();
}
QScrollArea::resizeEvent(event);
}
Получили 703 мс, и это как раз то замедление, которое видят наши глаза, и которое послужило причиной оптимизации.
Что дальше? Как ускорить-то, если вся работа фактически происходит в затаенных глубинах Qt, в которые у нас доступа нет? (Да, если кто-то думает, что можно взять исходники Qt и немного подрихтовать, то эта плохая идея, но зато лучший способ убить пару месяцев.)
На самом деле все просто. Просто понять, почему такой долгий расчет идет в глубине кода Qt. Мы пытаемся разместить виджеты на QFormLayout. То есть, фактически, на табличном лэйауте. А ему для отрисовки каждого виджета нужно знать координаты, на которых рисовать. И также ширину и высоту. А значит, нужно опросить каждый виджет, в каких размерах ему будет комфортно отрисоваться. То есть как минимум, обратиться к функции sizeHint() для каждого виджета. А ведь есть еще функция QWidget::heightForWidth(int). А еще у виджетов могут быть разные политики QSizePolicy… И это все надо лэйауту учесть до отрисовки, потом провести расчет координат всех строк и столбцов, и только потом можно рисоваться. Потому неудивительно, что такой расчет для нескольких сотен виджетов может быть не такой мгновенный, к которому мы привыкли.
Хорошо, это мы поняли, а что с этим делать? Можно попытаться по сути сделать то же самое, что делает QFormLayout, но сэкономить при этом на обёртках. Получим немного больше кода, но более быстрого. За базу для отрисовки будем брать QTableWidget (ну раз мы поняли, что мы фактически таблицу и рисуем). Получим такой код (в нем сразу привожу танцы с бубном по поводу замера времени):
Файл TwoColumnWgt.h
#pragma once

#include <QTableWidget>
#include <QElapsedTimer>

class TwoColumnWgt : public QTableWidget
{
public:
TwoColumnWgt(QWidget* parent = 0);
virtual ~TwoColumnWgt();

typedef QList< QPair<QString, QWidget*> > WidgetList;

void setContents(const WidgetList& widgetList);
void clear();

protected:
virtual void paintEvent(QPaintEvent* event);

private:
QElapsedTimer _timer;
};
Файл TwoColumnWgt.cpp
#include "TwoColumnWgt.h"

#include <QHeaderView>
#include <QTime>
#include <QDebug>

TwoColumnWgt::TwoColumnWgt(QWidget* parent)
: QTableWidget(parent)
{
setColumnCount(2);
verticalHeader()->hide();
horizontalHeader()->hide();
setShowGrid(false);
setWordWrap(false);
setSortingEnabled(false);
setSelectionMode(QAbstractItemView::NoSelection);
setFocusPolicy(Qt::NoFocus);
setHorizontalScrollMode(QAbstractItemView::ScrollPerPixel);
setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
verticalHeader()->setSectionResizeMode(QHeaderView::Fixed);
verticalHeader()->setDefaultSectionSize(QFontMetrics(this->font()).height());

auto pal = palette();
pal.setColor(QPalette::Base, pal.color(QPalette::Window));
setPalette(pal);
}

TwoColumnWgt::~TwoColumnWgt()
{
clear();
}

void TwoColumnWgt::clear()
{
for (int i = rowCount() - 1; i >= 0; i--)
model()->removeRow(i);
}

void TwoColumnWgt::setContents(const WidgetList& widgetList)
{
int maxW1 = 0;
int maxW2 = 0;
int maxSpanW = 0;
QFontMetrics fm(this->font());
int pixelsHigh = fm.height();
int hOffset = pixelsHigh / 2;

setRowCount(widgetList.size());

for (int row = 0; row < widgetList.size(); row++)
{
verticalHeader()->setSectionResizeMode(row, QHeaderView::Fixed);
QWidget* w = widgetList.at(row).second;
QString title = widgetList.at(row).first;
if (title.isEmpty())
{
setSpan(row, 0, 1, 2);
setCellWidget(row, 0, w);

QSize sh = w->sizeHint();
verticalHeader()->resizeSection(row, sh.height() + hOffset);
maxSpanW = std::max<int>(sh.width(), maxSpanW);
}
else
{
int pixelsWide = fm.horizontalAdvance(title);
maxW1 = std::max<int>(pixelsWide, maxW1);

setItem(row, 0, new QTableWidgetItem(title));
setCellWidget(row, 1, w);

QSize sh = w->sizeHint();
verticalHeader()->resizeSection(row, sh.height() + hOffset);
maxW2 = std::max<int>(sh.width(), maxW2);
}
}

int wOffset = fm.horizontalAdvance("123");
maxW1 += wOffset;
maxW2 = std::max<int>(maxSpanW - maxW1, maxW2);
horizontalHeader()->resizeSection(0, maxW1);
horizontalHeader()->resizeSection(1, maxW2);
_timer.start();
}

void TwoColumnWgt::paintEvent(QPaintEvent* event)
{
if (_timer.isValid())
{
qDebug() << "gui time = " << _timer.elapsed();
_timer.invalidate();
}
QTableWidget::paintEvent(event);
}
Этот код уже показывает выполнение в 65 мс против старых 710. Ускорение в 11 раз. Причем, вся работа уже делается именно в коде TwoColumnWgt::setContents, а не в цикле обработки событий Qt. По крайней мере, теперь можно отказаться от трюков при замерах времени.
Хорошо, ускорили в 11 раз, стоит ли продолжать? Однозначно да. Во-первых, такая оптимизация может не работать при переходе на следующую версию Qt (мы же сэкономили на внутренних обертках Qt, а их реализация может поменяться, значит, и прирост скорости может нивелироваться). Во-вторых, ускорение в 11 раз мы получили именно на этом примере, а на других похожих может получиться и другая, меньшая цифра (например, попробуйте увеличить длину текста, и увидите, что коэффициент ускорения упадет).
Как можно ускорить еще? Да просто двигаться в этом же направлении. Мы уже перешли от QFormLayout с ячейками-виджетами к QTableWidget с ячейками-виджетами. Осталось совсем избавиться от виджетов внутри QTableWidget, заменить их на более легковесные (и привычные таблицам) ячейки QTableWidgetItem. При этом, как может показаться, мы потеряем в функциональности, потому что раньше мы же могли ставить ЛЮБЫЕ виджеты в таблицу, а теперь только текст! На самом деле, и раньше не требовались ЛЮБЫЕ виджеты, раз уж мы говорим про конкретную задачу с двумя колонками «имя-значение». Нужен так или иначе текст, а значит, в QTableWidgetItem мы вписываемся. Но если всё же действительно требуется нарисовать что-то эдакое, то тогда нужно будет переносить это на делегаты, и это тоже будет довольно быстро работать.
К этой мысли – что от виджетов в таблице надо отказываться – можно прийти и другим путем. Запустив профилировщик, мы увидим, что критическое место – замеры размеров виджета (sizeHint). То есть надо заменить эти замеры на какие-то другие, более простые и быстрые. А это могут быть только функции класса QFontMetrics.
Итак, с этим пониманием упростим тогда систему входных
параметров и изменим немного код. Также приблизим конечную визуализацию
(размеры шрифтов, отступы) к начальной. Получим вот это:
58971eca51c48c61fa6d8e170ad875b2.png
Файл tcwvar.h
#pragma once

#include <QString>
#include <variant>

struct TcwContent
{
QString title;
QString value;
};

struct TcwHeader
{
QString title;
};

struct TcwSeparator
{
};

using TcwVariant = std::variant<TcwContent, TcwHeader, TcwSeparator>;
Файл TwoColumnWgt.h
#pragma once

#include "tcwvar.h"

#include <QTableWidget>

class TwoColumnWgt : public QTableWidget
{
public:
TwoColumnWgt(QWidget* parent = 0);

void setContents(const std::vector<TcwVariant>& variants);
void clear();

private:
QString separatorText(const std::vector<TcwVariant>&) const;
};
Файл TwoColumnWgt.cpp
#include "tcw.h"

#include <QHeaderView>
#include <QFontMetrics>

TwoColumnWgt::TwoColumnWgt(QWidget* parent)
: QTableWidget(parent)
{
setColumnCount(2);
verticalHeader()->hide();
horizontalHeader()->hide();
setShowGrid(false);
setWordWrap(false);
setSortingEnabled(false);
setSelectionMode(QAbstractItemView::NoSelection);
setFocusPolicy(Qt::NoFocus);
setHorizontalScrollMode(QAbstractItemView::ScrollPerPixel);
setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
verticalHeader()->setSectionResizeMode(QHeaderView::Fixed);
verticalHeader()->setDefaultSectionSize(QFontMetrics(this->font()).height());

auto pal = palette();
pal.setColor(QPalette::Base, pal.color(QPalette::Window));
setPalette(pal);
}

void TwoColumnWgt::clear()
{
for (int i = rowCount() - 1; i >= 0; i--)
model()->removeRow(i);
}

void TwoColumnWgt::setContents(const std::vector<TcwVariant>& variants)
{
QFontMetrics fm(this->font());
int maxW1 = 0;
int maxW2 = 0;
int maxSpanW = 0;
int pixelsHigh = fm.height();
int hOffset = pixelsHigh / 2;
QString sepText = separatorText(variants);

setRowCount(variants.size());

for (int row = 0; row < variants.size(); row++)
{
if (const TcwContent* content = std::get_if<TcwContent>(&variants[row]))
{
// add left margin
QString title = " " + content->title;

// add two cells
setItem(row, 0, new QTableWidgetItem(title));

auto* item = new QTableWidgetItem;
item->setData(0, content->value);
setItem(row, 1, item);

// calculate sizes
int pixelsWide = fm.horizontalAdvance(title);
maxW1 = std::max<int>(pixelsWide, maxW1);

QFontMetrics fm(item->font());
auto rect = fm.boundingRect(QRect(0, 0, 1000, 1000),
Qt::AlignTop | Qt::AlignLeft, content->value);
maxW2 = std::max<int>(rect.width(), maxW2);

// correct row height
verticalHeader()->resizeSection(row, rect.height() + hOffset);
}
else if (const TcwHeader* header = std::get_if<TcwHeader>(&variants[row]))
{
// add left margin
QString title = " " + header->title;

// add cell
setSpan(row, 0, 1, 2);

auto* item = new QTableWidgetItem;
auto font = item->font();
font.setBold(true);

constexpr double fontRatio = 10.0 / 8.25;
int pixelSize = font.pixelSize();
double pointSizeF = font.pointSizeF();
if (pointSizeF != -1)
font.setPointSizeF(pointSizeF * fontRatio);
else
font.setPixelSize(qRound(pixelSize * fontRatio));

item->setFont(font);
item->setData(0, title);
setItem(row, 0, item);

// calculate sizes
QFontMetrics fm(font);
int hw = fm.horizontalAdvance(title) + fm.horizontalAdvance("1");
maxSpanW = std::max<int>(hw, maxSpanW);
}
else if (const TcwSeparator* separator = std::get_if<TcwSeparator>(&variants[row]))
{
// add cell
setSpan(row, 0, 1, 2);
auto* item = new QTableWidgetItem;
item->setData(0, sepText);
setItem(row, 0, item);

// correct row height
verticalHeader()->resizeSection(row, hOffset);
}
}

//correct column sizes
maxW1 += fm.horizontalAdvance("12");
horizontalHeader()->resizeSection(0, maxW1);
if (maxW2 > maxSpanW - maxW1)
resizeColumnToContents(1);
else
horizontalHeader()->resizeSection(1, maxSpanW - maxW1);
}

QString TwoColumnWgt::separatorText(const std::vector<TcwVariant>& variants) const
{
// calculate column sizes
int column1Size = 0;
int column2Size = 0;
for (auto& variant : variants)
{
if (auto* content = std::get_if<TcwContent>(&variant))
{
column1Size = std::max<int>(content->title.size(), column1Size);
auto list = content->value.split('\n');
for (auto item : list)
column2Size = std::max<int>(item.size(), column2Size);
}
}

// add left margin
return " " + QString(column1Size + column2Size, '-');
}
Файл MainWindow.cpp (фрагмент)
std::vector<TcwVariant> MainWindow4::generate() const
{
std::vector<TcwVariant> results;

for (int i = 0; i < 300; i++)
{
results.emplace_back(TcwHeader{ QString("Group%1").arg(i + 1) });
results.emplace_back(TcwContent{ "Field1", "Value1" });
results.emplace_back(TcwContent{ "Field2", "Value2 long long long long" });
results.emplace_back(TcwContent{ "Field3\n", "Value3 \n two rows" });
results.emplace_back(TcwSeparator());
}

return results;
}
Это решение работает уже 21 мс, то есть в 34 раза быстрее, чем исходное решение.

Итог​

Оптимизационные задачи, связанные с GUI, на Qt возникают не так уж часто, поскольку платформа Qt сама по себе очень неплоха (я не говорю про задачи типа «нарисовать график из миллиарда точек», там, конечно, нужно писать свою быструю библиотеку, как это сделали мы). Но, когда такие задачи возникают на обычном офисном GUI (набор простых контролов и форм), их всё же можно решать относительно несложными действиями и с весьма приятным результатом по скорости. Однако, как правило, всё же чем-то (немного) придется жертвовать, ибо чудес не бывает. Благо, что в архитектуре и в требованиях к софту, скорее всего, заложена некоторая избыточность, и вот ей как раз и можно пожертвовать.


 
Сверху