ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于Qt的流程图编辑器:QGraphicsView架构与避坑指南

基于Qt的流程图编辑器:QGraphicsView架构与避坑指南 简介一份面向Qt初、中级开发者的流程图编辑器完整源码工程核心对应《基于Qt的流程图编辑器开发详解》演示如何使用QGraphicsView/QGraphicsItem构建自定义图形元素、绘制连接线、处理拖拽交互并实现基于信号槽的组件通信、QDataStream序列化保存和撤销重做栈。压缩包共43个文件约68KB包含14个.cpp与14个.h源码模块、3个.ui界面文件、6个.png图标资源以及.pro/.pri/qrc等工程配置整体结构清晰便于直接打开编译和按模块拆解学习。该工程在CSDN已有1579人浏览学习适合希望掌握Qt图形视图框架、事件处理及数据持久化并想快速搭建可视化编辑器原型的开发者。通过学习源码可直观理解自定义图元绘制、连线自动吸附、操作历史维护等关键实现为后续复杂可视化应用开发提供可复用的代码参考。1. 基于Qt的流程图编辑器为什么我劝你别用QPainter硬画去年做交付项目时客户要求做一个能拖节点、拉连线、保存再打开的流程图编辑器。我起初图省事打算用QPainter在QWidget上自己画节点一多就卡点击命中判定写起来像在造轮子做到一半实在顶不住把整个底层换成了QGraphicsView体系。这套基于Qt的流程图编辑器就是从那个项目里抽出来的工程骨架它不是一个玩具Demo而是把节点、连线、缩放、拖拽、序列化、Undo这些流程图中躲不开的问题都处理干净的完整样本。适合刚接触QGraphicsView、想省调研时间的人也适合被坐标换算、缩放漂移、连线刷新折磨过一阵子的熟手。看完你会明白每个类该继承谁、每个坐标该从哪来以及哪些坑值得提前绕开。2. 场景与节点设计三层结构分工与坐标系的三个隐蔽坑2.1 三层结构为什么你不需要自己处理重绘QGraphicsView体系把画布拆成三层View是用户看到的视口Scene是无限大的逻辑世界Item是Scene里的单个图元。从职责上看Scene管坐标和碰撞检测View管显示与交互Item管自己长什么样。三者是组合关系不是继承关系——View通过setScene绑定SceneItem通过scene()指针注册进Scene。很多刚上手的人搞不清该继承哪个类。我的建议编辑器主体继承QGraphicsView节点和连线都继承QGraphicsItem场景用一个QGraphicsScene实例就够。为什么连线也要继承Item因为连线需要被选中、被删除、被Undo这些行为Item天生自带自己用QPainter画线路反而要重写一堆命中逻辑。class NodeItem : public QGraphicsItem { public: NodeItem(const QString title, QGraphicsItem *parent nullptr) : QGraphicsItem(parent), m_title(title) { setFlags(QGraphicsItem::ItemIsMovable | QGraphicsItem::ItemIsSelectable | QGraphicsItem::ItemSendsGeometryChanges); setAcceptHoverEvents(true); // 让悬停高亮生效 } QRectF boundingRect() const override { // 返回的是本地坐标下的范围不是场景坐标 return QRectF(-80, -40, 160, 80); } void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override { Q_UNUSED(option); Q_UNUSED(widget); painter-setRenderHint(QPainter::Antialiasing); // 选中时描边换色配合 ItemIsSelectable if (isSelected()) painter-setPen(QPen(QColor(52, 120, 246), 2)); else painter-setPen(QPen(QColor(120, 120, 120), 1)); painter-setBrush(QColor(245, 245, 245)); painter-drawRoundedRect(boundingRect(), 6, 6); painter-setPen(Qt::black); painter-drawText(boundingRect(), Qt::AlignCenter, m_title); } private: QString m_title; };这个类的几个关键选择值得说。ItemIsMovable让节点拖动由框架完成不需要自己写mouseMoveEvent跟踪坐标ItemSendsGeometryChanges让位置变化能通知连线刷新后面章节会专门讲这条链boundingRect返回的是整个图形在本地坐标下的逻辑大小别把scenePos塞进去。还有一点容易被忽略paint里默认的画笔宽度是1像素在视图缩放到200%时这条线会跟着变粗这是后话先记住这个现象。场景创建同样有技巧。我一般会显式设置一下SceneRect否则刚打开时视口里什么都没有新手会以为程序出bug了。QGraphicsScene默认的逻辑世界坐标从(0,0)开始向右下延伸。节点setPos传入的也是这个坐标系的坐标和你在界面上看到的像素没有直接换算关系中间隔着一层视图变换矩阵。QGraphicsScene *scene new QGraphicsScene(this); scene-setSceneRect(0, 0, 4000, 3000); // 逻辑画布尺寸单位不是像素 ui-graphicsView-setScene(scene); ui-graphicsView-setRenderHint(QPainter::Antialiasing); ui-graphicsView-setDragMode(QGraphicsView::NoDrag);注意setSceneRect影响的是可滚动区域不是物理边界。Scene的坐标默认集中在(0,0)左上象限所有顶层item的pos()就是它的场景坐标。这块不搞清楚后面缩放平移全是乱的。2.2 锚点与坐标转换setPos之前先弄清楚谁是谁流程图编辑器里最容易翻车的错误是把item的本地坐标当成场景坐标。举一个我踩过的实例节点A在场景(500, 500)它的子端口item的本地坐标是(0, 20)。如果我在PortItem上调用pos()返回的是相对父item的位置(0,20)但要定位端口在场景里的实际位置必须调port-scenePos()。这两个值差了整整一个父节点偏移量。用在连线上就是灾难节点拖到右边新连线的起点还在左下角看起来就像线从节点身上穿出去了。鼠标事件里的坐标同理。item的mousePressEvent收到的pos()默认是item本地坐标要转成场景坐标必须显式调用mapToScene(pos)。QGraphicsItem提供了一组成对的方法mapToScene、mapFromScene、mapToItem、mapFromItem。常见做法是全部走映射方法不要去手动加减pos()因为item一旦有旋转或缩放简单相减得到的结果在数学上就不成立。// 把 Port 的圆心本地坐标原点映射到场景坐标 QPointF startPos startPort-mapToScene(QPointF(0, 0)); // 逆运算把场景坐标转回某个 item 的本地坐标 QPointF localPos node-mapFromScene(targetScenePos);我在项目里统一约定所有业务数据保存场景坐标所有绘制坐标用本地坐标跨层索取坐标时一律走map函数。从维护角度看这个约定救了很多次命。给节点设置初始位置时也别直接操作boundingRect用setPos告诉Scene节点摆在哪底层重绘和碰撞检测会自动跟上。2.3 缩放与平移视图矩阵与线宽控制View的缩放本质是操作QTransform矩阵。普通控件背景用setScale是改自己的绘制比例但QGraphicsView的scale是在当前视图变换上再叠加一层矩阵正负倍数决定放大还是缩小。我习惯用滚轮控制缩放以鼠标所在位置为锚点这样用户盯着哪里就放大哪里手感接近专业绘图工具。void FlowView::wheelEvent(QWheelEvent *event) { qreal factor event-angleDelta().y() 0 ? 1.1 : 0.9; scale(factor, factor); // 在原有变换上叠加 event-accept(); }这段代码能跑但要先设置setTransformationAnchor(QGraphicsView::AnchorUnderMouse)否则默认以视口中心为缩放中心鼠标滚轮一滚内容会向屏幕中间聚拢用户感觉整个画布在漂。这是缩放体验里第一个隐蔽坑。第二个坑就是线宽。默认情况下item的paint在本地坐标里执行但缩放作用在view的变换矩阵上paint时画笔宽度也会被矩阵放大。节点放大是需要的连线线宽跟着放大就显得粗糙。解决办法是给连线item设置ItemIgnoresTransformations标志并在paint里把世界变换重置掉if (flags() QGraphicsItem::ItemIgnoresTransformations) { painter-save(); painter-setWorldTransform(QTransform()); // 忽略视图缩放按屏幕像素画 painter-drawPath(m_path); // 绘制逻辑保持不变 painter-restore(); }注意必须成对使用save/restore否则后面的绘制操作都在被重置的坐标系里执行位置就全错了。这个技巧一般只用在连线和端口标注上节点本身不应该设置这个标志否则节点不随场景缩放流程图会变得很怪。3. 连线交互端口检测、拖拽状态机与贝塞尔曲线3.1 端口热区与拖拽状态机端口是连线的锚点。有两种实现路线一种是单独建PortItem挂在NodeItem下面把命中和绘制交给框架另一种是不建item在NodeItem里维护端口数据paint时一起画然后自己算热区。我一般用子item方式理由很简单——热区检测、光标变化、悬停效果框架全包了自己写命中要同时维护坐标和冲突检测两套逻辑。每个端口是16x16的圆形热区boundingRect返回QRectF(-8,-8,16,16)这就是它可点击的范围。然后重写鼠标事件进入“正在拉线”的状态机。三个事件各管一段按下记录起点移动时持续更新预览线的终点松开时判断目标是不是别的端口。void PortItem::mousePressEvent(QGraphicsSceneMouseEvent *event) { if (event-button() ! Qt::LeftButton) return; m_state Linking; // 进入连接模式 m_startPort this; emit linkStarted(mapToScene(QPointF(0, 0))); // 起点必须是场景坐标 event-accept(); } void PortItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) { if (m_state ! Linking) return; emit linkMoved(event-scenePos()); // 预览线跟随鼠标 event-accept(); }mouseReleaseEvent里有个细节用scene()-itemAt(scenePos, QTransform())检测命中拿到的是Z值最高的item未必是PortItem。如果端口和节点是两个item挨在一起时命中的往往是NodeItem本体。正确做法是向上遍历parent链路直到找到PortItem或者nullptr。这个遍历逻辑不复杂但漏掉它连线偶尔会连不上的问题特别难查。void PortItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) { if (m_state ! Linking) return; QGraphicsItem *hit scene()-itemAt(event-scenePos(), QTransform()); PortItem *target nullptr; while (hit) { target qgraphicsitem_castPortItem *(hit); if (target) break; hit hit-parentItem(); // 沿父子链向上找 } if (target target ! m_startPort) emit linkFinished(m_startPort, target); m_state Idle; event-accept(); }这里把“完成连线”的信号挂出去由主窗口负责创建LinkItem并注册到绑定表里。你说的“disconnect”操作走同一套信号点击某条连线按Delete键主窗口从绑定表移除并delete掉LinkItem。状态机只有三态但边界条件不少——比如按下后移到视图外再松开itemAt返回nullptr要直接落回Idle状态。3.2 贝塞尔连线绘制与实时刷新连线本身是LinkItem继承QGraphicsItem。它不响应鼠标移动也不做碰撞检测就干两件事根据两个端点坐标重算路径然后把自己画出来。我用贝塞尔曲线而不是直线视觉上柔和很多专业流程图工具基本都是这个画法。控制点沿水平方向偏移路径从平缓弯曲到陡峭过渡自然。void LinkItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) { Q_UNUSED(option); Q_UNUSED(widget); QPainterPath path; path.moveTo(m_startPos); // 控制点向水平方向拉形成平滑弯曲 qreal dx (m_endPos.x() - m_startPos.x()) * 0.5; path.cubicTo(m_startPos.x() dx, m_startPos.y(), m_endPos.x() - dx, m_endPos.y(), m_endPos.x(), m_endPos.y()); painter-setRenderHint(QPainter::Antialiasing); painter-setPen(m_selected ? QPen(QColor(52,120,246), 2) : QPen(QColor(100,100,100), 2)); painter-drawPath(path); }这段代码能出画面但有两个坑藏在背后。第一个是boundingRect必须把整条贝塞尔曲线包住否则曲线两端会被视口裁剪。用QPainterPath::boundingRect()算出来的范围是准的记得扩几个像素给画笔留余量不然曲线边缘会缺一块。第二个坑更隐蔽端点坐标变化后必须调用prepareGeometryChange()再改m_startPos和m_endPos否则Scene里图元索引不更新drawPath画的是新位置但重绘区域还是旧的范围就会出现残影。节点拖动时连线怎么跟着动在NodeItem里维护一个绑定列表把连到自己端口的LinkItem都记下来。配合ItemSendsGeometryChanges标志重写itemChange在位置变化后统一刷新连线的路径。这是节点和连线解耦最干净的做法不需要信号槽也不需要LinkItem时刻轮询。QVariant NodeItem::itemChange(GraphicsItemChange change, const QVariant value) { if (change ItemPositionHasChanged) { // 通知所有关联连线重算路径 for (LinkItem *link : m_links) link-updatePath(); // 内部准备 prepareGeometryChange setPos } return QGraphicsItem::itemChange(change, value); }3.3 拖动画布与连线误触的取舍视图上拖动画布有两条路线。一条是给View设置setDragMode(QGraphicsView::ScrollHandDrag)鼠标按住空白处自动变成抓手滚动。代价是框选功能被挤掉ScrollHandDrag模式下拖动空白区域时不会触发RubberBandSelect节点没法一次框多个。另一条是自己重写mousePressEvent判断点击位置是不是落在空白处是则自己管理平移否则把事件交给上层继续处理。我推荐第二条因为流程图编辑器即需要拖画布又需要框选。判空白的标准是itemAt返回nullptr或者命中的item没有ItemIsSelectable标志。命中到节点但节点不可选时也应该走平移逻辑否则用户点在节点空隙上会突然只能框选画布纹丝不动。void FlowView::mousePressEvent(QMouseEvent *event) { QGraphicsItem *hit itemAt(event-pos()); if (!hit || !hit-flags().testFlag(QGraphicsItem::ItemIsSelectable)) { m_panning true; m_panStart event-pos(); setCursor(Qt::ClosedHandCursor); event-accept(); return; } QGraphicsView::mousePressEvent(event); }有个细节我把setDragMode设成了NoDrag因为ScrollHandDrag会和手写平移逻辑打架两个都开会出现一次拖动触发两次位移。手写平移时mouseMoveEvent里用horizontalScrollBar()-setValue和verticalScrollBar()-setValue更新滚动而不是移动场景。这是很多人容易混的地方——拖动画布改的是滚动条位置不是Scene坐标。4. 高频翻车现场流程图编辑器避坑指南六则4.1 场景坐标与本地坐标混用连线对不上节点现象节点拖走了连线起点还留在原地或者自己画的临时预览线位置完全随机跟鼠标不在一个地方。原因把item的pos()当成场景坐标直接用或者把mousePressEvent里的本地坐标pos()直接传给了需要场景坐标的接口。这两个坐标在默认情况下相差一个item自身的位置偏移节点一拖动差值就暴露了。解决统一规范存业务数据一律用scenePos()获取坐标一律走mapToScene或scenePos()。连线存场景坐标节点变化时通过itemChange同步通知。写完这个规范后坐标错乱的问题基本绝迹。4.2 缩放后线宽跟着变节点看起来像贴了黑边现象视图放大到200%节点边框和连线粗得离谱缩到50%时线条又细得看不见。原因view的scale作用于QTransform矩阵paint里的pen宽度会跟随矩阵缩放。这不是bug是QGraphicsView的默认行为几乎所有初用的人都会撞上一次。解决给连线item设置ItemIgnoresTransformations标志paint里先save再setWorldTransform(QTransform())把世界变换重置为屏幕像素坐标系画完restore。这个技巧只用于线宽和标注大小节点本体不要加这个标志。4.3 boundingRect返回过小出现选不中、擦不干净现象点击明明在节点图形上却选不中拖动节点后留下残影旧位置总有块颜色擦不掉。原因boundingRect返回的范围没有包住所有绘制内容。QGraphicsItem的碰撞检测和重绘区域都只信任boundingRectpaint里画出来的内容超出这个范围系统默认你看不见也碰不着。解决算完几何边界后多包几个像素特别是画笔宽度要考虑进去。改形状前必须调用prepareGeometryChange()告诉Scene索引要失效重建改完再调update()触发重绘。顺手记一条经验用QPen画线时boundingRect要按pen宽度的一半往外扩这是最容易漏的部分。4.4 保存后加载错位或者恢复时直接崩溃现象工程保存所有item的pos()重开工程后节点位置全乱或者加载到一半程序崩掉。原因pos()对无parent的顶层item是场景坐标但有父节点的子itempos()返回的是相对父节点的本地坐标。如果保存时混用了两种坐标加载时用setPos()设置就会错位。更经典的是保存了QGraphicsItem指针地址进程重启后指针失效解引用直接崩。解决保存时统一存scenePos()加载时统一setPos()不要存item地址。节点和连线用业务id映射保存时给每个节点生成自增id连线保存两端的端口id加载时先建所有节点再靠id找到端口把连线接上。4.5 设置了setAcceptHoverEvents(true)还是没有hover效果现象响应鼠标移入移出的逻辑就是不触发鼠标移到节点上一点反馈都没有。原因最常见的是boundingRect没包住想交互的区域鼠标落在boundingRect外面Events系统直接把命中判定为false。另一个原因是paint里没有画填充内容item表面透明命中计算拿透明区域当空白处理。解决先确认boundingRect足够大再确认paint里有painter-fillPath或setBrush drawPath的调用哪怕填充色是半透明也行。检查顺序先打印boundingRect和鼠标scenePos看坐标在不在范围内再往上查flags。4.6 Undo之后节点位置恢复了连线还连到老位置现象QUndoStack执行undo节点回到原地但连线端点没有跟着回来图面看起来像线断了。原因move命令只改节点位置没有触发连线刷新链。或者命令对象里记录的from/to不是场景坐标撤销时setPos用的是本地坐标位置自然不对。解决命令对象里记录from/to都用scenePos()redo和undo统一调用setPos()。节点ItemPositionHasChanged的事件链保持唯一入口所有位置变化都走itemChange通知连线updatePath命令模式只是包装这一层不要另起一套刷新逻辑。5. 从能画到能用序列化落盘、Undo栈与发布前的最后检查5.1 落盘格式JSON id映射流程图编辑器的保存格式我直接用QJsonDocument。每个节点存id、标题、scenePos连线存两个端口id。端口id是节点内唯一的字符串比如“in_1”“out_0”不要用item地址。QJsonArray nodes; for (NodeItem *node : findTopLevelNodes(scene)) { QJsonObject obj; obj[id] node-nodeId(); obj[title] node-title(); obj[x] node-scenePos().x(); obj[y] node-scenePos().y(); nodes.append(obj); }加载的逆过程是先遍历nodes数组创建NodeItem并setPos再遍历links数组通过id找到对应端口执行重连最后全场景统一update()。注意连线的m_startPos和m_endPos在加载后要重新计算一次落盘的坐标只作为备份参考真正以端口场景坐标为准。5.2 用QUndoStack包装位置变化移动节点这种高频操作用QUndoCommand包装最稳妥。关键是记录from和to的场景坐标redo和undo都走同一条setPos路径触发itemChange通知连线刷新的链路不能断。class MoveCommand : public QUndoCommand { public: MoveCommand(NodeItem *item, const QPointF from, const QPointF to) : m_item(item), m_from(from), m_to(to) {} void undo() override { m_item-setPos(m_from); // 触发 itemChange 通知连线 } void redo() override { m_item-setPos(m_to); } private: NodeItem *m_item; QPointF m_from, m_to; };写的时候注意两点一是入栈时机放在mouseReleaseEvent之后不要在mouseMove中不断入栈否则Undo一次只能退一步本来一次拖动变成了上百步操作二是栈对象归主窗口统一持有。我在Qt 5.15.2的MSVC套件上实际跑过这套组合稳定性和内存占用都可接受。5.3 发布前三个检查先检查高DPI。在main函数最前面加QApplication::setAttribute(Qt::AA_EnableHighDpiScaling)否则交付到2K屏上视图区细节全是糊的。第二是viewport更新模式节点多的场景建议设置QGraphicsView::MinimalViewportUpdate拖动时只刷新变化区域避免整屏重绘掉帧。第三是部署用windeployqt跑一遍依赖别在客户机器上缺DLL才想起打包的事。这三件事我全部靠实际交付踩出来的。第一次发布对方2K屏打开工程截图发过来节点边缘全是锯齿我一看就是高DPI没开后来改完又发现拖动30个以上节点时帧率不对劲MinimalViewportUpdate一开立刻流畅。从那以后我每次交付前都强制把高DPI、viewport更新模式和部署三条过一遍。希望帮到你。本文还有配套的精品资源点击获取
返回列表