ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Qt Graphics View 手搓流程图编辑器:从零到专业实战

Qt Graphics View 手搓流程图编辑器:从零到专业实战 简介这是一份面向Qt初学者与图形界面开发者的流程图编辑器完整工程源码围绕图形视图框架实现节点绘制、拖拽、连线与交互编辑帮助读者理解可视化编辑器的底层实现思路。压缩包共43个文件约68KB以14个cpp与14个h源文件为核心配合3个ui界面文件、6张png图片资源、qrc资源脚本及pro、pri工程配置结构上按主窗口、工具箱、流程图节点与连接线等模块拆分便于对照阅读。内容涵盖自定义QGraphicsItem绘制、QGraphicsPathItem连线与自动吸附、信号槽事件处理、QDataStream序列化保存以及基于操作历史栈的撤销重做等关键知识点。目前已有1579人学习下载适合希望从零搭建流程图或节点编辑器的开发者参考也可作为Qt图形界面课程设计与进阶练习的实践素材。1. 从零手搓流程图编辑器Qt 能给你省掉多少脏活打开 Qt Creator新建一个 QWidget 工程把 QGraphicsView 拖进窗口你就已经拿到了一个流程图编辑器的骨架。这不是夸张——QGraphicsScene 负责管理图元QGraphicsView 负责渲染和交互QGraphicsItem 负责每个节点的绘制与事件响应这套 Graphics View Framework 就是 Qt 给流程图类应用准备好的地基。你要做的不是从零实现画布、坐标变换、碰撞检测而是把「矩形节点」「菱形判断框」「带箭头的连线」这三类图元的行为定义清楚。适合谁读写过 C、装过 Qt、但一想到「拖拽画线、撤销重做、序列化保存」就不知道从哪下手的桌面开发工程师。接下来的内容按「最小可跑 → 交互补全 → 数据落地 → 避坑 → 进阶」推进每一步都有可复现的代码和参数说明。2. 最小可跑QGraphicsScene 里画出第一个流程节点2.1 为什么选 Graphics View 而不是 QWidget 自绘常见做法有两种一种是在 QWidget 的 paintEvent 里自己算坐标、画矩形、处理鼠标命中另一种是用 Qt 的 Graphics View Framework。前者的血泪经验是一旦节点超过几十个、需要缩放平移、要做层级遮挡你的命中检测代码会膨胀到无法维护。Graphics View 自带 BSP 索引树图元多了以后查找和重绘效率是数量级差异。QGraphicsItem 的 boundingRect 和 shape 分离设计让你可以精确控制「点击哪里算选中」——比如连线只有靠近线体 5 像素内才响应而不是整个包围盒。选型理由归结为三条第一坐标系统一场景坐标、视图坐标、图元局部坐标的转换由框架处理第二事件分发完整鼠标按下、移动、释放、双击、滚轮、键盘都有对应的虚函数可重写第三序列化友好每个图元自己负责读写自己的状态保存成 JSON 或 XML 时结构清晰。2.2 定义节点图元矩形、圆角、文字三件套下面是一个最小可用的流程节点类。它继承 QGraphicsItem画一个圆角矩形加居中文字支持选中高亮。// FlowNode.h #include QGraphicsItem #include QPainter class FlowNode : public QGraphicsItem { public: FlowNode(const QString text, QGraphicsItem *parent nullptr) : QGraphicsItem(parent), m_text(text) { setFlags(ItemIsMovable | ItemIsSelectable | ItemSendsGeometryChanges); setAcceptHoverEvents(true); } QRectF boundingRect() const override { // 节点固定 160x60留 2px 给选中边框 return QRectF(-82, -32, 164, 64); } void paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) override { QRectF rect boundingRect().adjusted(2, 2, -2, -2); painter-setRenderHint(QPainter::Antialiasing); // 选中时边框变蓝加粗 if (isSelected()) { painter-setPen(QPen(QColor(0, 120, 215), 2)); } else { painter-setPen(QPen(QColor(80, 80, 80), 1.5)); } painter-setBrush(QColor(245, 248, 252)); painter-drawRoundedRect(rect, 8, 8); painter-setPen(Qt::black); painter-drawText(rect, Qt::AlignCenter, m_text); } // 让点击区域就是矩形本身而不是包围盒 QPainterPath shape() const override { QPainterPath path; path.addRoundedRect(boundingRect().adjusted(2, 2, -2, -2), 8, 8); return path; } private: QString m_text; };boundingRect 返回的是图元本地坐标下的包围矩形Qt 用它做重绘区域计算和碰撞粗筛。shape 返回精确路径用于鼠标命中判断。setFlags 里的 ItemIsMovable 让节点可以被拖动ItemIsSelectable 支持 Ctrl 多选ItemSendsGeometryChanges 是后面做连线跟随的关键——节点移动时能收到 itemChange 回调。注意 boundingRect 比实际绘制区域大 2 像素这是给选中时的粗边框留空间否则边框会被裁掉。2.3 主窗口装配View、Scene 和最小交互// MainWindow.cpp 关键片段 #include QGraphicsView #include QGraphicsScene MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { auto *scene new QGraphicsScene(this); scene-setSceneRect(-2000, -2000, 4000, 4000); // 给足画布空间 scene-setBackgroundBrush(QColor(250, 250, 250)); auto *view new QGraphicsView(scene, this); view-setRenderHint(QPainter::Antialiasing); view-setDragMode(QGraphicsView::RubberBandDrag); // 框选 view-setTransformationAnchor(QGraphicsView::AnchorUnderMouse); setCentralWidget(view); // 放两个节点 auto *start new FlowNode(开始); start-setPos(0, 0); scene-addItem(start); auto *process new FlowNode(处理数据); process-setPos(0, 120); scene-addItem(process); }setSceneRect 决定了滚动条范围和视图能平移到的边界不给的话场景会随图元自动伸缩体验很怪。RubberBandDrag 让空白处拖拽变成框选这是流程图编辑器的标配。AnchorUnderMouse 保证滚轮缩放时鼠标指向的位置不动不然缩放会「跑偏」。到这里编译运行你应该能看到两个可拖动、可选中的圆角矩形。这是最小闭环接下来所有功能都在这上面叠加。3. 交互补全连线、拖拽创建与撤销重做3.1 带箭头的连线图元怎么写连线比节点麻烦因为它要跟随两端节点移动还要画箭头。核心思路是连线不存绝对坐标只存起点节点和终点节点的指针每次 paint 时实时计算两端位置。// FlowEdge.h #include QGraphicsItem #include QPainter #include QtMath class FlowNode; // 前向声明 class FlowEdge : public QGraphicsItem { public: FlowEdge(FlowNode *from, FlowNode *to) : m_from(from), m_to(to) { setFlags(ItemIsSelectable); setZValue(-1); // 连线在节点下方 } QRectF boundingRect() const override { // 返回两端点包围盒加 10px 余量给箭头 QPointF p1 m_from-pos(); QPointF p2 m_to-pos(); return QRectF(p1, p2).normalized().adjusted(-10, -10, 10, 10); } void paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) override { QPointF p1 m_from-pos(); QPointF p2 m_to-pos(); painter-setRenderHint(QPainter::Antialiasing); painter-setPen(QPen(isSelected() ? QColor(0,120,215) : Qt::darkGray, 2)); painter-drawLine(p1, p2); // 在终点画箭头 double angle std::atan2(p2.y() - p1.y(), p2.x() - p1.x()); QPointF arrowP1 p2 - QPointF(std::cos(angle - M_PI/6) * 12, std::sin(angle - M_PI/6) * 12); QPointF arrowP2 p2 - QPointF(std::cos(angle M_PI/6) * 12, std::sin(angle M_PI/6) * 12); QPolygonF arrow; arrow p2 arrowP1 arrowP2; painter-setBrush(isSelected() ? QColor(0,120,215) : Qt::darkGray); painter-drawPolygon(arrow); } // 节点移动时通知连线更新 void updatePosition() { prepareGeometryChange(); update(); } private: FlowNode *m_from; FlowNode *m_to; };prepareGeometryChange 必须在 boundingRect 要变之前调用否则场景索引会残留旧区域表现为连线移动后残影不消失——这是新手最容易翻车的地方之一。箭头用三角函数算两个偏移点M_PI/6 是 30 度箭头张角 60 度长度 12 像素这两个参数按视觉习惯调即可。3.2 节点移动时连线跟随itemChange 的正确用法在 FlowNode 里重写 itemChange当位置变化时通知所有关联连线。// FlowNode 补充 QVariant FlowNode::itemChange(GraphicsItemChange change, const QVariant value) { if (change ItemPositionHasChanged) { for (FlowEdge *edge : m_edges) { edge-updatePosition(); } } return QGraphicsItem::itemChange(change, value); } void FlowNode::addEdge(FlowEdge *edge) { m_edges.append(edge); }注意用 ItemPositionHasChanged 而不是 ItemPositionChange。前者是位置已经改完后者是即将改变在后者里做更新会拿到旧坐标。m_edges 需要在创建连线时通过 addEdge 注册进去。这个模式在节点数量上千时会有性能压力因为每次移动要遍历所有关联边但流程图场景通常几十到几百节点完全够用。3.3 拖拽创建节点与连线鼠标事件的状态机在 QGraphicsScene 子类里重写 mousePressEvent 和 mouseReleaseEvent用一个枚举记录当前操作模式。// FlowScene.h 关键状态 enum class Mode { Select, DrawNode, DrawEdge }; void FlowScene::mousePressEvent(QGraphicsSceneMouseEvent *event) { if (m_mode Mode::DrawNode event-button() Qt::LeftButton) { auto *node new FlowNode(新节点); node-setPos(event-scenePos()); addItem(node); event-accept(); return; } if (m_mode Mode::DrawEdge event-button() Qt::LeftButton) { m_edgeStart dynamic_castFlowNode*(itemAt(event-scenePos(), QTransform())); event-accept(); return; } QGraphicsScene::mousePressEvent(event); } void FlowScene::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) { if (m_mode Mode::DrawEdge m_edgeStart) { auto *end dynamic_castFlowNode*(itemAt(event-scenePos(), QTransform())); if (end end ! m_edgeStart) { auto *edge new FlowEdge(m_edgeStart, end); addItem(edge); m_edgeStart-addEdge(edge); end-addEdge(edge); } m_edgeStart nullptr; event-accept(); return; } QGraphicsScene::mouseReleaseEvent(event); }itemAt 的第二个参数传 QTransform() 表示用场景坐标直接命中测试。dynamic_cast 失败返回 nullptr所以点到空白处不会崩。这个状态机是最简版本实际产品里还要处理「拖拽过程中画预览线」「松开在空白处取消」等细节但骨架就是这些。3.4 撤销重做QUndoStack 接入的三个关键点Qt 的 Undo Framework 用起来不复杂关键是每条可撤销操作要封装成 QUndoCommand 子类。class AddNodeCommand : public QUndoCommand { public: AddNodeCommand(FlowScene *scene, FlowNode *node, QUndoCommand *parent nullptr) : QUndoCommand(parent), m_scene(scene), m_node(node) { setText(添加节点); } void undo() override { m_scene-removeItem(m_node); } void redo() override { m_scene-addItem(m_node); } private: FlowScene *m_scene; FlowNode *m_node; };三个关键点第一undo 和 redo 必须对称removeItem 之后节点对象不能 delete否则 redo 时指针悬空第二命令对象自己管理内存QUndoStack push 之后由栈负责释放第三连续拖拽移动应该合并成一条命令用 mergeWith 实现否则拖一次产生几十条记录撤销起来像卡带。我一般会在鼠标释放时才 push 一条 MoveCommand记录起始和终止位置而不是在 itemChange 里逐帧记录。4. 数据落地序列化、反序列化与文件格式选型4.1 用 QJsonDocument 保存流程图每个图元实现自己的 toJson 和 fromJson场景负责遍历。QJsonObject FlowNode::toJson() const { QJsonObject obj; obj[type] node; obj[id] m_id; // 唯一 ID连线靠它引用 obj[text] m_text; obj[x] pos().x(); obj[y] pos().y(); return obj; } QJsonObject FlowEdge::toJson() const { QJsonObject obj; obj[type] edge; obj[from] m_from-id(); obj[to] m_to-id(); return obj; }保存时先收集所有节点再收集所有连线写成一个 JSON 数组。读取时两遍扫描第一遍建节点并存进 QHashQString, FlowNode*第二遍根据 from/to 的 id 建连线。这个两遍扫描是必须的因为连线引用的节点可能还没创建。ID 用 QUuid::createUuid().toString() 生成保证唯一。4.2 文件格式对比JSON、XML 还是二进制格式可读性体积解析速度适用场景JSON好中快中小型流程图需要人工查看XML好大中需要 schema 校验的企业集成二进制差小最快超大图节点数万以上SQLite中中快需要增量保存和查询我一般选 JSON因为调试期可以直接用文本编辑器改文件验证逻辑出问题时肉眼就能定位。节点数超过五千再考虑二进制或 SQLite。注意 QJsonDocument 对浮点数精度有损坐标如果要求亚像素精度存成字符串或用 QDataStream。4.3 自动保存与崩溃恢复Qt 崩溃是绕不开的话题。一个实用做法是每次场景变更后启动一个 500ms 的单次定时器超时后写临时文件。定时器重置而不是累积避免频繁写盘。void FlowScene::scheduleAutoSave() { if (!m_autoSaveTimer) { m_autoSaveTimer new QTimer(this); m_autoSaveTimer-setSingleShot(true); connect(m_autoSaveTimer, QTimer::timeout, this, FlowScene::doAutoSave); } m_autoSaveTimer-start(500); // 500ms 内的多次变更合并成一次写盘 }临时文件路径用 QStandardPaths::writableLocation(QStandardPaths::TempLocation) 加固定文件名。程序启动时检查临时文件是否存在存在则提示「检测到未保存的内容是否恢复」。这个后悔药机制在演示和长时间编辑场景里救过我很多次。5. 避坑排查流程图编辑器开发中最容易翻车的五件事5.1 现象连线移动后残影不消失原因boundingRect 变化前没有调用 prepareGeometryChange。场景的 BSP 索引还记着旧区域新区域重绘了但旧区域没擦除。解决任何会导致 boundingRect 改变的成员变量修改前先调 prepareGeometryChange()。连线跟随节点移动时updatePosition 里第一句就是它。5.2 现象节点拖动时连线闪烁或抖动原因在 ItemPositionChange 里更新连线此时节点位置还没真正改变连线用的是旧坐标下一帧又用新坐标来回跳。解决改用 ItemPositionHasChanged。如果还有轻微抖动检查是否在 paint 里做了不必要的坐标转换尽量用场景坐标直接计算。5.3 现象大量节点时拖动卡顿原因每个节点移动都触发全场景重绘或者连线更新时遍历了所有边而不是只遍历关联边。解决第一确保 setViewportUpdateMode 用 SmartViewportUpdate 或 MinimalViewportUpdate第二节点只保存自己关联的边列表不要全局遍历第三如果节点超过两千考虑给节点加 ItemIgnoresTransformations 之外的优化或者用 QGraphicsScene 的索引模式调优。5.4 现象撤销后节点消失但连线还在程序崩溃原因undo 时 removeItem 了节点但连线还持有节点指针paint 时访问已删除对象。解决删除节点时必须级联删除关联连线或者用 QPointer 弱引用。更稳妥的做法是节点和连线都交给场景管理删除节点前先遍历 m_edges 把连线也删掉并且这些操作要封装在同一条 QUndoCommand 里保证撤销时一起恢复。5.5 现象保存的 JSON 重新打开后连线错位原因保存时用了视图坐标或父项相对坐标读取时按场景坐标还原。解决统一用场景坐标。pos() 返回的就是场景坐标对顶层图元而言不要混用 mapToScene 和 mapFromScene 的结果去存。读取时先 setPos 再建连线顺序不能反。6. 进阶技巧用 QGraphicsItem 分组和自定义连接点把编辑器做专业节点多了以后用户会想要「组合」「对齐」「吸附」。Qt 提供了 QGraphicsItemGroup但直接用它有个坑分组后子项的坐标变成组内相对坐标序列化时要额外处理。我一般不用 QGraphicsItemGroup而是自己维护一个 groupId选中多个节点时统一移动保存时记录分组关系。这样序列化格式不变撤销重做也好控制。连接点吸附是另一个提升手感的功能。默认连线连到节点中心视觉上箭头会插进矩形里。改进做法是在 FlowNode 里定义四个连接点上下左右连线时根据两端相对位置选最近的连接点。QPointF FlowNode::nearestPort(const QPointF target) const { QPointF local mapFromScene(target); QRectF r boundingRect(); QPointF ports[4] { QPointF(0, r.top()), // 上 QPointF(r.right(), 0), // 右 QPointF(0, r.bottom()), // 下 QPointF(r.left(), 0) // 左 }; QPointF best ports[0]; double minDist std::numeric_limitsdouble::max(); for (const QPointF p : ports) { double d QLineF(local, p).length(); if (d minDist) { minDist d; best p; } } return mapToScene(best); }FlowEdge 的 paint 里不再直接用节点 pos()而是调用 from-nearestPort(to-pos()) 和 to-nearestPort(from-pos())这样连线永远从最近的边出来视觉上干净很多。这个改动很小但用户感知很明显。验证方法建一个包含分支和循环的流程图反复拖动节点、缩放视图、撤销重做、保存再打开观察连线是否始终贴合、箭头方向是否正确、有无残影。我习惯在开发期开一个「调试模式」按 F12 显示每个图元的 boundingRect 和连接点位置肉眼确认命中区域和实际绘制区域是否一致。这个习惯帮我省下了大量靠猜来定位问题的时间。最后说一个我踩过的坑不要试图用 QSS 去美化 QGraphicsItem它不走样式表那套。所有外观都在 paint 里用 QPainter 画颜色、圆角、阴影、渐变都自己控制。想通这一点之后流程图编辑器的视觉风格反而更容易统一。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进