简介:面向Qt数据可视化学习者的轻量级C++源码包,提供散点图完整实现,解决在Qt图形视图框架中自定义二维散点图的展示问题。示例围绕场景、视图与图形项三类核心组件展开,演示数据点如何映射到坐标、如何通过重写绘制方法定制点的颜色、形状与大小,并融入鼠标事件、缩放平移、数据更新与性能优化等实际开发中常用的处理技巧,帮助读者从数据绑定、坐标计算到图形刷新串起完整的绘图流程。压缩包共5个文件,包括C++源文件、头文件和Qt工程文件,源文件承载主要实现逻辑,头文件声明关键类与接口,工程文件便于直接导入Qt Creator运行调试,整体仅6KB,结构精简。已有231人学习下载,适合初、中级Qt开发者学习自定义绘图机制,也可作为课程设计或小型项目中的数据可视化参考。
1. 处理多维数据的 Qt 散点图
要在一张图里同时看清几千个样本的分布趋势、聚类边界和离群点,散点图是比折线图、柱状图更直接的选择。Qt 场景下做散点图,很多人第一反应是接第三方库,但拿到一个「qt散点图实现源码.zip」这样的资源包时,核心价值往往不在点子怎么画出来,而在于整套交互框架怎么搭:坐标轴缩放、点选高亮、数据点提示、动态刷新。这篇文章会从 Qt 绘图的底层机制讲起,对照三种常见实现方案——QPainter 手绘、Qt Charts 模块、QCustomPlot 开源库——把散点图从静态绘制推进到可交互、高性能的完整实现,并附上参数取值建议和排错经验。适合已经能写简单 Qt 界面、想搞清绘图背后坐标变换和事件分发逻辑的开发者,也适合要在嵌入式或工控上位机里做实时数据可视化的工程人员。
2. Qt 散点图绘制的三条技术路线
动手写代码之前,先想清楚一个问题:散点图在你的项目里是静态展示,还是需要支持缩放、拖拽、实时追加数据?这个答案直接决定选哪条路。
2.1 QPainter 手绘:最可控的 Qt 绘图方案
QPainter 是 Qt 自带的绘图引擎,所有 Qt 绘图最终都落到它的 drawPoint 或 drawEllipse 系列接口上。手绘散点图的本质是重写 QWidget 的 paintEvent,在事件回调里把数据坐标映射到窗口坐标,然后逐个画点。
一条典型的数据流是这样:
void ScatterWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); painter.setPen(QPen(QColor(41, 128, 185), 2)); for (const QPointF &p : m_points) { // 数据坐标 -> 窗口坐标 int x = (p.x() - m_xMin) / (m_xMax - m_xMin) * width(); int y = height() - (p.y() - m_yMin) / (m_yMax - m_yMin) * height(); painter.drawEllipse(QPoint(x, y), 3, 3); } }这段代码的逻辑很直白:先对数据做归一化,映射到控件尺寸范围内;y 轴方向要翻转,因为屏幕坐标的原点在左上角,y 正向向下。drawEllipse 的第三、第四个参数分别是椭圆半宽半高,值越大点越大,但绘制开销也随之上升。
这段代码没有处理坐标轴刻度、网格线、图例,也没有做绘制区域边距控制。真实场景里还需要在控件四周留出边距,给坐标轴标注留空间——这属于所有自定义绘图都要处理的布局问题。最省事的做法是定义QRectF plotArea,在它的范围内做映射,坐标轴文本画在 plotArea 外的留白区。
QPainter 方案的优点是零依赖、完全可控,QSS 样式表管不到绘图区域,但可以配合 QPainter 的 brush/pen 自由定义点色。缺点是所有绘制代码都得自己写,坐标轴刻度算法、鼠标框选、缩放动画这些都要手搓。适用于点数不超过几千、交互需求简单的场合。
2.2 Qt Charts 模块:GT 官方方案与许可边界
Qt Charts 是 Qt 官方提供的图表模块,从 Qt 5.7 开始作为独立模块发布,使用QScatterSeries可以直接生成散点图。配置方式是在 .pro 文件里加QT += charts,CMake 则在 find_package 后链接 Qt::Charts。
#include <QScatterSeries> #include <QChartView> QScatterSeries *series = new QScatterSeries(); series->append(1.0, 2.0); series->append(2.5, 1.8); series->setMarkerSize(8.0); series->setColor(QColor(230, 85, 55)); QChart *chart = new QChart(); chart->addSeries(series); chart->createDefaultAxes(); QChartView *view = new QChartView(chart); view->setRenderHint(QPainter::Antialiasing);Qt Charts 的明显优势是坐标轴、网格、图例、鼠标缩放、提示框都内置了,几十行代码就能出一个带交互的散点图。createDefaultAxes 会根据数据自动计算刻度范围,省掉手写刻度算法的麻烦。
这里要提醒一句:Qt Charts 在 Qt 5.7 到 5.14 的开源版本里存在 GPL 和商业许可的约束,使用前要确认项目授权模式。Qt 6 时代 Charts 并入 Qt Graphics 模块体系,但底层 API 变化不大。如果你的项目团队对第三方库没有硬性限制,官方上手成本确实最低。
需要注意的一点是 QScatterSeries 的默认交互有限。框选、数据点高亮、点击信号(QScatterSeries 继承 QXYSeries,可以发 QXYSeries::clicked)都需要手动连接。它适合做常规的数据分析界面,点数上到十万级时性能会明显下降——因为 Qt Charts 内部的点渲染使用 QPainterPath 批量绘制,点太多时 path 更新会阻塞 UI 线程。
2.3 QCustomPlot:开源界的事实标准
QCustomPlot 是一个单文件(qcustomplot.h/cpp)的开源绘图库,在 qcustomplot.com 发布,GPL 协议(商用需要购买授权)。它被工业界广泛采用的原因很简单:坐标轴刻度算法成熟、支持多层图元叠加、内置拖拽缩放和十字光标,性能比 Qt Charts 扎实。
散点图在 QCustomPlot 里的实现方式是这样的:
#include "qcustomplot.h" QCustomPlot *customPlot = new QCustomPlot(this); customPlot->addGraph(); customPlot->graph(0)->setData(xs, ys); customPlot->graph(0)->setLineStyle(QCPGraph::lsNone); customPlot->graph(0)->setScatterStyle(QCPScatterStyle(QCPScatterStyle::ssCircle, QColor(230, 85, 55), QColor(230, 85, 55), 6)); customPlot->graph(0)->setPen(Qt::NoPen); customPlot->rescaleAxes(); customPlot->replot();QCPGraph 是 QCustomPlot 里「折线图 + 散点图」的复合对象:setLineStyle(QCPGraph::lsNone) 表示不连线,setScatterStyle 定义数据点的形状、边框色、填充色和尺寸。setData 接收两个 QVector ,性能上比逐点 append 快得多。
对于「qt散点图实现源码.zip」这个标题,QCustomPlot 是大概率最贴近它的实现形态——因为它的工程集成方式简单(两个文件拖进工程就能编译),社区里流传的散点图源码也以 QCustomPlot 为主。
2.4 选型对照:按项目阶段挑选 Qt 绘图方案
下面这张表汇总三条路线的取舍关键,方便你在拿到任何一套散点图源码时快速判断它属于哪类方案。
| 维度 | QPainter | Qt Charts | QCustomPlot |
|---|---|---|---|
| 外部依赖 | 无 | Qt 模块 | 单文件 |
| 输出代码体积 | 中(全部自己写) | 小 | 小 |
| 坐标轴自动刻度 | 自己实现 | 内置 | 内置 |
| 数据点数量上限 | 几千 | 十万级以下 | 十万级 |
| 缩放/拖拽 | 自己实现 | 内置(开箱即用) | 内置(强大) |
| 自定义程度 | 最高 | 中 | 高 |
| 商用合规 | 无问题 | 需留意 GPL | 需购买授权 |
从实际工程角度讲,时间紧用 Qt Charts,追求可交付的交互体验和长期可维护性选 QCustomPlot,想彻底掌控渲染细节、或者图表模块被裁剪过的嵌入式环境,老老实实 QPainter。
3. 解析散点图源码的坐标变换与 paintEvent 核心逻辑
拿到一套散点图源码,最先要读的不是散点绘制循环,而是坐标变换函数——它决定数据到屏幕的映射关系,后续所有缩放、鼠标取点都建立在这套变换之上。
3.1 从数据空间到屏幕空间的二维映射
散点图全部绘制逻辑的基础是两组值:数据范围(xMin、xMax、yMin、yMax)和绘图区矩形(QRectF plotArea)。映射公式可以拆成一个独立函数,方便复用:
QPointF ScatterWidget::dataToPixel(const QPointF &data) { qreal x = plotArea.left() + (data.x() - m_xMin) / (m_xMax - m_xMin) * plotArea.width(); qreal y = plotArea.bottom() - (data.y() - m_yMin) / (m_yMax - m_yMin) * plotArea.height(); return QPointF(x, y); }反向映射 pixelToData 用于鼠标交互:
QPointF ScatterWidget::pixelToData(const QPointF &px) { qreal x = m_xMin + (px.x() - plotArea.left()) / plotArea.width() * (m_xMax - m_xMin); qreal y = m_yMin + (plotArea.bottom() - px.y()) / plotArea.height() * (m_yMax - m_yMin); return QPointF(x, y); }逻辑不复杂,但有个最容易踩坑的地方:y 方向映射。plotArea.bottom() 是绘图区底边的屏幕坐标,减去偏移量后,数据值越大屏幕坐标越小——这就是为什么用bottom() -而不是top() +。
另一个关键点是保持横纵等比。如果 x 和 y 的缩放比例不一致,散点会在屏幕上被拉伸变形,圆形数据点会变椭圆。修复方式有两种:
- 按最小缩放比统一绘图区宽高:调整 plotArea,使其宽高比与数据范围宽高比一致
- 不修改绘图区,用
painter.scale(ratioX, ratioY)统一变形
第一种更常用,因为坐标轴刻度标注也要基于这个比例。
3.2 paintEvent 中的绘制顺序优化
整个散点图绘制顺序直接影响显示效果。标准顺序是:先画背景和网格,再画散点,最后画坐标轴刻度和边界框。中间层的散点绘制要按数据密度决定策略:
void ScatterWidget::paintEvent(QPaintEvent *event) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); drawGrid(&painter); drawScatterPoints(&painter); drawAxes(&painter); } void ScatterWidget::drawScatterPoints(QPainter *painter) { painter->setPen(QPen(m_pointColor, m_pointSize / 2)); painter->setBrush(QBrush(m_pointColor)); for (int i = 0; i < m_visibleIndices.size(); ++i) { QPointF px = dataToPixel(m_data[m_visibleIndices[i]]); painter->drawEllipse(px, m_pointSize, m_pointSize); } }m_visibleIndices 是经过视口裁剪后的可见数据索引数组。散点图性能差的根因不是 drawEllipse 本身慢,而是把不可见区域的点也画了一遍。当用户把视图缩放到局部区域时,绘图区外的点仍然参与绘制循环,白白消耗 CPU。常见的做法是在 paintEvent 里做一次可见性过滤,只绘制落在 plotArea 范围内的点。
3.3 坐标轴刻度计算:三段式数值区间拆分
坐标轴刻度不需要写得多复杂,但至少要能应对「数据范围变化导致刻度重叠」的问题。一个可用的刻度算法思路是:目标刻度数(比如 5~8 个)决定步长量级,再把步长取整到 1/2/5 的倍数。
double niceStep(double range, int targetTicks) { double raw = range / targetTicks; double magnitude = std::pow(10, std::floor(std::log10(raw))); double normalized = raw / magnitude; double niceStep; if (normalized < 1.5) niceStep = 1; else if (normalized < 3) niceStep = 2; else if (normalized < 7) niceStep = 5; else niceStep = 10; return niceStep * magnitude; }参数说明如下:
- range 是坐标轴数据跨度的绝对值
- targetTicks 是你期望的刻度数量,源码里通常取 6 或 8
- magnitude 把步长缩放到合适量级,比如 range = 0.35,targetTicks = 6,raw ≈ 0.058,magnitude = 0.01,normalized ≈ 5.8,最后 niceStep = 0.05
刻度最小值用qFloor(min / step) * step向下取整,最大值用qCeil(max / step) * step向上取整,保证数据的极值点不被坐标轴边界切掉。调试时可以直接打印 step 和实际刻度列表,对照数据范围人工核对。
3.4 源码阅读时的三个定位点
读别人写的 Qt 散点图源码,按下面三个点逐个突破,比从第一行读到最后一行效率高得多:
- 找 paintEvent:先看绘制入口,确认重绘由什么触发(数据更新调用 update(),改变尺寸调用 resizeEvent,鼠标操作直接 replot)
- 找坐标变换:搜索 dataToPixel 或类似命名,这块是最容易写死但最容易出错的部分
- 找数据容器:确认 QVector/QList 之外有没有做坐标索引加速结构(比如四叉树或网格分箱)
如果源码包里的实现是基于 QWidget 重写的,配置接口一般长这样:setData(const QVector<QPointF> &points)、setXRange(double min, double max)、setYRange(...)。这类命名风格很常见,读的时候留意参数是不是按值传递,大型数据要用 const 引用。
4. 实战改造:给 Qt 散点图加入缩放、框选与动态更新
静态散点图只能看个形态,真正要投入使用的版本必须具备三个能力:滚轮缩放、鼠标框选、数据动态追加。这三个功能覆盖了大部分数据探索和实时监控场景。
4.1 滚轮缩放与坐标轴范围联动
QWidget 的 wheelEvent 接收滚轮事件,缩放逻辑是:根据鼠标在绘图区的位置确定缩放中心,然后按比例收缩坐标轴范围。缩放中心的计算要先做像素到数据坐标的反变换,然后按比例调整 m_xMin、m_xMax、m_yMin、m_yMax。
void ScatterWidget::wheelEvent(QWheelEvent *event) { QPointF mousePos = event->position(); if (!plotArea.contains(mousePos)) return; QPointF dataPos = pixelToData(mousePos); double factor = (event->angleDelta().y() > 0) ? 0.8 : 1.25; double newXMin = dataPos.x() - (dataPos.x() - m_xMin) * factor; double newXMax = dataPos.x() + (m_xMax - dataPos.x()) * factor; double newYMin = dataPos.y() - (dataPos.y() - m_yMin) * factor; double newYMax = dataPos.y() + (m_yMax - dataPos.y()) * factor; setRange(newXMin, newXMax, newYMin, newYMax); }关键点是 factor 取倒数:向上滚动是放大(乘 0.8),向下滚动是缩小(乘 1.25),这样用户操作方向符合直觉。setRange 内部要更新成员变量并调用 update() 触发重绘,不需要手动指定重绘区域,整个控件重绘即可。
第二处要注意的问题是缩放极限。无限缩放会把坐标轴压缩到接近零,导致浮点精度问题。工程上的做法是设定范围最小宽度,比如if (newXMax - newXMin < 1e-6) return;直接丢弃这次缩放操作,或者把范围下限设成数据全体跨度的百分之一。
4.2 鼠标框选与选中状态高亮
框选散点需要三步:mousePressEvent 记录起始点,mouseMoveEvent 更新橡皮筋矩形,mouseReleaseEvent 计算命中点并改写其绘制样式。命中判定最简单的方式是遍历比较像素距离。
void ScatterWidget::mouseReleaseEvent(QMouseEvent *event) { QRectF selection = QRectF(m_dragStartPx, event->position()).normalized(); if (selection.width() < 3 && selection.height() < 3) return; m_selectedMask.fill(false); for (int i = 0; i < m_data.size(); ++i) { QPointF px = dataToPixel(m_data[i]); if (selection.contains(px)) { m_selectedMask[i] = true; } } update(); }框选命中后的高亮在 paintEvent 的散点循环里判断:
for (int i = 0; i < m_visibleIndices.size(); ++i) { int idx = m_visibleIndices[i]; if (m_selectedMask[idx]) { painter->setPen(QPen(QColor(236, 65, 65), 2)); painter->setBrush(QColor(236, 65, 65)); } else { painter->setPen(QPen(m_pointColor, 1)); painter->setBrush(m_pointColor); } QPointF px = dataToPixel(m_data[idx]); painter->drawEllipse(px, m_pointSize, m_pointSize); }框选性能的瓶颈在 selection.contains 的逐个调用。数据点超过十万时,这个 O(n) 遍历会让鼠标松开瞬间卡顿几十毫秒。改进方向是预建一张空间索引:把整个数据空间切成固定大小的网格(比如 64×64),每个格子存点的索引列表,框选时只取矩形覆盖到的格子做精确判定。这样单次框选的计算量从 O(n) 降到 O(格子数 + 命中点数),几十万点的场景也能流畅处理。
4.3 setData 与 appendData:显式区分两种更新模式
静态散点图和实时散点图的数据更新逻辑不同,接口设计上要分开。静态图直接整体替换数据;实时监控图需要增量追加且要限制数据总量,避免无限增长拖垮绘制性能。
void ScatterWidget::appendData(const QPointF &pt, int maxPoints) { m_data.append(pt); while (m_data.size() > maxPoints) { m_data.removeFirst(); } adjustRangeToData(); update(); }maxPoints 可以通过构造函数参数传入。滚动窗口的常见取值是 1000~5000 点,具体值取决于你的更新频率:100ms 刷新一次时,5000 点刚好覆盖 500 秒的数据窗口,屏幕上也吃不满像素密度。
appendData 内部的 adjustRangeToData 会让坐标轴范围跟随数据动态变化。实现时要注意,如果缩放过,用户可能手动锁定了坐标轴范围,此时不应该自动调整范围,否则滚动窗口图会不停被拉回初始范围。工程上的做法是加个bool m_autoRange标志,默认 true,用户滚轮缩放后自动置为 false。
4.4 动态追加数据时的性能保护
高频动态更新的场景中,paintEvent 每次全量重绘所有点,即使画面里只有新增了几个点。对嵌入式设备或工控上位机来说,这个浪费是致命的。
三个优化手段从简单到复杂:
- 设置最小重绘时间间隔。用 QTimer 把更新频率限制在 20~30 FPS,中间时间里的数据变化会聚合到一次重绘里
- 只重绘脏区域。Qt 的 update(QRect) 可以指定局部刷新矩形,新点只落在有限的屏幕范围内时,重绘区域可以大幅缩小
- plotDirectly 直绘。QCustomPlot 的 replot(QCustomPlot::rpQueuedReplot) 可以把多次数据更新的重绘合并到事件循环空闲时统一执行
高频数据场景下,还有一个更隐蔽的问题:QVector 在数据量超过容量时会重新分配内存,导致 UI 线程卡顿。解决思路是预先 reserve 一个大致的容量,然后用索引写入而不是 append:
m_data.reserve(5000); m_data[m_data.size()] = pt;如果点是按固定窗口滚动移除头部数据,用 QVector 的 removeFirst 会引发大量元素移位,性能比 QList 差。QCustomPlot 会在内部维护它的数据容器,这个问题要看你用的方案是什么。
5. 把散点图源码封装成可复用 Qt 组件的验收清单
散点图源码从「能跑」到「能交付」之间还有一段路要走,封装和验收的标准决定了它能复用几次。
5.1 对外接口设计骨架
组件化散点图类的对外接口至少包含数据、坐标轴范围、选型和外观配置四类方法,下面给出一个参考接口设计:
class ScatterPlotWidget : public QWidget { Q_OBJECT public: explicit ScatterPlotWidget(QWidget *parent = nullptr); // 数据接口 void setData(const QVector<QPointF> &points); void appendData(const QPointF &point); void clearData(); // 坐标轴接口 void setXRange(double min, double max); void setYRange(double min, double max); void setAutoRange(bool enable); // 外观配置 void setPointSize(int sizePx); void setPointColor(const QColor &color); void setSelectionColor(const QColor &color); // 交互开关 void setZoomEnabled(bool enable); void setSelectionEnabled(bool enable); signals: void pointHovered(int index, QPointF dataPos); void selectionChanged(QVector<int> indices); };每个接口函数对应一个内部私有成员变量的设置函数,最后调用 update() 刷新。信号在上面的源码里是关键设计——pointHovered 可以对接状态栏实时显示坐标,selectionChanged 可以联动其他平铺视图(比如表格、直方图)。
接口粒度要有分寸。只暴露 setData 和 setXRange 这一层,把轴刻度计算、网格画笔、缩放算法全部封在 private 实现里。过度开放的接口会让调用方乱改内部状态,后续维护时很被动。
5.2 分五个维度验证组件健壮性
完成了接口设计后,用一套固定检查路径来验收散点图组件,以下五个维度在实际项目中都有对应事故:
- 空数据与单点数据:setData 传入空 QVector 时,绘图区域不能留下上一次的残留数据,坐标轴范围要有默认值(比如 0~1);单点数据时,niceStep 计算中 range 为零,要加保护分支避免除零崩溃
- 极端坐标值:数据里混入 NaN 或正负无穷时,paintEvent 里的线段绘制会异常;防御策略是在 setData 里过滤
qIsFinite失败的样本,或者返回 false 让调用方感知 - 窗口缩放极值:把窗口从全屏缩到 100×100 像素时,drawEllipse 的绘制半径不应该超过窗口宽度,否则视觉上点会超出边界被裁剪
- 频繁切换数据源:setData 被连续调用时不能产生内存泄漏,特别是旧 QVector 的释放时机要确认,Qt 的隐式共享会让这个问题藏得很深
- HiDPI 显示兼容性:开启了设备像素比的环境里,dataToPixel 用的是逻辑坐标,绘图区矩形要用逻辑值,否则点位置会偏移,需要在 paintEvent 里检查 devicePixelRatio 的配合方式
组件要被多个项目复用时,高 DPI 和多语言(tr 包裹坐标轴标题)是小问题里最影响观感的两个。
5.3 三点改进延展:Annotations、多序列、导出高清图
顺着封装接口的方向做进一步延展,有三处改动工作量不大但收益明显:
第一处是数据点注解(Annotation)。在某个散点旁显示自定义文本标签,实现成本只是在 paintEvent 末尾用 drawText 额外绘制。注意文本锚点和点的相对位置在缩放时要保持一致,不能让标签和点分离:
painter->drawText(QPoint(px.x() + 8, px.y() - 8), annotationText);第二处是多序列散点图支持。把原有的单个 m_points 改成QVector<SeriesData>,其中 SeriesData 持有自己的点集、颜色、形状和可见开关。绘制时遍历所有序列,点选命中时也要遍历全部序列。QCustomPlot 支持多 graph,它的 addGraph 返回值可以逐个设置 scatter style,实现方式照搬即可。
第三处是导出高清图。组件里加一个 render 到 QImage 的接口,可以用 QPainter 直接渲染组件到指定分辨率的图像:
QImage image(2048, 1536, QImage::Format_RGB32); QPainter imgPainter(&image); imgPainter.setRenderHint(QPainter::Antialiasing, true); render(&imgPainter); image.save("scatter_export.png");导出时数据点尺寸要按缩放因子放大一定比例,否则导出图片里的点会比屏幕上看起来细一半。做法是把 m_pointSize 乘导出分辨率相对于控件尺寸的比例。
验收清单过完之后,再回到这套散点图源码本身:QPainter 方案的好处在于完全透明,任何一行绘制逻辑都能改;QCustomPlot 方案则是少写大量底层代码,要改的关键逻辑集中在坐标轴和数据接口层。确定你的项目到底要哪种自由度,再去处理细节——这样拿到的源码在手里的价值会大得多。
本文还有配套的精品资源,点击获取