尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

用QGraphicsView构建流程图编辑器:场景、图元与交互设计详解

发布时间:2026/9/24 5:39:16

资讯中心
01
ARTICLE

用QGraphicsView构建流程图编辑器:场景、图元与交互设计详解

用QGraphicsView构建流程图编辑器:场景、图元与交互设计详解
1. 为什么说流程图编辑器才是QGraphicsView的正确打开方式1.1 先搞明白QGraphicsView到底比普通控件强在哪我在不少项目里见过同一种用法把QGraphicsView当成了一个高级QLabel加载一张图片然后fitInView()一下就完事了。如果你也停留在这一步那确实体会不到这个框架的设计意图。QGraphicsView真正的价值在于它内部有一套完整的图元Item场景Scene管理机制。你可以把它理解成一块无限大的画板每个节点、每条连线都是一个可以独立处理鼠标事件、碰撞检测、坐标变换的对象而不是一张扁平的位图。做流程图编辑器时这块画板天然就适合承载节点、连线、锚点、选区这一整套图形交互逻辑。Qt 6.5在QGraphicsView这块的API基本延续了经典设计但也对坐标变换和高DPI渲染做了不少底层修正因此在6.5上做开发不用再像Qt 5时代那样频繁处理一些莫名其妙的分辨率适配问题。本文我以一个简易流程图编辑器为例把这套框架从搭建场景到序列化保存完整过一遍适合已经写过一点Qt界面、但没深入用过Graphics View框架的读者。1.2 一个迷你流程图的组成拆解先想清楚我们要做出来的东西长什么样。拿我最常用的一个内部工具为例它需要支持从左侧工具栏拖出开始、处理、判断、结束四种节点节点可以整体拖动可以编辑标题从节点边缘的连接点拖出连线连接到另一个节点的连接点支持框选、删除、撤销重做可以保存为JSON文件下次打开还能原样恢复这个需求列表一出来你就能发现纯靠QPainter在QWidget上自己画不是不行但一旦要处理连线跟着节点移动而实时更新点选到一条弯曲连线框选手势这些交互自己实现的工作量会指数级上升。QGraphicsView把这些底层事件分发和坐标转换全包了我们只需要聚焦在业务逻辑上。所以本文的核心思路是用QGraphicsScene管理数据用QGraphicsItem描述节点和连线用QGraphicsView做交互入口这三层各司其职缺一不可。2. 场景、图元与视图三层架构的实战理解2.1 从零搭建Graphics View框架在Qt 6.5里新建一个基于QMainWindow的项目后最简单的初始化代码如下// MainWindow构造函数中 scene_ new QGraphicsScene(this); view_ new QGraphicsView(scene_, this); view_-setRenderHint(QPainter::Antialiasing); view_-setViewportUpdateMode(QGraphicsView::BoundingRectViewportUpdate); view_-setDragMode(QGraphicsView::RubberBandDrag); setCentralWidget(view_);这里有三个关键选项我分别解释一下Antialiasing流程图里大量使用曲线、圆角矩形和斜线不开抗锯齿的话边缘锯齿感非常明显尤其在4K屏上一眼过去全是毛边这是体验分水岭。BoundingRectViewportUpdate希望拖动图元时视图不要整块重绘而是按脏矩形局部刷新。选这个模式在节点数量较少时足够流畅如果图元数量过万再考虑MinimalViewportUpdate。RubberBandDrag这是框选模式直接给视图开了默认的橡皮筋框选交互配合后面讲的setFlag(QGraphicsItem::ItemIsSelectable)就能用。一个容易忽略的细节是场景坐标系的初始范围。很多新手发现节点拖到很远的空白处之后找不到了就是因为场景默认范围是无限扩张的。我一般在初始化时给一个足够大的sceneRectscene_-setSceneRect(-2000, -2000, 4000, 4000);这样既不影响自由布局也给后续的迷你地图或缩放定位留了基准。2.2 自定义流程节点图元从QGraphicsItem开始自定义节点是编辑器最重要的图元。我通常把它拆成两层外形绘制和数据绑定但都放在同一个QGraphicsObject子类里。关键在于怎么设计它的boundingRect和绘制逻辑。先看一个标准节点类的最小实现class FlowNodeItem : public QGraphicsObject { Q_OBJECT public: enum NodeType { Start, Process, Decision, End }; FlowNodeItem(NodeType type, const QString title, QGraphicsItem *parent nullptr); QRectF boundingRect() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; // 给外部访问的引脚信息 QListQPointF inputPortPositions() const; QListQPointF outputPortPositions() const; protected: void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; private: NodeType type_; QString title_; QRectF rect_; };boundingRect要返回整个图元占用的矩形范围包括阴影、边框宽度。流程图节点常见尺寸是160x70左右判断节点可以稍宽一点比如160x90。绘制时用QPainter画圆角矩形、标题文字、端口小圆圈void FlowNodeItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) { Q_UNUSED(option); Q_UNUSED(widget); painter-setRenderHint(QPainter::Antialiasing); // 节点背景 QColor bgColor; switch (type_) { case Start: bgColor QColor(#D5F5E3); break; case Process: bgColor QColor(#AED6F1); break; case Decision: bgColor QColor(#F9E79F); break; case End: bgColor QColor(#F5B7B1); break; } painter-setBrush(bgColor); painter-setPen(QPen(Qt::darkGray, 1.5)); if (isSelected()) { painter-setPen(QPen(QColor(#2E86C1), 2.5)); } painter-drawRoundedRect(rect_, 8, 8); // 标题文字 painter-setPen(Qt::black); painter-drawText(rect_.adjusted(5, 5, -5, -5), Qt::AlignCenter, title_); // 输入/输出端口锚点这些点要留给连线计算用 painter-setBrush(Qt::white); painter-setPen(QPen(Qt::black, 1)); for (const QPointF p : inputPortPositions()) { painter-drawEllipse(p, 5, 5); } for (const QPointF p : outputPortPositions()) { painter-drawEllipse(p, 5, 5); } }端口位置的计算是个重点。以输出端口为例左右各放一个竖直方向排在节点中部输入端口在左侧输出端口在右侧判断节点的话左右两端都可以输出。inputPortPositions和outputPortPositions要在返回QPointF时调用mapToScene或者直接返回本地坐标由外界再映射。我倾向于在节点内部返回场景坐标因为连线需要直接使用。2.3 图元的数据模型别把业务数据塞进Item里这是我认为新手最容易踩的坑。早期我图省事把所有字段全塞进FlowNodeItem结果遇到重新打开工程文件和撤销重做需求时发现要么序列化逻辑散落得到处都是要么一个节点对象承担了显示和业务双重职责代码越改越乱。现在的做法是单独维护一个FlowDataModelstruct FlowNodeData { QString nodeId; FlowNodeItem::NodeType type; QString title; QPointF position; QJsonObject extraProps; }; struct FlowEdgeData { QString edgeId; QString sourceNodeId; int sourcePortIndex; QString targetNodeId; int targetPortIndex; };FlowNodeItem只负责把FlowNodeData渲染出来并负责拖拽移动后的坐标回写。其他所有逻辑包括连线匹配、删除、保存都操作FlowNodeData和FlowEdgeData而不是直接操作Item。这样设计带来的直接好处是你需要保存的数据结构和UI状态天然分离。做撤销重做时只需对数据做快照做JSON序列化时直接遍历数据列表输出不用从图形Item身上反推。否则等流程图复杂到几百个节点时再拆模型就痛苦了。3. 锚点与连线流程图编辑器最绕的设计决策3.1 连接端子Port的数据结构设计节点与节点之间靠什么连接很多教程里直接用节点中心点连线那是拓扑图的做法流程图里更接近真实的做法是端口到端口源节点的一个输出端口连到目标节点的一个输入端口。我按端口索引来标识而不是按端口对象。理由很简单端口坐标是动态的节点一移动就得重新计算存坐标没有意义端口也没有全局唯一ID存对象在序列化时无法还原。所以每条边记录的是sourceNodeId - sourcePortIndex targetNodeId - targetPortIndexsourcePortIndex代表输出端口列表里的第几个targetPortIndex代表输入端口列表里的第几个。这样节点定义只要保证端口数量和顺序固定序列化就绝对稳定。端口坐标的暴露我给两个方法QPointF FlowNodeItem::outputPortPos(int index) const { QRectF r rect_; if (index 0) return mapToScene(QPointF(r.width(), r.height() / 2.0)); // 判断节点可能有第二输出口 return mapToScene(QPointF(r.width(), r.height() / 2.0 12)); } QPointF FlowNodeItem::inputPortPos(int index) const { QRectF r rect_; return mapToScene(QPointF(0, r.height() / 2.0 index * 12)); }这里一定要用mapToScene让返回坐标在场景坐标系下因为连线属于场景层级不归属于任何节点。3.2 连线怎么画从QGraphicsPathItem到动态更新连线的绘制方式有两种流派一种是直接继承QGraphicsPathItem重写paint另一种是自定义QGraphicsItem保存路径并自己画。我实践下来推荐后者因为需要在路径变化时通知视图刷新且想自定义箭头和命中逻辑。连线类大致长这样class FlowEdgeItem : public QGraphicsItem { public: FlowEdgeItem(FlowNodeItem *src, int srcPort, FlowNodeItem *dst, int dstPort); void updatePath(); QRectF boundingRect() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; private: QPainterPath path_; QPointF srcPoint_; QPointF dstPoint_; };updatePath()里最核心的是贝塞尔曲线的控制点计算。流程图的连线一般不画直线而是用三阶贝塞尔让连线看起来像绕了一下可读性更好。控制点根据源点和终点的横向距离动态决定void FlowEdgeItem::updatePath() { srcPoint_ sourceNode_-outputPortPos(sourcePort_); dstPoint_ targetNode_-inputPortPos(targetPort_); QPainterPath path(srcPoint_); qreal dx qMax(qAbs(dstPoint_.x() - srcPoint_.x()), 60.0); QPointF c1(srcPoint_.x() dx, srcPoint_.y()); QPointF c2(dstPoint_.x() - dx, dstPoint_.y()); path.cubicTo(c1, c2, dstPoint_); path_ path; prepareGeometryChange(); }那个qMax(..., 60)非常关键。如果两个节点靠得很近而横向距离极小控制点会挤在一起曲线拉不出弧度视觉上看起来像一条折角直线。固定一个最小控制距离后线条才会始终带一点平滑弯曲。prepareGeometryChange()必须在路径更新、boundingRect变化前调用否则视图刷新的脏矩形范围还是旧的就会出现残影或线头断裂。3.3 连线命中检测为什么不能用默认方式连接线太细用户很难点选到。QGraphicsItem的默认shape()是线的路径本身宽度1个像素点击命中范围几乎不可用。这就需要重写shape()给线加上命中“胖度”QPainterPath FlowEdgeItem::shape() const { QPainterPathStroker stroker; stroker.setWidth(12.0); return stroker.createStroke(path_); }QPainterPathStroker会把路径沿线扩展出一个宽12像素的带状区域点击接近线附近都能选中。12是经验值太小不好点太大会导致线之间互相遮挡框选时容易误选。另外选中后要把线的颜色改重一些、线宽加粗避免选完自己都分不清选中了哪条。真正坑人的是另一个问题线应该在节点下层还是上层如果节点在连线上层拖节点的时候线会被节点盖住一部分观感没问题但连线如果设了ItemIsSelectable且层级在节点上方用户在节点上拖动时可能误触到线。我的做法是连线永远在节点底层通过构造时给连线设置较低Z值edgeItem-setZValue(-1);这样节点永远在上层鼠标拖拽节点时事件优先派发给节点。4. 交互设计让编辑器用起来顺手的细节4.1 拖拽新建节点与框选从工具栏拖出节点我用的是一种比较轻的方案在工具栏的QListWidget里设置setDragEnabled(true)给每个项存一个Qt::UserRole里面记录节点类型。然后在视图上重写dragEnterEvent和dropEventvoid FlowView::dropEvent(QDropEvent *event) { const QMimeData *mime event-mimeData(); if (mime-hasFormat(application/x-flow-node)) { QByteArray data mime-data(application/x-flow-node); int type data.toInt(); FlowNodeItem *node createNodeByType(static_castFlowNodeItem::NodeType(type)); QPointF scenePos mapToScene(event-position().toPoint()); node-setPos(scenePos); scene_-addItem(node); bindDataModel(node); // 生成FlowNodeData并追加到模型列表 event-acceptProposal(); } }注意Qt 6里的event-position()返回QPointF但它是视图坐标要用mapToScene转换到场景坐标。很多从Qt 5迁移过来的老代码还在用event-pos()6.x里会编译警告甚至出错。框选这块直接用的RubberBandDrag模式已经能工作。但你如果想要只有框选到节点主体才算选中建议给节点重写shape()时收窄范围只返回圆角矩形本身而不是默认矩形范围。否则框选时只要碰到外边距阴影也算选中容易把一堆不想选的节点捎带上。4.2 节点移动时的连线跟随节点移动后连线不能留在原地这是编辑器最基本的手感要求。实现方式有两种在FlowNodeItem::mouseMoveEvent里手动通知所有关联连线调用updatePath()利用场景的changed信号遍历所有连线刷新第一种更精确第二种实现简单但可能造成无谓刷新。我选择的是在节点移动事件里集中通知void FlowNodeItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) { QGraphicsObject::mouseMoveEvent(event); // 更新数据模型中的坐标 if (dataCallback_) dataCallback_(pos()); // 通知关联连线刷新 emit nodeMoved(); // 如果本节点是拖动目标也要同步更新 for (FlowEdgeItem *edge : connectedEdges_) { edge-updatePath(); } }这里有个细节QGraphicsObject::mouseMoveEvent内部已经调用了setPos所以不要再重复设置位置否则会出现抖动。更新数据模型的位置信息时最好在鼠标松开后只发一次防止每次移动都触发序列化或撤销记录。4.3 右键菜单与删除操作的坑右键菜单是编辑器标配但QGraphicsView的contextMenuEvent默认只处理视口。我的经验是直接在节点图元里处理右键事件因为右键菜单内容往往依赖具体节点void FlowNodeItem::contextMenuEvent(QGraphicsSceneContextMenuEvent *event) { QMenu menu; QAction *editAction menu.addAction(编辑标题); QAction *deleteAction menu.addAction(删除节点); QAction *selected menu.exec(event-screenPos()); if (selected editAction) { emit editRequested(this); } else if (selected deleteAction) { emit deleteRequested(this); } }删除节点时有个非常隐蔽的坑必须先删除关联连线再删除节点本身。如果顺序反了连线的sourceNode_或targetNode_指针会变成悬垂指针在updatePath()里调用outputPortPos()时直接崩溃。我习惯封装一个deleteNode(node)函数void MainWindow::deleteNode(FlowNodeItem *node) { // 收集所有关联边 QListFlowEdgeItem* edges node-connectedEdges(); for (FlowEdgeItem *edge : edges) { scene_-removeItem(edge); delete edge; model_.edges.removeAll(edge-edgeData()); } scene_-removeItem(node); delete node; model_.nodes.removeAll(node-nodeData()); }QGraphicsItem的删除还有个细节scene_-removeItem(item)只把item从场景移除不会delete对象必须手动delete否则内存泄漏。如果你用delete item而item仍在场景里则会在销毁时触发断言崩溃。顺序一定是先remove再从场景删除必要时加QPointer保护。5. 持久化与撤销从Demo到工具的分水岭5.1 用JSON序列化整个流程图一个流程图编辑器如果只能画不能存那永远是玩具。Qt的QJsonDocument和QJsonObject足以满足这个需求。我把序列化分成三步节点、连线、视图状态。QJsonObject FlowEditorModel::toJson() const { QJsonObject root; QJsonArray nodesArray; for (const FlowNodeData nd : nodes_) { QJsonObject obj; obj[id] nd.nodeId; obj[type] static_castint(nd.type); obj[title] nd.title; obj[x] nd.position.x(); obj[y] nd.position.y(); nodesArray.append(obj); } root[nodes] nodesArray; QJsonArray edgesArray; for (const FlowEdgeData ed : edges_) { QJsonObject obj; obj[id] ed.edgeId; obj[source] ed.sourceNodeId; obj[sourcePort] ed.sourcePortIndex; obj[target] ed.targetNodeId; obj[targetPort] ed.targetPortIndex; edgesArray.append(obj); } root[edges] edgesArray; return root; }反序列化时要特别注意节点顺序。因为边缘数据里引用的是节点ID如果节点还没创建完就开始建连线查找ID会失败。正确顺序是清空场景和旧数据遍历nodes数组创建所有节点建立一个QHashQString, FlowNodeItem*的ID映射表遍历edges数组用映射表找到两端节点创建连线恢复视图缩放中心点这个顺序只要乱一步就会遇到连线全丢或连线错位的诡异现象。5.2 简版撤销/重做的实现思路做完整Command模式需要不少篇幅这里我提供一个够用的快照式方案在每次结构性操作添加节点、删除节点、添加连线、删除连线、拖动结束之前把整个流程图的JSON快照压入撤销栈。void MainWindow::pushUndoState() { undoStack_.push(editorModel_.toJson()); if (undoStack_.size() 50) undoStack_.removeFirst(); redoStack_.clear(); }撤销void MainWindow::undo() { if (undoStack_.isEmpty()) return; QJsonObject current editorModel_.toJson(); redoStack_.push(current); QJsonObject target undoStack_.pop(); loadFromJson(target); }这个方案有个前提拖动节点时不能每一步都push否则一个拖动动作会生成几十条历史记录撤销一下退一个像素体验极差。所以前面代码里我专门用dataCallback_只在鼠标松开时同步坐标并在同一个回调里push当前快照。快照式撤销的缺点是节点多时内存占用偏高但流程图编辑器通常节点数在几十到几百这个量级JSON也就几百KB完全够用。真要追求内存极致优化再换Command模式也不迟。6. 踩坑实录与性能优化6.1 高DPI缩放下的渲染问题Qt 6默认启用了高DPI缩放但QGraphicsView里如果图元没有正确处理设备像素比会出现文字模糊或线条粗细不一。多数情况下你只需要在main函数里设置QApplication::setHighDpiScaleFactorRoundingPolicy(Qt::HighDpiScaleFactorRoundingPolicy::PassThrough);然后让所有图元绘制时使用painter-setRenderHint(QPainter::Antialiasing)即可。更隐蔽的一个坑是当你用scale缩放视图时QGraphicsItem的pen宽度也会被缩放。流程图缩放后线宽变粗或变细视觉上很破坏观感。解决办法是让图元忽略视图缩放重新计算合适线宽void FlowEdgeItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) { qreal scaleFactor painter-transform().m11(); QPen pen(QColor(#34495E), 2.0 / scaleFactor); painter-setPen(pen); painter-drawPath(path_); }这样不管视图放大多少倍线宽始终是视觉上的2像素。6.2 大量图元时的性能怎么兜底如果把流程图编辑器扩展到几百上千个节点你会立刻感到拖动和缩放卡顿。我的优化优先级是开启ItemIgnoresTransformations标志对节点上的文本标签和端口小圆圈设置该标志避免它们随缩放频繁重绘变换。关闭不必要的绘制在paint里判断option-levelOfDetailFromTransform(painter-transform())缩放过小时不绘制文字只画色块。局部刷新确认setViewportUpdateMode不是FullViewportUpdate否则一次拖动全屏重绘算法再好也白搭。避免频繁update()整场景连线updatePath后调用update(boundingRect())而不是scene_-update()。实测在2000个节点、3000条边的情况下上述优化能把交互帧率从个位数拉到30FPS以上作为流程图编辑器完全可接受。6.3 我的建议先把交互模型想清楚再动手写代码写到最后我想说点项目层面的体会。第一次做这类编辑器很容易被QGraphicsView的万能感带着走想到什么加什么最后代码变成一团乱麻。我吃过大亏之后现在习惯开工前先画一张交互状态表比如空场景左键拖拽是框选节点上左键拖拽是移动节点按Ctrl加左键是追加选中在连线上双击是删除连线。这些规则先定死后面实现就是按规则填代码。另外建议把数据模型这个模块放在第一位。哪怕第一版只支持三个节点也要先定义好FlowNodeData和FlowEdgeData让所有操作走模型再反映到视图。这是整个项目后期能不能持续扩展的分水岭比任何渲染技巧都重要。这套方案我已经在自己的内部工具里用了一年多从最初只能画节点到现在支持条件分支、子流程跳转底层框架几乎没动过。你现在照着做一版简易编辑器后面想加什么功能都会顺手很多。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。