简介这是一份基于Qt图形视图框架的流程图绘制示例工程面向需要掌握图元QGraphicsItem与视图QGraphicsView核心机制的C/Qt开发者。资源完整覆盖流程图绘制关键环节自定义图元类、QGraphicsScene场景管理、鼠标事件交互、缩放旋转操作及工程配置文件适合用于理解图形化编辑工具的底层实现思路。压缩包共36个文件以C源码为主含9个cpp与8个头文件并配有12张界面效果图以及qrc资源文件、pro工程文件可快速编译查看运行效果整体仅47KB体量轻巧便于逐文件阅读。目前已有3996人学习下载是Qt图形视图框架入门与阶段提升的实用素材。通过阅读源码可掌握图元与视图的协作方式并可在现有代码基础上扩展保存、加载、连线增删等后续功能具有较强的参考与进阶价值。1. 流程图不是画出来的是“搭”出来的Qt 图元系统的落地姿势如果你用过 Visio 或者 draw.io可能会觉得流程图就是把几个框拖到画布上、连上线、调调颜色。但换到 Qt 里自己写一个流程图编辑器时事情就没那么浪漫了——鼠标拖动、框选、连线、缩放、撤销重做任何一个环节都能让你怀疑人生。我第一次用 QGraphicsView 做图元编辑时最直观的感受是Qt 把图元QGraphicsItem、视图QGraphicsView和场景QGraphicsScene这三层关系搞明白流程图就完成了一半。剩下的一半全在事件处理和 item 的交互细节里。这篇笔记拆的就是一个基于 Qt 的流程图绘制项目核心围绕“图元”和“视图”两个关键词展开图元怎么设计、怎么拖、怎么连、怎么选中视图怎么缩放、怎么平移、怎么让图元在鼠标操作下不“飘”。无论你是想写一个类似节点编辑器的工具还是要在现有系统里嵌入一个简单的拓扑图编辑器这篇文章都能让你少走几天的弯路。我会从整体架构讲到核心代码再讲怎么跑起来、怎么避坑、怎么改出自己的版本。2. 整体架构拆解Scene 是容器View 是窗口Item 才是主角2.1 为什么 Qt 的图元系统天生适合画流程图Qt 的 Graphics View Framework 把“绘图”这件事拆成了三个角色QGraphicsScene 负责管理图元对象的生命周期和碰撞检测QGraphicsView 负责把场景可视化成视口里的内容而 QGraphicsItem 则是单个可交互的图形元素。流程图里的圆角矩形、菱形、箭头线本质上都是不同的 QGraphicsItem 子类。这个分层最巧妙的地方在于View 和 Scene 不是一一绑定的。同一个 Scene 可以被多个 View 观察这意味着你可以做一个缩略图导航minimap再做一个主编辑区两个 View 指向同一个 Scene数据天然同步。我在最初设计时参考了 Qt 官方 Diagram Scene 示例的思路但把图元做得更贴近真实流程图的语义开始/结束节点、处理节点、判断节点、数据节点每种节点都有独立的 item 类。项目里的 Scene 也不是“裸用”QGraphicsScene而是继承了一个自定义的 FlowScene。FlowScene 维护了所有节点和连线的注册表统一处理节点之间的连接关系这样当节点被删除时关联的连线也能自动清除不会留下孤立线段。View 同样做了封装FlowView 重写了滚轮事件和拖拽事件在缩放时始终以鼠标光标所在位置为锚点而不是默认的左上角缩放这一步操作手感会好很多。2.2 目录结构每个文件负责什么我先给你看一下这个项目的核心目录结构这样后面讲代码的时候你能对应上文件位置。FlowChart/ ├── FlowChart.pro ├── main.cpp ├── flowscene.h / flowscene.cpp # 场景类管理节点和连线 ├── flowview.h / flowview.cpp # 视图类处理缩放平移交互 ├── nodes/ │ ├── nodeitem.h / nodeitem.cpp # 节点基类圆角矩形 │ ├── startnode.h / startnode.cpp # 开始/结束节点 │ ├── processnode.h / processnode.cpp # 处理节点 │ ├── decisionnode.h / decisionnode.cpp# 判断节点 ├── edges/ │ ├── edgeitem.h / edgeitem.cpp # 连线基类箭头线 │ └── edgefactory.h / edgefactory.cpp # 连线创建工厂 ├── items/ │ ├── portitem.h / portitem.cpp # 连接点端口 │ └── textitem.h / textitem.cpp # 可编辑文本块 └── commands/ ├── addnodecommand.h/cpp # 撤销/重做添加节点 ├── deletecommand.h/cpp # 撤销/重做删除节点 └── movecommand.h/cpp # 撤销/重做移动节点这里最值得学习的是 nodes 和 edges 的分离。节点item只知道自己有哪些端口PortItem端口负责接收鼠标按下事件并触发连线连线item只负责关联两个端口并在重绘时根据端口坐标动态更新路径。这样节点和连线之间的耦合被降到最低新增一种节点类型时你只需要继承 NodeItem 实现一个 paint() 函数不需要动连线逻辑。2.3 数据流一次拖拽连线的完整生命周期从用户鼠标按下端口到连线生成整个过程分四步第一步鼠标在 PortItem 上按下PortItem 触发信号 portPressed(portId, scenePos)。第二步FlowScene 收到信号后创建一个“临时连线” EdgeItem起点就是该端口终点跟随鼠标移动。第三步鼠标移动到另一个端口并释放PortItem 发出 portReleased(portId, scenePos)FlowScene 用 EdgeFactory 判断两个端口类型是否兼容比如输出端口只能连输入端口。第四步如果兼容临时连线转正并加入场景如果类型不匹配则回收临时连线。我后面在核心模块里会逐个贴出 PortItem、EdgeItem 和 FlowScene 的关键代码。这里你先在脑子里建立这个流程模型后面读代码就不会乱了。3. 核心模块代码拆解图元如何动起来3.1 端口 PortItem连线的“插座”也是交互的第一触点PortItem 是整个系统里最容易被忽略但最关键的部分。它不只是一个画在节点边缘的小圆点它需要同时处理三种状态空闲、预选鼠标悬停、连接中。它还必须在被拖动时把事件“移交”给 Scene 处理因为单纯的图元并不知道另一个图元的信息。class PortItem : public QGraphicsItem { public: enum PortType { Input, Output }; PortItem(PortType type, NodeItem* parentNode, QGraphicsItem* parent nullptr) : QGraphicsItem(parent), m_type(type), m_node(parentNode) { setFlag(ItemIsSelectable, false); setAcceptHoverEvents(true); setCursor(Qt::CrossCursor); } QRectF boundingRect() const override { return QRectF(-6, -6, 12, 12); } void paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget) override { Q_UNUSED(option) Q_UNUSED(widget) painter-setBrush(m_hovered ? QColor(0, 160, 230) : QColor(80, 80, 80)); painter-setPen(QPen(QColor(30, 30, 30), 1.5)); painter-drawEllipse(QPointF(0, 0), 5, 5); } protected: void hoverEnterEvent(QGraphicsSceneHoverEvent* event) override { m_hovered true; update(); QGraphicsItem::hoverEnterEvent(event); } void hoverLeaveEvent(QGraphicsSceneHoverEvent* event) override { m_hovered false; update(); QGraphicsItem::hoverLeaveEvent(event); } void mousePressEvent(QGraphicsSceneMouseEvent* event) override { event-accept(); emit portPressed(this, m_type, scenePos()); } void mouseReleaseEvent(QGraphicsSceneMouseEvent* event) override { event-accept(); emit portReleased(this, m_type, scenePos()); } signals: void portPressed(PortItem* port, PortType type, QPointF scenePos); void portReleased(PortItem* port, PortType type, QPointF scenePos); private: PortType m_type; NodeItem* m_node; bool m_hovered false; };端口的大小我设成 12×12 像素实际可视区域是半径 5 的圆。为什么可视半径和命中半径不一样因为小的圆形在鼠标操作时比较难瞄准适当扩大命中区域能让用户更容易拖出连线。portPressed 信号里携带了 scenePos这个坐标是场景坐标系下的绝对位置后续创建连线时不需要再在 item 坐标系和场景坐标系之间做转换。有一个小细节需要注意PortItem 的父节点是 NodeItem也就是端口最终是节点的一个子图元它的位置是相对节点坐标系的。这样节点整体移动时端口自动跟着移动不需要重算连线端点。而连线两个端点的全局坐标则通过 port-scenePos() 获得这个函数会自动把相对坐标转换成场景坐标。3.2 连线 EdgeItem两条贝塞尔曲线永远动态贴合端口连线最简单的做法是 QGraphicsLineItem从端口 A 画一条直线到端口 B。但在真实流程图中节点移动后直线会显得僵硬而且当两个节点水平排列时直线会从节点内部穿过很难看。这个项目用了三次贝塞尔曲线而且曲线方向基于端口类型自动选择。class EdgeItem : public QGraphicsItem { public: EdgeItem(PortItem* srcPort, PortItem* dstPort, QGraphicsItem* parent nullptr) : QGraphicsItem(parent), m_srcPort(srcPort), m_dstPort(dstPort) { setZValue(-1); // 连线在节点下层防止遮挡端口 } QRectF boundingRect() const override { // 给路径留出足够的边界避免重绘时产生残影 QRectF rect m_path.boundingRect().adjusted(-20, -20, 20, 20); return rect; } void paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget) override { Q_UNUSED(option) Q_UNUSED(widget) painter-setRenderHint(QPainter::Antialiasing); QPen pen(QColor(50, 50, 50), 2.0); painter-setPen(pen); painter-setBrush(Qt::NoBrush); painter-drawPath(m_path); // 绘制箭头取路径终点处的切线方向 QPointF endPoint m_path.pointAtPercent(1.0); QPointF tangentDir m_path.pointAtPercent(1.0) - m_path.pointAtPercent(0.95); tangentDir / std::sqrt(QPointF::dotProduct(tangentDir, tangentDir)); // 归一化 QPolygonF arrowHead; QPointF arrowP1 endPoint - tangentDir * 12 QPointF(-tangentDir.y(), tangentDir.x()) * 5; QPointF arrowP2 endPoint - tangentDir * 12 - QPointF(-tangentDir.y(), tangentDir.x()) * 5; arrowHead endPoint arrowP1 arrowP2; painter-setBrush(QColor(50, 50, 50)); painter-drawPolygon(arrowHead); } void updatePath() { QPointF srcPos m_srcPort-scenePos(); QPointF dstPos m_dstPort-scenePos(); // 控制点偏移距离按两个端口的类型区分方向 qreal delta 50.0; QPointF srcCtrl, dstCtrl; if (m_srcPort-portType() PortItem::Output) { srcCtrl srcPos QPointF(delta, 0); dstCtrl dstPos - QPointF(delta, 0); } else { srcCtrl srcPos - QPointF(delta, 0); dstCtrl dstPos QPointF(delta, 0); } QPainterPath path; path.moveTo(srcPos); path.cubicTo(srcCtrl, dstCtrl, dstPos); m_path path; prepareGeometryChange(); update(); } private: PortItem* m_srcPort; PortItem* m_dstPort; QPainterPath m_path; };连线的核心逻辑全在 updatePath() 里。每次节点移动时FlowScene 会遍历所有边并调用 updatePath()重新根据端口场景坐标计算控制点。控制点偏移量 delta 取 50 像素这决定了曲线的“弯曲程度”我一般建议这个值和节点的宽度成正比。如果节点宽 150那么 delta 取 6080 视觉上比较自然太小了曲线会显得像折线太大了当两个节点靠得很近时会出现诡异的回头。这段代码里最容易被忽略的是 prepareGeometryChange()。QGraphicsItem 在 boundingRect() 变化前必须调用这个函数否则视图的缓存索引不会更新画布上会出现拖影或者残留线头。我最初写的时候漏了这一行拖动节点时整个屏幕都花了。3.3 FlowScene连线注册表与节点生命周期管理FlowScene 是整个项目的中央控制器。它不仅要处理端口信号的转发还要维护两个 QHash节点表 m_nodes 和连线表 m_edges。当用户删除节点时Scene 要先找到所有连到这个节点端口的 EdgeItem然后把它们从场景和表中移除再删除节点。void FlowScene::portPressed(PortItem* port, PortItem::PortType type, const QPointF scenePos) { // 只能从输出端口发起连线 if (type ! PortItem::Output) return; m_tempEdge new EdgeItem(port, port); m_tempEdge-updatePath(); addItem(m_tempEdge); m_isConnecting true; } void FlowScene::portReleased(PortItem* port, PortItem::PortType type, const QPointF scenePos) { if (!m_isConnecting) return; // 找到鼠标释放位置的端口 item QGraphicsItem* item itemAt(scenePos, QTransform()); PortItem* targetPort qgraphicsitem_castPortItem*(item); if (targetPort targetPort ! m_tempEdge-srcPort() targetPort-portType() PortItem::Input targetPort-parentNode() ! m_tempEdge-srcPort()-parentNode()) { // 合法连接让临时连线转正 m_tempEdge-setDstPort(targetPort); m_tempEdge-updatePath(); m_edges.insert(m_tempEdge-id(), m_tempEdge); // 同时通知两个端口所属的节点让节点记录这个连接 targetPort-parentNode()-addEdge(m_tempEdge); m_tempEdge-srcPort()-parentNode()-addEdge(m_tempEdge); } else { removeItem(m_tempEdge); delete m_tempEdge; } m_tempEdge nullptr; m_isConnecting false; }这段代码里最关键的是 itemAt() 的调用。你可能注意到我在 portReleased 里直接用 scenePos 去查找 item而没有继续使用 event 的原始对象。这是因为 mouseReleaseEvent 的接收者可能还是那个端口 item但 PortItem 已经把事件 accept 掉了release 时如果它还在原位置则没事如果鼠标在释放时已经移出端口范围事件的接收对象可能会变成 Scene。为了统一我干脆通过场景坐标重新做一次碰撞检测这样最可靠。另外有一个限制条件目标端口不能是自己必须和源端口类型不同一个输出一个输入还不能是同一个节点。在实际流程图的约定里自己连自己是没意义的这种自环我直接禁止。3.4 FlowView以鼠标为锚点的缩放与手型平移FlowView 继承自 QGraphicsView重写了两个事件wheelEvent 和 mouseMiddleButtonDrag。滚轮缩放是用户感知最强烈的交互不缩放到光标位置的话你放大一个节点后会发现它立刻跑出视野了。void FlowView::wheelEvent(QWheelEvent* event) { // 缩放系数每次滚轮缩放 1.15 倍 qreal factor 1.15; if (event-angleDelta().y() 0) factor 1.0 / factor; // 记录鼠标在视口中的位置和当前变换 QPointF mouseInView event-position(); QPointF mouseInScene mapToScene(mouseInView.toPoint()); qreal currentScale transform().m11(); // 限制缩放范围防止无限缩放到不可见 if (currentScale * factor 3.0) factor 3.0 / currentScale; if (currentScale * factor 0.2) factor 0.2 / currentScale; // 先缩放再平移回鼠标位置 scale(factor, factor); QPointF newMouseInScene mapToScene(mouseInView.toPoint()); QPointF delta newMouseInScene - mouseInScene; translate(delta.x(), delta.y()); }这里有个计算细节translate(delta.x(), delta.y()) 是在视图坐标系下操作的而 delta 是场景坐标的位移。QGraphicsView 的 translate 实际上作用于变换矩阵所以传入的数值就是场景单位。这样算出来的效果是缩放后鼠标下的场景点保持在鼠标位置不变。中键平移我用了 setDragMode(QGraphicsView::ScrollHandDrag)不过直接设置后鼠标左键也会变成拖拽画布而不是框选。我的做法是重写 mousePressEvent单独判断中键void FlowView::mousePressEvent(QMouseEvent* event) { if (event-button() Qt::MiddleButton) { m_lastPanPoint event-pos(); setCursor(Qt::ClosedHandCursor); event-accept(); return; } QGraphicsView::mousePressEvent(event); } void FlowView::mouseMoveEvent(QMouseEvent* event) { if (m_lastPanPoint.isNull()) { QGraphicsView::mouseMoveEvent(event); return; } QPointF delta event-pos() - m_lastPanPoint; m_lastPanPoint event-pos(); // 反向移动内容 horizontalScrollBar()-setValue(horizontalScrollBar()-value() - delta.x()); verticalScrollBar()-setValue(verticalScrollBar()-value() - delta.y()); event-accept(); }我把左键留给框选右键留给菜单中键专门用于拖动画布这是桌面软件用户比较习惯的交互。如果你想让左键也能平移可以把 setDragMode 打开但那样框选就要改用右键或者配合 Shift 键。这个取舍完全看你自己的使用场景没有绝对的对错。4. 项目运行指南从 qmake 到第一个可拖动的流程图4.1 开发环境与依赖这个项目基于 Qt Widgets 模块不涉及 Qt Quick所以安装 Qt 时只需要选择 Qt Widgets 组件即可。我用的开发版本是 Qt 5.15.2 MinGW 32-bit但代码没有用到任何 Qt 6 独有特性你用 Qt 6.2 以上也可以直接编译。如果系统里同时装了多个 Qt 版本要注意 qmake 的路径是否指向了正确的版本这种“环境陷阱”经常让人觉得代码本身出了问题。打开 FlowChart.pro 文件你会看到QT core gui greaterThan(QT_MAJOR_VERSION, 4): QT widgets TARGET FlowChart TEMPLATE app SOURCES main.cpp \ flowscene.cpp \ flowview.cpp \ nodes/nodeitem.cpp \ nodes/startnode.cpp \ nodes/processnode.cpp \ nodes/decisionnode.cpp \ edges/edgeitem.cpp \ edges/edgefactory.cpp \ items/portitem.cpp \ items/textitem.cpp \ commands/addnodecommand.cpp \ commands/deletecommand.cpp \ commands/movecommand.cpp HEADERS ...如果你是用 Qt Creator 打开直接选择 .pro 文件然后构建运行即可。如果偏好命令行Windows 下可以用mkdir build cd build qmake ../FlowChart.pro mingw32-make ./FlowChart.exeLinux/macOS 下把 mingw32-make 换成 make最后运行的可执行文件是 ./FlowChart没有 .exe 后缀。4.2 运行后的界面与基本操作启动程序后左侧是一个节点工具箱右侧是画布。工具箱里列了四类节点开始/结束、处理、判断、数据。拖拽任意节点到画布上双击节点上的文字可以编辑内容。在节点边缘的圆形端口上按下鼠标左键并拖出移动到另一个节点的端口上松开即可建立一条带箭头的贝塞尔连线。鼠标滚轮缩放按住中键拖动平移左键拖拽可框选多个节点。选中节点后按 Delete 键删除会同时删除关联的连线。CtrlZ 撤销CtrlY 重做。整个项目默认没有持久化保存功能但如果你想保存流程图可以基于 QSaveFile 自己写一个序列化接口这个我会放在最后一节的改造思路里。4.3 初始化场景第一次按下鼠标时发生了什么如果你想在这个项目基础上增加自己的启动逻辑比如程序打开后自动放两个节点并连接可以在 MainWindow 构造函数里直接操作 FlowScene。下面是一个简单的示例创建开始节点和处理节点坐标分别为 (0, 0) 和 (200, 100)再手动建立一条连线。StartNode* startNode new StartNode(); ProcessNode* processNode new ProcessNode(); scene-addNode(startNode, QPointF(0, 0)); scene-addNode(processNode, QPointF(200, 100)); // 获取两个节点的输出和输入端口 PortItem* outPort startNode-outputPort(); PortItem* inPort processNode-inputPort(); EdgeItem* edge new EdgeItem(outPort, inPort); edge-updatePath(); scene-addEdge(edge);addNode 内部会把节点加入场景并把它关联到 FlowScene 的 m_nodes 表里。这个 table 的 key 我用了 QUuid所以每个节点都有唯一标识以后扩展保存功能时可以直接拿这个 id 当数据库主键。不建议用节点对象指针作为 key因为撤销删除时对象会被销毁重做时需要重新创建指针不稳定。如果你希望程序启动时自动加载某个流程图也可以在 addNode 之后调用 view-ensureVisible(node)让画布滚动到可见区域。否则节点坐标如果是负数用户可能找不到自己刚创建的节点。5. 避坑指南图元系统的五个高频“玄学”问题这一章是血泪经验的集合。我在做这个项目时几乎每个问题都折腾过一天以上调试到最后发现原因往往是一行代码的事。把它写下来希望你能绕开这些坑。5.1 节点拖动时疯狂闪烁CPU 占用率飙升现象拖动节点时节点和连线高频闪烁界面有明显的残影CPU 占用 30% 以上。原因节点的 boundingRect() 写得太大或者每次移动都触发 prepareGeometryChange()导致整个场景被重绘。另外如果连线边没有调用 setZValue(-1)每次重绘时连线会被节点覆盖产生“先画线、再盖住、再重画线”的循环效果。解决把所有连线统一放在 Z 值为 -1 的层级树节点子图元不要独立设置 itemChange 重绘。我的习惯是只有节点根图元实现 ItemPositionHasChanged 接口连线通过 FlowScene 的一个槽函数批量更新而不是让每个节点自己去通知所有边。5.2 缩放时空心矩形位置对不上鼠标下的点越滚越偏现象使用 setTransformationAnchor(AnchorUnderMouse) 后缩放精度依然有问题尤其是进行过多次平移后缩放会以奇怪的点为中心。原因AnchorUnderMouse 只固定了当前鼠标位置但在缩放下滚动条的值也会变化。如果你先 zoomIn 再移动滚动条再 zoomOut坐标系已经偏移Anchor 失效。解决不要依赖内置 Anchor完全按我第 3.4 节的方法每次缩放后计算场景坐标偏移量再 translate 回去。这个方法在任意滚动条位置下都是精确的亲测有效。5.3 连线无法命中点击箭头时选不中现象鼠标点击连线中部能选中但点击连线两端箭头附近经常选不中或者点击时选中了底层的节点。原因QGraphicsItem 的 shape() 默认返回 boundingRect()而 boundingRect 我为了防残影调大了 20px导致 shape 和实际路径不匹配。箭头区域在路径终点命中的是那 40×40 的空白区域。解决重写 shape()用 QPainterPathStroker 生成沿路径的细长 shapeQPainterPath EdgeItem::shape() const { QPainterPathStroker stroker; stroker.setWidth(6); // 命中的不再是整个矩形而是路径周围 6px return stroker.createStroke(m_path); }这样既不会残留残影又不会挡住射线穿透选中节点。5.4 删除节点后连线对象还在内存里现象删除节点后用 itemAt 查看场景发现之前连到该节点的箭头线还在虽然看不见。原因你只执行了 removeItem(edge)但没有 delete。QGraphicsScene 不会自动释放 item 内存它会再被删除节点时回收但严格的流程是先从 m_edges 表移除再从场景移除最后 delete。解决FlowScene 里统一走 deleteFromScene 函数先取所有关联边逐条调用 removeItem 和 delete然后 removeItem 节点本身并 delete。所有删除操作都过这一个函数避免漏掉一边。5.5 文本编辑框中光标乱跑现象双击端口附近的文本光标不跟随鼠标或者输入的字符显示在另一个 item 上。原因QGraphicsTextItem 默认大小随内容变化但没有设置 TextEditorInteraction 时会覆盖相邻 item 的 mousePress。解决建议用 QGraphicsProxyWidget 嵌入 QLineEdit而不是用 QGraphicsTextItem。虽然 ProxyWidget 在旋转时会有一点性能问题但流程图场景下文字本来就不旋转用 QLineEdit 的交互稳定性好得多。我在这个项目里后来就统一换成了 LineEdit双击节点弹出编辑框焦点离开自动保存。6. 二次开发改造把固定模板变成你自己的图元系统6.1 扩展新节点继承 NodeItem 还是组合复用最常见的需求是增加一种新的节点类型比如“数据库节点”圆柱形。如果你只需要改变外观优先组合复用在 NodeItem 里增加一个枚举 NodeTypepaint() 函数根据 m_type 分支绘制。如果你的新节点有额外的数据属性比如数据库节点要存表名、查询语句那就继承 NodeItem在新的子类里添加成员变量和对应的属性编辑器。我倾向的做法是NodeItem 作为纯基类只提供端口管理、选中态、文本编辑槽等通用能力不负责具体绘制。StartNode、ProcessNode、DecisionNode 各自写 paint()。新增一种节点只需要实现三个函数boundingRect、paint、nodeName。6.2 增加保存与加载用 QUuid 做外键关联这个项目目前没有持久化但改造成本非常低因为你所有的节点和边都用 QUuid 标识。序列化建议直接用 QJsonDocument结构如下{ nodes: [ { id: uuid-string, type: process, x: 120.5, y: 80.0, text: 读取配置文件 } ], edges: [ { srcNodeId: uuid-string, srcPortIndex: 0, dstNodeId: uuid-string, dstPortIndex: 0 } ] }反序列化时先创建所有节点存入一个 id → NodeItem 的映射表再根据映射表创建连线。这个顺序不能反过来因为边在创建时就要拿到两个端口的指针。void FlowScene::loadFromJson(const QJsonObject json) { const QJsonArray nodesArr json[nodes].toArray(); QHashQString, NodeItem* idMap; for (const auto val : nodesArr) { auto obj val.toObject(); NodeItem* node createNodeByType(obj[type].toString()); node-setPos(obj[x].toDouble(), obj[y].toDouble()); node-setText(obj[text].toString()); addNode(node, node-pos()); idMap.insert(obj[id].toString(), node); } const QJsonArray edgesArr json[edges].toArray(); for (const auto val : edgesArr) { auto obj val.toObject(); NodeItem* srcNode idMap.value(obj[srcNodeId].toString()); NodeItem* dstNode idMap.value(obj[dstNodeId].toString()); if (srcNode dstNode) { EdgeItem* edge new EdgeItem(srcNode-outputPort(), dstNode-inputPort()); edge-updatePath(); addEdge(edge); } } }createNodeByType 里用一个 switch 把字符串映射到节点类。如果你以后要支持插件式扩展可以把这一步替换成工厂注册表但小工具场景下没必要过度设计。6.3 增加右键菜单和属性面板右键菜单可以在 FlowScene 的 contextMenuEvent 里统一处理。先用 itemAt(pos) 判断右键点到了什么如果是一个 NodeItem弹出“删除”“重命名”“属性编辑”如果背景弹出“新建节点”子菜单。属性面板和 QTreeWidget 或者 QTableWidget 绑定选择节点时在面板里显示节点属性修改后调用 node-setProperty 并 update()。这块代码没有什么难度但有一个交互细节右键菜单弹出前要先通过 scene-clearSelection() 清空之前的选择然后把右键点击的那个 node 置为选中态否则菜单里那些针对选中节点的操作会应用到一个用户看不到的旧节点上。6.4 建议的验证方式每次改完一个功能我习惯用一段手工脚本验证核心链路新建场景、添加若干节点、连线、移动、缩放、删除、撤销全部走完后检查 m_edges.size() 和 m_nodes.size()确认没有泄漏。然后用这段脚本作为单元测试的种子把鼠标操作替换成直接调用 scene 的方法。Qt Test 框架里有 QSignalSpy 可以用来监听端口信号不过对这个小项目的体量来说不引入测试框架而是写一个 debug 窗口函数就够了。整套项目拆下来其实最难的不是某个具体技术点而是把交互设计、数据管理和图元绘制拧在一起的统筹能力。我第一次替同事改这种图元编辑器时从端口信号到连线的生命周期排摸了两天才把所有状态机捋顺。从那以后我每次新增交互都会先画一张端口/场景的信号流向图把“谁发信号、谁响应、谁负责销毁”写死在注释里再开始写代码。希望这篇拆解能帮你把这个项目的后续改造走得比我当初顺畅。本文还有配套的精品资源点击获取