☰
QtCharts 实时曲线绘制与性能优化实践
2026/10/15 16:08:47 网站建设 项目流程

1. 项目设计与选型思路

1.1 为什么在实时曲线项目里选 QtCharts

做 Qt 实时曲线,第一反应通常不是 QtCharts,而是 QCustomPlot 或者 Qwt。这两个库在工业界存量很大,网上例程也多,很多老项目甚至从 MFC 时代就开始用 Qwt。但我在这个项目里反复对比之后,最后还是选了 QtCharts,理由其实不复杂。

曲线控件最怕的是“画得出图,却接不住数据”。QCustomPlot 用自绘的方式做渲染,交互响应确实快,但它毕竟是第三方库,每次 Qt 主版本升级都要等适配,而且如果要做跨平台发布,许可证和依赖链都要额外处理。Qwt 则是“老牌稳重”,深得嵌入式工程师喜欢,但它的 API 风格偏传统,现代 Qt 特性支持得不够顺滑,遇到 QML 混用或者高分屏缩放时,坑比想象中多。

QtCharts 是 Qt 官方模块,直接跟随 Qt 版本走,接口风格和 Qt Widgets 保持一致,工程里加一行QT += charts就能用,发布部署时不需要额外带一堆私有动态库。它在底层基于 QGraphicsView 渲染,看起来架构重,但换来的是默认就支持缩放、平移、图例、主题这些做实时监控界面几乎必用的功能。我最终选它的决定性因素,是项目还要在同一界面里放多组曲线的历史趋势对比,QtCharts 对多个 QLineSeries 的管理比自绘方案省太多事。

1.2 实时曲线的核心需求拆解

先把这个项目的需求说清楚。我这里要做的不是一个静态的曲线展示,而是“数据一边产生、曲线一边滚动”的监控画面。数据源模拟的是一个传感器通道,每 50 毫秒产生一个新数值,软件需要把最近 90 秒的数据画在图上,超过窗口的数据自动丢弃,同时 X 轴跟随最新时间一直向右滚动。

把这个需求拆开,其实就是三件事。

第一,数据采集。不管数据来自串口、网口、文件还是模拟器,最终都要能按固定频率拿到一个带时间戳的数值。这一步决定了后续所有逻辑的节奏。

第二,曲线刷新。拿到新数据后,要以足够低的延迟把它画到图表上。这里的关键不是“能画”,而是“持续画不卡”。如果只是偶然画一张静态图,任何库都能做到;但实时曲线是要在几小时甚至几天的运行周期里,每秒稳定追加几十个点,还要保持界面流畅。

第三,窗口控制。如果数据不断往 series 里塞,点会无限增长,图表迟早要被撑爆。所以必须设计一个滑动窗口,只保留当前时刻前 90 秒的数据点。窗口控制做得好不好,直接决定长时间运行后内存是否持续上涨。

这三个需求放在一起,才构成一个完整的“实时曲线绘制”项目。很多人只盯着第三个需求写代码,结果第一版跑起来桌面端还好,一放到配置低的机器上,CPU 占用全被图表吃光了。

1.3 刷新机制与方案取舍

实时曲线最直观的做法,是给图表绑定一个 QTimer,每 50 毫秒触发一次,在槽函数里往 QLineSeries 追加一个点。这个方案在数据量小的时候没有任何问题,我最早也是这么做的。

但深入想一下,图表刷新和数据显示其实是两回事。数据源自己有采集节奏,比如传感器每 20 毫秒上报一次,而界面刷新并不需要跟着这个节奏走。人的肉眼对曲线平滑度的感知是有上限的,每秒 20 帧左右的刷新频率已经足够,再高只是在浪费 CPU。因此更好的做法是把“数据到达”和“图表重绘”解耦,数据来了先进入一个缓冲容器,由界面侧决定以什么频率把缓冲里的数据一次性地画到曲线上。

