Введение
Графики, отображающие данные в реальном времени — телеметрию датчиков, биржевые котировки, аудио-визуализацию — предъявляют принципиально иные требования к производительности, чем статичные графики: данные нужно обновлять десятки раз в секунду без видимых просадок частоты кадров и без неконтролируемого роста потребления памяти при неограниченно растущем потоке новых точек. В статье разберём практические приёмы построения производительных real-time графиков на основе QCustomPlot.
Концепция
Ключевые приёмы для real-time графиков: (1) ограничение числа хранимых точек данных через скользящее окно (старые точки, ушедшие за пределы видимого временного диапазона, удаляются, чтобы не накапливать неограниченно растущий массив); (2) использование QTimer с разумным интервалом обновления (соответствующим реальной частоте кадров экрана, обычно 30-60 раз в секунду, а не чаще, что не даёт визуального выигрыша, но нагружает CPU); (3) частичная перерисовка вместо полного replot() там, где библиотека это поддерживает (например, QCustomPlot::replot(QCustomPlot::rpQueuedReplot) для объединения нескольких запросов перерисовки в один кадр).
Пример кода
#include "qcustomplot.h"
#include <QTimer>
#include <QElapsedTimer>
#include <QRandomGenerator>
class RealtimePlotWidget : public QWidget
{
Q_OBJECT
public:
explicit RealtimePlotWidget(QWidget *parent = nullptr) : QWidget(parent)
{
m_plot = new QCustomPlot(this);
auto *layout = new QVBoxLayout(this);
layout->addWidget(m_plot);
m_graph = m_plot->addGraph();
m_graph->setPen(QPen(Qt::darkGreen, 2));
m_plot->xAxis->setLabel("Время, с");
m_plot->yAxis->setLabel("Значение датчика");
m_plot->yAxis->setRange(-1.2, 1.2);
m_elapsedTimer.start();
// Частота обновления данных синхронизирована с частотой кадров экрана
m_updateTimer = new QTimer(this);
connect(m_updateTimer, &QTimer::timeout, this, &RealtimePlotWidget::addDataPoint);
m_updateTimer->start(16); // ~60 кадров в секунду
}
private slots:
void addDataPoint()
{
const double currentTime = m_elapsedTimer.elapsed() / 1000.0;
const double value = std::sin(currentTime * 2.0)
+ 0.05 * QRandomGenerator::global()->generateDouble();
m_graph->addData(currentTime, value);
// Скользящее окно: храним только последние 10 секунд данных,
// удаляя устаревшие точки, чтобы память не росла неограниченно
const double windowSeconds = 10.0;
m_graph->data()->removeBefore(currentTime - windowSeconds);
m_plot->xAxis->setRange(currentTime - windowSeconds, currentTime);
// Объединяет несколько запросов перерисовки в кадре в одну операцию
m_plot->replot(QCustomPlot::rpQueuedReplot);
}
private:
QCustomPlot *m_plot;
QCPGraph *m_graph;
QTimer *m_updateTimer;
QElapsedTimer m_elapsedTimer;
};
Пояснения к коду
m_updateTimer->start(16) запускает обновление данных с интервалом примерно 16 миллисекунд, что соответствует частоте около 60 раз в секунду — синхронизация с типичной частотой обновления экрана избегает как избыточной нагрузки на CPU от слишком частых обновлений, не дающих визуального выигрыша, так и заметных «рывков» от слишком редких. m_graph->data()->removeBefore(currentTime - windowSeconds) — критичный для долгоиграющих real-time графиков вызов: без него внутренний контейнер данных графика рос бы неограниченно на протяжении всего времени работы приложения, в конце концов исчерпывая память.
m_plot->xAxis->setRange(currentTime - windowSeconds, currentTime) сдвигает видимый диапазон оси X вместе с поступлением новых данных, создавая эффект «плывущего» графика, в котором новые данные постоянно появляются справа, а старые уходят за левую границу видимой области — типичная визуализация для мониторинга показаний в реальном времени.
Подводные камни
- Отсутствие ограничения скользящего окна данных (забытый вызов
removeBefore()или аналога) — на первый взгляд работающий код может постепенно замедляться по мере накопления всё большего числа точек данных в памяти, что особенно заметно при длительной непрерывной работе приложения (часы или дни), а не за время короткого тестирования при разработке. - Слишком частое обновление таймера без видимого выигрыша. Установка интервала таймера короче, чем реальная частота обновления экрана (например, 1 мс вместо 16), не даёт дополнительной визуальной плавности (поскольку экран физически не может отрисовать больше кадров, чем позволяет его частота обновления), но создаёт избыточную нагрузку на CPU и может конкурировать за ресурсы с другой логикой приложения.
- Накопление случайного «шума» точек данных без визуальной агрегации при сильном уменьшении временного масштаба. Если пользователь масштабирует график для просмотра данных за длительный период (где видимое окно содержит намного больше точек, чем пикселей по горизонтали доступно для отображения), отрисовка каждой отдельной точки избыточна — для таких случаев стоит реализовать предварительную агрегацию (например, отображение минимума/максимума/среднего за интервал) вместо отрисовки каждой «сырой» точки.
- Блокирующие операции внутри обработчика таймера обновления данных (например, синхронное чтение из файла или сети для получения нового значения) непосредственно в
addDataPoint()могут вызывать заметные «заикания» графика, поскольку весь UI-поток блокируется на время этой операции — получение данных из медленных источников должно происходить асинхронно, в отдельном потоке, а в обработчик таймера должны передаваться только уже готовые значения.