简介:这是一份面向Qt开发者的散点图实现源码示例。它聚焦于QGraphicsView图形视图框架,适合需要掌握自定义二维数据可视化、或想用C++在Qt中绘制动态散点图的中初级开发者。压缩包体积仅6KB,共含5个文件,其中包含2个cpp源文件、1个头文件以及1个pro工程文件,结构完整精简,可直接用Qt Creator打开构建。已有231人参与学习。源码虽然小巧,却完整呈现了散点图从数据绑定到界面渲染的典型流程:通过QGraphicsScene管理散点项、子类化QGraphicsItem并重写paint()绘制点形状,同时涵盖鼠标交互、视图缩放与平移、批量渲染性能优化等实操技巧。读者可一边阅读代码一边运行调试,既能理解图形视图框架的对象组织方式,也能借鉴其中事件处理和更新刷新的写法,快速迁移到更复杂的大数据量可视化项目中。
1. 为什么大数据量散点图不自己 paintEvent 而要选 QGraphicsView
两万个点实时刷新,如果还在 QWidget::paintEvent 里逐个 drawPoint,一次重绘就能让界面卡掉半秒,更别提还要做缩放平移和点选。这套qt散点图实现源码.zip用的思路是把每个数据点封装成 QGraphicsItem,交给 QGraphicsScene 管理,再由 QGraphicsView 按可视区域裁剪。它不是为了替代 matplotlib 或 QCustomPlot 这类重型绘图库,而是解决工控看板、设备监控、科研数据预览这类“数据量几万到十几万,还要缩放、平移、点选”的 qt 界面需求,源码里的 scatterchart.pro、chartview.cpp、chartview.h、main.cpp 四件套提供了一条可以直接复用的完整链路。适合已经会用基础控件、想摸清 Qt 图形视图框架边界的开发者,也能给那些正打算从自绘切到 GraphicsView 的工程做一个最小可运行范本。
2. 搭出可复用的 qt 散点图骨架:Scene、View、Item 三角分工
2.1 工程文件与类的初始分工
解压qt散点图实现源码.zip后,第一件事不是去看 chartview.cpp,而是先看 scatterchart.pro。这个文件决定了整个项目依赖哪些 Qt 模块,很多初学把自己的 .cpp 加进工程后报QGraphicsScene not found,就是 pro 里少了模块声明。Qt 5.15 下典型的工程配置是:
QT += core gui widgets TARGET = scatterchart TEMPLATE = app SOURCES += \ main.cpp \ chartview.cpp \ scatterchart.cpp HEADERS += \ chartview.h \ scatterchart.h这里QT += widgets是必须的,QGraphicsView、QGraphicsScene、QPainter 都依赖这个模块;core和gui在 Qt 5 里虽然默认引入,但显式写出来能让工程后续迁到 Qt 6 的 CMakeLists.txt 时少踩一个坑。scatterchart.pro.user是 Qt Creator 生成本地配置,里面记录的是编译器路径和构建目录,不需要提交进版本库,但拿到源码后如果没有这个文件,用 Qt Creator 直接打开 .pro 也会自动生成,不影响编译。
main.cpp 的逻辑非常收敛,只做一件事:构造 QApplication,把 ChartView 实例设为窗口的中央部件。真正画散点的代码全部收在 ChartView 和散点 Item 里。这种组织方式的好处是:如果你有现成的监控面板,只需把 ChartView 当成普通 widget 塞进 QSplitter 或 QTabWidget,耦合度很低。
2.2 ChartView 如何组装 Scene 和 View
ChartView 继承 QGraphicsView 而不是直接 new 一个裸视图,是为了把初始化参数统一封装在构造函数里。核心代码在 chartview.h 和 chartview.cpp 中:
// chartview.h #ifndef CHARTVIEW_H #define CHARTVIEW_H #include <QGraphicsView> class QGraphicsScene; class ChartView : public QGraphicsView { Q_OBJECT public: explicit ChartView(QWidget *parent = nullptr); void loadPoints(const QString &filePath); protected: void resizeEvent(QResizeEvent *event) override; private: QGraphicsScene *m_scene; void setupScene(); }; #endif // CHARTVIEW_H// chartview.cpp #include "chartview.h" #include "scatterchart.h" #include <QGraphicsScene> #include <QResizeEvent> ChartView::ChartView(QWidget *parent) : QGraphicsView(parent) , m_scene(nullptr) { setupScene(); // 抗锯齿只开在视图层,Item 内部不需要重复设置 setRenderHint(QPainter::Antialiasing); // 左键按住拖动整个视图,适合大范围浏览散点分布 setDragMode(QGraphicsView::ScrollHandDrag); // 缩放锚点跟随鼠标,滚轮向上时向光标位置放大 setTransformationAnchor(QGraphicsView::AnchorUnderMouse); // 窗口尺寸变化时保持场景中心不跑偏 setResizeAnchor(QGraphicsView::AnchorViewCenter); } void ChartView::setupScene() { m_scene = new QGraphicsScene(this); // 先给一个足够大的绘图区域,后续加载数据时再按数据范围收紧 m_scene->setSceneRect(-500, -500, 1000, 1000); setScene(m_scene); }参数这里有几个细节:setDragMode(ScrollHandDrag)会让左键变成抓手,直接把视图拖走,这适合“先看全局,再放大看细节”的散点场景;如果改成RubberBandDrag,左键就变成框选,拖拽浏览就得换成中键或右键,交互习惯完全不同。AnchorUnderMouse是地图类应用最常见的缩放锚点,光标指哪放大哪;而AnchorViewCenter更适合按钮驱动的缩放,比如工具栏里的 + / - 键。这套源码里直接用了AnchorUnderMouse,说明设计目标就是鼠标滚轮为主的浏览体验。
2.3 自定义 QGraphicsItem:边界与绘制回调
散点图中每一个点都是一个 QGraphicsItem 子类,工程里的 scatterchart.cpp/h 承担这个职责。这里最容易写错的是boundingRect()和paint()的配比:
// scatterchart.h #ifndef SCATTERCHART_H #define SCATTERCHART_H #include <QGraphicsItem> #include <QColor> class ScatterItem : public QGraphicsItem { public: ScatterItem(qreal x, qreal y, const QColor &color); QRectF boundingRect() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; protected: void mousePressEvent(QGraphicsSceneMouseEvent *event) override; void hoverEnterEvent(QGraphicsSceneHoverEvent *event) override; private: QColor m_color; qreal m_x; qreal m_y; qreal m_radius = 5.0; }; #endif // SCATTERCHART_H// scatterchart.cpp #include "scatterchart.h" #include <QPainter> #include <QGraphicsSceneMouseEvent> ScatterItem::ScatterItem(qreal x, qreal y, const QColor &color) : m_color(color), m_x(x), m_y(y) { setPos(m_x, m_y); setAcceptHoverEvents(true); setCursor(Qt::PointingHandCursor); } QRectF ScatterItem::boundingRect() const { // 半径 5,外扩 2 像素作为鼠标热区和抗锯齿余量 return QRectF(-m_radius - 2, -m_radius - 2, (m_radius + 2) * 2, (m_radius + 2) * 2); } void ScatterItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) { Q_UNUSED(option); Q_UNUSED(widget); painter->setPen(Qt::NoPen); painter->setBrush(m_color); painter->drawEllipse(QPointF(0, 0), m_radius, m_radius); }这里的边界外扩值得解释一句:如果boundingRect()刚好等于圆的外切正方形,很多场景下鼠标点击圆边缘会判定成“未命中”,因为抗锯齿绘制的像素略微超出几何边界。留出 2 像素余量后,命中检测稳定得多,代价可以忽略。paint()里用drawEllipse(QPointF(0,0), radius, radius)而不是drawArc,前者在现代 Qt 版本里会直接走 GPU 加速的二次贝塞尔光栅化路径,后者是 16 分之一的弧线逼近,点数量上去后差别很明显。还要注意不要把场景坐标再叠加一次setPos和绘制坐标,否则所有点会整体偏移到两倍坐标的位置。
场景、视图、Item 三者的职责可以用一张表概括,这也是整个qt散点图实现源码最核心的分工:
| 组件 | 负责内容 | 本工程对应文件 |
|---|---|---|
| QGraphicsScene | 管理 Item 生命周期、空间索引、碰撞检测 | chartview.cpp 中 m_scene |
| QGraphicsView | 视口裁剪、坐标变换、滚轮缩放、拖动事件分发 | chartview.h / chartview.cpp |
| QGraphicsItem | 单点位置、绘制外观、鼠标事件回调 | scatterchart.h / scatterchart.cpp |
3. 数据绑定、命中检测与视图交互,让散点图可以真正查数
3.1 从文本数据批量生成散点 Item
qt散点图实现源码里的数据加载通常走文件路径,这是最通用的方式。常见做法是 QFile 加 QTextStream 逐行读取,再按逗号或制表符切列。这里不推荐用 QRegExp 做整文件正则解析,因为当第一行是表头时反而要额外处理脏数据,逐行判断更直观:
bool ChartView::loadPoints(const QString &filePath) { QFile file(filePath); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) return false; QTextStream in(&file); QVector<QPointF> raw; raw.reserve(10000); // 预分配,避免频繁扩容 while (!in.atEnd()) { const QString line = in.readLine(); const QStringList cols = line.split(QLatin1Char(','), Qt::SkipEmptyParts); if (cols.size() < 2) continue; bool okX = false; bool okY = false; const qreal x = cols.at(0).trimmed().toDouble(&okX); const qreal y = cols.at(1).trimmed().toDouble(&okY); if (okX && okY) raw.append(QPointF(x, y)); } m_scene->clear(); for (const QPointF &p : raw) { ScatterItem *item = new ScatterItem(p.x(), p.y(), QColor(31, 119, 180)); m_scene->addItem(item); } return true; }这段逻辑里有两个容易踩的坑。第一个是Qt::SkipEmptyParts,它在 Qt 5.14 以后取代了旧的QString::SkipEmptyParts,如果你把源码里的代码直接往 Qt 5.9 的老工程里搬,编译就会报SkipEmptyParts is not a member,反过来也一样。第二个是 CSV 解析和 Item 生成分成两步,先收集到raw再统一建 Item,这样可以在解析阶段把非法行过滤掉,也方便后续做坐标归一化。如果数据量在 5 万行以上,raw.reserve预分配能明显减少 QVector 扩容时的内存拷贝。
实际项目里数据往往不是干净的 CSV,可能有重复点、极值点。极值会导致setSceneRect被拉得非常大,散点整体缩成一团。处理方式是先扫一遍数据算 min/max,再根据数据范围收缩 sceneRect:
QRectF bounds = m_scene->itemsBoundingRect(); m_scene->setSceneRect(bounds.adjusted(-20, -20, 20, 20));这样左侧留出 20 像素余量,刻度标签不会贴边。
3.2 鼠标点选与悬停反馈的实现
源码中交互事件放在 ScatterItem 内部而不是 ChartView 里,这个设计对新手不太直观,但实际更合理。如果放在 QGraphicsView 的mousePressEvent里,拿到的坐标是视口坐标,还得用mapToScene才能换算成场景坐标;而 Item 内部回调拿到的已经是本地坐标,判断是否点在圆内只做一个距离计算即可:
void ScatterItem::mousePressEvent(QGraphicsSceneMouseEvent *event) { if (event->button() == Qt::LeftButton) { // 先把上一次高亮恢复,再高亮当前点 setBrush(QColor(214, 39, 40)); // 红色 update(); // 只重绘当前 item 的边界区域 } QGraphicsItem::mousePressEvent(event); } void ScatterItem::hoverEnterEvent(QGraphicsSceneHoverEvent *event) { Q_UNUSED(event); setToolTip(QStringLiteral("(%1, %2)").arg(m_x).arg(m_y)); setBrush(m_color.lighter(120)); update(); }update()在这里很重要。QGraphicsItem 的 update 会通知场景把该 Item 的 boundingRect 加入脏区,但不会触发整张场景重绘。很多人在 Item 里改了颜色后忘记调用 update,画面上毫无反应,这不是 bug,是更新机制没走完。鼠标事件处理完后记得要调QGraphicsItem::mousePressEvent(event),否则可能破坏框架内部的事件传递链。
如果要做“点击后高亮并维持选中”,建议配合setFlag(QGraphicsItem::ItemIsSelectable),然后在 paint 里判断option->state & QStyle::State_Selected来决定画刷颜色,这样比在事件里手动维护状态更符合 Qt 的原生交互模型。本工程源码里用的是比较朴素的事件内 setBrush,适合初学理解事件流。
3.3 缩放平移参数与常见的误配
ChartView 构造里已经设置了ScrollHandDrag和AnchorUnderMouse,这是散点图浏览的默认组合。还有一个容易被忽略的是setRubberBandSelectionMode,它控制框选时 Item 的命中规则:
setDragMode(QGraphicsView::RubberBandDrag); setRubberBandSelectionMode(Qt::ContainsItemShape);Qt::ContainsItemShape表示只有完全被框住的 Item 才被选中;Qt::IntersectsItemShape则只要沾边就算选中。做数据筛选时后者更顺手,做精确抠点分析时前者更合理。下面是三组常用组合的对比:
| 组合 | 用户体感 | 适用场景 |
|---|---|---|
ScrollHandDrag+AnchorUnderMouse | 左键拖拽,滚轮朝光标方向放大 | 通用散点浏览 |
RubberBandDrag+AnchorViewCenter | 左键框选,按视图中心缩放 | 数据筛选、圈选分析 |
NoDrag+AnchorUnderMouse | 禁止拖拽,仅滚轮缩放 | 展示大屏、只读模式 |
有一个误配特别常见:在构造函数里先setSceneRect(0, 0, 800, 600),然后在resizeEvent里又根据窗口大小重新设置 sceneRect,结果数据点坐标是正的,sceneRect 原点却是负的,视图一启动就看不到点。正确做法是保留上面 loadPoints 最后的itemsBoundingRect()收缩方式,resizeEvent 里只做视口适配,不要反复重置 sceneRect。
4. 渲染性能与绘图细节:散点从能跑到跑得流畅
4.1 别把 ItemHasNoContents 误当成散点加速开关
在 Qt 的图形视图框架里,提到 qt绘图效率比较时,很多资料会提到QGraphicsItem::ItemHasNoContents这个 flag。它的本意是告诉框架该 Item 没有可见内容,可以被跳过绘制,但散点 Item 恰恰是需要绘制的对象。如果给散点加上这个 flag,最直接的结果是界面上的点全部消失,而不是跑得更快。这个源码里出现过类似尝试,正确理解是:它适合那些只做坐标占位、不参与视觉输出的辅助 Item。
真正对散点绘制有效的三个手段,按收益从高到低排列:
// 1. 缓存绘制结果,缩放平移时不再反复执行 paint() item->setCacheMode(QGraphicsItem::DeviceCoordinateCache); // 2. 关闭 Item 的默认转换更新,如果移动不频繁 item->setFlag(QGraphicsItem::ItemSendsGeometryChanges, false); // 3. 背景网格用单一 QGraphicsItemGroup,而不是每个刻度一个 ItemDeviceCoordinateCache的代价是缓存位图会占用额外的内存,一个 20 MB 的场景可能因此多占几 MB,但换来的滚轮缩放流畅度提升非常明显。注意:如果 Item 被设置成ItemIsMovable且频繁拖动,缓存反而会因为反复失效而降低性能,所以移动型散点和静态展示型散点要用不同的缓存策略。
paint 内部的绘图指令也值得抠细节。同样的圆,不同写法性能差距成倍:
// 高效:无笔 + 实心画刷 + drawEllipse 中心半径重载 painter->setPen(Qt::NoPen); painter->setBrush(m_color); painter->drawEllipse(QPointF(0, 0), m_radius, m_radius); // 低效:带宽度画笔 + drawArc 弧线逼近 painter->setPen(QPen(m_color, 2)); painter->drawArc(QRectF(-5, -5, 10, 10), 0, 360 * 16);第二段代码使用了360 * 16定义整圆,最终会被拆成几十段贝塞尔曲线,点数量到一万以上时 CPU 占用差别肉眼可见。drawEllipse的重载在 Qt 内部走短路径,且没有画笔时省掉了描边计算。
4.2 大数据量批量添加与进度反馈
几千个数据点时直接循环addItem毫无压力;数据量到 5 万以上时,除了绘制,Item 对象的构造、场景空间索引的插入也开始成为瓶颈。除了前面reserve预分配,另一个实用技巧是分块让出事件循环:
const int total = raw.size(); for (int i = 0; i < total; ++i) { ScatterItem *item = new ScatterItem(raw.at(i).x(), raw.at(i).y(), QColor(31, 119, 180)); m_scene->addItem(item); // 每 2048 个点处理一次事件,避免界面长时间无响应 if ((i & 0x7FF) == 0) { QCoreApplication::processEvents( QEventLoop::ExcludeUserInputEvents); } }ExcludeUserInputEvents在这里有讲究:加载期间仍处理绘制事件和定时器,但不响应鼠标键盘输入,防止用户在数据没加载完时拖拽视图导致状态错乱。要更精细的反馈,可以配合 QProgressDialog 或状态栏进度条,加载到一半还能响应取消,这是 qt 开发里处理长任务的常规套路。
不同数据量级下的策略可以按下面的表来选:
| 数据量 | 策略 | 说明 |
|---|---|---|
| < 5k | 直接 Item 绘制 | 无需额外优化 |
| 5k ~ 50k | DeviceCoordinateCache+ 关闭 ItemIsMovable | 缩放平移明显提速 |
| > 50k | 抽稀或转为 QImage 整体渲染 | 继续用 Item 会拖垮场景索引 |
超过 5 万点再逐项维护就没有意义了。更极端的设计是把点画进一张 QImage,然后只给场景添加一个 Image Item,这样缩放时图像的清晰度会受损,但交互流畅度能保住,适合不需要逐点精确查看的分布概览。
4.3 QSS 只影响视图层,Item 外观靠 QPen 和 QBrush
很多人习惯用 QSS 给整个界面换皮肤,但 QGraphicsItem 并不是 QWidget,Qt 样式表作用不到 Item 内部的画笔和画刷。这套源码里样式表只用在 QGraphicsView 这一层:
setStyleSheet(R"( QGraphicsView { background-color: #f8f9fa; border: 1px solid #d0d7de; border-radius: 4px; } )");背景色、边框、圆角都作用于 viewport,散点的颜色仍由ScatterItem构造函数里传入的 QColor 决定。如果你发现 Item 颜色没有按 QSS 变化,这不是源码 bug,而是机制本来就如此。更可控的做法是把颜色、线宽等外观参数封装成结构体传给 Item,需要换肤时直接重建颜色并调用update()。背景网格则用drawLine画在 scene 的最低层,或者干脆在 ChartView 里重写drawBackground:
void ChartView::drawBackground(QPainter *painter, const QRectF &rect) { painter->setPen(QPen(QColor(230, 230, 230), 1)); // 以 50 像素为步长画网格 for (qreal x = rect.left(); x < rect.right(); x += 50) painter->drawLine(QPointF(x, rect.top()), QPointF(x, rect.bottom())); for (qreal y = rect.top(); y < rect.bottom(); y += 50) painter->drawLine(QPointF(rect.left(), y), QPointF(rect.right(), y)); }drawBackground在每次 View 重绘时自动调用,网格会跟随缩放步长自适应,而不会像普通 Item 那样被随意拉伸变形。这段代码放在 ChartView 子类里比放在场景中合成更干净。
5. 动态追加数据时的局部刷新与散点过渡动画
5.1 让新点只刷新局部区域
运行时新数据进入时,直接QGraphicsScene::addItem会把整个场景的脏区范围扩大,数据点密集且刷新频率高时,全区域重绘的成本会迅速吃掉性能。更克制的做法是只让新增点对应的矩形失效:
ScatterItem *item = new ScatterItem(x, y, QColor(31, 119, 180)); m_scene->addItem(item); // 只把新 item 的边界区域加入重绘队列 m_scene->invalidate(item->boundingRect());invalidate和update的区别在于,前者同时会使该区域的缓存失效,后者只是排队重绘;配合上一章DeviceCoordinateCache使用时,invalidate必须被调用,否则缓存里的旧位图会被继续使用,新点显示不出来。
5.2 用 QVariantAnimation 实现散点平滑移入
直接新点出现和老点跳变,在监控类界面里会显得很突兀。给 Item 单独挂一个 QVariantAnimation,从上一帧坐标插值到当前位置,视觉上就有“数据流动”的感觉:
QVariantAnimation *anim = new QVariantAnimation(item); anim->setDuration(300); // 300ms 过渡 anim->setStartValue(item->pos()); anim->setEndValue(QPointF(newX, newY)); QObject::connect(anim, &QVariantAnimation::valueChanged, item, [item](const QVariant &value) { item->setPos(value.toPointF()); }); anim->start(QAbstractAnimation::DeleteWhenStopped);注意把 QVariantAnimation 的父对象设为 item,这样 item 销毁时动画会自动停止,不会产生悬空指针。DeleteWhenStopped保证动画结束后内存被回收。每秒刷新几百个点以内的场景用这个方案没问题,超过则应该取消动画直接 setPos,否则动画对象大量堆积反而拖慢主线程。
5.3 离线渲染验证点密度与边界裁剪
最后给一个快速验证手段。需要确认散点图在不同缩放级别下有没有被错误裁剪时,不要反复用肉眼拖视图,直接离屏渲染一张图出来对比:
QImage img(800, 600, QImage::Format_ARGB32); img.fill(Qt::white); QPainter painter(&img); m_scene->render(&painter, img.rect(), m_scene->sceneRect()); painter.end(); img.save("scatter_preview.png", "PNG");打开生成的预览图,重点看两个地方:散点圆边缘是否完整,以及坐标轴边界是否被视口切断。如果发现最外圈的点只剩一半,通常不是drawEllipse的问题,而是boundingRect没有留出余量,按第 2 章里外扩 2 像素的做法即可修复。另一种常见情况是数据量大时预览图点太密集、分不清真实分布,可以在渲染前临时把画笔透明度降到 0.5,利用叠色的深浅判断点密度。这类做法很适合作为自动测试的一部分,把不同数据集的渲染结果保存下来做像素差异比对。
本文还有配套的精品资源,点击获取