简介:QT项目实战:曲线图制作是一套面向C++开发者的实战学习资料,专注于利用QT框架实现功能完善的曲线图组件。课程内容覆盖QT事件处理机制、QPainter绘图系统、模型/视图架构应用等关键技术,并结合线性插值、贝塞尔曲线等插值算法讲解数据点处理与平滑连接方法,适合有基础QT使用经验、希望深入图形绘制领域的读者。资源压缩包共261个文件、约1.81MB,以cpp源代码、h头文件、ui界面文件和pro工程文件为主,同时包含Makefile等构建配置,结构清楚,便于直接导入QT Creator进行编译与调试。目前已有689人浏览学习。通过该项目可掌握QGraphicsView/QGraphicsScene图形场景搭建、基于QGraphicsPathItem的自绘曲线对象设计,以及鼠标键盘事件驱动的缩放、拖拽等交互操作;代码将数据处理、曲线绘制与事件逻辑模块化分离,利于二次开发与功能扩展。
1. 曲线图不是画线那么简单:先把 QT 绘图的底层逻辑理顺
接到一个数据采集类的桌面工具,界面其他都好说,唯独画曲线这个环节翻了好几次车。一开始想当然用 QPainter 画折线,一个点一个点连起来,数据量小还好说,数据一多,缩放、平移、实时刷新就全乱了,界面卡顿不说,坐标轴还经常对不上。后来换成专门的曲线图组件,问题迎刃而解。这篇内容讲的就是 QT 项目里曲线图制作的完整路径:怎么选组件、怎么做静态曲线、怎么做实时刷新、以及那些不跑一遍根本发现不了的坑。适合刚上手 QT 没多久、正在做监控面板或数据可视化模块的开发者,也适合那些已经画出了曲线、但一遇到性能和交互就头疼的熟练工。看完你会有能力独立实现一条能扛住几万点、支持缩放拖拽、实时更新的曲线图。
2. 选型对比:QCustomPlot 与 Qt Charts,以及曲线图背后的坐标映射和重绘机制
2.1 先选对库:QCustomPlot 和 Qt Charts 到底差在哪
做 QT 曲线图,主流选项无非两个:QCustomPlot 和 Qt Charts。这两个我都在实际项目里用过,先给结论:如果你的曲线图是核心功能,数据量大、交互要求高,选 QCustomPlot;如果只是界面里附带一个统计图,数据量不大,用 Qt Charts 更省事。
QCustomPlot 是一个开源的第三方绘图库,整个库就两个文件,一个头文件一个源文件,直接塞进工程就能用,不需要额外安装运行时。它没有依赖 Qt Charts 那种模块化安装的负担,对数据结构的暴露也更直接,你可以拿到底层数据容器去操作,所以做大量数据的实时曲线时性能更好。它的授权方式是 GPL,商用需要购买商业授权,这一点要先确认清楚。
Qt Charts 是 Qt 官方提供的图表模块,和 Qt 的集成更自然,API 风格也更“官方”。但它的问题是重度定制不够灵活,数据量大了以后刷新效率明显不如 QCustomPlot,而且安装配置比 QCustomPlot 多一步。从实际效果看,QCustomPlot 绘制出的曲线在抗锯齿、渲染效率、交互反馈上都更细,也因此成了很多工控、采集项目的首选。我做曲线图这类需求时基本默认 QCustomPlot,除非客户明确要求只用官方模块。
| 对比项 | QCustomPlot | Qt Charts |
|---|---|---|
| 集成方式 | 两个源文件直接加入工程 | 需安装 Qt Charts 模块 |
| 数据量大时的性能 | 较好,支持自适应采样 | 一般,大量数据刷新有卡顿风险 |
| 定制灵活度 | 高,可直接操作数据容器 | 中等,受官方 API 限制较多 |
| 授权 | GPL/商业授权 | LGPL/商业授权 |
| 典型场景 | 实时曲线、数据量大、需深度定制 | 普通统计图、数据量小 |
2.2 曲线图的底层机制:坐标轴映射与重绘
不管用哪个库,曲线图的核心原理都绕不开坐标映射。一个点之所以能画在屏幕上,是因为把数据的“世界坐标”转换成了控件的“像素坐标”。比如数据点是 (x, y),x 映射到控件宽度方向的位置,y 映射到高度方向的位置,这个映射由坐标轴的范围和控件的尺寸共同决定。
在 QCustomPlot 里,这个机制被封装成了坐标轴类和绘图层。每个 QCPGraph(曲线对象)内部维护两组数据:key(对应 x 轴值)和 value(对应 y 轴值)。当你调用 setData 把数据塞进去时,组件并不会立刻画出来,而是先更新内部数据结构,等到 replot 被调用时才会触发真正的绘制。
这里有一个新手很容易误解的点:replot 不是“重设数据”,而是“重新渲染”。这是两个步骤,必须都执行到,曲线才会更新。理解了这一点,后面遇到“数据变了画面没变”的问题时就不会一头雾水。绘制流程大致是:数据更新 → 坐标轴范围计算 → 线条生成 → 渲染到控件 → 显示。每一步都有对应的接口可以调整,这也是 QCustomPlot 强大和复杂的来源。
2.3 理解 setData 与 replot 的协作:为什么曲线不刷新
很多刚接触 QCustomPlot 的开发者会写这样的代码:构造时 addGraph 添加曲线,然后调用 graph(0)->setData(...) 设置数据,接着发现界面上什么都没有。原因就是忘了调用 replot,或者数据范围不对导致画到了坐标系外面。
更隐蔽的情况是:你调用了 replot,但坐标轴的范围还是初始值。初始范围通常是 -10 到 10,如果数据是一批几百到几千的大数值,那曲线画得再正确也会超出视野范围,看起来就像没画一样。所以常见的做法是设置完数据后,调用 rescaleAxes() 让坐标轴自动适配数据范围,然后再 replot。
记住这个协作顺序:先 setData 或 addData 更新数据,再调整坐标轴范围,最后 replot。这三步缺一不可。很多“曲线不显示”“曲线只显示一半”“移动位置后曲线消失”的问题,本质上都是这个协作链断裂了。搞懂这一层,再去看后面的代码,你会觉得顺手很多。
3. 静态曲线图制作:用 QCustomPlot 跑通第一版完整代码
3.1 用 CMake 把 QCustomPlot 接进工程
拿到 QCustomPlot 后,第一件事是把它正确接进工程。我一般用 CMake 管理 QT 项目,这样跨平台切换时不用改配置。把下载到的 qcustomplot.h 和 qcustomplot.cpp 放进项目源码目录,然后在 CMakeLists.txt 里加一段配置。
cmake_minimum_required(VERSION 3.16) project(CurveDemo VERSION 1.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Qt5 REQUIRED COMPONENTS Widgets PrintSupport) set(SOURCES main.cpp mainwindow.cpp qcustomplot.cpp ) set(HEADERS mainwindow.h qcustomplot.h ) add_executable(CurveDemo ${SOURCES} ${HEADERS}) target_link_libraries(CurveDemo PRIVATE Qt5::Widgets Qt5::PrintSupport )这段配置里最容易被忽略的是 PrintSupport 组件,QCustomPlot 的导出图片、打印功能依赖它。如果漏掉这个,编译时会出现 QCustomPlot 相关函数无法解析的错误。加入工程后,在需要使用曲线图的那个类头文件里包含 qcustomplot.h 就行了。注意 qcustomplot.h 在编译时会检查 QT 版本和配置,如果主工程没有正确加载 Widgets 模块,会报出一堆看不懂的模板错误。
3.2 绘制第一条曲线:坐标轴、数据和重绘
先说一个我常用的做法:不把 QCustomPlot 当成一个独立窗口,而是直接把它嵌到 QMainWindow 或其他容器的布局里。在界面构造函数里动态创建 QCustomPlot 控件,然后往里面加曲线。下面是一段完整的静态曲线绘制代码,可以直接跑。
#include "qcustomplot.h" // 在 MainWindow 构造函数或初始化函数中调用 void MainWindow::setupCurve() { // 创建曲线图控件,并添加到界面布局中 QCustomPlot *customPlot = new QCustomPlot(this); ui->centralWidget->layout()->addWidget(customPlot); // 添加一条曲线 QCPGraph *graph = customPlot->addGraph(); graph->setName("示例曲线"); // 构造测试数据:一个二次函数曲线 QVector<double> x(100), y(100); for (int i = 0; i < 100; ++i) { x[i] = i * 0.1; // x 从 0 到 9.9 y[i] = x[i] * x[i] - 4 * x[i] + 3; // y 随 x 变化 } // 设置数据 graph->setData(x, y); // 样式设置 graph->setPen(QPen(QColor(0, 120, 255), 2)); // 蓝色实线,线宽2 graph->setScatterStyle(QCPScatterStyle(QCPScatterStyle::ssCircle, Qt::red, Qt::white, 4)); // 坐标轴设置 customPlot->xAxis->setLabel("时间 (s)"); customPlot->yAxis->setLabel("数值"); customPlot->xAxis->setRange(0, 10); customPlot->yAxis->setRange(-5, 10); // 关键步骤:让坐标轴范围适应数据,然后重绘 customPlot->rescaleAxes(); customPlot->replot(); }setData(x, y) 这里接收两个 QVector,长度必须一致,否则数据会被截断或直接失败。setPen 控制曲线颜色和线宽,setScatterStyle 控制数据点的形状,如果不需要显示离散点,可以把这行注释掉,视觉效果就是纯折线。最后一行 replot 必须调用,不然界面不会有任何反应。这套写法的好处是结构清晰,初学者照着改数据就能看到自己的曲线。
3.3 让曲线更完整:坐标轴标签、范围控制和图例
基础曲线画出来以后,下一步就是完善细节。坐标轴的标签和范围是第一个要调的,xAxis 和 yAxis 是 QCPAxis 类型对象,setLabel 设置在轴旁边显示的文字,setRange 设置显示范围。这里有个细节:QCustomPlot 默认还有一个右上轴和左上轴,即 xAxis2 和 yAxis2,默认不可见,也不需要额外设置范围,但如果你把哪一轴设置为可见却不手动同步范围,就会出现刻度对不齐的问题。
图例是另一个容易忽视的部分。在有多条曲线对比时必须开图例,否则看图的人分不清哪条线是哪个变量。开启方式如下:
customPlot->legend->setVisible(true); graph->setName("温度曲线"); // 图例文字来自 setName customPlot->axisRect()->insetLayout()->setInsetAlignment(0, Qt::AlignTop | Qt::AlignRight);图例位置默认在右上角,可以通过 setInsetAlignment 调整到左上角或右下角。如果图例盖住了曲线,可以把图例移到角落再配合 setFont 缩小字号,QCPLegend 继承自 QCPLayoutElement,字体和边框都能统一调整。这里我一般会把图例文字当成曲线名字,所以每条曲线的 setName 要起得有意义,比如“通道1”或“温度”。
到这里,静态曲线已经完全可用。你可以用它画文件里读取的一段数据,也可以画传感器采样的历史回放。下一步要解决的是动态场景:数据持续进来,曲线要跟着实时动起来。
4. 实时曲线与性能调参:定时刷新、滑动窗口和缓存策略
4.1 实时曲线的数据组织方式:定长缓冲与滑动窗口
实时曲线和静态曲线的本质区别在于数据是源源不断追加的。如果无限制往 QVector 里 append,内存和绘制开销都会不断上涨,最终把界面拖垮。我通常的做法是维护一个定长环形缓冲,或者用 QVector 模拟滑动窗口。
具体来说,维护两个 QVector,一个存 x 数据,一个存 y 数据。新数据到达时 append 进尾部,然后检查总长度是否超过了预设的最大窗口值,比如 10000 点。超过就把前面的点一次性删除。这里有个性能细节:QVector 头部删除是 O(n) 操作,但如果按窗口大小成批删除而不是每个点删一次,效率是可以接受的。代码层面这样处理:
// 自定义实时数据缓冲 void RealtimeBuffer::appendData(double x, double y) { m_keys.append(x); m_values.append(y); // 超出窗口大小,丢弃最旧的数据 const int maxPoints = 10000; if (m_keys.size() > maxPoints) { int removeCount = m_keys.size() - maxPoints; m_keys.remove(0, removeCount); m_values.remove(0, removeCount); } }把两个 QVector 的删除操作合在一起,保证它们长度永远一致。窗口大小可以根据实际需要调整,数据点越多曲线越完整,但绘制压力也越大。对于实时刷新,我更关心老数据及时移出视野,所以窗口长度通常和显示范围挂钩,比如会让窗口长度略大于一屏能显示的点数。
有了这个缓冲类,界面层只需要每次刷新时把这组数据整体塞给曲线图。这个方案简单、直观,而且能满足绝大多数“最近 N 个点”的监控场景。
4.2 用 QTimer 驱动刷新:QTimer 与 QCustomPlot 的配合
数据来了之后,曲线需要周期性重绘。最常见驱动方式是 QTimer,每隔固定毫秒数触发一次刷新操作。刷新的频率不是越高越好,一般 30 到 60 毫秒刷新一次已经能形成流畅的动画效果,再高会增加 CPU 负担。
// 在界面初始化中启动定时器 QTimer *timer = new QTimer(this); connect(timer, &QTimer::timeout, this, &MainWindow::updateRealtimeCurve); timer->start(50); // 每 50ms 刷新一次,约 20 帧 // 刷新函数 void MainWindow::updateRealtimeCurve() { // 模拟产生新数据,实际场景中从这里读取采集队列 double key = m_keyCounter * 0.05; double value = qSin(key * 2.0) + qrand() % 100 / 50.0; m_buffer->appendData(key, value); ++m_keyCounter; // 把缓冲数据同步给曲线图 QCPGraph *graph = customPlot->graph(0); graph->setData(m_buffer->keys(), m_buffer->values(), true); // 动态调整坐标轴范围:x 轴跟随最新数据移动 double xRange = 20; // 显示最近 20 个时间单位 customPlot->xAxis->setRange(key - xRange, key); // y 轴自动适配当前窗口内的数据范围 customPlot->yAxis->rescale(); customPlot->replot(); }setData 的第三个参数是 alreadySorted,传 true 表示数据已经按 x 排序,QCustomPlot 可以跳过排序步骤,节省时间。因为我们的数据是持续追加的,天然有序,所以一定要传 true。x 轴范围手动设置成 [最新值 - 20, 最新值],制造出“窗口跟随滚动”的效果,这是监控波形最常见的视觉形式。y 轴用 rescale 动态适配当前窗口数据的上下界,避免波形超出视野。
这套写法的优点是见效快,逻辑直白,数据新鲜度完全由 timer 周期决定。缺点是如果偶尔一次数据产生速度远大于 timer 刷新速度,一次性塞入过多新点会导致曲线出现突跳。更好的做法是每次刷新只取增量数据,用 addData 而非 setData。
4.3 性能调参:关抗锯齿、开自适应采样、setData 与 addData 的取舍
实时曲线最怕数据量大以后界面卡顿。QCustomPlot 提供了几个开关,能在不影响视觉的前提下把性能拉起来。关键是分清哪些点在 QCustomPlot 内部计算,哪些开销可以通过参数规避。
第一个开关是抗锯齿。抗锯齿能让曲线边缘更平滑,但代价是渲染时间成倍增加。在追踪实时波形时有轻微锯齿完全不影响读图,我一般会在实时模式里关掉曲线抗锯齿,只在静态图导出时打开。
// 关闭整个绘图区的抗锯齿 customPlot->setNotAntialiasedElements(QCP::aeAll); // 或者只关闭曲线抗锯齿,保留文字抗锯齿 customPlot->graph(0)->setAntialiased(false);第二个开关是自适应采样。QCustomPlot 的 setAdaptiveSampling 可以在数据点极多时自动丢弃屏幕上看不见的像素点,把重叠点合并掉,大幅减少绘制时间。这个选项默认是开启的,但如果之前误关过,记得重新打开。
customPlot->graph(0)->setAdaptiveSampling(true);第三个取舍是 setData 与 addData。setData 是整段替换数据,适合数据量不大、每次刷新把窗口内所有数据重新塞一遍的场景。addData 是增量追加,适合数据量大、每次只更新末端少部分数据的场景。我在数据总量超过 5000 点时坚决用 addData,配合定长缓冲每次只追加几百个点,性能明显比整表替换好。
// 增量更新模式:只把新数据追加进去 graph->addData(newKeys, newValues, true); customPlot->xAxis->setRange(latestKey - xRange, latestKey); customPlot->replot(QCustomPlot::rpQueuedReplot);最后那个 replot 参数也值得说。rpQueuedReplot 会把重绘请求排队,同一时间多次调用只真正重绘一次,在定时器和数据采集两个频率不一致的时候能避免不必要的重复绘制。如果只是点一次按钮触发刷新,用默认的 rpHarmless 就行。
5. 曲线图制作避坑清单:坐标轴错乱、内存暴涨和闪烁问题的排查
5.1 曲线只显示了一部分,坐标轴没有跟随数据
现象描述:数据量很大,但曲线只有最左边一小段,拖动或缩放后其余部分才显示出来。这个问题在第一次做实时曲线时特别常见。
原因分析:坐标轴范围是固定的,默认是 -10 到 10。第一次插入数据显示完整,是因为正好在这个范围内。后续数据范围扩大,坐标轴范围没有更新,新数据就落在可视区域外。实时场景下,这个问题还会演变成:曲线走到右边边缘后“消失”,因为 x 轴范围没有跟随最新数据滑动。
解决方法:每次更新数据后,明确设置坐标轴范围。静态场景调用 rescaleAxes();实时场景手动指定 x 轴跟随最新值,y 轴根据窗口内数据动态调整。不要依赖 QCustomPlot 的自动范围,自动范围默认只在 rescaleAxes 时生效。
5.2 数据点超过几万后,界面操作卡成幻灯片
现象描述:一次性 setData 塞入几万个点,之后无论是缩放、平移还是实时刷新,界面都有明显的卡顿,CPU 占用率居高不下。
原因分析:绘制的数据点过多,且每次重绘都在做全量计算。还有一个隐蔽原因是数据本身没有排序,QCustomPlot 每次重绘前做排序检查,以及开启了不必要的抗锯齿。
解决方法:先确认 setData 第三个参数 alreadySorted 是否传了 true,没有排序前提就传入 true 反而会出错。然后开启自适应采样、关闭抗锯齿。最后检查是不是每次刷新都在重复 setData 全量数据,如果是,就改成增量 addData。数据总量在不影响细节的前提下尽量限制在几万点以内,用滑动窗口裁剪旧数据。
5.3 实时刷新时曲线闪烁或抖动
现象描述:波形刷新过程中,曲线有明显的闪烁感,或者 y 轴范围频繁跳动导致波形上下乱跳,用肉眼无法稳定观察。
原因分析:闪烁通常和重绘效率低有关,而抖动几乎总是因为对 y 轴反复调用 rescale()。如果某个瞬间窗口内混入一个异常大或小的点,y 轴范围会瞬间被拉得非常大,波形立刻被压低,下一个瞬间异常点滑出窗口,y 轴范围又快速缩回,于是看起来曲线在“喘气”。
解决方法:对 y 轴手动设定一个相对合理的上下限,不指望它每帧都自适应。或者给 rescale 的结果做平滑处理:检测到范围变化过大时,沿用上一帧的范围。另一个技巧是每次 setData 或 addData 前先暂停重绘,全部更新完再一次 replot,避免中间状态被渲染出来。
5.4 跨线程更新数据导致界面崩溃或无响应
现象描述:采集线程通过网络或串口接收数据,直接在子线程里调用 setData、replot,程序运行几分钟后崩溃,或者在 Qt Creator 调试模式下弹出“Cannot create children for a parent that is in a different thread”的警告。
原因分析:QCustomPlot 继承自 QWidget,遵守 Qt 的 GUI 线程约束。所有绘图操作必须在主线程(GUI 线程)执行。子线程直接操作控件后,事件循环的线程模型被破坏,崩溃只是时间问题。
解决方法:子线程采集到数据后,通过信号槽把数据发回主线程,在主线程的槽函数里完成 setData 和 replot。连接方式默认是自动的,跨线程时会自动排队等待主线程执行,安全无脑。
5.5 QCustomPlot 的 include 和编译问题总是复现
现象描述:编译时报错一大堆,字体、模板、未定义类型,其中还有不少错误出现在 qcustomplot.cpp 内部,但排查下来又不是自己代码的问题。
原因分析:绝大多数情况是工程缺少 PrintSupport 模块。QCustomPlot 的源码中包含了 QPrinter 相关逻辑,没有该模块时编译器会找不到相应符号,报错指向 qcustomplot 内部,特别有迷惑性。
解决方法:在 qmake 的 .pro 文件里加 QT += widgets printsupport,或在 CMake 里按前文写的 find_package 方式包含 PrintSupport。改完配置后先 qmake 重新生成 Makefile 再编译,不要只点“构建”按钮,有时候增量编译会跳过配置更新。
6. 进阶:跨线程更新曲线数据与自定义悬浮提示
前文讲了子线程直接操作控件会崩溃,这里展开说一下正确的跨线程方案。我比较习惯的做法是:采集线程只负责把原始数据塞进一个线程安全队列或发信号,主线程定时器周期性从队列取数据并刷新。这样两者的节奏天然解耦,即使采集频率波动也不会影响界面刷新。
实际项目中,我在子线程里定义了一个信号,参数是两个 double,一个是时间戳,一个是采样值。主线程用 Qt::QueuedConnection 连接这个信号,每来一条数据就调用一次 addData。
// 采集线程侧 void DataAcquisitionWorker::onPacketReceived(double timestamp, double value) { emit dataReady(timestamp, value); // 信号跨线程投递到主线程 } // 主线程侧 Connect(worker, &DataAcquisitionWorker::dataReady, this, &MainWindow::appendDataPoint, Qt::QueuedConnection); void MainWindow::appendDataPoint(double timestamp, double value) { m_buffer->appendData(timestamp, value); customPlot->graph(0)->addData(timestamp, value, true); customPlot->xAxis->setRange(timestamp - 20, timestamp); customPlot->yAxis->rescale(); customPlot->replot(QCustomPlot::rpQueuedReplot); }这个模式的好处是每个数据点只做一次追加,没有全量替换的浪费。高频采集下新增点一次不会太多,曲线的运动轨迹也更平滑。另一个小技巧是自定义悬浮提示:鼠标在曲线上移动时显示当前点的坐标值。QCustomPlot 提供了 QCP::Signals 中的 mouseMove 信号,配合坐标轴 pixelToCoord 方法可以反算出鼠标所在位置对应的数据坐标。
connect(customPlot, &QCustomPlot::mouseMove, this, [=](QMouseEvent* event) { double x = customPlot->xAxis->pixelToCoord(event->pos().x()); double y = customPlot->yAxis->pixelToCoord(event->pos().y()); statusBar()->showMessage(QString("坐标: (%1, %2)").arg(x).arg(y), 200); });这样的一段逻辑,我已经在新项目里直接当成模板用了。验证曲线图做没做扎实,我会用三个自身标准去检查:一是塞入 10 万个点后缩放拖拽是否还能保持流畅;二是数据更新时曲线的走势有没有跳变和闪烁;三是调整窗口大小坐标轴刻度和曲线有没有错位。这几年做 QT 项目的经验告诉我,曲线图这个功能看起来不起眼,但它对底层机制和数据结构的掌握要求相当综合。只要你把坐标映射、数据缓冲和重绘机制三者想通,用 QCustomPlot 还是 Qt Charts 都不是关键问题。希望这篇实战拆解对你有帮助。
本文还有配套的精品资源,点击获取