☰
Qt图形视图框架流程图编辑器:图元分层与正交连线实现
2026/10/26 2:05:45 网站建设 项目流程

简介:面向Qt/C++初学者与需要实现图形化流程编辑器的开发者,这套“Qt流程图绘制”示例围绕图元与视图两大核心,演示如何借助QGraphicsScene、QGraphicsView构建可交互的流程图画布,并覆盖图元创建、连线、缩放旋转及场景事件处理等常见需求。压缩包共36个文件、约47KB,包含9个cpp与8个h源码文件,另有12个png界面图标、1个qrc资源文件及1个pro工程文件,并保留少量bak备份,便于直接打开工程对照源码梳理模块关系。已有3999人学习下载,说明其作为入门级参考案例具有一定认可度。读者可从中获得可运行的工程骨架、图元与视图类的组织方式,以及鼠标交互、自动连线、保存打印等功能的实现线索,适合用作课程设计、毕业设计或Qt图形视图框架练手的基础模板。

1. 从一次“拖得动却连不上”的画布说起

有人在技术群里贴了段录屏:Qt 流程图绘制做出来的画布,拖一个矩形框很顺,一旦拉出第二根连线,节点一移动,线就飘到屏幕外面去了。翻了下他的代码,节点是直接 new 出来的QGraphicsRectItem,连线是QGraphicsLineItem,移动节点的时候在mouseMoveEvent里手动去改线的两个端点,中间少算了一层坐标变换,一缩放就彻底乱套。这个场景其实很典型——图形视图框架早就把这件事拆好了:图元管自己长什么样、包围盒多大、怎么被点中,视图管缩放平移和事件分发,场景负责把图元组织成一张能查能遍历的树。真要把画布做成编辑器,难点不在于会不会画矩形,而在于图元愿不愿意把端口位置和位置变化暴露出来,让连线去订阅。这套结构适合已经有 Qt 基础、想把画板做成流程图编辑器的人,也适合接手老项目里那块半成品画布的开发者。

2. 工程骨架与分层:视图、场景、图元三件套怎么摆

一个能撑住后续迭代的流程图工程,最怕所有东西都堆进MainWindow。我在拆这类项目时第一件事就是看目录——如果mainwindow.cpp超过八百行,里面既有绘制又有序列化还有撤销栈,那基本可以判定这项目没法二次开发了。合理的切法是按“谁负责什么”分,而不是按“谁调用了谁”分。

2.1 目录结构与模块边界

flowchart/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp │ ├── view/ │ │ ├── FlowView.h/.cpp # 视图:缩放、平移、右键菜单 │ │ └── GridBackground.h/.cpp # 网格背景绘制 │ ├── scene/ │ │ ├── FlowScene.h/.cpp # 场景:图元增删、连线登记 │ │ └── SceneSerializer.h/.cpp # JSON 读写 │ ├── items/ │ │ ├── FlowNodeItem.h/.cpp # 节点图元基类 │ │ ├── StartEndItem.h/.cpp # 起止节点(椭圆) │ │ ├── ProcessItem.h/.cpp # 处理节点(矩形) │ │ ├── DecisionItem.h/.cpp # 判断节点(菱形) │ │ └── FlowEdgeItem.h/.cpp # 连线图元 │ ├── commands/ │ │ ├── MoveNodeCommand.h/.cpp │ │ └── DeleteItemCommand.h/.cpp │ └── ui/ │ ├── MainWindow.h/.cpp │ └── NodePropertyDialog.h/.cpp └── resources/ └── icons.qrc

这个划分的关键约束是三条:视图层不碰业务数据,它只负责把鼠标滚轮翻译成缩放、把中键拖动翻译成滚动条位移;场景层不碰绘制,它只知道“有一个节点被加进来了”“这条边要跟着哪个节点走”;图元层不碰持久化,它不该知道 JSON 长什么样,也不该知道撤销栈的存在。一旦某天你想把流程图导出成 SVG,只需要在场景层加一个遍历函数,图元和视图一行都不用动。

items下面把三种节点拆成独立类,看起来有点啰嗦,但实际项目里节点的形状差异会越来越大——判断节点要画菱形,起止节点可能是圆角胶囊,后面还会加并行网关(带十字的矩形)、注释节点(带折角的方框)。如果全塞在一个FlowNodeItem里用switch分支,写到第五种类型时那个paint函数就会变成灾难。

