简介“QT项目实战曲线图制作”是一份面向QT/C初、中级开发者的完整实战源码包聚焦于利用QPainter、QGraphicsView/QGraphicsScene等组件实现可交互曲线图工具覆盖事件处理、模型视图架构、数据点插值与平滑算法等核心知识点。压缩包共261个文件约1.81MB以240个索引文件为主同时包含C源文件、头文件、界面文件和工程配置文件可据此还原项目结构并理解QT绘图与交互流程。目前已有689人学习下载。源码包在代码层面提供了从数据到图形展示的完整实现包括自定义图形对象、曲线路径构建、鼠标键盘事件响应以及数据处理、曲线绘制和事件处理的模块化类设计插值算法让曲线平滑连续缩放拖拽等交互操作提升了可用性适合系统掌握QT自定义绘图与图形界面开发的读者参考和二次开发。1. 曲线图实战数据流如何变成可视形状QCustomPlot 也好Qt Charts 也好QT C 曲线图这个需求本质上是把一串不断增长的数据流变成一眼能看懂的几何形状。之前调某个采集设备时回传数据用 QTextEdit 打印成文本看着十六进制字符串半天看不出规律换成曲线图之后毛刺、漂移、周期波动全部浮出水面。曲线图在项目里既能当临时调试器也能直接变成正式功能面板。本文按实际开发顺序展开绘图方案怎么选、数据怎么接入、实时刷新怎么不卡、常见坑在哪个位置、最后如何做成可交互仪表盘。新手能照着把曲线画出来熟手可以直接跳到性能与排障部分。2. 方案选型QCustomPlot 与 Qt Charts 怎么选做曲线图之前先卡一下方案。很多新人会直接在 Qt Creator 里选中 Qt Charts 模块理由是官方组件不用额外下载但项目凡是涉及高频刷新、自定义坐标轴、来回拖拽缩放的我一般先用 QCustomPlot。这里不是说 Qt Charts 不能用而是两者的适用场景有明显分叉选错了后面全是坑。2.1 两种库的代价与边界下表是从实际工程角度看的两者差异不是官方文档复述是改代码时真实会遇到的分歧点。维度QCustomPlotQt Charts集成成本两个源文件进工程加一行 printsupport需要额外启用 charts 模块曲线层模型graph/plottable 概念追加数据直接 setDataQLineSeries 配合 QChart 视图模型实时性能内置 OpenGL 加速开关适合高频刷新常规刷新可用高频时需要手动合并数据点坐标轴定制ticker 机制自由时间轴、对数轴都好做定制刻度器相对受限交互扩展十字光标、范围拖拽、点击图例都容易实现交互可以但代码绕一些如果只是给报表画几条静态曲线、柱状图Qt Charts 交付更快如果曲线要跟着串口或传感器数据实时滚动、还要能在界面上放大看细节QCustomPlot 更顺手。我手头的工程基本是第二种场景所以后文全部围绕 QCustomPlot。2.2 工程初始化一个能出画面的最小骨架QCustomPlot 的集成方式很朴素它本质上就是一个 QWidget 子类有自己的坐标轴、网格、图形层不依赖额外动态库。最常见的做法是把两个源文件放进工程根目录然后在 .pro 里加上 printsupport。// 工程文件里需要追加的模块 QT core gui greaterThan(QT_MAJOR_VERSION, 4): QT widgets printsupportprintsupport 是 Qt 的打印支持模块QCustomPlot 源码直接依赖它漏掉这一行最常见的报错是找不到 QPrinter 相关声明。另外如果用的是 qmake 而不是 CMakeinclude 路径不需要手动配pro 文件会自动把当前目录加入头文件搜索路径。在窗口构造函数里创建一个画布并设置初始坐标轴范围#include qcustomplot.h PlotPanel::PlotPanel(QWidget *parent) : QWidget(parent) { QCustomPlot *plot new QCustomPlot(this); plot-setGeometry(0, 0, 720, 420); // 先固定坐标轴范围避免自动缩放在初始阶段来回跳 plot-xAxis-setRange(0, 5); plot-yAxis-setRange(-1.5, 1.5); plot-xAxis-setLabel(QStringLiteral(时间)); plot-yAxis-setLabel(QStringLiteral(信号)); // 添加第一条曲线层没有 addGraph 之前 setData 会直接崩溃 plot-addGraph(); plot-graph(0)-setData( QVectordouble{0, 1, 2, 3, 4}, QVectordouble{0, 0.8, 0.9, 0.1, -0.6}); plot-replot(); }setData 接收两个长度相同的 QVector一个存 X一个存 Y。这里有个细节graph(0) 这个索引是在 addGraph 之后才存在的索引从 0 开始多加一条曲线就要再 addGraph 一次。setRange 放在 setData 之前是为了让画面一开始就处于一个稳定的视角避免自动缩放把尺度打乱。2.3 坐标轴类型把 X 轴变成真实时间轴默认的 xAxis 是数值轴对调试数据来说已经很够用。但如果你要画的是“每秒采样一次、持续跑半小时”的实时信号X 轴最好用时间刻度器否则刻度显示的是 0.5、1.5 这种相对采样序号看久了还得心算换算成真实时间。// 使用系统时间作为坐标轴刻度 QSharedPointerQCPAxisTickerTime timeTicker(new QCPAxisTickerTime); timeTicker-setTimeFormat(%h:%m:%s); // 时:分:秒 plot-xAxis-setTicker(timeTicker); // 轴线标签、字体统一走 QFont 控制 plot-xAxis-setTickLabelFont(QFont(Monospace, 9)); plot-xAxis-setLabelColor(QColor(60, 60, 60));timeTicker 是 QCustomPlot 提供的专用刻度器负责在缩放时自动切换时间粒度。比如你能看到完整半小时时刻度可能是每 5 分钟一条放大到最近 30 秒时它会自动变成每 5 秒一条。这个自动切换行为是默认数值轴做不到的。3. 数据接入从静态曲线到实时刷新画一条静态曲线只是热身真实需求永远是数据不断进入、界面持续更新。这一章拆解数据容器、追加逻辑、坐标轴联动三个关键点把实时曲线的基础骨架搭起来。3.1 数据容器QVector、deque 与环形缓冲的取舍实时曲线的数据源一般有两种一种是每来一帧追加一个点另一种是定时器轮询缓冲区。无论哪种最终都要把数据交给 setData。最直接的做法是维护两个 QVector每次 append 之后整体 setData// 通道追加一个采样点 void PlotPanel::appendChannelData(double x, double y) { m_xData.append(x); m_yData.append(y); // 最多保留可视窗口内的 2000 个点超过就丢弃最早的数据 const int maxPoints 2000; if (m_xData.size() maxPoints) { m_xData.remove(0, m_xData.size() - maxPoints); m_yData.remove(0, m_yData.size() - maxPoints); } m_plot-graph(0)-setData(m_xData, m_yData); m_plot-replot(QCustomPlot::rpQueued); }QVector 的 remove(0, count) 会触发元素前移点多了以后有明显的性能损耗。如果通道多、点数大更常见的做法是换成 std::deque它的 pop_front 是常数时间。用 deque 之外的另一个选择是固定大小数组配合环形指针但 QCustomPlot 的 setData 不支持直接传裸数组还得拷回 QVector反而多一道工序。// 用 deque 替代 QVectorpop_front 更高效 std::dequedouble m_xBuf; std::dequedouble m_yBuf;setData 本身接收的是容器QCustomPlot 内部会做一份存储所以函数返回后你清空外部容器也没有问题。另一个注意点是replot(QCustomPlot::rpQueued)它把重绘请求压入事件队列和直接调用 replot 的区别是连续追加多个点时不会每次都立即触发绘制高频数据下能明显降低 CPU 占用。3.2 坐标轴联动不要无脑 rescaleAxes新手写实时曲线很容易用 rescaleAxes 让坐标轴自动适应数据结果曲线确实能看到但只要数据里混入一个异常值整个 Y 轴范围瞬间变成 -10000 到 10000曲线被压成一条直线。这个问题的根源是 rescaleAxes 会同时重新计算 X 轴和 Y 轴的范围而 X 轴又是持续增长的两个轴互相牵扯。调整思路是分开控制X 轴跟随时间窗口滑动Y 轴以最近一段数据的统计值作为基准。void PlotPanel::updateAxesRange() { if (m_xBuf.empty()) return; // X 轴固定显示最近 10 秒 double curX m_xBuf.back(); m_plot-xAxis-setRange(curX - 10.0, curX 0.5); // Y 轴基于最近 N 个点算上界下界避免单个异常点放大范围 double minY *std::min_element(m_yBuf.end() - 200, m_yBuf.end()); double maxY *std::max_element(m_yBuf.end() - 200, m_yBuf.end()); double span (maxY - minY) * 1.2 1e-6; m_plot-yAxis-setRange((maxY minY) / 2.0 - span / 2.0, (maxY minY) / 2.0 span / 2.0); }X 轴用动态窗口Y 轴只看最近的 200 个采样点。这样当某个点突然跳到 100 时只要它不在窗口内或者只占少数曲线不会剧烈缩放。计算上下界时乘以 1.2 的目的是留出 20% 的边距让曲线顶部不顶到绘图区边缘观感友好很多。末尾加一个极小量 1e-6 是防止数据恒定时 span 为 0。3.3 双通道实时曲线一组可直接跑的完整样例把上面两节的逻辑组装起来一个双通道实时曲线面板长这样void PlotPanel::initChannels() { int idx0 m_plot-addGraph(); int idx1 m_plot-addGraph(); m_plot-graph(idx0)-setPen(QPen(QColor(0, 150, 255), 2)); m_plot-graph(idx1)-setPen(QPen(QColor(255, 120, 0), 2)); m_plot-graph(idx0)-setName(QStringLiteral(通道A)); m_plot-graph(idx1)-setName(QStringLiteral(通道B)); // 图例显示在右上角 m_plot-legend-setVisible(true); m_plot-axisRect()-insetLayout()-setInsetAlignment( 0, Qt::AlignRight | Qt::AlignTop); } void PlotPanel::onTimerTick() { static double t 0; t 0.05; appendChannelData(0, t, qSin(t * 3.0)); appendChannelData(1, t, qCos(t * 2.0) * 0.5); updateAxesRange(); }addGraph 返回值可以直接作为后续索引使用能避免硬编码 0、1 造成的索引错位。图例默认位置在绘图区右下角改成右上角是形成统一视觉习惯也方便后续在左侧留出空间显示读数。曲线颜色选蓝和橙是因为两者色差大即便黑白打印也有明显区分度。4. 实时刷新与性能把重绘成本压下去实时曲线最容易翻车的地方不是画不画得出来而是画出来以后界面拖不动、CPU 飙高。这个问题的本质是重绘成本失控。一条曲线 1000 个点加抗锯齿加渐变填充每次 replot 的开销在低配机器上会到几十毫秒级别一旦定时器比绘制速度还快界面就进入持续排队重绘的状态。4.1 定时器频率不是越快越流畅很多工程师觉得刷新频率越高曲线越平滑于是把 QTimer 设成 10ms结果 100Hz 的界面刷新率远超显示器和绘制能力界面假死。常见做法是控制在 30ms 到 50ms 之间也就是 20 到 33Hz对绝大多数传感器数据已经完全够用。数据源本身频率高的话可以在数据侧累积、在定时器侧批量取而不是让 UI 跟着数据源跑。// 定时器驱动刷新而不是在数据回调里直接画图 m_refreshTimer new QTimer(this); m_refreshTimer-setInterval(50); connect(m_refreshTimer, QTimer::timeout, this, PlotPanel::onTimerTick); m_refreshTimer-start(); void PlotPanel::onTimerTick() { // 从采集缓冲区一次性取 20 个新点 QVectordouble batch m_acquireBuffer.takeNewSamples(); for (double v : batch) { appendChannelData(currentX, v); currentX 0.05; } updateAxesRange(); m_plot-replot(QCustomPlot::rpQueued); }定时器落到 UI 线程数据采集合到一个独立缓冲区再由 UI 线程批量消费。这样既保证了点数充足又不让采集线程直接触碰 QCustomPlot 的内部状态。跨线程直接调用 replot 属于常见的未知错误来源轻则花屏重则崩溃数据采集和界面绘制尽量分线程。4.2 双缓冲、OpenGL 与抗锯齿的取舍QCustomPlot 默认是软件双缓冲绘制也就是说它先把画面画到一块后台位图再一次性切到前台理论上不该出现闪烁。但实际项目里闪烁仍然出现原因往往不是 QCustomPlot 本身而是你的窗口有多个子控件或者 QCustomPlot 被嵌入了一个需要整体重绘的布局。性能提升首先考虑的是 OpenGL 加速。QCustomPlot 从某个版本开始支持把绘图后端切到 OpenGL打开方式很简单// 尝试启用 OpenGL 绘图后端 bool ok m_plot-setOpenGl(true); if (!ok) { qDebug() OpenGL init failed, fallback to software; } // 拖动时关闭抗锯齿明显降低交互卡顿 m_plot-setNoAntialiasingOnDrag(true);setOpenGl 返回值要检查一下低配机器或默认 OpenGL 环境异常时这个调用会失败需要用软件绘制兜底。打开 OpenGL 后视觉效果几乎没有差别但大量点和缩放拖动的流畅度能上一个台阶。如果曲线有大量填充区域、渐变着色协处理器加速的效果更明显。抗锯齿是另一个隐性消耗。默认情况下 QCustomPlot 对曲线走抗锯齿但拖动或缩放时每帧都做抗锯齿会让视觉上“变慢”。setNoAntialiasingOnDrag 是一个很实用的开关按下鼠标拖动期间自动关闭抗锯齿松开后恢复人眼在拖动过程中不太感知得到锯齿但操作的顺滑度提升明显。4.3 动态范围的边界窗口长度与点数的权衡X 轴窗口长度直接决定显示多少数据也决定内存耗用。如果窗口设成 60 秒、每秒 50 个点就是 3000 个点不算大但如果每秒几百个点、窗口 10 分钟那就是几十万个点任何绘图库都会吃力。处理方式是降低采样密度而不是扩大点数上限。下表是不同点数下的典型表现参考具体数值取决于机器但趋势是一致的曲线点数软件绘制OpenGL 绘制建议500流畅流畅常用2000轻微掉帧流畅默认上限10000卡顿明显勉强可拖需要抽稀抽稀是常见做法保留的数据点并不是全部而是按时间窗口做均值或等间隔取样。对温度、压力这类缓变信号用均值抽稀反而更平滑对振动信号均值抽稀会淹没尖峰这时更适合用峰值包络。曲线图不是越密越好能让使用者看出趋势才是目标。5. 常见问题排查四个让曲线图翻车的实战坑曲线图项目实战里遇到的问题往往不是绘图 API 不会用而是画出来之后在真实场景下表现不对。以下四个问题我在不同项目里反复遇到每条都按现象、原因、解决写清楚了。5.1 高频刷新导致界面卡成幻灯片现象定时器设成 10ms数据点只有 1000 个但窗口拖动时明显掉帧CPU 占满一个核心。原因10ms 定时器意味着每秒触发 100 次重绘这对于任何复杂波形都不可能完成。此外每次 replot 时抗锯齿和网格线重算都会消耗 CPU。QCustomPlot 本身不缓存上一次的绘制结果每次 replot 都是全量重绘。解决把定时器降到 30ms 到 50ms打开 OpenGL 后端拖动过程中设置 setNoAntialiasingOnDrag(true)如果还是卡把最大点数从 5000 降到 1500优先保证交互。m_plot-setOpenGl(true); m_plot-setNotAntialiasedElements(QCP::aeAll); m_plot-setNoAntialiasingOnDrag(true);setNotAntialiasedElements(QCP::aeAll) 是把所有元素都强行关掉抗锯齿适合嵌入式设备一类的低性能环境。桌面机上只关曲线以外的网格抗锯齿就够了全关之后曲线边缘能看到轻微锯齿。5.2 曲线重绘闪烁与残影现象数据更新过程中曲线周围出现短暂的重影或者背景网格在缩放后残留旧画面像没擦干净黑板。原因最常见的是同一个 QCustomPlot 之外又叠了一个普通 QWidget导致窗口系统级重绘和 QCustomPlot 内部缓冲区不同步。另一个原因是反复调用 setData 后没有走 replot而是直接调 update()会绕过 QCustomPlot 的双缓冲机制。解决确认 QCustomPlot 是独立覆盖在目标区域不要把另一个控件叠在上面刷新统一走 replot(QCustomPlot::rpQueued)不要混用 update 和 repaint如果残影只在缩放时出现关闭 OpenGL 后端再试某些显卡驱动的合成器和 QCustomPlot 的 FBO 有兼容问题。m_plot-replot(QCustomPlot::rpQueued);rpQueued 是把重绘请求排到事件队列下一次处理多个数据点到达时可以合并成一次绘制。相反repaint 是立即同步重绘连续调用会导致大量帧积压反而加重闪烁。5.3 Y 轴范围剧烈跳动曲线被压成一条直线现象程序跑着跑着曲线突然看到不波形画面里只有上下两个边界中间是一道水平细线。原因数据里混入了一个极大或极小的异常点比如传感器初始化时输出 0、通信中断时的溢出值、或者计算 NaN。用 rescaleAxes 时这些异常点被纳入范围计算整个 Y 轴被拉宽正常数据自然看不出起伏。解决数据进入曲线之前先做数值有效性检查过滤掉非有限值Y 轴范围改用固定窗口或者最近 N 点的统计值不要全局 rescaleAxes如果异常点是真实测量值但你要看的正常区间很小可以给 Y 轴设置一个合法边界。bool isFinite std::isfinite(v) std::isfinite(x); if (!isFinite) { continue; } // 过滤掉偏离中位数超过 3 倍方差的点 double avg channelAverage(); double dev channelStddev(); if (fabs(v - avg) 3.0 * dev) { continue; }这个中位数加 3 倍方差的过滤谨慎使用信号本身有大尖峰时会把有用信息滤掉。对工业采集场景我更推荐只过滤非有限值然后让使用者自行缩放找到合适的显示范围。5.4 图例和坐标轴中文乱码现象graph 名称设置成“电压信号”界面显示成一串问号或方块坐标轴标签也是同样问题。原因源码文件的编码不是 UTF-8在 Windows 上用 MSVC 编译时字符串字面量按本地代码页解释中文就乱套了。有时候在 Linux 上正常一到 Windows 就乱多半是这一条。解决统一用 QStringLiteral 包裹中文字符串源码保存为 UTF-8。如果工程历史包袱重没法统一改文件编码只能在 MSVC 环境加一个编译开关。// 强制编译器把窄字符串按 UTF-8 解释 #pragma execution_character_set(utf-8)这条 pragma 只对 MSVC 有效GCC 和 Clang 不需要。加上之后 QStringLiteral 和 tr 里的中文在 Windows 下基本都能正常显示。还有一个小习惯坐标轴标签用 QFont 设置具体字体族避免 QCustomPlot 默认字体在某些 Linux 环境下缺少中文字形导致字是正常的但显示成方框。6. 进阶交互十字光标与图例联动曲线图做到这一步基本显示已经没问题再进一步是把曲线图变成能交互的分析工具。两个高性价比功能是十字光标读数与点击图例隐藏曲线。十字光标让使用者能精确读出某个时刻的数值图例联动则让多通道曲线可以在同一块区域里自由开关对比。实现十字光标只需要两个竖直和水平的线条元素鼠标移动时更新它们的位置connect(m_plot, QCustomPlot::mouseMove, this, PlotPanel::onMouseMove); void PlotPanel::onMouseMove(QMouseEvent *event) { double x m_plot-xAxis-pixelToCoord(event-pos().x()); double y m_plot-yAxis-pixelToCoord(event-pos().y()); m_cursorVLine-setVisible(true); m_cursorHLine-setVisible(true); m_cursorVLine-point1-setCoords(x, m_plot-yAxis-range().lower); m_cursorVLine-point2-setCoords(x, m_plot-yAxis-range().upper); m_cursorHLine-point1-setCoords(m_plot-xAxis-range().lower, y); m_cursorHLine-point2-setCoords(m_plot-xAxis-range().upper, y); m_readoutLabel-setText(QStringLiteral((%1, %2)).arg(x, 0, f, 2).arg(y, 0, f, 2)); m_plot-replot(QCustomPlot::rpQueued); }pixelToCoord 是把像素坐标转成数据坐标反过来也有 coordToPixel双向转换是曲线图交互的基础。十字线用 QCPItemStraightLine 创建然后把两条线固定到坐标轴范围窗口移动时十字线自动跟着拉伸。图例联动实现更简单点击图例项时切换对应 graph 的可见性connect(m_plot, QCustomPlot::plottableClick, this, PlotPanel::onGraphClicked); void PlotPanel::onGraphClicked(QCPAbstractPlottable *plottable, int, QMouseEvent *) { if (QCPGraph *graph qobject_castQCPGraph *(plottable)) { graph-setVisible(!graph-visible()); m_plot-replot(); } }点击图例项后被点击的 graph 隐藏再点一次恢复显示。注意 plottableClick 信号的第一个参数是基类指针需要 qobject_cast 转换成 QCPGraph 才能调用 setVisible。如果只点击曲线本体也应该触发这个信号天然覆盖。有一次做现场调试坐标轴被异常数据拉飞甲方看着屏幕上一条直线问是不是设备坏了从那以后我每次接手曲线图项目都会先看数据有效性校验有没有加、Y 轴是不是固定偏移、最高点数有没有限制这三个点确认完再谈性能优化。曲线图是调试工具也是门面稳定显示比花哨功能重要得多希望这几条经验能帮到你少走两趟弯路。本文还有配套的精品资源点击获取