这个取舍在初版看起来好像是多此一举,但当你把采样频率提到 100Hz、500Hz 的时候,两种方案的差距立刻就拉开了。我见过有人把图表刷新直接挂在每个数据点的信号上,数据点一秒 200 个,曲线也一秒重绘 200 次,CPU 直接被拉到满。事实上,绝大多数监控场景根本不需要那么高的视觉刷新率。

所以我在这个项目里把结构分成三层:DataSource 负责按固定频率产数据,MainWindow 负责接收并把数据交给 series,而 series 的更新策略则单独设计。后面核心实现部分,我会把这三层拆开细讲。

2. 工程搭建与基础框架实现

2.1 工程文件与基础窗口准备

先说工程配置。我用的环境是 Qt Widgets 项目,编译器是常见的 MSVC 套件,Qt 版本在 5.15 以上。如果用的是 Qt 6,QtCharts 模块的引入方式略有区别,但核心 API 基本一致。

.pro文件里关键就是这一行:

QT += core gui widgets charts

加不加widgets取决于项目里是否用传统的 QWidget 界面。Qt 6 以后部分模块单独拆分了,如果你编译时提示找不到 QtCharts 头文件,检查一下是不是少了模块或者安装 Qt 时没有勾选 Charts 组件。

主窗口的构造函数里,我直接用一个 QChartView 作为 centralWidget。这样整个窗口就只有一张图,方便调试。如果你的界面里还有操作按钮,那就再套一个 QSplitter 或 QVBoxLayout,把图表区域单独放到一块面板里。

需要注意的一点是,给 QChartView 设置一个合理的最小尺寸。如果不限制,窗口缩得很小时曲线会被压成一团,Y 轴数值完全看不清。我在项目里给图表区域设置的最小高度是 400 像素。

2.2 图表控件初始化流程

初始化 QtCharts 的流程看起来简单,但有三个容易漏掉的地方:series 必须 addAxis 之后 attachAxis,addAxis 的顺序影响轴的方向,以及 QChartView 默认会显示图例。

基础的初始化代码如下:

#include <QtCharts/QChartView> #include <QtCharts/QLineSeries> #include <QtCharts/QValueAxis> // 在 MainWindow 构造函数里 m_chart = new QChart(); m_series = new QLineSeries(); m_series->setName("温度曲线"); m_chart->addSeries(m_series); m_chart->legend()->setVisible(true); m_chart->setAnimationOptions(QChart::NoAnimation); m_axisX = new QValueAxis(); m_axisX->setTitleText("时间(s)"); m_axisX->setLabelFormat("%.0f s"); m_axisX->setTickCount(7); m_axisY = new QValueAxis(); m_axisY->setTitleText("温度(℃)"); m_axisY->setRange(0, 100); m_chart->addAxis(m_axisX, Qt::AlignBottom); m_chart->addAxis(m_axisY, Qt::AlignLeft); m_series->attachAxis(m_axisX); m_series->attachAxis(m_axisY); m_chartView = new QChartView(m_chart); m_chartView->setRenderHint(QPainter::Antialiasing); setCentralWidget(m_chartView);

这里我故意把setAnimationOptions(QChart::NoAnimation)放在前面。QChart 默认开启动画,实时曲线场景下动画不仅没有意义,还会占用额外的计算资源,曲线密集时会造成明显的延迟感。实时监控画面要的是跟手,不是过渡效果。

另一个容易踩的坑是attachAxis的顺序。必须在addSeries之后再addAxis,然后让 series 关联坐标轴,否则轴虽然加到了 chart 上,曲线却不会绘制上去。这个顺序问题非常隐蔽,我后面在问题排查部分会再讲。

2.3 数据源模块设计:把“数据采集”和“画图”解耦

数据采集这一层,我建议哪怕你只是做 Demo,也务必单独封装成一个类。原因很简单:业务逻辑会变。今天你用 QTimer 模拟数据,明天就可能要接串口、接 UDP、接数据库。如果采集逻辑和图表逻辑混在 MainWindow 里,改动的时候会牵连一片。

我这里的 DataSource 结构大概是这样的:

