简介:这是一份基于Qt框架的动态曲线绘制完整示例工程,面向需要实现实时数据可视化的Qt开发者与学习者,演示如何借助QCustomPlot库在同一图表中绘制并动态更新多条曲线。压缩包体量轻巧,仅242KB,共9个文件,涵盖4个cpp、3个h、1个pro工程文件与1个ui界面布局文件。其中,自定义绘图组件负责曲线渲染,多曲线逻辑文件处理数据同步及样式区分,主窗口类通过定时器触发数据更新和重绘,pro与ui文件则分别定义构建配置和界面结构。目前已有446人学习下载。通过这份工程,读者可掌握QCustomPlot的集成方法、定时器驱动的实时刷新机制、曲线颜色与线型定制,以及鼠标缩放平移等交互处理思路,是学习Qt动态可视化开发的实用参考。 前阵子帮朋友整理了一套基于 Qt 的实时数据可视化工程,核心功能就是动态曲线绘制。做这类需求的人其实不少——串口收传感器数据、TCP 收设备状态、后台跑算法实时出波形图,最后都要落到屏幕上。如果每次都在 QChart、QCustomPlot、自绘 QPainter 之间反复横跳,效率太低。我干脆把一套完整可跑的示例工程打包成 rar 拿出来分享,这篇博文就把工程里的设计思路、关键代码、打包细节、坑点全部拆开讲清楚。
这套工程做的是这么几件事:实时生成模拟数据流,用 QCustomPlot 绘制动态卷动曲线,支持暂停、清空、Y 轴自适应缩放,还附带了一个扩展模块——把时域波形通过 kissfft 转成频域频谱图。整体基于 Qt 5.15.2 + MSVC2019 开发,源码里自带 qcustomplot.h/cpp 和 kissfft 相关文件,解压后用 Qt Creator 打开 .pro 就能直接编译运行。适合做上位机开发、数据采集、仪器仪表类项目的同学参考。
1. 先看工程里都有什么:环境与整体结构
1.1 我用的环境,你最好也用同版本
工程基于 Qt 5.15.2 构建,编译器选择 MSVC2019 64 位。为什么是 5.15.2 而不是 Qt 6?这里有个非常现实的原因:QCustomPlot 虽然已经支持 Qt 6,但不少老项目的第三方库、串口通信模块、甚至一些工业现场的 SDK 还停留在 Qt 5 时代,兼容性最稳的就是 5.15 LTS。如果你在公司维护旧工程,大概率也是这个版本。用 Qt 5.15.2 还有一个额外好处——支持离线安装包,不需要联网登录账号,这对内网开发环境特别友好。
安装时记得勾选MSVC 2019 64-bit组件和Qt Charts(虽然本工程没用到 Charts,但保不齐你后续扩展要用)。编译器配套装好 Visual Studio 2019 或者只装 Build Tools 也可以,但 Qt Creator 里要正确识别 MSVC 工具链,否则 Kit 会显示黄色警告。
1.2 工程目录是怎么组织的
我把整个工程拆成了清晰的模块结构,打开压缩包后你会看到:
DynamicCurveDemo/ ├── DynamicCurveDemo.pro ├── main.cpp ├── mainwindow.h ├── mainwindow.cpp ├── mainwindow.ui ├── qcustomplot.h ├── qcustomplot.cpp ├── kissfft/ │ ├── kiss_fft.h │ ├── kiss_fft.c │ ├── tools/ │ └── ... └── README.md关键点在于 qcustomplot 和 kissfft 都是直接以源码形式放到工程里的。QCustomPlot 本身就是一个超大的头文件加一个超大的 cpp 文件,官方就推荐直接把这两个文件拷进工程,比编静态库再链过来省心得多。kissfft 是一套极其轻量的 FFT 实现,纯 C 写的,性能比 FFTW 差一点,但胜在代码量小、无依赖、拿到就能编。对于做实时频谱显示这种场景完全够用。
1.3 QCustomPlot 的两种集成方式,我为什么选源码直连
集成 QCustomPlot 有两条路:一种是官方推荐的方式,把 qcustomplot.h 和 qcustomplot.cpp 拖进工程,然后.pro里加一行QT += printsupport;另一种是先编译成静态库,再从别的工程引用。第二种方式适合团队里多个项目共享同一份 QCustomPlot 的场景,能统一管理版本。但静态库方式有个坑——QCustomPlot 开启某些宏定义(比如 QCUSTOMPLOT_USE_OPENGL)时,库的编译参数必须和主工程一致,否则会出现各种诡异的链接错误。单工程开发我建议源码直连,改起来也方便。
.pro文件里核心配置就是下面这几行:
QT += core gui printsupport greaterThan(QT_MAJOR_VERSION, 4): QT += widgets CONFIG += c++11 TARGET = DynamicCurveDemo TEMPLATE = app SOURCES += main.cpp \ mainwindow.cpp \ qcustomplot.cpp \ kissfft/kiss_fft.c HEADERS += mainwindow.h \ qcustomplot.h \ kissfft/kiss_fft.h FORMS += mainwindow.ui这里最容易漏掉的是QT += printsupport。QCustomPlot 的打印功能依赖这个模块,不加上直接编译报错,网上搜"qcustomplot 编译报错"一多半都是这个问题。
2. 动态曲线的核心思路:为什么这样设计
2.1 动态曲线本质:数据流加定时刷新
动态曲线说穿了就两件事——生产数据、消费数据。数据生产方可以是串口回调、网络 socket 接收、传感器驱动,甚至是一个简单的高精度定时器;消费方就是 QCustomPlot 绘图控件。我工程里的数据生产方用QTimer模拟,每 20ms 生成一个点,算下来 50Hz 的刷新率,曲线滚动非常平滑。
这里有个重要的设计决策:生产者只负责向缓冲区追加数据,消费者(界面刷新)从缓冲区读取数据绘制,两者通过 Qt 的信号槽机制解耦。为什么这样设计?因为如果你的数据源是真实的串口数据,它的到达时间是不均匀的,可能一秒来 100 个点,下一秒突然断流。如果每次收到一个点就立刻调用replot(),界面会被疯狂触发刷新,CPU 占用直接拉满。用定时器在固定间隔统一绘制,再配合缓冲区做数据累积,性能会稳定得多。
2.2 两种滚动模式对比
QCustomPlot 搞动态滚动曲线,有两种典型做法。第一种是滑动窗口模式:X 轴范围固定为一个窗口长度,比如 500 个点,每来一个新数据,就把窗口起点往后挪一个点,实现"数据在窗口中从左往右流淌"的效果。第二种是游标模式:X 轴范围跟随最新数据不断向右移动,整个曲线像心电图一样被"拽"着走,新数据始终在右边缘出现。
两种模式我都试过,工程代码里默认采用了滑动窗口模式,因为它对用户视觉最友好——窗口宽度不变,刻度范围稳定,观察数据变化趋势很直观。关键代码如下:
void MainWindow::updateCurve() { // 追加一个模拟数据点 static double t = 0; t += 0.02; m_xData.append(t); m_yData.append(qSin(t) + 0.1 * qSin(30 * t)); // 只保留窗口内的数据 const int maxPoints = 500; while (m_xData.size() > maxPoints) { m_xData.remove(0); m_yData.remove(0); } // 批量更新曲线数据 m_curve->setData(m_xData, m_yData); // 滚动窗口 m_plot->xAxis->setRange(t - 10, t); m_plot->yAxis->rescale(true); m_plot->replot(QCustomPlot::rpQueuedRepaint); }注意setData和addData的差别。QCustomPlot 的addData单个点追加很方便,但它内部会触发倒排索引重建,点多了以后效率下降特别明显。实测超过 1 万个点,addData的平均耗时是setData的 3 倍以上。所以我优先用QVector缓存数据,攒够一批再一次性setData传进去,这是最关键的优化点。
2.3 性能优化的三个关键参数
再展开说说性能优化。做实时曲线最怕两件事:一是刷新卡顿,二是内存只增不减。我总结出三个必须控制的参数:
第一,数据点总量上限。如果不限制缓冲区大小,跑个几天几夜的设备会积累几百万个点,QCustomPlot 每次重绘都要遍历全部数据,再好的机器也扛不住。工程里maxPoints = 500,你也可以根据实际需求调整。限制方法就是上面代码里的while循环,超出的头部数据直接丢弃。
第二,重绘刷新率。QCustomPlot 的replot()是全量重绘,非常耗资源。定时器间隔建议设在 10ms 到 100ms 之间,默认 20ms 表现不错。再低没意义,人眼本来也分不清 60Hz 以上的画面差异,反而白白消耗 CPU。
第三,QVector而不是QList。很多新手用QList存曲线数据,这就埋了个大坑。QCustomPlot 的setData内部需要把数据拷进自己的存储结构,QList在随机访问和遍历效率上明显不如QVector。Qt 官方文档也建议默认使用QVector。数据量小的时候区别不明显,一旦数据点超过几万个,差距就是几十毫秒级别的,直接表现为肉眼可见的卡顿。
3. 从零到一:核心代码的实现细节
3.1 初始化 QCustomPlot 画布
初始化部分决定了曲线最终长什么样。我在MainWindow构造函数里完成画布配置,包括设置背景色、生成曲线、设置坐标轴标签、指定自适应缩放策略。贴上关键代码:
MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { // 从 ui 文件加载界面,customPlot 控件是拖到 ui 里的 setupUi(this); // 初始化画布 m_plot->setBackground(QColor(30, 30, 30)); m_plot->axisRect()->setupFullAxesBox(true); // 生成一条曲线,设置颜色和线宽 m_curve = new QCPGraph(m_plot->xAxis, m_plot->yAxis); m_curve->setPen(QPen(QColor(0, 255, 0), 2)); m_curve->setName(QStringLiteral("实时数据")); // 坐标轴标签 m_plot->xAxis->setLabel(QStringLiteral("时间 (s)")); m_plot->yAxis->setLabel(QStringLiteral("幅值")); // 坐标轴范围初始值 m_plot->xAxis->setRange(0, 10); m_plot->yAxis->setRange(-2, 2); // 允许用户拖动缩放 m_plot->setInteractions(QCP::iRangeDrag | QCP::iRangeZoom); // 数据显示鼠标坐标的 tooltip connect(m_plot, &QCustomPlot::mouseMove, this, &MainWindow::onMouseMove); // 启动数据更新定时器 m_timer = new QTimer(this); m_timer->setInterval(20); connect(m_timer, &QTimer::timeout, this, &MainWindow::updateCurve); m_timer->start(); }需要说明的是,工程里我用的是.ui设计器直接拖了一个QCustomPlot控件。你需要在 Qt Creator 里把qcustomplot.h的头文件加入工程后,设计器的控件面板才会出现 QCustomPlot 的入口。具体操作是:在.ui文件里右键选择“提升为”,输入QCustomPlot和头文件名qcustomplot.h,之后就可以像普通控件一样拖拽布局了。
3.2 数据更新与滚动逻辑
数据更新槽函数的核心部分,其实上面已经展示过。这里再补充一个"自动 Y 轴缩放"的实现细节:
if (m_autoScaleEnabled) { m_plot->yAxis->rescale(true); } else { m_plot->yAxis->setRange(m_userYMin, m_userYMax); }rescale(true)的作用是让 Y 轴自动适配当前可见数据的最小值和最大值。这个功能在观察信号幅度变化时非常有用——有些时候波形太矮,平铺在屏幕中间看不见细节;有些时候波形又太高,超出屏幕。自动缩放能确保曲线始终充满 Y 轴方向的可视区域。
但注意,如果用户手动拖拽过 Y 轴范围,自动缩放会把用户设定覆盖掉,体验不好。所以工程里加了一个"自动缩放"开关复选框,选中时每次更新都调用rescale,取消时就锁定用户当前设定的范围。这个细节虽然小,但对用户体验提升很明显。
3.3 暂停、清空、光标取值的小功能
一个完整的示例工程光画曲线还不够,必须有配套的操作交互。工程里实现了三个小功能,代码都很直接。
暂停/继续。本质就是m_timer->stop()和m_timer->start()。注意暂停时不应该清空缓冲区,否则恢复时曲线会出现断层。工程里用了一个m_paused布尔变量控制,暂停期间定时器停止触发,缓冲区保持不变。
清空。把m_xData和m_yData全部 clear,然后调用m_plot->replot()。这里有个容易忽略的细节:清空曲线后 Y 轴范围也应该重置,不然下一次数据幅度偏小的时候曲线会贴在最底下。正确的做法是清空数据后调用一次m_plot->yAxis->rescale(),让坐标轴恢复默认状态。
光标取值。QCustomPlot 的mouseMove信号会返回鼠标在控件中的坐标位置,利用它可以在状态栏实时显示鼠标对应的数据值。关键是坐标变换:鼠标像素坐标要经过pixelToCoord映射成数据坐标。
void MainWindow::onMouseMove(QMouseEvent *event) { double x = m_plot->xAxis->pixelToCoord(event->pos().x()); double y = m_plot->yAxis->pixelToCoord(event->pos().y()); statusBar()->showMessage( QStringLiteral("鼠标位置: x = %1, y = %2").arg(x).arg(y)); }更进阶的做法是加一个十字光标线,QCustomPlot 提供了QCPItemStraightLine,可以画出延伸到无穷远的直线,两者配合就能做成一个简易示波器的取点效果。工程里只做了状态栏显示,够用就行。
3.4 扩展:时域波形转频域频谱
这个扩展模块花了我最多时间调试,也是这套工程的一大亮点。做信号采集的人都知道,光看时域波形很多时候看不出问题,一定要转成频域看频谱。比如一个 50Hz 的工频干扰混在信号里面,时域图上一片混杂,但频谱图上会有一个明显的尖峰。
FFT 选型。Qt 本身不提供 FFT 接口,QCustomPlot 也不管这事,你得自己找轮子。工程里选的是 kissfft,在 GitHub 上能直接找到源码。为什么不用 FFTW?因为 FFTW 的许可证是 GPL,商用项目有法律风险;kissfft 的许可证是 BSD,宽松得多,而且代码就几个 .c/.h 文件,集成成本极低。
FFT 流程。核心思路是把时域数据截取一段(比如 1024 个点),加窗函数(Hanning 窗)抑制频谱泄漏,然后做 FFT,取复数结果的模作为幅值点,画到另一个 QCustomPlot 控件上。
void MainWindow::updateSpectrum() { const int n = 1024; if (m_xData.size() < n) return; // 取最近 n 个点,做 hanning 窗 kiss_fft_cfg cfg = kiss_fft_alloc(n, 0, nullptr, nullptr); QVector<kiss_fft_cpx> fin(n); QVector<kiss_fft_cpx> fout(n); for (int i = 0; i < n; ++i) { double w = 0.5 * (1 - qCos(2 * M_PI * i / (n - 1))); fin[i].r = m_yData[m_yData.size() - n + i] * w; fin[i].i = 0; } kiss_fft(cfg, fin.data(), fout.data()); kiss_fft_free(cfg); // 取模值,只保留前半部分(奈奎斯特频率以内的有效频谱) QVector<double> freq(n / 2); QVector<double> mag(n / 2); double fs = 50; // 根据你的采样率设置,这里 50hz 是模拟数据的采样率 for (int i = 0; i < n / 2; ++i) { freq[i] = i * fs / n; mag[i] = qSqrt(fout[i].r * fout[i].r + fout[i].i * fout[i].i) * 2 / n; } m_spectrumGraph->setData(freq, mag); m_spectrumPlot->rescaleAxes(); m_spectrumPlot->replot(); }这里有三个坑值得单独说明一下。第一个是加了窗函数之后,幅值要乘个系数补偿,不然窗函数会把信号能量削掉一部分,频谱幅值偏小。Hanning 窗的幅度恢复因子是 2,所以代码里乘了2 / n。第二个是FFT 的输出前半部分是有效频谱,后半部分是关于采样率的一半对称的镜像,直接扔掉不看。第三个是采样率要和真实物理量对应,模拟数据每秒 50 个点,fs = 50,如果接入真实串口数据,这里就要改成实际采样率,否则 X 轴的频率值全是错的。
4. 打包发布与跨平台注意事项
4.1 windeployqt 打包全流程
工程调试好了总要发给别人用。Qt 程序发布和普通 C++ 程序不一样,需要带上 Qt 运行库,最简单的办法是使用 Qt 自带的windeployqt工具。打开 Qt 的 MSVC 命令行环境(不是普通 cmd),进入 exe 所在目录运行:
windeployqt DynamicCurveDemo.exe这个工具会自动分析 exe 依赖的所有 Qt DLL,并把它们拷到当前目录。执行完后你会看到目录里多了一堆 Qt5Core.dll、Qt5Gui.dll、platforms、styles 等文件夹。这些就是程序运行的必要组件。然后把整个目录压缩发给别人,目标机器不用装 Qt 也能运行。
4.2 那个让人崩溃的 "no Qt platform plugin" 错误
打包后最容易遇到的错误信息是This application failed to start because no Qt platform plugin could be initialized. Reinstalling the application may fix this problem.这个错误十有八九是platforms文件夹缺失或者路径不对。Qt 在启动时要在这个文件夹里找qwindows.dll,找不到就直接退出。解决办法是:确认 exe 同级目录下有platforms/qwindows.dll,不要试图自己改源码绕过,这是 Qt 的固有机制。
另外注意一个细节:调试编译的 exe 必须用 debug 版的 Qt DLL 才能运行,发布编译的 exe 必须用 release 版。windeployqt 默认会根据传入 exe 所在目录判断是 debug 还是 release,自动拷贝对应版本。但如果你手动从 Qt 的 bin 目录乱拷,就会把 debug 和 release 混在一起,启动时报"部署不正确"的错误。
4.3 中文路径和乱码的坑
我强烈建议工程路径、exe 路径、dll 路径全用英文,不要带中文。QCustomPlot 源码本身对中文路径没意见,但是 MSVC 编译器的 include 解析和某些字符集处理,在中文路径下偶尔会出一些莫名其妙的问题,最常见的表现是 moc 生成代码报错。虽然加/utf-8编译选项能缓解,但与其跟编译器搏斗,不如直接把路径改成英文,一了百了。
代码里字符串输出到 QCustomPlot 坐标轴标签时,如果出现乱码,大概率是源文件编码问题。我在工程里统一用QStringLiteral包裹中文字符串,并在.pro里加了:
msvc { QMAKE_CXXFLAGS += /utf-8 }这是 MSVC 环境下最省事的解决方案——告诉编译器源文件以 UTF-8 编码解析,避免误判成其他编码导致乱码。
5. 常见问题速查表与调试心得
5.1 问题速查表
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 编译报错找不到 qcustomplot.h | 头文件路径未添加到 INCLUDEPATH | 把包含 qcustomplot.h 的目录加入 INCLUDEPATH |
| 链接错误 LNK2019 无法解析的外部符号 | 未链接 printsupport 模块 | .pro里加QT += printsupport |
| 运行时提示 platform plugin 错误 | platforms/qwindows.dll 缺失 | 用 windeployqt 重新部署 |
| 曲线滚动非常卡顿 | QList 存储数据点或刷新率过高 | 改用 QVector,定时器间隔不低于 10ms |
| 中文字体在曲线上显示为方块 | 字体不支持中文 | 调用m_plot->setFont()设置中文字体 |
| 暂停后继续曲线出现断层 | 暂停期间缓冲区未保留 | 暂停时停止定时器,不清空数据 |
| 频谱幅值整体偏小 | 加窗后未做幅度补偿 | 幅值乘2/n恢复系数 |
5.2 几条独家经验
最后聊几个我调这套工程时的体会,都是文档里查不到的东西。
第一,调试动态曲线不要用断点。数据流是实时的,你断下来看变量值,等继续跑的时候数据早就跳过去几百个点了,上下文全丢了。我建议用 qDebug 打印关键点的时间戳和数据值,或者把数据存到文件里复盘。QCustomPlot 自带savePng功能,关键时刻截个图比什么都直观。
第二,模拟数据源不要只给正弦波。我调试初期一直用纯正弦波,界面漂亮得很,结果一接真实串口数据,各种毛刺、毛边、直流偏置全冒出来了。后来我把模拟数据源改成正弦波加随机噪声,再加一点随机毛刺,这才把绘图性能和缩放交互的真实问题暴露出来。
第三,动态曲线和实时频谱同时显示时,存储压力不容小觑。时域波形要存原始点,频谱模块又要把最近 N 个点拿出来做 FFT,如果缓冲区管理不当,内存会悄悄增长。建议对频谱模块只保留最近 1024 个点,时域曲线保留 500 点,两个缓冲区独立管理,互不干扰。我工程里就是这么干的,跑了一整天实测内存占用非常平稳。
另外还想提一个 QCustomPlot 的小技巧:在鼠标拖拽缩放之后,如果 Y 轴自动缩放开关还开着,会跟用户的手动缩放打架。我的处理方法是,捕获到axisRect的rangeChanged信号后自动关掉自动缩放开关,这样用户一拖拽,自动缩放就退出,尊重用户的手动操作。
这套工程包里的代码基本覆盖了 Qt 动态曲线绘制的常见需求——滚动显示、缩放交互、暂停清空、时域频域切换,全部是生产级可用的实现。如果你要接入真实串口或者网络数据源,只需要把updateCurve里的模拟数据替换成真实数据回调即可,其余代码一行不用改。用时域转频域的时候记得根据实际采样率调整fs参数,这是最容易忽略但影响最大的一步。
最后再提醒一件事:如果你用的是 Qt 6,QT += printsupport依然有效,但 QCustomPlot 需要下载支持 Qt 6 的最新版本,老版本会出现信号槽语法不兼容的编译错误。建议直接从 QCustomPlot 官网下载当前最新源码替换进工程,剩下的配置完全一致。这套工程不挑平台,Windows、Linux 下用对应套件打开都能直接跑通。
本文还有配套的精品资源,点击获取