资讯动态

Qt图形视图框架流程图编辑器:图元分层与正交连线实现

发布时间:2026/10/26 2:05:46 来源:尧图企业网站定制
简介面向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_castT*不像dynamic_cast那样查 RTTI它比对的是type()返回值所以在场景里做类型筛选时速度快很多for (QGraphicsItem *item : scene-items()) { if (auto *node qgraphicsitem_castFlowNodeItem *(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里根据当前的 LODlevel 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; QHashFlowNodeItem *, 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 里就会多出一个新类型值旧版本读到会当作普通矩形渲染向后兼容。第二个是自动本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