class DataSource : public QObject { Q_OBJECT public: explicit DataSource(QObject *parent = nullptr); signals: void dataReady(qreal timestamp, qreal value); public slots: void start(); void stop(); private slots: void tick(); private: QTimer m_timer; int m_count = 0; };

模拟实现里,计时器每 50 毫秒触发一次 tick,生成一个带噪声的正弦波数据。用正弦波的好处是调试时能直观看出曲线的形状,如果数据里加了噪声,可以顺便检验曲线在视觉上的平滑度。

void DataSource::tick() { m_count++; qreal t = m_count * 0.05; qreal v = 30.0 + 20.0 * qSin(t) + QRandomGenerator::global()->bounded(-2.0, 2.0); emit dataReady(t, v); }

MainWindow 只需要连接这个信号:

connect(&m_source, &DataSource::dataReady, this, &MainWindow::onDataReady);

这样数据源和图表就完全解耦了。后面改成从串口读取,只需在 DataSource 内部替换数据生产逻辑,MainWindow 一行都不用改。这个解耦思路,是实时采集类项目里最值得坚持的设计习惯。

3. 实时曲线的核心实现与性能调优

3.1 滚动窗口:append + remove 与 replace 两条路线

实时曲线最经典的实现是“追加 + 删除头部”。每次新点到达,往 series 尾部 append,同时判断最老的点是否已经超出了窗口范围,如果超出就 remove 掉。

代码长这样:

