资讯动态

Qt图形视图框架实战:构建可拖拽连线的流程图工具

发布时间:2026/10/17 19:07:43 来源:尧图企业网站定制
简介Qt流程图绘制是一套基于 Qt 图形视图框架的流程图编辑器实现资源面向需要学习 Graphics View 架构或快速搭建图形化编辑器的 C/Qt 开发者。资源以 Qt 工程形式组织共 36 个文件压缩包约 47KB主要包含可编译的 .pro 工程文件、12 个 PNG 工具栏图标、9 个 .cpp 源码与 8 个 .h 头文件另有 4 个备份文件便于对照修改记录源码覆盖自定义图元如流程步骤、连接线及分支节点和视图交互逻辑。通过阅读工程代码可掌握 QGraphicsScene/QGraphicsView 的图元管理、鼠标事件重写、图元缩放旋转、连线规则以及保存打印等核心技巧代码按照主窗口、属性控件、视图与工具按钮等模块进行了清晰划分结构完整便于直接运行调试也可对照实际业务场景灵活扩展为自定义流程设计器。目前已有 3996 人学习下载适合作为 Qt 图形编程从入门到进阶的练习项目。1. 从一张可拖拽的流程草图说起这个 Qt 项目到底解决了什么我接手过不少需要画流程图的桌面工具最直接的需求就是用鼠标把图元拖到画布上连上箭头然后能导出或保存。某实验室的模拟项目X最初就是这样一个内部工具用它画设备间数据传输链路的草图。但真正让人头疼的不是画图本身而是图元与视图之间那层说不清道不明的交互逻辑——缩放后连线错位、选中选框时误拖了图元、撤销时连接关系断裂这些问题在纸面上设计很容易落到代码里却全是细节。这个 Qt 流程图绘制项目就是把「图元Item、场景Scene、视图View」三者按 Qt 图形视图框架的标准方式拆开实现的一套可复现代码——它适合那些想在 Qt 桌面应用里快速嵌入一个可拖拽、可连线、可缩放流程图模块的开发者也适合想弄明白 QGraphicsScene 和 QGraphicsItem 到底怎么协作的新手。你将看到的不只是某个 demo而是一份能直接抄进业务代码的骨架。2. 技术选型与整体结构为什么非要用图形视图框架2.1 为什么选 QGraphicsView 而不是自己 paintEvent如果你只是画几张静态流程图重写 paintEvent 也能做但一旦涉及鼠标拖拽、碰撞检测、选中态、坐标变换自己造轮子的成本会暴涨。这个项目选用 QGraphicsView / QGraphicsScene / QGraphicsItem 这套框架核心原因是它把「视图变换」和「图元对象」分层每个图元是独立对象能响应 hover、press、move 事件场景负责管理图元间的空间索引视图只负责把场景坐标渲染到屏幕上。这样当你的流程图节点超过几百个时场景的 BSP 树索引能快速判断鼠标点中了哪个图元而不必遍历所有节点做矩形碰撞。项目整体目录结构是常见的 Qt Widgets 应用布局一个主窗口类 QMainWindow 派生类一个场景类继承 QGraphicsScene若干个图元类继承 QGraphicsItem外加一个连接线类。我从源码里看到的是这样的分层items/目录存放节点图元、连接线图元scene/目录自定义场景处理鼠标框选、连接线创建view/目录自定义视图处理缩放、平移mainwindow工具栏、菜单、图元列表与画布的组合这种结构在扩展时会很舒服。比如你想增加一种新的图元类型只需在items/下加一个类重写boundingRect()和paint()然后在工具栏注册一个工厂函数。场景和视图的代码不需要大改因为所有图元都遵循 QGraphicsItem 的接口约定。2.2 场景、图元、视图三者的职责边界这是整个项目最容易搞混的地方。我在改造这个项目时反复提醒自己视图只负责「看」不负责「算」。鼠标点击坐标是视图坐标需要调用mapToScene()转成场景坐标拖拽移动是图元自己响应mouseMoveEvent后调用setPos()而框选多图元则是视图的rubberBandChanged信号驱动的。场景则充当中间人它维护所有图元的父子关系以及连接线两端图元的锚点引用。连接线在这个项目里不是普通直线而是 QGraphicsPathItem 的子类。它重写了shape()方法让用户鼠标能更容易点选连线同时它保存了起点图元和终点图元的指针在每次updatePath()时读取两个图元的中心坐标重新计算折线或贝塞尔曲线。这样拖动节点时连线会实时跟随而不需要手动刷新。常见做法是连接线监听端点图元的itemPositionChanged信号但更轻量的做法是在场景的某个类中统一调用updateConnections()这个项目就采用了后者——每次场景内图元移动结束时遍历所有连线重新计算路径。2.3 数据层与显示层的默认协议从源码看图元的数据结构直接绑定在 QGraphicsItem 子类的成员变量里比如节点图元有一个data_字典存节点名称、类型、端口列表。视图上拖拽改变位置后位置信息就是item-pos()。如果要把流程图持久化项目里做了简单的 JSON 序列化——遍历场景中所有图元把类型、坐标、属性打包成 QJsonObject连接线则记录两个端点图元的 ID在 scene 中用一个整数自增字段维护。这个设计很朴素但对大多数工具场景够用。如果你要做撤销重做可以把这些序列化结果塞进 QUndoStack这一点我们在最后一章展开。3. 核心模块拆解一个图元从创建到渲染的完整链路3.1 图元基类把通用交互逻辑收敛到一处这个项目里所有可放置的图形节点都继承自一个BaseNodeItem类它继承了 QGraphicsItem 并且启用ItemIsMovable、ItemIsSelectable、ItemSendsGeometryChanges三个 flag。这三者的组合决定了图元能拖、能选、并且在坐标变化时发出通知信号。关键代码在鼠标交互方面class BaseNodeItem : public QGraphicsItem { public: BaseNodeItem(QGraphicsItem* parent nullptr) : QGraphicsItem(parent) { setFlags(QGraphicsItem::ItemIsMovable | QGraphicsItem::ItemIsSelectable | QGraphicsItem::ItemSendsGeometryChanges); setAcceptHoverEvents(true); } protected: QVariant itemChange(GraphicsItemChange change, const QVariant value) override { if (change QGraphicsItem::ItemPositionHasChanged) { emit itemPositionChanged(this); } return QGraphicsItem::itemChange(change, value); } void hoverEnterEvent(QGraphicsSceneHoverEvent* event) override { setCursor(Qt::OpenHandCursor); QGraphicsItem::hoverEnterEvent(event); } void mousePressEvent(QGraphicsSceneMouseEvent* event) override { setCursor(Qt::ClosedHandCursor); QGraphicsItem::mousePressEvent(event); } };这段代码说明了几个惯例ItemSendsGeometryChanges配合itemChange才能在位置变化时捕捉到值否则你只能在鼠标释放事件里自己比较旧位置。我在写这段时吃过亏——最初只设了ItemIsMovable结果拖动过程中完全拿不到实时坐标只能在mouseReleaseEvent里取最终位置导致连线更新有延迟。注意 hover 事件里切换光标这套交互在流程图工具里是标配。3.2 连接线重绘从直线到可点选路径的细节连接线是这个项目里最容易翻车的部分。如果用 QGraphicsLineItem鼠标点击很难精准命中细线。项目里用 QGraphicsPathItem 并重写shape()来加宽点击命中区同时在paint()里绘制带箭头的路径。以下是连接线的路径更新逻辑void ConnectionItem::updatePath() { QPointF p1 startItem_-centerPos(); QPointF p2 endItem_-centerPos(); QPainterPath path(p1); // 用两个控制点画贝塞尔曲线让连线更平滑 if (p1.x() p2.x()) { path.cubicTo((p1.x() 80), p1.y(), (p2.x() - 80), p2.y(), p2); } else { // 反向连接时控制点往左偏移 path.cubicTo((p1.x() - 80), p1.y(), (p2.x() 80), p2.y(), p2); } setPath(path); } QPainterPath ConnectionItem::shape() const { QPainterPathStroker stroker; stroker.setWidth(12); // 命中区域比视觉宽度宽很多 return stroker.createStroke(path()); }参数说明80是我在设计里约定的控制点水平偏移量它决定了两节点连线弯曲的弧度。如果两个节点水平距离小于 160 像素这个偏移可能导致曲线出现奇怪的回头后来我在代码里对偏移量做了动态压缩——取max(40, (p2.x() - p1.x()) / 2)。同理shape()里的 12 像素命中宽度在视觉线宽只有 2 像素的情况下大大方便了鼠标选取。这里有个常见的坑如果你重写了shape()paint()里画箭头的位置必须基于path的方向角计算而不是简单在终点画一个三角形否则节点拖到左边时箭头会指向错误方向。3.3 场景中的框选与连接创建流程场景类FlowScene负责两类核心操作一是鼠标空白处按下拖拽出橡皮筋选框二是从一个节点的端口拖出一条临时的线去连接另一个节点。框选动作是场景的默认行为之一但在这个项目里它被自定义了——因为默认的QGraphicsScene框选会选中所有完全包含在矩形内的图元而项目想要「接触即选中」所以需要自行处理void FlowScene::mousePressEvent(QGraphicsSceneMouseEvent* event) { if (event-button() Qt::LeftButton) { QGraphicsItem* item itemAt(event-scenePos(), QTransform()); if (item nullptr || item-type() ConnectionItem::Type) { // 只从空白处或连接线上开始创建框选 rubberStart_ event-scenePos(); rubberBandActive_ true; event-accept(); } else { QGraphicsScene::mousePressEvent(event); } } } void FlowScene::mouseMoveEvent(QGraphicsSceneMouseEvent* event) { if (rubberBandActive_) { updateRubberBand(QRectF(rubberStart_, event-scenePos())); // 手动选中所有与矩形相交的图元 QPainterPath selectionPath; selectionPath.addRect(QRectF(rubberStart_, event-scenePos())); for (QGraphicsItem* item : items()) { setItemSelected(item, selectionPath.intersects(item-mapToScene(item-shape()))); } event-accept(); } else { QGraphicsScene::mouseMoveEvent(event); } }这里的逻辑要点在于itemAt()判断鼠标按下的位置是否有图元。从连接线开始框选是特意支持的——因为在很多流程图工具里用户习惯从空白处拖框而连接线本身很细如果按下时正好点在连线上还需要能拖动控制点而不是误触发框选。项目为了简化直接让连线也参与框选起点这个决策在大量连线的场景下效率不错但如果你希望从连线上拖出新的连接线就需要再细分。对多数流程图场景来说框选和连接是互斥的两种模式很多工具用模式切换来实现而这里利用「从端口拖出连线」和「从空白框选」自然地分离了。3.4 视图缩放与坐标映射setTransformationAnchor 的玄学视图部分项目里维护了一个缩放比例通过滚轮事件来放大缩小。这里有个值得讲透的细节缩放的中心点。如果直接scale()默认变换锚点是视图中心你会发现鼠标指向哪里缩放后那个点反而跑到了视图中间体验很怪。这个项目里先设置了setTransformationAnchor(QGraphicsView::AnchorUnderMouse)这样缩放时会保持鼠标下的场景点不动。代码只有三行但效果天差地别void FlowView::wheelEvent(QWheelEvent* event) { qreal factor pow(1.0015, event-angleDelta().y()); qreal currentZoom transform().m11(); if ((factor 1 currentZoom 3.0) || (factor 1 currentZoom 0.2)) { scale(factor, factor); } event-accept(); }参数说明1.0015是缩放步进因子滚轮每滚一格大约缩放 0.6%这样在大量节点时缩放比较细腻。transform().m11()读取当前水平缩放系数限制缩放范围在 0.2 到 3.0 之间——低于 0.2 时图元文字糊成一团高于 3.0 时绘制开销明显增加。另一个关键点是缩放后连线线宽会跟着变如果不想这样需要在连接线的paint()里把画笔宽度除以sceneTransform().m11()这个项目就做了这件事保证打印时线宽一致。4. 避坑指南坐标映射、选中状态与缩放这几个坑我都踩过4.1 鼠标坐标与场景坐标不一致导致的「点不中图元」现象是在电脑上能精准拖拽节点换到高分屏或窗口缩放后鼠标点击位置总是偏了一截或者点图元左上角才能选中。原因很典型直接用了QMouseEvent::pos()而没有通过mapToScene转换。在 Qt 中视图接收的鼠标事件是视图坐标图元接收的是场景坐标中间隔着滚动条偏移和缩放变换。这个项目里场景的mousePressEvent接收到的已经是场景坐标因为事件经过场景分发但如果你自定义了视图的鼠标事件又要在场景里做判断就很容易直接用event-pos()而忘记mapToScene(event-pos())。解决方法是凡是视图层收到鼠标事件第一行永远是QPointF scenePos mapToScene(event-pos());。4.2 连接线端点引用的悬空指针另一个翻车点是删除节点时连接线仍然保存着指向被删除图元的指针下次updatePath()调用startItem_-centerPos()直接崩溃。这个项目在场景中维护了一个connections_列表但在删除节点时只调用了removeItem(node)没有同步删除与之关联的连线。解决方法是重写场景的removeItem或自定义一个deleteNodeSafe方法在删除前先找出所有端点属于该节点的连接线从场景中移除。后来我加了一个纯虚接口isConnectedTo(QGraphicsItem*)节点删除前遍历所有连接线判断。这属于图元生命周期管理问题凡是涉及跨图元引用的项目都要小心。4.3 缩放过快导致绘制性能劣化甚至卡死当你把缩放比例放到 3 倍以上paint()里如果有较重的阴影或复杂形状每帧都要重新计算拖动时会有明显掉帧。项目里对缩放上限做了限制但还有一个容易被忽略的地方boundingRect()返回的范围一定要覆盖所有绘制内容否则图形被裁剪。我最初在节点图元里绘制了阴影效果boundingRect()忘了包含阴影的扩展范围缩小到 50% 时节点边缘的阴影被截断看起来像毛边。这个问题的排查方式是缩小视图看图形边缘是否异常断裂如果是就把boundingRect()的边距调大 810 像素。4.4 框选模式下拖拽图元冲突场景中已经覆盖了mousePressEvent来做框选但框选开启后如果你点在某个图元上应该让图元正常拖拽而不是开启选框。项目第一版代码里判断条件只写了if (item nullptr)才开启框选结果用户试图拖拽图元时反而拉出了一个矩形选框而且选框把图元也选中了导致拖拽行为错乱。解决方法是增加一个类型判断只有点击空白区域或点击到连线但连线不可移动时才进入框选模式。同时要注意event-accept()的调用如果不接受事件图元的mousePressEvent可能收不到。4.5 撤销重做时连接关系丢失如果直接对QGraphicsItem的 pos 变化做 QUndoCommand你会发现每次撤销只移动了节点位置连线的路径却没有刷新。这是因为连线更新是在场景内部的信号槽或者移动结束回调里触发的而撤销栈的命令在执行时直接调用了setPos()绕过了场景的updateConnections。项目后来的处理是在ConnectionItem的构造函数里给端点的itemPositionChanged信号连接一个 lambda让每个连线自己监听端点的变化。这样无论你是拖拽、代码移动还是撤销栈移动连线都能跟随。这个设计比场景统一刷新要更解耦也避免了很多「为什么移动了但不更新」的玄学问题。5. 往业务层改如何把这个骨架变成你自己的流程图工具5.1 第一步自定义图元类型并接入工具栏拿到这份源码你最先想改的往往是把默认的矩形节点换成你的业务对象。做法是继承BaseNodeItem重写paint()和boundingRect()加上一个typeId()用于序列化区分。然后在主窗口的工具栏里加一个按钮点击后调用场景工厂void MainWindow::addNewNode(const QPointF scenePos, int nodeType) { BaseNodeItem* node nullptr; switch (nodeType) { case NodeType::Start: node new StartNodeItem(); break; case NodeType::Process: node new ProcessNodeItem(); break; case NodeType::Decision: node new DecisionNodeItem(); break; } if (node) { node-setPos(scenePos); scene_-addItem(node); } }这里要注意新图元最好在构造函数里就初始化自己的默认尺寸和端口位置不要依赖 addItem 后的外部设置。端口本身就是子图元可以是PortItem它重写shape()返回小圆点区域并实现isConnectable。如果业务需要动态端口数量比如可配置的输入输出就在节点里管理一个端口列表并在paint()里绘制端口位置。5.2 第二步接入你自己的数据模型与持久化Qt 图形视图的图元本身就是模型的一部分所以图元属性自然会合并进业务对象。常见做法是让节点图元保存一个QJsonObjecttoJson()返回节点类型、坐标、属性和端口数据fromJson()则从 JSON 恢复。对于连接线序列化时需要保存两端图元 ID这个 ID 可以用scene_-addItem时给每个图元分配一个自增整数。我在改造某个设备链路项目时把节点的端口映射成「输入点列表」和「输出点列表」连接线序列化时记录sourceNodeId和sourcePortIndex这样导入导出就不会因为端口顺序变化而错连。别忘了在场景层面存一份QListConnectionItem*的副本便于在序列化前遍历。5.3 第三步为移动和属性编辑加上撤销重做QUndoStack 是 Qt 原生机制但你需要把每个操作封装为一个 QUndoCommand。对于节点移动最简单的方式是通过ItemPositionHasChanged信号记录操作前后位置在redo()和undo()中调用setPos()。这里的关键是一个命令对应一个完整的用户操作而不是每一次像素移动。所以不要在mouseMoveEvent中压栈而是在mouseReleaseEvent时判断位置是否变化创建一个MoveNodeCommand。对于节点添加和删除则用scene-addItem/removeItem包一层同时在undo()里要恢复图元的所有属性——因为图元对象在redo()时可能被新建原对象已被 delete所以你的命令里需要保存序列化的 JSON 而不是指针。那就是把fromJson和toJson当作对象工厂的构造与销毁流程来用。从一个项目拆解的角度看这份源码最值得学习的并不是某个炫技的效果而是它对交互细节的取舍连线命中区域的放宽、缩放下线宽的稳定、框选相交判断的简化。这些决策看着零碎但没有它们工具在真实使用中会处处恼人。我后来再做类似工具都会先把这些基础交互钉死在基类里再往上加业务逻辑省掉了好几个晚上的折腾——希望这份拆解也能帮你绕开那些我踩过的坑。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