2.2 三种基类的职责边界

Qt 提供三个层级的基类,选错了后面全是坑。我在选型时一般按这张表判断:

基类是否带信号槽是否支持属性动画典型用途
QGraphicsItem否否纯静态图形、性能敏感的大批量元素
QGraphicsObject是是(配合QPropertyAnimation)需要对外发信号的图元,比如节点
QGraphicsWidget是是需要布局管理器、需要嵌入子控件的图元

节点图元必须继承QGraphicsObject。原因很直接:连线需要知道节点什么时候移动了,最干净的写法是节点在itemChange里发一个nodeMoved信号,连线槽函数接住重算路径。如果用QGraphicsItem,就只能让场景在mouseMoveEvent里轮询所有节点位置,既丑又容易漏。

提示:QGraphicsObject自带QObject的开销,节点数量上千时内存会明显上升。真到那个量级,更常见的做法是节点退回QGraphicsItem,位置变化统一由场景在mouseReleaseEvent时批量通知连线更新。

连线图元反而适合继承QGraphicsPathItem,它本身就是一条路径,不需要对外发信号,只需要被动接收节点的移动通知。这是个很实用的不对称设计:主动方用QGraphicsObject,被动方用轻量的路径图元。

2.3 CMake 构建与最小可运行窗口

工程用 CMake 管理,Qt6 起 CMake 支持已经相当完整。这里的关键是AUTOMOC必须打开,否则带Q_OBJECT宏的类会链接失败,报一堆undefined reference to vtable。

cmake_minimum_required(VERSION 3.16) project(FlowChart LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 带 Q_OBJECT 的类必须由 moc 处理,这行漏了就是一堆 vtable 报错 set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) find_package(Qt6 REQUIRED COMPONENTS Widgets) add_executable(FlowChart src/main.cpp src/view/FlowView.cpp src/view/GridBackground.cpp src/scene/FlowScene.cpp src/scene/SceneSerializer.cpp src/items/FlowNodeItem.cpp src/items/ProcessItem.cpp src/items/DecisionItem.cpp src/items/StartEndItem.cpp src/items/FlowEdgeItem.cpp src/commands/MoveNodeCommand.cpp src/commands/DeleteItemCommand.cpp src/ui/MainWindow.cpp ) target_include_directories(FlowChart PRIVATE src) target_link_libraries(FlowChart PRIVATE Qt6::Widgets)

target_include_directories这一行别省,把src加进搜索路径后,头文件里就可以写#include "items/FlowNodeItem.h"而不是一堆../../,后面挪目录不至于全项目改 include。

入口保持极简,所有装配工作丢给MainWindow:

// src/main.cpp #include <QApplication> #include "ui/MainWindow.h" int main(int argc, char *argv[]) { QApplication app(argc, argv); MainWindow w; w.resize(1280, 800); w.show(); return app.exec(); }

MainWindow构造函数里做三件事:创建FlowScene、创建FlowView并setScene、把视图设为中心控件。视图上有几个属性是必设的,我在每个项目里都会检查一遍:

FlowView::FlowView(QWidget *parent) : QGraphicsView(parent) { setRenderHint(QPainter::Antialiasing, true); // 抗锯齿,否则菱形边缘全是毛刺 setDragMode(QGraphicsView::RubberBandDrag); // 空白处拖拽框选 setTransformationAnchor(QGraphicsView::AnchorUnderMouse); // 缩放锚点跟着鼠标 setResizeAnchor(QGraphicsView::AnchorViewCenter); setViewportUpdateMode(QGraphicsView::BoundingRectViewportUpdate); setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); }

BoundingRectViewportUpdate这个模式值得单独说一句。默认的MinimalViewportUpdate会精确计算脏区域,图元多的时候计算开销反而超过重绘本身;FullViewportUpdate又太粗暴,拖一个节点整屏重画。BoundingRectViewportUpdate取中间值,是流程图这类场景的常规选择。滚动条关掉是因为流程图通常配合中键拖动平移,留个滚动条反而占地方。

3. 图元实现:把一个矩形做成可复用、可连接的节点

图元这一层是整个项目里改动最频繁的地方。用户今天要圆角矩形,明天要虚线边框,后天要节点上带图标和状态灯。所以基类的设计目标不是“一次写全”,而是“留够扩展点,让新增节点类型只需要改两三个函数”。

3.1 节点基类 FlowNodeItem 的骨架

// src/items/FlowNodeItem.h #pragma once #include <QGraphicsObject> class FlowNodeItem : public QGraphicsObject { Q_OBJECT public: enum NodeType { StartEnd = 0, Process, Decision }; // 自定义 type 值,qgraphicsitem_cast 靠它做类型识别 enum { Type = UserType + 1 }; int type() const override { return Type; } explicit FlowNodeItem(NodeType t, QGraphicsItem *parent = nullptr); QRectF boundingRect() const override; QPainterPath shape() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; NodeType nodeType() const { return m_type; } void setNodeText(const QString &text); QString nodeText() const { return m_text; } void setNodeSize(const QSizeF &size); // 端口索引:0 上、1 右、2 下、3 左 QPointF portPos(int index) const; QPointF portDir(int index) const; static constexpr int PortCount = 4; signals: void nodeMoved(FlowNodeItem *self); protected: QVariant itemChange(GraphicsItemChange change, const QVariant &value) override; void hoverEnterEvent(QGraphicsSceneHoverEvent *event) override; void hoverLeaveEvent(QGraphicsSceneHoverEvent *event) override; private: QPolygonF buildPolygon() const; NodeType m_type; QString m_text; QSizeF m_size{140.0, 64.0}; bool m_hovered = false; };

enum { Type = UserType + 1 }这一行经常被忽略。qgraphicsitem_cast<T*>不像dynamic_cast那样查 RTTI,它比对的是type()返回值,所以在场景里做类型筛选时,速度快很多:

for (QGraphicsItem *item : scene->items()) { if (auto *node = qgraphicsitem_cast<FlowNodeItem *>(item)) { // 只处理节点,连线自动被过滤掉 } }

如果所有自定义图元都用默认的Type值,这个 cast 会把连线也当成节点返回,序列化的时候就会写出几百条空记录,这是典型的“玄学 bug”——代码看着没错,导出的 JSON 就是不对。

构造函数里还要设置几个 flag,这几行决定了节点能不能被拖动、能不能被选中、能不能收到悬停事件:

FlowNodeItem::FlowNodeItem(NodeType t, QGraphicsItem *parent) : QGraphicsObject(parent), m_type(t) { setFlag(QGraphicsItem::ItemIsMovable, true); setFlag(QGraphicsItem::ItemIsSelectable, true); setFlag(QGraphicsItem::ItemSendsGeometryChanges, true); // 不发这个,itemChange 收不到位置变化 setAcceptHoverEvents(true); setZValue(1); // 节点压在连线之上 setCursor(Qt::SizeAllCursor); }

ItemSendsGeometryChanges是最容易被漏掉的一个。漏了之后现象是:节点能拖,连线不动,加日志发现itemChange压根没被调用。这个 flag 是位置变化通知的总开关,漏了它,后面nodeMoved信号永远不会发出来。

3.2 boundingRect 与 shape 的取舍

这两个函数的关系,我见过太多人搞混。boundingRect是“我要占多大一块画布”,shape是“鼠标点在哪儿算点中我”。前者影响重绘和碰撞检测的性能,后者影响交互手感。

QRectF FlowNodeItem::boundingRect() const { // 外扩 8px:给选中描边和阴影留位置,不外扩会拖出残影 const qreal pad = 8.0; return QRectF(-m_size.width() / 2 - pad, -m_size.height() / 2 - pad, m_size.width() + pad * 2, m_size.height() + pad * 2); } QPainterPath FlowNodeItem::shape() const { QPainterPath path; const QRectF body(-m_size.width() / 2, -m_size.height() / 2, m_size.width(), m_size.height()); switch (m_type) { case Decision: path.addPolygon(buildPolygon()); // 菱形命中要跟着菱形走 break; case StartEnd: path.addEllipse(body); // 椭圆同理 break; default: path.addRoundedRect(body, 6, 6); break; } return path; }

boundingRect必须满足三个条件:包含shape的全部范围、包含paint里画的所有东西(包括选中虚线)、在尺寸变化时能被正确通知。第三条是最容易翻车的——如果节点支持双击编辑文本后改变宽高,必须在改m_size之前调用prepareGeometryChange():

void FlowNodeItem::setNodeSize(const QSizeF &size) { if (qFuzzyCompare(size.width(), m_size.width()) && qFuzzyCompare(size.height(), m_size.height())) return; prepareGeometryChange(); // 必须在改尺寸之前调用,否则旧区域不刷新 m_size = size; update(); }

漏掉prepareGeometryChange()的现象非常典型:节点变大之后,旧位置留下一块灰色矩形残影,拖一下画布才消失。这个函数的作用是告诉场景“我的包围盒要变了,把旧的脏区域也标记上”。

shape那边则要注意别偷懒直接返回boundingRect转出来的路径。判断节点是菱形,如果shape用外接矩形,用户点在菱形四个角的空白处也能选中它,两个节点挨得近的时候会互相抢点击,体验很糟。用QPainterPath精确描述轮廓,代价是每次命中检测多算一条路径,但这个开销在同屏几百个节点的量级上完全可以接受。

3.3 paint 里的选中态、悬停态与端口

paint函数的参数里,option->state是唯一可靠的选中状态来源。不要去查isSelected()之外的东西,也不要在paint里改图元状态,那是纯函数式的绘制过程。

void FlowNodeItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *) { painter->setRenderHint(QPainter::Antialiasing, true); const bool selected = option->state & QStyle::State_Selected; const QPainterPath outline = shape(); // 填充与描边:悬停浅蓝,选中加粗蓝边 QColor border = selected ? QColor("#1a73e8") : QColor("#4a4a4a"); QColor fill = m_hovered ? QColor("#eef4ff") : QColor("#ffffff"); painter->setPen(QPen(border, selected ? 2.0 : 1.2)); painter->setBrush(fill); painter->drawPath(outline); // 节点文字,自动换行居中 QFont font = painter->font(); font.setPointSizeF(10.5); painter->setFont(font); painter->setPen(QColor("#202124")); painter->drawText(QRectF(-m_size.width() / 2 + 8, -m_size.height() / 2 + 6, m_size.width() - 16, m_size.height() - 12), Qt::AlignCenter | Qt::TextWordWrap, m_text); // 悬停时把四个端口画出来,给用户接线的视觉反馈 if (m_hovered) { painter->setPen(QPen(QColor("#1a73e8"), 1.0)); painter->setBrush(QColor("#ffffff")); for (int i = 0; i < PortCount; ++i) { const QPointF p = mapFromScene(portPos(i)); painter->drawEllipse(p, 4.0, 4.0); } } }

painter->setRenderHint(QPainter::Antialiasing, true)这行放在paint内部而不是只在视图上设一次,是因为场景可以同时挂多个视图,某些导出场景(比如QGraphicsScene::render到QImage)不会继承视图的渲染设置。多写这一行,导出图片时菱形边缘就不会是锯齿状。

端口绘制里用了mapFromScene(portPos(i)),注意这是有意的反向转换:portPos返回的是场景坐标,因为连线需要场景坐标;但paint工作在局部坐标里,所以要转回来。这种坐标来回转换是流程图项目里最容易写错的地方,后面第 4 章会专门列一张对照表。

3.4 端口计算与 itemChange 的位置约束

端口位置必须在场景坐标系里返回,这是连线能正确绘制的前提:

QPointF FlowNodeItem::portPos(int index) const { const qreal hw = m_size.width() / 2; const qreal hh = m_size.height() / 2; QPointF local; switch (index) { case 0: local = QPointF(0, -hh); break; // 上 case 1: local = QPointF(hw, 0); break; // 右 case 2: local = QPointF(0, hh); break; // 下 default:local = QPointF(-hw, 0); break; // 左 } return mapToScene(local); // 局部 -> 场景,连线只认场景坐标 } QPointF FlowNodeItem::portDir(int index) const { switch (index) { case 0: return QPointF(0, -1); case 1: return QPointF(1, 0); case 2: return QPointF(0, 1); default:return QPointF(-1, 0); } }

portDir返回的是出线方向单位向量,正交折线算法靠它决定第一段往哪拐。这两个函数是节点对外暴露的全部接口,连线只要拿到端口位置和方向就能算路径,完全不需要知道节点是矩形还是菱形。

itemChange里同时处理两件事:位置吸附和变化通知。

QVariant FlowNodeItem::itemChange(GraphicsItemChange change, const QVariant &value) { if (change == ItemPositionChange && scene()) { // 位置即将变化:吸附到 10px 网格 QPointF pos = value.toPointF(); const qreal grid = 10.0; pos.setX(qRound(pos.x() / grid) * grid); pos.setY(qRound(pos.y() / grid) * grid); return pos; } if (change == ItemPositionHasChanged) { emit nodeMoved(this); // 位置已变,通知连线重算 } return QGraphicsObject::itemChange(change, value); }

ItemPositionChange是“将要变”,返回值会作为最终位置;ItemPositionHasChanged是“已经变完”,只能发通知不能改值。这两个回调的顺序是固定的,用错了就白改。血泪经验是:千万不要在itemChange里再调setPos(),会触发递归,轻则卡死重则栈溢出,调试器里看到的是一串无穷无尽的itemChange调用。

注意:网格吸附的粒度建议做成可配置项而不是写死。有些用户要 5px 精调,有些要 20px 快速对齐,写死的值最后一定会被要求改。

4. 连线与场景交互:正交折线、跟随更新和坐标换算

节点画得再漂亮,连线一歪整个图就没法看了。流程图连线的行业习惯是正交折线,不是直线也不是曲线——直线在节点密集时会穿过其他节点,贝塞尔曲线在流程图中显得不专业。这一章把路径算法、跟随机制和视图交互拆开讲。

4.1 正交折线连线的路径计算

连线图元继承QGraphicsPathItem,构造函数里完成三件事:压到节点下面、开启选中、订阅两端的移动信号。

// src/items/FlowEdgeItem.cpp FlowEdgeItem::FlowEdgeItem(FlowNodeItem *from, int fromPort, FlowNodeItem *to, int toPort) : m_from(from), m_fromPort(fromPort) , m_to(to), m_toPort(toPort) { setZValue(-1); // 连线压在节点下方 setFlag(QGraphicsItem::ItemIsSelectable, true); setPen(QPen(QColor("#5f6368"), 1.5)); // 两端任一移动都触发重算 connect(m_from, &FlowNodeItem::nodeMoved, this, &FlowEdgeItem::updatePath); connect(m_to, &FlowNodeItem::nodeMoved, this, &FlowEdgeItem::updatePath); updatePath(); }

路径算法本身,第一版不需要追求完美,先保证八成的场景正确:

void FlowEdgeItem::updatePath() { if (!m_from || !m_to) return; const QPointF start = m_from->portPos(m_fromPort); const QPointF end = m_to->portPos(m_toPort); const QPointF dirS = m_from->portDir(m_fromPort); const QPointF dirE = m_to->portDir(m_toPort); const qreal stub = 26.0; // 出线后的第一段长度 const QPointF a = start + dirS * stub; const QPointF b = end + dirE * stub; QPainterPath path(start); path.lineTo(a); const bool sameVertical = qFuzzyIsNull(dirS.x()) && qFuzzyIsNull(dirE.x()); const bool sameHorizontal = qFuzzyIsNull(dirS.y()) && qFuzzyIsNull(dirE.y()); if (sameVertical) { // 上下端口互连:走 Z 字,中间横切 const qreal midY = (a.y() + b.y()) / 2.0; path.lineTo(QPointF(a.x(), midY)); path.lineTo(QPointF(b.x(), midY)); } else if (sameHorizontal) { // 左右端口互连:走横 Z 字 const qreal midX = (a.x() + b.x()) / 2.0; path.lineTo(QPointF(midX, a.y())); path.lineTo(QPointF(midX, b.y())); } else { // 一横一竖:先横后竖拐一次 if (qFuzzyIsNull(dirS.x())) path.lineTo(QPointF(a.x(), b.y())); else path.lineTo(QPointF(b.x(), a.y())); } path.lineTo(b); path.lineTo(end); setPath(path); update(); }

参数说明:stub = 26.0决定连线离开节点后直行多远再拐弯。太小(比如 8)会导致连线贴着节点边缘走,视觉上像是"粘"在节点上;太大(比如 60)会让小图里的连线绕出很远的空路。26 是 140×64 节点尺寸下的经验值,如果节点尺寸改动,这个值最好跟着按比例调。

qFuzzyIsNull而不是== 0是因为浮点比较,端口方向向量由整数构造出来,理论上精确,但一旦有人把节点做了旋转,portDir就会返回浮点值,用==比较会漏判。

箭头单独在paint里画,不放进路径里:

void FlowEdgeItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *) { painter->setRenderHint(QPainter::Antialiasing, true); painter->setPen(pen()); painter->setBrush(Qt::NoBrush); painter->drawPath(path()); // 箭头永远贴着终点,方向由入线方向决定 const QPointF end = m_to->portPos(m_toPort); const QPointF dirE = m_to->portDir(m_toPort); const QPointF back = -dirE; // 指向节点外部的反方向 const QPointF perp(-back.y(), back.x()); // 垂直向量 const qreal len = 12.0, halfW = 5.0; QPolygonF arrow; arrow << end << end + back * len + perp * halfW << end + back * len - perp * halfW; painter->setBrush(option->state & QStyle::State_Selected ? QColor("#1a73e8") : QColor("#5f6368")); painter->drawPolygon(arrow); }

箭头不塞进QPainterPath的原因是:路径带setPen会描边,箭头是实心三角形,塞进去后会画出一圈轮廓线,看起来像空心箭头。分开画能精确控制填充色和描边。

4.2 节点移动时连线的跟随与生命周期

跟随机制靠信号槽完成,但只有信号还不够,得管住连线的生命周期。节点被删除时,挂在它上面的连线必须一起删,否则场景里会残留一堆指向野指针的路径图元,程序在下次移动节点时崩溃——崩溃点还不在删除的地方,排查起来相当费劲。

我在场景层统一管这件事:

// src/scene/FlowScene.cpp void FlowScene::removeNode(FlowNodeItem *node) { if (!node) return; // 先摘掉所有以该节点为端点的连线 const auto edges = m_edges; // 拷贝一份,循环中会修改原容器 for (FlowEdgeItem *edge : edges) { if (edge->fromNode() == node || edge->toNode() == node) { m_edges.removeOne(edge); removeItem(edge); delete edge; } } removeItem(node); delete node; }

m_edges是场景维护的连线列表,加连线时同步插入。这个列表看起来和scene->items()有冗余,但实际很值:删除节点时不用遍历整个场景做类型筛选,序列化时也直接遍历这个列表拿端点 ID,比从items()里反推快得多。

还有一种情况是节点被移动时连线要跟着,但节点自己不知道有哪些连线。这里的方向不能反——不能让节点持有连线指针列表。原因很简单:一个节点可能连着十几条线,节点类里维护这个列表会让序列化和复制粘贴逻辑变得极其复杂。正确的依赖方向是连线持有节点指针,节点只发信号,谁也不认识谁。

4.3 视图层的缩放、平移与框选

视图这一层的核心原则是:不要直接改变换矩阵,让 Qt 的滚动条机制去处理平移。用translate()平移视图会导致坐标系原点漂移,后面mapToScene算出来的值全部偏移一个常量,缩放时还会放大这个误差。

// src/view/FlowView.cpp void FlowView::wheelEvent(QWheelEvent *event) { // Ctrl + 滚轮才缩放,普通滚轮留给滚动 if (event->modifiers() & Qt::ControlModifier) { const qreal factor = event->angleDelta().y() > 0 ? 1.15 : 1.0 / 1.15; const qreal next = m_scale * factor; // 限制在 0.2 ~ 5.0 之间,超出范围图形会失真 if (next > 0.2 && next < 5.0) { scale(factor, factor); m_scale = next; } event->accept(); return; } QGraphicsView::wheelEvent(event); } void FlowView::mousePressEvent(QMouseEvent *event) { if (event->button() == Qt::MiddleButton) { m_panning = true; m_lastPanPos = event->position().toPoint(); setCursor(Qt::ClosedHandCursor); event->accept(); return; } QGraphicsView::mousePressEvent(event); } void FlowView::mouseMoveEvent(QMouseEvent *event) { if (m_panning) { const QPoint delta = event->position().toPoint() - m_lastPanPos; m_lastPanPos = event->position().toPoint(); horizontalScrollBar()->setValue(horizontalScrollBar()->value() - delta.x()); verticalScrollBar()->setValue(verticalScrollBar()->value() - delta.y()); event->accept(); return; } QGraphicsView::mouseMoveEvent(event); }

event->position()是 Qt6 的写法,Qt5 里是event->pos()。这两者在高分屏上行为不同,position()返回QPointF精度更高,缩放比例非整数时不会丢像素。

框选靠setDragMode(QGraphicsView::RubberBandDrag)一行搞定,但有个细节要注意:这个模式下,鼠标在图元上拖是移动图元,在空白处拖才是框选。如果用户反馈"框选不灵",先看他是不是点在网格背景上了——如果背景是一个铺满全场景的QGraphicsRectItem,那永远不会进入框选状态。网格正确做法是用drawBackground画,不生成图元。

4.4 场景坐标系与坐标映射对照

坐标转换是流程图项目里翻车率最高的部分,我整理了一张常用对照表:

场景正确写法常见错写
图元局部 → 场景item->mapToScene(localPoint)直接加item->pos(),忽略旋转和缩放
场景 → 图元局部item->mapFromScene(scenePoint)用scenePos() - pos(),父子嵌套时错
视图 → 场景view->mapToScene(viewPos)用view->transform().inverted()手算
场景 → 视图view->mapFromScene(scenePoint)忘了视图自身有scroll偏移
鼠标事件位置 → 场景event->scenePos()用event->pos(),那是视图坐标

最后一行尤其关键。QGraphicsSceneMouseEvent::scenePos()已经帮你把视图映射做完了,直接用就行。但如果是重写QGraphicsView::mousePressEvent,拿到的QMouseEvent只有视图坐标,此时必须先mapToScene(event->position().toPoint())才能和图元坐标对齐。

在场景里新增图元时还有一个隐含坑:addItem之后图元位置是(0, 0),也就是场景原点。如果用户是在鼠标右键的位置新建节点,必须显式setPos(scenePos),否则节点全堆在左上角,用户以为没创建成功,反复点击,最后叠了几十个。

5. 常见问题排查:图元、连线、视图上的六个坑

这一章是我在实际项目里攒下来的问题记录,按出现频率排序。每个都给出可复现的现象和具体改法,遇到类似症状可以直接对照。

5.1 图元与绘制类问题

现象一:拖动节点时身后拖出一条灰色残影,松手才消失。

原因几乎都是boundingRect()返回的区域小于实际绘制范围。选中态描边比正常描边粗 0.8px,这 0.8px 超出了包围盒,重绘时只刷新了包围盒内部,超出的那圈就留在屏幕上。如果节点还画了阴影或者悬停光圈,超出得更多。

解决分两步:先把boundingRect外扩一圈 padding,值取最大描边宽度的一半再加 2;再检查所有改变尺寸的路径是否都调了prepareGeometryChange()。第二点常被漏掉——文本编辑后节点自动撑宽这种场景,如果不调,包围盒还是旧的,残影照样出现。

现象二:菱形判断节点的四个角点不中,但中间能点中。

shape()没有重写,用了基类默认实现(返回boundingRect转的矩形路径)。菱形外接矩形的四角在菱形外面,视觉上没有图形,逻辑上却能命中。

解决是重写shape()返回精确的QPainterPath。对于多边形类节点,把顶点数组复用给paint和shape两个函数,避免两处各写一遍顶点坐标导致不一致——这种不一致造成的 bug 极其难查,画出来是菱形,点起来是六边形。

现象三:节点上的文字在缩放后糊成一团。

paint里给字体设了固定的pointSizeF,但视图放大 3 倍后,图元还是按原始像素密度绘制文字,被拉伸后自然糊。

解决是在paint里根据当前的 LOD(level of detail)调整,或者更简单:让文字始终按场景坐标绘制,QGraphicsView在缩放时会自动处理。如果一定要保持清晰,可以从option->levelOfDetailFromTransform(painter->worldTransform())拿到缩放系数,低于某个阈值时干脆不画文字,只画色块,这个策略在大图浏览时效果很明显。

5.2 交互与坐标类问题

现象四:缩放之后连线全部偏移,偏移量随缩放比例变化。

根因是连线端点混用了坐标系。常见写法是在paint里用m_from->pos()加上一个局部偏移当起点——pos()是父坐标系下的位置,一旦节点有父图元或者场景做过变换,这个值就不等于场景坐标。缩放比例变化时,误差被同步放大,看起来就是"越缩越歪"。

解决是统一在场景坐标系里算路径:portPos()返回mapToScene的结果,buildOrthogonalPath()全程用场景坐标,最后setPath()之前确保连线图元自身的pos()是(0, 0)。如果连线有父图元,路径要相应减掉父的偏移,但更推荐的做法是让连线直接挂在场景根下,不设父图元。

现象五:中键拖动平移,拖到边界后视图跳回原点。

这个是我踩过的坑。用translate()做平移时,视图的变换矩阵会累积偏移,但滚动条范围没跟着更新,拖到一定程度滚动条钳制生效,视图就被"拉回"了。

解决是改滚动条而不是改变换矩阵,也就是 4.3 节那段代码。滚动条的值域由sceneRect决定,场景范围设得足够大(比如所有图元包围盒外扩 2000px),就不会撞到边界。sceneRect如果留空,Qt 会根据图元包围盒自动算,但这个值是动态的,图元一移动就可能变,导致平移手感飘忽,所以我在场景初始化时显式设置一个足够大的固定sceneRect。

5.3 序列化与性能类问题

现象六:保存后重新打开,节点都在,连线全没了。

序列化时用图元指针值当 ID 存进 JSON,反序列化时指针值完全对不上,连线找不到端点就被丢弃了。指针值不稳定是必然的——每次运行的堆地址都不同。

解决是给每个节点分配一个稳定的 UUID,在创建时生成并存进图元成员,序列化和反序列化都以它为准:

// 序列化:先写全部节点,再写全部连线 QJsonArray nodesArr; QHash<FlowNodeItem *, QString> idMap; for (FlowNodeItem *node : m_nodes) { QJsonObject obj; obj["id"] = node->uuid(); // QUuid::createUuid().toString() obj["type"] = int(node->nodeType()); obj["x"] = node->pos().x(); obj["y"] = node->pos().y(); obj["w"] = node->nodeSize().width(); obj["h"] = node->nodeSize().height(); obj["text"] = node->nodeText(); nodesArr.append(obj); idMap.insert(node, node->uuid()); } QJsonArray edgesArr; for (FlowEdgeItem *edge : m_edges) { QJsonObject obj; obj["fromId"] = idMap.value(edge->fromNode()); obj["fromPort"] = edge->fromPort(); obj["toId"] = idMap.value(edge->toNode()); obj["toPort"] = edge->toPort(); edgesArr.append(obj); }

顺序必须是先节点后连线,反序列化时先建一张uuid -> 节点指针的哈希表,再遍历连线数组去查表。反过来做,连线那一步查不到任何节点。

至于性能,几个节点时怎么都流畅,超过两百个之后问题就来了。最常见的瓶颈是每次节点移动都触发全场景的update()。改法是:连线的updatePath()里只调update()更新自身脏区,不要调scene()->update();节点位置变化用ItemPositionChange做网格吸附,避免亚像素级的连续重绘;背景网格用drawBackground画并且只在必要时刷新,不要做成图元。

6. 二次开发:从“能画”到“能自动排版”的改造入口

项目跑起来之后,绝大多数人的下一步都是加功能。这里给几个我实际改过的方向,按改动量从小到大排。

第一个是加自定义节点类型。新增一个继承FlowNodeItem的类,重写buildPolygon()和paint(),再在场景的工厂函数里加一个case分支就行。改造量在一个文件里,不动连线、不动序列化——只要新节点的nodeType()值不跟已有类型冲突,JSON 里就会多出一个新类型值,旧版本读到会当作普通矩形渲染,向后兼容。

第二个是自动

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询