void MainWindow::onDataReady(qreal t, qreal v) { m_series->append(t, v); // 只保留最近 90 秒,也就是 90 / 0.05 = 1800 个点 while (m_series->count() > 0 && m_series->at(0).x() < t - 90.0) { m_series->remove(0); } m_axisX->setRange(t - 90.0, t); }

这个方案能跑,而且在小数据量时非常直观。但它的问题在于append和remove都会触发 QtCharts 内部的信号,每一次操作都可能引起坐标轴重新计算和整条曲线重绘。当点数量到几千个、每秒刷新几十次时,这些信号交互带来的开销就很可观了。

我在项目里实际改用的,是一个基于固定窗口的 replace 方案。思路是先用一个 QVector 保存点的数据,窗口容量大小等于90 / 0.05 = 1800。当窗口没满时直接追加;窗口满了之后整体前移一位、末尾填入新点,最后调用一次replace,把整条数据批量交给 series。

const int m_windowCount = 1800; QVector<QPointF> m_points; void MainWindow::onDataReady(qreal t, qreal v) { if (m_points.size() < m_windowCount) { m_points.append(QPointF(t, v)); } else { // 窗口满了,把点集整体左移一位,新点放到末尾 for (int i = 1; i < m_points.size(); ++i) { m_points[i - 1] = m_points[i]; } m_points.last() = QPointF(t, v); } m_series->replace(m_points); m_axisX->setRange(t - 90.0, t); }

QXYSeries::replace(const QVector<QPointF> &points)这个接口会一次性替换整条曲线的数据点,取代逐个 append/remove 的方式。虽然每帧都有一次 O(N) 的拷贝,但对于 1800 个点来说,这个成本远低于多次 remove 触发的图表内部信号风暴。我在实际测试里,同样 50ms 刷新频率,replace 方案在高点数量下的 CPU 占用比 append + remove 低了将近一半。

如果你不需要滚动窗口,而是想让曲线一直从头画到尾,那更简单,直接在series->append就行,把 X 轴范围设成setRange(0, t)。

3.2 坐标轴动态范围管理

坐标轴滚动是很多人容易忽略的部分。曲线数据每秒在变,X 轴如果不跟着动,数据很快就冲出画面。常见的做法是让 X 轴跟随最新时间点,永远显示“最近 90 秒”。

在 onDataReady 里这样设置:

m_axisX->setRange(t - 90.0, t);

这里有两个细节。第一,建议用setRange一次性设置最小值和最大值,避免分别调用setMin和setMax。因为两次调用会触发两次轴更新,曲线刷新频率高时会有闪烁感。第二,QValueAxis默认会显示浮点刻度,如果时间戳越来越大,刻度上的数字会很零碎。我习惯把setLabelFormat("%.0f")或者设置成“秒数”的显示格式,界面会清爽很多。

Y 轴的范围管理也要做,不然数据本身的变化范围不大时,Y 轴会从 0 一直画到一个很大的值,曲线细节完全看不出来。常见的策略有两种:固定量程和动态自适应。

固定量程适合传感器量程已知的场景,比如温度就是 0 到 100,直接把 Y 轴设成固定范围,省心且稳定。动态自适应适合变化范围不确定的场景,公式是我写了一个辅助函数,每次新数据到达时更新 Y 轴上限和下限。

这里有一个需要权衡的点:动态量程如果每次都用数据的 min/max,那么当曲线出现一个瞬时尖峰时,Y 轴会被瞬间拉得很大,视觉上整个曲线会“跳动”一次。我一般是给 Y 轴更新加一个滞回逻辑——只有当最新值超出当前 Y 轴范围一定比例时才去更新范围,小波动不理会。这样曲线稳定很多。

3.3 刷新频率、数据点规模与 CPU 占用的平衡

关于刷新频率,我的经验是:数据产生频率和曲线绘制频率不需要保持一致。数据源可以 50ms 来一个点,但图表重绘没必要跟着 50ms 疯狂跑。你要做的是在 onDataReady 里更新 points 数据,但画笔绘制的那部分由 QtCharts 内部根据事件循环调度。

不过实际工作中,我们通常还是会追求“数据来了就画出”。这里最关键的是别让多余的操作吃掉 CPU。

我做了三个优化:

第一,关闭动画。m_chart->setAnimationOptions(QChart::NoAnimation);动画对实时曲线百害无一利,尤其是点数上千之后,动画会让曲线拖影严重。

第二,按需开启 OpenGL 加速。QtCharts 的 QLineSeries 支持setUseOpenGL(true),实测在点数超过 5000 的曲线上效果明显。但注意,OpenGL 加速有兼容性限制,比如某些交互功能和部分轴类型不兼容,所以一般我建议点数低于 2000 时不必开。

第三,控制点集规模。90 秒窗口、50ms 一个点,点数是固定的 1800。如果需要更长的窗口,比如 10 分钟的监控,那点数会涨到 12000,此时即便 replace 单次拷贝也会显得吃力。这种情况下可以考虑降采样,比如窗口内超过 3000 个点时,每两个点保留一个。

我在这台开发机上做过的简单压测结果如下表,仅供参考,不同配置差异会很大:

方案数据量刷新频率大致 CPU 占用
append + remove1800 点20 fps20% 左右
replace 整批提交1800 点20 fps10%~15%
replace + OpenGL1800 点20 fps8%~12%

这组数据说明的问题很简单:瓶颈通常不在绘制本身,而在你触发重绘的方式。reduce 掉多余的内部刷新,CPU 立刻降下来。

4. 常见问题排查与避坑记录

4.1 曲线不显示,先检查这四个地方

新手最常遇到的情况是:代码什么都能编译过,界面也有坐标轴,但曲线上一个点都看不到。这种问题十有八九出在以下四件事上。

第一,addSeries和attachAxis的顺序。很多初学者在调用 addSeries 之前就把轴创建好,然后直接在轴上设范围,却忘了把 series attach 到轴。注意顺序是 addAxis 之后再 attachAxis。如果忘了 attach,曲线数据在,但坐标轴不知道要画它,画面自然一片空白。

第二,坐标轴范围设置不合理。比如 Y 轴设成了 0 到 1,而数据点在 30 到 50 之间,那曲线当然超出画面看不见。检查一下setRange的参数是否覆盖了实际数据范围。

第三,数据里有 NaN 或 inf。用qSin这类的函数生成模拟数据一般不会出问题,但如果数据源来自外部,异常数据点会导致整条曲线中断或者消失。可以在数据入口加一个过滤。

第四,series 没有 line。对了,还有一种是 setBrush 或者 setPen 设置成了透明颜色。有时候为了调试复制了一段设置样式的代码,结果把画笔弄成了透明,问题藏得特别深。

排查时我习惯先用一个固定值点测试:

m_series->append(0.0, 50.0); m_series->append(1.0, 60.0);

如果这两个固定点能显示出来,那问题就在动态数据或坐标轴范围上;如果连固定点都不显示,直接回去检查 attachAxis 和 axis 设置。

4.2 CPU 占用为什么无缘无故飙升

实时曲线项目跑一段时间后,CPU 占用慢慢升高是常见现象。如果你没有注意点集规模控制,这个问题几乎必然出现。

一个典型的反面案例是只做 append 不做 remove。运行一小时,每秒 20 个点,一小时累计 72000 个点。只要曲线一直 append,QtCharts 内部所有跟绘制有关的操作都要遍历这些点,CPU 自然越来越高,还伴随着内存上涨。

即使你做了窗口裁剪,如果用的是 append + remove,在每次调用 remove(0) 时 QtCharts 也要做内部数据的移动和信号发射。数据点一多,这个操作比想象中贵得多。所以我在调优部分强烈建议至少在数据量超过 2000 个点的时候切换到 replace 方案。

还有一个更隐蔽的原因,是 QChartView 默认在resizeEvent里会触发整条曲线的重绘。如果你把窗口设置成可随意拉伸,拖动窗口边缘时曲线会不断重算,CPU 一拉一个准。监控界面在调试阶段可以随意伸缩,但在正式运行时尽量固定窗口大小,会省出不少性能。

最后检查一下有没有误开动画。这个我提过多次,因为它太容易被遗漏了。创建 QChart 时默认是开动画的,你如果没设置过setAnimationOptions,那么曲线每追加一个点都会播放一段补间动画,实时刷新场景里 CPU 占用高得离谱。

4.3 跨线程刷新和界面卡顿

当数据源不在主线程时,情况会更复杂。比如从串口读取线程直接发信号给主线程的 onDataReady,这时候必须注意信号槽的连接方式。

默认的 AutoConnection,如果信号在子线程发出,接收槽会在主线程执行,这一步是正确的。但如果数据到达频率非常高,子线程向主线程发送的信号会在事件队列里堆积,导致主线程忙于处理事件,界面操作响应变慢。

我处理这个问题的办法有两个。

优先选择:不在子线程里直接发高频信号。子线程只负责把数据写入一个加锁的缓冲区,主线程的 QTimer 每隔 50ms 去缓冲区取一次数据并更新图表。这样无论采集线程产生多少数据,界面处理的数据量都是恒定的。

其次:如果信号必须直接发,连接时用Qt::QueuedConnection,并保持数据采集与绘制逻辑简单,避免在槽函数里做耗时操作。千万别在子线程里直接调用 series 的方法,Qt 的界面类原则上只能在主线程操作,跨线程访问轻则崩溃重则不确定行为。

4.4 长时间运行的稳定性问题

实时监控项目的运行时间经常是数小时甚至数天,所以稳定性要求比普通 Demo 高得多。

最容易出问题的就是内存增长。你可以通过任务管理器观察内存的上升趋势,如果曲线运行越久内存越大,优先检查数据点是否一直在增长。我之前在写滚动窗口代码时,第一版漏掉了 remove,只靠 X 轴范围去限制视觉区域,结果曲线数据一直在后台累积,跑了几个小时内存明显上涨。检查方法很简单,在 onDataReady 里加上:

qDebug() << m_series->count();

看个数是否永远增长。实时窗口曲线的数据点数应当是恒定不变的。

内存问题排除后,再看 CPU 占用曲线。如果是缓慢上升,考虑是不是 QChart 内部缓存的数据点越来越多;如果是一阵一阵的尖峰,多半是某段时间数据频率变高或者是垃圾回收在跑。尖峰还可以接受,持续高占用不行。

还有一个我在项目中遇到的细节:QtCharts 在高分屏缩放下,默认的轴标签字体可能模糊或者重叠。建议在创建 chart 时设置一下字体:

m_axisX->setLabelsFont(QFont("Microsoft YaHei", 9)); m_axisY->setLabelsFont(QFont("Microsoft YaHei", 9));

长时间运行后,曲线和标签的稳定清晰度,决定了这个监控界面能不能真正投入日常使用。

5. 后续扩展与个人实操体会

5.1 多曲线、图例与交互

做实时监控很少只画一条曲线。我在项目里同时画了温度和湿度两条曲线,QLineSeries 的加法很简单,再加一个 series 并 attach 同样的轴就行。

多曲线的关键难点在于每条曲线刷新频率和数据量可能不一样。这时候就不能在同一个 onDataReady 里写死逻辑,而应该给每个 series 分配独立的数据缓冲,或者把它们封装成一个 CurveModel 对象。否则当数据不再同步时,一个系列的数据更新会引起另一个系列整批 replace,性能直接受影响。

图例建议保留,默认 chart 右上角会显示。如果你希望图例显示在底部,可以设置:

m_chart->legend()->setAlignment(Qt::AlignBottom);

如果你还想支持鼠标缩放和拖动查看,QChartView 有内置的逻辑,只要设置 rubber band 或者自定义交互就能实现。但注意,开启交互后最好关闭 OpenGL 加速,因为某些交互操作会和 OpenGL 渲染冲突,我在实际项目里踩过一次,导致缩放时画面直接空白。

5.2 让曲线“活”得更久:数据缓冲与落盘

实时曲线不仅要画,很多时候还要把数据记录下来,供事后分析。如果你只是把数据 append 给 series 就完事,关掉界面数据全丢,非常可惜。

我建议在 DataSource 和 MainWindow 之间再增加一个 DataRecorder 模块,负责把每次 dataReady 的数据以追加方式写入本地文件或数据库。记录和显示要并行,而且记录不能阻塞显示线程。最简单的方式是让 DataRecorder 成为一个独立对象,内部自己开文件流,把数据写进缓冲区,由另一个线程把它刷到磁盘。

这里要注意,高频写入文件会拖慢整个程序,所以落盘频率不要跟着数据频率走。我习惯每攒够 500 条记录才批量写入一次,既保证不丢数据,又不影响实时绘图的流畅度。

5.3 我实际做这个项目时最想分享的点

如果让我总结这次做实时曲线项目最大的收获,不是学会了 QtCharts 的 API,而是明白了“实时”这两个字全部藏在细节里。

“实时”不代表你每秒往 series 里塞几个点就完事。它考验的是你对数据链路每一环的控制力:数据源产出的节奏、缓冲区的管理、series 的更新策略、坐标轴的动态范围、以及界面事件的调度。任何一个环节失控,曲线就不再“实时”——要么数据被丢弃,要么界面卡成幻灯片。

我自己的习惯是,每写到一个关键点,就在代码里留一个简单的日志输出。比如在数据入口打印当前时间戳和数据值,在 series 更新后打印点数。这些日志在调试阶段看似多余,但一旦在真机上遇到问题,能帮你瞬间定位是哪一层出了问题。

最后再分享一个很小的细节:调 antialiasing。QChartView 上我开了抗锯齿,曲线观感会平滑很多。但开抗锯齿也有代价,点数多到一定程度时绘制开销上升。如果你的曲线点数常年低于 2000,放心开;如果到了上万点,建议关掉抗锯齿,优先保刷新速度。这个选择没有标准答案,取决于你的数据和设备,多试两次就能找到自己项目里的舒服点。

把 QtCharts 的动态刷新做顺了之后,你再回来看这份代码会发现,它已经不只是“一个画曲线的 Demo”了。数据源、缓冲、绘图、落盘各层分的清清楚楚,以后接真实的硬件采集、加告警判断、扩展成多路监控面板,都是在现有骨架上加砖添瓦的事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询