简介:一套基于Qt5与QCustomPlot的实时波形绘制控件资源,面向需要快速搭建上位机、实时曲线或数据监测界面的Qt初学者,目的是降低实时绘图功能的入门与集成难度。核心组件为WidgetPlot2D,内部集成QCustomPlot源码,提供initGraphName与addData两个简洁接口,无需深入理解绘图底层即可为多条曲线实时追加数据,适合波形、频谱、传感器数据等多种动态展示场景,也可作为自定义绘图控件的封装范例。资源共15个文件,涵盖cpp/h源码、ui界面文件、png图标、qrc资源及pro工程配置,压缩包仅459KB,体积小巧、结构清晰,便于直接拷贝到工程或作为子模块嵌入现有项目。已有9653人学习使用,配套博客对QCustomPlot的文件引入、pro配置和调用接口作了说明,可帮助理解控件在实际项目中的接入方式;对于希望快速产出可运行上位机原型或学习Qt绘图封装的开发者,是一份轻量且实用的起步资料。
1. 实时曲线没那么玄:QCustomPlot 到底帮你省了什么
做采集软件、设备调试工具或传感器监控界面时,"实时波形"几乎是绕不开的一块。很多人第一反应是自己拿 QPainter 画,画着画着发现又要缩放又要拖动又要滚动窗口,还动不动就卡;换上 Qt Charts 后,又觉得交互细节不太受控。QCustomPlot 在这类场景里一直是被反复验证的开源方案:它把绘图控件、坐标轴、曲线对象和数据管理都封装好了,你要做的只是决定"数据怎么进来、多久刷一次、窗口怎么滚动"。本文按一条落地路线走:先说明为什么选它、再给一套最小可运行代码,然后处理性能、踩坑和进阶技巧。适合正在做实时采集上位机、想快速把波形模块做稳的开发者,也适合从自绘/Qt Charts 切换过来的人。
2. 选型与原理:为什么实时曲线用 QCustomPlot 而不是自绘或 Qt Charts
2.1 三种方案的取舍:自绘、Qt Charts、QCustomPlot
先解决一个经常被问的问题:Qt 明明自带绘图系统,官方也有 Qt Charts,为什么还要引入第三方 QCustomPlot?这取决于你的实时场景对"交互"和"性能控制"的要求有多高。自绘 QPainter 最灵活,但所有的事都要自己管:坐标变换、曲线对象管理、鼠标事件、缩放逻辑、重绘优化。做一个简单的静态曲线没问题,一旦要支持鼠标框选缩放、拖拽坐标轴、滚动窗口,这些活会迅速变成几千行与业务无关的代码。
Qt Charts 胜在 Qt 官方维护、API 干净,但它是基于 QGraphicsView 框架实现的,曲线数据多、刷新频繁时的开销不算小,而且一些底层行为(比如 replot 的时机、画布图层的控制)它不太愿意让你碰。QCustomPlot 则把"绘制"收敛到单个 QWidget 里,用 QCPGraph、QCPAxis、QCPLayer 这样的对象模型组织场景,重绘走自己的 replot(),内存和 CPU 的增量都可以预估。实际做采集类工具,我一般首选 QCustomPlot,不是因为它功能最多,而是因为它把实时场景最常见的需求都预设好了。
三种方案的使用场景可以这样区分:如果只是偶尔显示一条曲线、不需要缩放拖动,自绘最快;如果项目强依赖 Qt 官方组件且曲线量小,Qt Charts 够用;如果要长时间运行、频繁刷新、需要滚动窗口和多曲线,QCustomPlot 的性价比更高。
| 方案 | 上手速度 | 高频刷新开销 | 交互(缩放/拖动) | 可定制性 | 适合场景 |
|---|---|---|---|---|---|
| QPainter 自绘 | 快 | 看实现,容易写差 | 需要自己实现 | 最高 | 一次性静态图、极简界面 |
| Qt Charts | 较快 | 数据量大时明显 | 提供基础操作 | 中等 | 少量曲线、偏向官方栈 |
| QCustomPlot | 中等 | 可控且优化空间明确 | 内置,底层可调 | 高 | 实时采集、滚动波形、多曲线 |
2.2 QCustomPlot 的数据结构和重绘机制:graph、plottable、replot 与 update
QCustomPlot 的核心思路是:画布(QCustomPlot)上按"层(layer)"组织元素,曲线本体是 QCPGraph,继承自 QCPAbstractPlottable;每条 graph 有自己的数据容器,数据结构是 QVector<pair<double, double>> 或者两个 QVector 分别存 x、y。实时追加数据时,你操作的是这个 graph 对象,而不是直接调 paintEvent。
这里有个新手最容易绕弯的点:QCustomPlot 提供了两条刷新路径。一条是 QWidget 自带的 update(),它只触发 Qt 的事件系统去安排一次重绘,效果是"画面可能要等一阵子才刷新";另一条是 QCustomPlot 的 replot(),它跳过等待,直接在当前调用栈里重算并重绘画布。实时波形必须用 replot(),原因在于 update() 会被事件循环合并和延迟,连续来几十帧数据后画面仍然卡在上一帧,看起来就是曲线不动、一放大就跳变。
数据量对性能的影响也是从这个机制来的:replot() 每次都会把当前所有可见的 graph 扫描一遍,再按坐标轴范围画出可见区域内的点。如果每次把全部历史数据交给 graph,数据量会线性增长,replot 的耗时也线性增长。所以"实时"的关键不是把数据全塞进去,而是通过裁剪、降采样、控制可见范围来让每次 replot 处理尽量少的点。理解了 graph 和 replot 的关系,后面的调优就是围绕"减少每次重绘的点数、减少重绘次数"这两件事展开。
3. 从零搭一个实时波形窗口:核心步骤与最小可运行代码
3.1 工程准备:qmake / CMake 引入 QCustomPlot 与 Qt 版本注意
QCustomPlot 目前是单个类 + 公开头文件的形态,拿到源码包后把 qcustomplot.h、qcustomplot.cpp 放进工程即可。它基于 Qt 的 Widgets 和 PrintSupport 模块,实时应用还要用到 QtCore、QtGui。常见做法是用 qmake 时在 .pro 里加下面几行。
QT += core gui widgets printsupport CONFIG += c++11 SOURCES += \\ main.cpp \\ qcustomplot.cpp HEADERS += \\ qcustomplot.h这里单独说明两句:printsupport 模块不是可选的,它对应 QCustomPlot 里保存图片、导出打印的功能,漏掉会报链接错误;C++11 是它依赖的编译标准,老工程如果还在用 C++98 要先升级编译选项。用 CMake 的话,对应关系是 find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets PrintSupport),然后 target_link_libraries 里加上 Qt::PrintSupport。
一个兼容性提醒:QCustomPlot 分 Qt6 和 Qt5 两个分支的构建宏,新项目直接按你本机的 Qt 主版本下载对应分支,不要拿 Qt5 的源码硬编 Qt6 工程,也不要在头文件里自己去改 QCP 的宏。如果工程里还用了 Qt Charts,两者在命名上并不冲突,但没必要同时引入,一个工程里用两套绘图框架只会增加维护负担。
3.2 初始化画布:坐标轴、曲线、样式设置
拿到绘图控件后,第一件事是创建 graph、设置坐标轴和曲线属性。下面这段是典型的初始化代码,放在窗口构造或控件 show 之前。
// 在构造函数或 initPlot() 里 QCustomPlot *plot = new QCustomPlot(this); // 1. graph 准备:0 号 graph 作为实时波形 plot->addGraph(); plot->graph(0)->setName("ADC采样值"); plot->xAxis->setLabel("时间/s"); plot->yAxis->setLabel("幅值"); // 2. 曲线样式:实线 + 浅蓝 + 2 像素 QPen pen(QColor(60, 160, 255)); pen.setWidthF(1.5); plot->graph(0)->setPen(pen); plot->graph(0)->setLineStyle(QCPGraph::lsLine); // 3. 坐标轴范围:先给一个初始窗口,后续由代码滚动 plot->xAxis->setRange(0, 10); plot->yAxis->setRange(-5, 5); // 4. 开启几个实用的交互:拖动坐标轴 + 滚轮缩放 plot->setInteractions(QCP::iRangeDrag | QCP::iRangeZoom);要点在于 setInteractions:这里只开了拖动和缩放,没有开 iSelectPlottable 之类的选择交互,因为实时刷新时选择状态会带来额外的点击判定开销。坐标轴范围先给死,实际滚动通过后面更新 x 范围实现。曲线太细时高频波形会闪,1.5 到 2 像素是一个相对耐看的区间。如果你的采集值是电压、流量这类有物理单位的数据,建议在 setLabel 里把单位写进去,比如"幅值/mV",状态栏和截图都不用额外解释。
3.3 实时追加数据:定时器或采集线程里的更新写法
模拟数据时最简单的方式是 QTimer 定时向 graph 追加数据;真实采集时则是在采集线程拿到数据后,通过信号跨线程通知主线程再做绘图更新。这里以定时器给出一个最直接、可验证的形态,真实工程里把定时器换成 QSerialPort 的 readyRead 信号或采集线程的定时信号即可。
// 类的成员:QTimer *m_timer; double m_time = 0; QVector<double> m_x, m_y; m_timer = new QTimer(this); connect(m_timer, &QTimer::timeout, this, [=]{ m_time += 0.01; m_x.append(m_time); m_y.append(qSin(m_time * 2 * M_PI * 5)); // 模拟 5Hz 正弦 // 只保留最近 10 秒数据(滚动窗口) while (!m_x.isEmpty() && m_x.first() < m_time - 10.0) { m_x.removeFirst(); m_y.removeFirst(); } // 方式一:追加后直接 replot plot->graph(0)->setData(m_x, m_y); plot->xAxis->setRange(m_time - 10, m_time); plot->replot(); }); m_timer->start(20); // 50Hz 刷新QTimer 间隔 20ms 对应 50Hz 刷新率,对大多数传感器波形是足够的实时感。代码里滚动窗口用的是"移除最老数据 + 移动 x 轴范围",这是实时波形最常规的做法,保证 graph 里的点数永远不超过一个时间窗的数据量。注意 setData 每次传入的是整个 m_x/m_y,它内部会做一次拷贝,数据点不多时(几千个以内)开销可接受;超过几万点就要换第 4 章讲的增量方案。这里把窗口设成 10 秒、采样间隔 10ms,意味着最多 1000 个点,setData 拷贝几百个 double 的耗时可忽略,看清这个数量级对理解后面的优化很重要。
4. 实时绘制的性能调优:从卡顿到 60 帧的四个关键设置
4.1 按需重绘:replot 与 setReplotTime 的时机控制
很多人以为实时曲线要"越快越好",于是用 1ms 定时器疯狂 replot。实际上,屏幕刷新率是 60Hz 左右,replot 快于 16ms 没有任何人眼可感知的收益,反而浪费 CPU。我一般把刷新频率控制在 30~60Hz:用 QTimer 的话间隔 20ms,用采集线程的话,信号发出后 slot 里判断距离上次 replot 是否超过 16ms。
QCustomPlot 还给了一个内置节流接口 setReplotTime,单位毫秒。设置后,连续调用 replot 时,实际重绘会被合并到指定的时间粒度内。这个接口很适合"数据来的频率远高于刷新频率"的场景,避免高频率采集白白烧 CPU。
// 设置最小重绘间隔:20ms plot->setReplotTime(20); // 之后可以在任何高频回调里安全调用 replot()加上之后,即使数据 1ms 来一次,重绘也最多 50 次每秒。注意 setReplotTime 并不会把第一次 replot 延迟 20ms,它是合并紧邻的多次 replot,效果类似 debounce。CPU 占用能明显降下来,曲线观感基本不变。实测里,把这个参数从 0 改成 20,程序占用的 CPU 可以从 30% 降到 8% 左右,代价只是视觉上丢掉一些亚毫秒级变化,这对波形显示完全够用。
4.2 滚动窗口数据裁剪:环形缓冲与 setData 的选择
第 3 章的写法用 QVector 的 append 和 removeFirst,简单是简单,缺陷也明显:removeFirst 会把后面所有元素搬移,数据点超过几万后,每次搬移都有无谓开销,同时 setData 又要整体拷贝一遍。真正常用的做法是使用 QVector 配合头尾索引,或者干脆用定长环形缓冲。
实时场景我习惯用两层方案:采集线程里维护一个固定长度数组,按环形方式写入;主线程绘图时,把「当前待画区间」的数据封装成两个 QVector 给 setData。如果不想引入额外结构,也可以退而求其次:只在数据长度达到上限时才整体裁剪一次,比如保留 60 秒数据,每满 10 秒才把前面的裁掉一次,而不是每次 append 后都 removeFirst。
// 分段裁剪:避免每帧 removeFirst const int MAX_POINTS = 5000; auto trim = [&]{ if (m_x.size() > MAX_POINTS) { m_x.remove(0, m_x.size() - MAX_POINTS); m_y.remove(0, m_y.size() - MAX_POINTS); } }; // 在 append 后调用,但每 100 帧才执行一次,配合一个计数变量 if (++m_trimCounter % 100 == 0) trim();这样做的收益在长时间运行后非常明显:最坏情况下 QVector 只在每 100 帧时搬移一次,平时 append 摊销接近 O(1)。如果你连 QVector 拷贝都不想承担,可以研究 QCPGraph 的 setData(QVector x, QVector y, bool alreadySorted) 结合移动语义,用 std::move 把临时数组交进去,减少一次深拷贝。这里有个使用前提:两个数组必须都已按 x 升序排好,如果数据乱序,alreadySorted 要传 false,反而增加内部排序开销。
4.3 降采样与大点数显示:当波形密集到看不清时
曲线点数多到什么程度需要降采样?经验值是可见区域内超过 1 万点,replot 就开始明显吃力,而且横轴上这么密的点重叠在一起,视觉上只是一团黑,信息量并没有增加。这个时候与其堆点数,不如按屏幕像素密度做降采样。
QCustomPlot 支持在 setData 时传入一个"已排序"的标记,并利用坐标轴范围做显示优化,但它本身不做降采样,需要你侧自己去处理。常用方案有三种:抽稀(每 N 点取 1)、峰值保持(每 N 点里保留最小值和最大值,画出来的包络更接近真实波形)、时间区间聚合。峰值保持对波动信号最友好,代码也不复杂。
// 简单峰值保持:把 pts 降采样为窗口内 min/max 对 QVector<QCPData> downSample(const QVector<QCPData>& src, int step) { QVector<QCPData> out; for (int i = 0; i < src.size(); i += step) { double vmin = src[i].value, vmax = src[i].value; int jmax = qMin(i + step - 1, src.size() - 1); for (int j = i + 1; j <= jmax; ++j) { vmin = qMin(vmin, src[j].value); vmax = qMax(vmax, src[j].value); } out.append(QCPData(src[i].key, vmin)); out.append(QCPData(src[i].key, vmax)); } return out; }这一步要在数据进入 graph 之前做,不要在 replot 里反复做。降采样后的点数一般控制在几千点以内,曲线轮廓和实时性都能保住。峰值保持带来的额外好处是:正弦波、方波这类信号在降采样后不会丢失尖峰,而抽稀很容易把脉冲信号抽没。如果你画的是心电或振动波形,强烈建议用峰值保持而不是等间隔抽稀。
4.4 抗锯齿与图层:多曲线场景下的收益与代价
QCustomPlot 创建时会开抗锯齿,这个选项在静态图里非常好看,但实时更新时会带来可观的性能开销。多曲线同时刷新时,抗锯齿的 fill 与 line 走的是不同绘制路径,开销会翻倍。做法是:实时运行期间关掉抗锯齿,只有在用户停止滚动、需要截图时才临时打开。
// 关闭全部图层抗锯齿,实时刷新更快 plot->setNoAntialiasingOnDrag(true); plot->setNotAntialiasedElements(QCP::aeAll); // 需要高画质时再恢复 plot->setNotAntialiasedElements(QCP::aeNone);setNoAntialiasingOnDrag 是专门为滚动拖拽准备的:拖拽坐标轴时自动关闭抗锯齿,松手后恢复。这个开关处理了"交互时流畅 vs 静止时好看"的矛盾,强烈建议打开。图层的另一个坑是:如果曲线很多,不要让它自动创建 QCPGrid 的副本和装饰对象,网格每帧重画很快,问题不大;真正要注意的是避免用 QCPCurve 处理海量曲线段,QCPGraph 的折线路径在数据连续性上更友好。
5. 实时曲线避坑指南:波形跳动、CPU 飙高、内存泄漏的 5 个现场
5.1 现场一:定时器一旦开启,CPU 直接飙到 100%
现象:QTimer 间隔设成 10ms,启动后 Qt 程序 CPU 占用居高不下,曲线却很流畅,甚至因为线程忙不过来回调堆积。
原因:一是高频 replot 本身开销过大;二是 setData 每次拷贝 + removeFirst 搬移叠加;三是可能没有设置 setReplotTime,数据每帧都强制重绘。定时器事件被拖到极限后,Qt 还会不断补偿回调,造成恶性循环。
解决:把定时器间隔放宽到 20~30ms,加上 setReplotTime(20),再按 4.2 的裁剪方式减少数据量。如果 CPU 仍然高,检查是否在 slot 里做了大量字符串拼接或日志输出,实时绘图里最常见的 CPU 泄漏其实是日志——每次数据更新都 qDebug 一行波形值,再好的渲染优化也扛不住磁盘写入。
5.2 现场二:曲线不动,只有点击/放大时才跳变
现象:replot 也调了,setData 也调了,波形就是不动,用鼠标滚轮缩放一次后波形瞬间跳到最新数据。
原因:用的是 QWidget::update() 而不是 replot(),或者虽然在 slot 里调了 replot,但 slot 根本没被触发。有人不小心把 connect 的信号绑定写成了 Qt::QueuedConnection,而绘图对象在非主线程,Qt 事件队列把重绘调度延后,放大时强制同步刷新才露出真面目。
解决:统一在主线程更新数据并调用 replot(),跨线程信号用默认的 AutoConnection 即可;确认 slot 里写的是 plot->replot(),不是 plot->update()。可以在 slot 入口加一个计数器变量,把它显示到窗口标题,用肉眼确认调用在持续发生。这个"标题栏计数"的土办法比打断点好用得多,尤其是定时器驱动的高频回调里。
5.3 现场三:窗口滚动越久越卡,卡顿点不是一开始就出现
现象:程序刚启动时 60Hz 刷新流畅,运行 5 分钟后明显掉帧,曲线响应迟钝。
原因:滚动窗口的数据没有裁剪,或者裁剪条件是"保留所有数据直到某个上限",导致 graph 内部点数越攒越多。一次 replot 要遍历几万甚至几十万个点,耗时随运行时间线性增长。
解决:把数据裁剪改成"按时间窗 + 固定点数双上限",像第 3 章那样每帧丢弃窗口外的数据,或者用 4.2 的分段裁剪。检查 graph->dataCount() 是否长期稳定,如果它单调增长,说明裁剪逻辑没有生效。对比最直观:稳定运行时 dataCount() 应该是一条平的线,偶尔有小波动,一旦看到它上台阶或持续上升,一定有几处数据没收干净。
5.4 现场四:采集线程往 graph 里 setData,一开线程就崩溃
现象:程序在采集线程里直接调用 plot->graph(0)->setData(),编译没问题,运行随机崩溃,或者界面卡死。
原因:QCustomPlot 继承自 QWidget,要求所有绘图接口都在主线程(GUI 线程)调用。跨线程写法在数据量小时也许能跑,一旦碰到 Qt 内部对绘图的锁定,就会触发 "QObject: Cannot create children for a parent that is in a different thread" 或更隐蔽的段错误。
解决:采集线程只负责生产数据,把数据包通过信号发到主线程。在 Qt5 新语法下,connect 采集线程的信号到主线程 slot,槽里做 setData + replot。如果数据频率太高,可以让采集线程把数据先写入环形缓冲,再用 QMetaObject::invokeMethod 批量通知主线程取数据,避免高频信号风暴把事件队列塞满。
5.5 现场五:抗锯齿导致的线宽不均与波形"毛刺"
现象:曲线某些线段看起来比别处细,放大后有一段呈阶梯状;关闭抗锯齿后反而正常。
原因:抗锯齿开启时,QCustomPlot 对每段折线做光滑处理,线的首尾宽度处理在半像素上容易不一致;高频正弦波信号本身斜率变化大,更暴露这个问题。抗锯齿 + 极细线宽时,视觉上会像在抖动。
解决:实时模式按 4.4 关闭全部抗锯齿,线的宽度用 setWidthF 设置 1.5 以上,避免半像素边界。如果一定要抗锯齿观感,可以在 QCustomPlot::afterReplot 信号里做一次静态重绘,只在用户停止交互后启用。我还习惯把 setNoAntialiasingOnDrag 打开,拖拽时关抗锯齿、松手后自动恢复,几乎没有人能察觉到切换瞬间,但滚动的流畅度提升是很明显的。
6. 进阶技巧:时间轴自适应跟随与性能监视图层
波形滚动起来后,下一步能让体验上一个台阶的是"时间轴跟随策略"。默认的 setRange(m_time - 10, m_time) 是刚性跟随:用户想回头看看几秒前的波形,x 轴下一秒又被拽回最右端。常见做法是加一个"跟随开关",默认开启,用户滚动坐标轴时自动关闭,点一个按钮恢复。判断用户是否在滚动,可以监听 xAxis 的 rangeChanged 信号,里面记录最后一个人工滚动时间戳。
// 记录用户最后一次手动操作坐标轴的时间 qint64 lastManualTime = 0; connect(plot->xAxis, &QCPAxis::rangeChanged, this, [&](const QCPRange &newRange, const QCPRange &oldRange){ // 非跟随模式下,用户拖动/滚轮都会触发这里 if (!plot->xAxis->range() == oldRange) { lastManualTime = QDateTime::currentMSecsSinceEpoch(); } }); // 在定时器刷新数据的 slot 里: QCPRange current = plot->xAxis->range(); if (QDateTime::currentMSecsSinceEpoch() - lastManualTime < 2000) { // 用户在查看历史波形,不强制移动 x 轴 } else { plot->xAxis->setRange(m_time - 10, m_time); }另一个很实用的进阶是把性能数据画到同一张画布里:用第二个 graph 显示 replot 耗时,x 轴复用时间轴,y 轴放到右侧。在 replot 之后记录 QElapsedTimer 的纳秒耗时并追加到这条曲线,相当于给绘图模块装了个内置性能探针。线上出问题或者客户反馈卡顿时,看一眼这条曲线就能定位是数据量问题还是重绘频率问题。
// 用 QElapsedTimer 测量 replot 耗时 QElapsedTimer timer; timer.start(); plot->replot(); double ms = timer.nsecsElapsed() / 1e6; m_perfX.append(m_time); m_perfY.append(ms); if (m_perfX.size() > 500) { m_perfX.removeFirst(); m_perfY.removeFirst(); } plot->graph(1)->setData(m_perfX, m_perfY); plot->graph(1)->rescaleValueAxis(false);这里的 graph(1) 是预先创建的右侧坐标轴曲线,rescaleValueAxis 让它自动适应耗时范围。我把这套探针长期留在代码里,发布时用一个宏包住,本地调试时打开。它帮我排查过好几次"别人说卡但我复现不了"的问题,比外接性能分析工具更直接。
最后一个习惯是:每次调整完刷新策略,都要留出一段时间观察内存曲线。QCustomPlot 的数据容器会随 append 增长,配合 QVector 自动扩容,长时间运行最容易积累的是数据量而不是绘图对象。我一般把 graph->dataCount() 打印到状态栏,确认它稳定在一个区间内,再谈别的优化。这行小字能暴露很多隐藏问题,比如某个分支忘了裁剪、定时器泄漏导致重复创建 graph。希望帮到你。
本文还有配套的精品资源,点击获取