做这种可拖拽箭头连接的需求,我猜你一定是在做流程图编辑器、拓扑图工具、节点组态软件或者类似的“画布+节点+连线”交互。最早我拿到这个需求时,第一反应是直接用 QPainter 重绘整个画布,把所有节点和连线都画在一个 widget 上。但实际写到一半就发现不行——节点一多,手动处理点击命中、框选、拖拽、刷新区域、叠加层级这些事情,代码会膨胀得没法维护。后来切到 Qt 的 Graphics View 框架,用 QGraphicsItem 来实现箭头连接,才真正把复杂交互拆解开了。
这篇文章我把完整的实现思路、核心代码、以及调试过程里踩过的坑都整理出来。适合两类人看:一类是刚开始接触 Qt 绘图,想找一个能跑的参考工程;另一类是已经在用 QGraphicsScene/QGraphicsView,但一直搞不定连线交互细节的人。内容偏实战,理论只讲够用的部分,你可以直接抄代码,也可以先看懂设计再按自己的场景改。
1. 整体设计思路:为什么是 QGraphicsItem,而不是自绘
1.1 场景、视图、图元三层结构
Qt 的 Graphics View 框架把图形世界拆成了三个角色:QGraphicsScene 负责管理所有图元并维护它们之间的空间关系,QGraphicsView 负责把场景内容渲染到屏幕上并处理滚动和缩放,而 QGraphicsItem 是每个独立元素的基类,节点、连线、锚点都可以做成一种 item。
打个比方来说,Scene 就像一块无限大的白板,Item 是贴在白板上的便利贴,View 则是你戴着的一副眼镜。你移动的是便利贴,看便利贴的是眼镜,而白板自己只负责记录便利贴之间有没有重叠、谁在谁上面。这个分层最大的好处是所有和空间相关的计算都不用你自己写了,比如点击测试、碰撞检测、区域查找、框选,框架都提供现成接口。
箭头连接就是若干个 Item 之间的协作:两个节点是 item,节点上的锚点是小 item,连线本身也是 item。连线监听鼠标事件,拖拽时动态更新自己的端点坐标,释放时判断是否落到了目标锚点附近,是就吸附上去。
1.2 拖拽箭头的功能拆解
“可拖拽箭头连接”听起来像一个功能,实际拆开是 7 个独立的小问题:箭头线怎么画、箭头的尖端怎么算、用户怎么知道可以拖、按下鼠标后拖的是哪一端、拖拽中线条怎么实时跟手、松手后怎么识别目标锚点、以及连接关系怎么保存下来。每一个问题都对应独立的代码模块,如果上来就混在一个类里写,后面每改一个功能都会牵连其他地方。
我的建议是先把需求拆成这样:
| 子功能 | 对应实现 |
|---|---|
| 箭头外观 | ArrowItem 的 paint() 画主线、尖端、起点圆点 |
| 命中判定 | shape() 返回加宽的路径,方便鼠标点中细线 |
| 拖拽端点 | 鼠标 hitTest 命中起点端或终点端,按下后记录命中区域 |
| 动态跟手 | mouseMoveEvent 中更新端点坐标并触发重绘 |
| 锚点吸附 | 拖拽释放时查找场景范围内的 PortItem,修正端点位置 |
| 连接持久化 | 记录起点和终点的锚点ID,序列化为 JSON |
按这个列表去写代码,思路会清晰很多。
1.3 为什么不用 QPainter 自绘
如果整个画布只有一条静态的线,那你随便画都行。但只要业务里有“多个节点 + 多条连线 + 拖拽交互”的组合,自绘方案就必须自己解决一系列问题:鼠标按下的坐标要判断命中了哪条线,需要手动遍历所有图形做几何运算;节点拖拽后要刷新整个画布;框选要自己维护选区;图元多了还要做脏矩形裁剪优化。这些工作 Graphics View 框架已经内置了,QGraphicsItem 的 boundingRect、shape、paint 三个虚函数就是给引擎提供必要信息,剩下的命中检测、刷新调度、层级排序全部自动完成。
还有一个折中方案是用 QGraphicsProxyWidget 把自定义 QWidget 嵌进场景,但它本质上是把一张“控件画布”塞进场景,性能开销大,适合做复杂控件嵌入,不适合做大量轻量级图形。对于箭头连线这种高频绘制的场景,直接继承 QGraphicsItem 是性能与灵活性最平衡的选择。
2. 箭头图元:绘制、尖端计算与命中区域
2.1 箭头图元选型
实现箭头可以用 QGraphicsLineItem,也可以直接继承 QGraphicsItem。QGraphicsLineItem 自带一条直线的坐标和管理逻辑,改起来省事,但是有两个限制:一是它的 shape() 只认这条线的路径,要扩展命中区域必须自己重写;二是它把线条本身和 item 的位置混在一起,后续要扩展成曲线或折线就得推翻重来。我的实际选择是直接继承 QGraphicsItem,自己维护 m_start 和 m_end 两个 QPointF 成员,paint() 里用 QPainter 画。多写几行代码,但后面扩展空间大,而且坐标模型全部由自己掌控,不会踩到框架内部的隐式约定。
下面这个 ArrowItem 的核心声明是这样的:
#ifndef ARROWITEM_H #define ARROWITEM_H #include <QGraphicsItem> #include <QPainterPath> class ArrowItem : public QGraphicsItem { public: enum HitArea { None, StartArea, EndArea, MiddleArea }; explicit ArrowItem(const QPointF &start, const QPointF &end, QGraphicsItem *parent = nullptr); QRectF boundingRect() const override; QPainterPath shape() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; void setStartPoint(const QPointF &p); void setEndPoint(const QPointF &p); QPointF startPoint() const { return m_start; } QPointF endPoint() const { return m_end; } HitArea hitTest(const QPointF &pos) const; protected: void hoverMoveEvent(QGraphicsSceneHoverEvent *event) override; void mousePressEvent(QGraphicsSceneMouseEvent *event) override; void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override; private: void updateArrow(); QPointF arrowLeft() const; QPointF arrowRight() const; QPointF m_start; QPointF m_end; HitArea m_hoverArea = None; HitArea m_pressArea = None; qreal m_strokeWidth = 2.0; qreal m_arrowLength = 12.0; qreal m_arrowAngle = M_PI / 7.0; // 约 25.7 度,视觉上比较锐利 }; #endif // ARROWITEM_H构造函数中要设置几个关键属性,否则交互事件根本不会进入这个 item:
ArrowItem::ArrowItem(const QPointF &start, const QPointF &end, QGraphicsItem *parent) : QGraphicsItem(parent), m_start(start), m_end(end) { setFlag(ItemIsSelectable); setAcceptedMouseButtons(Qt::LeftButton); setAcceptHoverEvents(true); setZValue(1.0); }ItemIsSelectable 让线条可以被点击选中,配合视图之后可以响应删除操作;setAcceptHoverEvents(true) 是必须开的,否则 hoverMoveEvent 不会被触发,鼠标悬停光标变化就无从谈起。
2.2 箭头尖端计算原理
箭头尖端的计算是这类绘图需求最容易出错的地方。很多人会去手工判断八个方向,或者在角度边界加一堆 if 分支,其实完全没必要。给定一条从起点指向终点的向量,只需要用 atan2 求出方向角,然后向两侧各偏转一个固定角度,再乘上箭头长度,就得到了两个翼点的坐标。
具体公式是这样的:
QPointF ArrowItem::arrowLeft() const { QLineF line(m_start, m_end); qreal angle = line.angle(); qreal rad = qDegreesToRadians(angle + 30.0); return QPointF(m_end.x() - m_arrowLength * qCos(rad), m_end.y() - m_arrowLength * qSin(rad)); } QPointF ArrowItem::arrowRight() const { QLineF line(m_start, m_end); qreal angle = line.angle(); qreal rad = qDegreesToRadians(angle - 30.0); return QPointF(m_end.x() - m_arrowLength * qCos(rad), m_end.y() - m_arrowLength * qSin(rad)); }这里我直接用 QLineF::angle() 得到的是“从正 x 轴逆时针偏转的角度”,所以翼点的坐标是终点坐标减去沿偏转方向的偏移。为什么用 QLineF 而不自己 atan2?因为 QLineF 已经封装好了角度计算和长度计算,代码更短,不容易错。
箭头角度取 30 度是我试过多种角度后觉得视觉最舒服的。角度太大,箭头看起来像一把打开的剪刀,显得笨重;角度太小,尖端太细长,线条稍微一变就不容易看清方向。箭头长度 12 像素在 2 像素线宽的默认比例下刚好,如果你把线宽调整到 4 像素以上,建议把箭头长度加大到 16 到 18 像素,否则头部和线身比例失衡。
2.3 命中区域与热区处理
线条本身太细了,只有 2 像素,用户很难直接用鼠标精确点击到。QGraphicsItem 默认的命中检测依赖 shape() 的返回值,而 QGraphicsLineItem 默认 shape() 只包含这根线的路径,意味着鼠标要精确点在线段本身上才能选中。所以必须重写 shape(),返回一个加宽的路径。
用 QPainterPathStroker 可以很干净地解决这个问题:
QPainterPath ArrowItem::shape() const { QPainterPath path; path.moveTo(m_start); path.lineTo(m_end); path.lineTo(arrowLeft()); path.moveTo(m_end); path.lineTo(arrowRight()); path.moveTo(m_start); QPainterPathStroker stroker; stroker.setWidth(m_strokeWidth + 8.0); return stroker.createStroke(path); }加宽 8 像素的意思是,实际你点击线的命中范围比视觉宽度多了大约 4 像素的余量,这个数值在普通桌面端缩放比例下足够友好,又不会导致两个相近箭头之间误触频繁。
同时我还要在 hitTest 里区分用户点击的是起点端、终点端还是中间的线段,因为拖拽的行为不同:拖端点只改变那一端的坐标,拖中间则是整体移动整条线。判断逻辑很简单,计算点击位置到两个端点的距离,小于阈值就命中端点热区,否则再看是否落在加宽路径内。
ArrowItem::HitArea ArrowItem::hitTest(const QPointF &pos) const { const qreal hotRadius = 10.0; if (QLineF(pos, m_start).length() <= hotRadius) return StartArea; if (QLineF(pos, m_end).length() <= hotRadius) return EndArea; if (shape().contains(pos)) return MiddleArea; return None; }端点优先于中线判断,原因很实际:如果端点恰好挨着线段,用户明明是想拖拽端点,系统却判断为拖拽中线,交互就变得很别扭。热区半径 10 像素是我试过的平衡点,太小不好点,太大在锚点密集时容易误触。
3. 拖拽交互:事件处理与动态更新
3.1 从 hover 到 press 的状态流转
一条可拖拽的线,用户体验的基础是先能看到“这块能拖”的提示,然后按下、拖动、释放,每一步都有明确的状态。我先在 hoverMoveEvent 中根据命中区域切换光标,让用户提前知道自己能操作什么:
void ArrowItem::hoverMoveEvent(QGraphicsSceneHoverEvent *event) { m_hoverArea = hitTest(event->pos()); switch (m_hoverArea) { case StartArea: case EndArea: setCursor(Qt::CrossCursor); break; case MiddleArea: setCursor(Qt::OpenHandCursor); break; default: unsetCursor(); break; } QGraphicsItem::hoverMoveEvent(event); }hitTest 中的 pos 是图元局部坐标,因为 ArrowItem 自身没有移动和旋转,局部坐标直接和场景坐标对齐,所以这里可以直接用。如果以后你在 item 上调用了 setPos 或者 setRotation,就必须用 mapToScene 转换之后再判断了。
按下事件里要记录两个关键信息:按下时命中的热区,以及按下的场景坐标偏移量。前者决定拖拽时改变哪个端点,后者可以在整体移动时避免线条“跳一下”:
void ArrowItem::mousePressEvent(QGraphicsSceneMouseEvent *event) { if (event->button() != Qt::LeftButton) { QGraphicsItem::mousePressEvent(event); return; } m_pressArea = hitTest(event->pos()); m_pressScenePos = event->scenePos(); if (m_pressArea == MiddleArea) setCursor(Qt::ClosedHandCursor); event->accept(); }注意我在这里显式调用了 event->accept()。如果不调,QGraphicsView 可能会把事件继续传播给下层图元,导致一次点击引发了多个图元的行为。
普通多节点编辑器里,你不需要为每一次 press 都去记录“原位置”并手动物理移动,因为 item 的 ItemIsMovable 标志能自动处理移动。但 ArrowItem 的拖拽是“改端点坐标”而不是“移动 item 本身”,所以不能依赖框架的移动逻辑,必须自己在 mouseMoveEvent 里更新数据。
3.2 mouseMoveEvent 的动态线条更新
拖拽过程中的每一帧,都要根据鼠标当前坐标重新计算端点位置,并让场景重绘箭头。这段逻辑是整个交互流畅与否的关键:
void ArrowItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) { if (m_pressArea == None) { QGraphicsItem::mouseMoveEvent(event); return; } QPointF scenePos = event->scenePos(); if (m_pressArea == StartArea || m_pressArea == EndArea) { prepareGeometryChange(); if (m_pressArea == StartArea) m_start = scenePos; else m_end = scenePos; update(); } else if (m_pressArea == MiddleArea) { QPointF delta = scenePos - m_pressScenePos; prepareGeometryChange(); m_start += delta; m_end += delta; m_pressScenePos = scenePos; update(); } event->accept(); }这里有几个容易踩坑的地方。第一,prepareGeometryChange() 必须在修改几何数据之前调用,它通知场景旧的 boundingRect 需要重绘,而且要在数据变化前调用,否则旧的脏区域可能残留。第二,中段拖拽时 m_pressScenePos 要跟着更新,否则下一次计算 delta 会把之前所有位移重复累加,线条会越拖越快。第三,update() 只是请求重绘,并不会自动重算 boundingRect,所以几何数据变化时 prepareGeometryChange 是必需的。
如果在实际拖拽时发现线条有“拖尾”或者闪烁,大概率就是 prepareGeometryChange 的顺序问题。我在初次实现时,先更新 m_end 再调用 prepareGeometryChange,结果每次重绘都留下一截残影,就是因为旧区域没有被及时擦除。
3.3 mouseReleaseEvent 释放与状态复位
释放事件是拖拽的收尾,核心任务是重置状态,并为后续的“锚点吸附”提供入口:
void ArrowItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) { if (event->button() != Qt::LeftButton) { QGraphicsItem::mouseReleaseEvent(event); return; } m_pressArea = None; unsetCursor(); update(); event->accept(); }到这里,一条能拖拽、能实时变化的箭头已经能跑起来了。但距离“连接”业务还有一步,就是释放时如何识别并吸附到目标锚点。这一部分放在下一章,因为它涉及到与场景其他图元的协作,而不只是自身绘制。
4. 锚点吸附、场景集成与完整示例
4.1 自定义 PortItem 锚点
箭头连接不能依赖“连接到画布的任意坐标”,那会让整个关系维护变得混乱。业务上通常需要固定的连接点,就像电路板上的焊盘。我用一个超轻量的 PortItem 来表示焊盘,它是场景里最小粒度的图元类型。
class PortItem : public QGraphicsItem { public: explicit PortItem(qint64 id, const QPointF &pos, QGraphicsItem *parent = nullptr) : QGraphicsItem(parent), m_id(id) { setPos(pos); setFlag(ItemIsSelectable); setFlag(ItemSendsGeometryChanges); setAcceptHoverEvents(true); setZValue(2.0); } qint64 id() const { return m_id; } QRectF boundingRect() const override { return QRectF(-8, -8, 16, 16); } void paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) override { painter->setRenderHint(QPainter::Antialiasing); painter->setBrush(m_hovered ? Qt::yellow : Qt::white); painter->setPen(QPen(Qt::darkBlue, 2)); painter->drawEllipse(-6, -6, 12, 12); } protected: void hoverEnterEvent(QGraphicsSceneHoverEvent *) override { m_hovered = true; update(); } void hoverLeaveEvent(QGraphicsSceneHoverEvent *) override { m_hovered = false; update(); } private: qint64 m_id; bool m_hovered = false; };这里我把 PortItem 的局部坐标原点定在其正中心,这样在场景里设置 pos 就是锚点的实际中心位置,吸附判定时可以直接用 scenePos 比较,不需要额外加半径偏移。圆点绘制在 boundingRect 里预留了 2 像素的边距,防止抗锯齿边缘被裁掉。
不同的业务里,PortItem 往往会放在 NodeItem 的边框上,并随着节点一起移动。只要 PortItem 的 parent 设置为 NodeItem,并设置局部坐标,那么节点整体移动时锚点会自动跟随,不需要写任何同步代码。这是 Graphics View 框架里“父子图元”机制最省心的地方。
4.2 吸附判定算法与释放接入
吸附的思路很直接:鼠标释放时,在场景里查找所有 PortItem,看当前鼠标位置和哪个锚点中心足够近,如果足够近就把箭头的对应端点吸过去。
这里有两个实现细节值得注意。第一,不要用 QGraphicsScene::items(const QPointF &pos) 去查“点命中的图元”,因为 PortItem 只有 16 像素见方,如果鼠标没精确落在正方形内部,就查不到。更好的方式是直接用场景坐标做距离判断,遍历所有 PortItem,取距离小于阈值的最近一个。若图元数量大,可以用 QGraphicsScene::items(QRectF(...), Qt::IntersectsItemShape) 把搜索范围限定为鼠标周围的一个矩形区域,再用距离逆排序取最近。
PortItem *findNearestPort(QGraphicsScene *scene, const QPointF &scenePos) { const qreal threshold = 20.0; QList<QGraphicsItem *> candidates = scene->items( QRectF(scenePos.x() - threshold, scenePos.y() - threshold, 2 * threshold, 2 * threshold), Qt::IntersectsItemShape); PortItem *best = nullptr; qreal bestDist = threshold; for (QGraphicsItem *it : candidates) { PortItem *port = dynamic_cast<PortItem *>(it); if (!port) continue; qreal dist = QLineF(port->scenePos(), scenePos).length(); if (dist < bestDist) { bestDist = dist; best = port; } } return best; }吸附阈值我取 20 像素。比这更大容易把临近的多个锚点都圈进来,造成吸附到错误目标;比这更小则要求用户释放时位置非常精确,体验很差。
在 ArrowItem 的 mouseReleaseEvent 里,拿到最近的端口后,要把端点坐标修正到端口的中心:
// mouseReleaseEvent 内部追加 PortItem *target = findNearestPort(scene(), event->scenePos()); if (target) { prepareGeometryChange(); if (m_pressArea == StartArea) m_start = target->scenePos(); else if (m_pressArea == EndArea) m_end = target->scenePos(); update(); }这样箭头的两端就能稳定地“粘”在锚点上,视觉上完成了连接。但注意,这个修正只是坐标修正,ArrowItem 本身并不知道自己连到了哪个端口。业务上要记录“A 端口连到 B 端口”的关系,必须把端口 ID 存下来。你可以在吸附时顺手把目标端口的 id 保存在 ArrowItem 的两个成员变量里,例如 m_sourcePortId 和 m_targetPortId,存盘或者做业务识别时直接用这两个值。
我见过不少项目把所有连线逻辑塞在 ArrowItem 里,结果 ArrowItem 既要画线、又要处理拖拽、还要维护连接关系,代码越来越乱。更好的做法是让 ArrowItem 只负责“画”和“拖”,连接关系的建立、删除、校验交给专门的 ConnectionManager 或者场景图层去处理。箭头释放后通知管理器“我连到了端口 A 和端口 B”,由管理器统一维护拓扑数据,这样后续做撤销、重做、校验循环连接时都会轻松很多。
4.3 与场景的集成示例
最后我放一个最小可运行的集成示例。这段代码演示了如何创建场景、视图、两个节点、四个锚点以及一条可拖拽的箭头。实际业务中你会在 NodeItem 构造函数里创建 PortItem,并同步维护端口 ID 和业务数据的映射关系。
#include <QApplication> #include <QGraphicsScene> #include <QGraphicsView> #include <QGraphicsSimpleTextItem> // 这里需要包含 ArrowItem 和 PortItem 的头文件 int main(int argc, char *argv[]) { QApplication app(argc, argv); QGraphicsScene scene; scene.setSceneRect(-300, -200, 600, 400); // 简易节点节点:用一个矩形图元 + 文本临时充当 auto createNode = [&scene](const QString &text, const QPointF &pos) { auto *node = scene.addRect(QRectF(-50, -30, 100, 60), QPen(Qt::darkGray), QBrush(QColor("#f5f5f5"))); node->setFlag(QGraphicsItem::ItemIsMovable); node->setPos(pos); auto *label = scene.addSimpleText(text); label->setPos(pos.x() - 40, pos.y() - 12); label->setParentItem(node); return node; }; QGraphicsItem *nodeA = createNode(QStringLiteral("节点 A"), QPointF(-150, 0)); QGraphicsItem *nodeB = createNode(QStringLiteral("节点 B"), QPointF(150, 0)); // 在两个节点上放置锚点 auto *portLeft = new PortItem(1, QPointF(-150, 0)); portLeft->setPos(nodeA->scenePos()); scene.addItem(portLeft); auto *portRight = new PortItem(2, QPointF(150, 0)); portRight->setPos(nodeB->scenePos()); scene.addItem(portRight); // 创建一条从端口1指向端口2的箭头 auto *arrow = new ArrowItem(portLeft->scenePos(), portRight->scenePos()); scene.addItem(arrow); QGraphicsView view(&scene); view.setRenderHint(QPainter::Antialiasing); view.resize(800, 500); view.show(); return app.exec(); }需要强调一下,这段示例里的 PortItem 和 NodeItem 是通过 setParentItem 绑定的吗?并没有。我在示例里没有把 PortItem 的 parent 设为 NodeItem,所以当节点移动时,端口不会跟着移动。两种做法各有适用场景:如果你希望端口固定焊在节点上,就必须把 PortItem 的 parent 设置为 NodeItem,并设置局部坐标。这里为了代码简单,我让端口位于场景坐标的原点,实际项目里通常是在 NodeItem 内部创建 PortItem 并设置相对位置,比如顶部中心、底部中心、左右两侧。
正确的 NodeItem 内嵌方式大致是这样:
NodeItem::NodeItem(const QRectF &rect, QGraphicsItem *parent) : QGraphicsItem(parent) { m_portTop = new PortItem(0, QPointF(0, -rect.height() / 2), this); m_portBottom = new PortItem(1, QPointF(0, rect.height() / 2), this); // PortItem 的 pos 是相对父节点的局部坐标 // 节点移动时端口自动跟进 }用 parent 绑定之后,PortItem::scenePos() 会返回节点的场景坐标加相对偏移,ArrowItem 吸附时直接拿 scenePos 判断,逻辑不用任何改动。
5. 常见问题与排查技巧实录
5.1 拖拽时线条抖动或残影
抖动和残影大多是几何更新顺序不对。标准流程是先在 mouseMoveEvent 里调用 prepareGeometryChange(),然后修改 m_start 或 m_end,最后调用 update()。顺序错了,场景拿到的旧 boundingRect 可能没有覆盖新的绘制区域,旧区域没有擦干净,视觉上就是拖影。
还有一个容易忽略的点:如果 ArrowItem 被放进了 QGraphicsItemGroup 里,或者设置了负的 Z 值,场景重绘顺序可能导致它被其他不透明图元挡住。排查抖动时,先单测 ArrowItem 自身,排除场景其他图元的干扰。
5.2 箭头选不中、点击没反应
这是 QGraphicsItem 相关代码里出现频率最高的坑。一般有 3 个原因:第一,shape() 没有重写,默认命中路径是未设置的状态,导致鼠标点击永远不落在线条上;第二,setAcceptHoverEvents 或 setAcceptedMouseButtons 没有设置,事件压根不会派发到 ArrowItem;第三,boundingRect 返回的范围过小,QGraphicsView 只在 boundingRect 包围的屏幕区域内做命中测试,如果绘制内容有一部分超出 boundingRect,那部分区域就点不中。
解决方式也简单:把 hitTest 的阈值中心和 boundingRect 的扩展尺寸统一起来,确保 boundingRect 包含整个箭头本体加上热区范围。我的实现里 boundingRect 会把两个端点、箭头尖端和 10 像素热区余量全部包裹进去:
QRectF ArrowItem::boundingRect() const { const qreal extra = m_arrowLength + m_strokeWidth + 12.0; return QRectF(m_start, m_end).normalized().adjusted(-extra, -extra, extra, extra); }这里加 12 是因为命中热区半径是 10,再留 2 像素给抗锯齿边缘。宁可多算一点,也不能漏。
5.3 坐标错乱:图和鼠标位置对不上
坐标错乱几乎都是混用了局部坐标和场景坐标。在 ArrowItem 的 mouseMoveEvent 里,event->pos() 是图元局部坐标,event->scenePos() 是场景坐标。如果 item 没设置过 pos,二者一致,怎么用都行。一旦你把 ArrowItem 本身平移了,或者放到某个 group 里,局部坐标和场景坐标就不一样了,这时候还用 event->pos() 直接赋值给 m_end,箭头就会“飘”到一个跟鼠标无关的位置。
一套不容易出错的约定是:ArrowItem 自身的坐标系统统用场景坐标,内部不做局部偏移。ArrowItem 的 pos 保持 (0,0),创建时也不用 setPos。这样 hitTest 收到的局部坐标和 scenePos 完全等价,降低心智负担。等你的需求复杂到需要缩放、旋转线条时,再去考虑 mapToScene/mapFromScene 的完整转换,不要提前引入复杂性。
5.4 删除连接时崩溃
删除连接是一个高频操作。很多人直接写:
scene->removeItem(arrow); delete arrow;表面看没问题,但如果 arrow 正在处理鼠标事件,或者场景在遍历 item 列表时删除 item,就会崩溃。安全做法是先调用 scene->removeItem(arrow),再调用 arrow->deleteLater(),让 Qt 在事件循环空闲时真正释放。如果你在业务层维护了一个包含所有连接的容器,删除时还需要同步移除容器中的记录,避免出现悬空指针。
6. 后续可以这样扩展
整套“箭头图元 + 锚点 + 场景集成”的结构稳定后,扩展方向其实非常多。最直接的改进是把直线改成贝塞尔曲线,此时 ArrowItem 内部改成保存 QPainterPath,拖拽时重新构建路径,箭头尖端的方向用路径终点处的切线方向计算,核心交互逻辑完全不用动。其次可以给 ArrowItem 增加选中态和编辑态,比如双击显示控制点、拖动控制点调整曲线弯曲程度,这在很多流程图工具里都是刚需。
另一个值得做的扩展是连接关系的持久化。场景里所有节点和连线,理论上都可以序列化为 JSON 或者自定义格式,节点保存 ID 和位置,连线保存 sourcePortId 和 targetPortId,读取时按 ID 重建图元。这里有个经验是:千万不要直接保存坐标,因为用户调整布局后旧坐标就没意义了,而 ID 关系是稳定的。保存 ID 还有一个额外好处,就是将来做“撤销/重做”的时候,只需要比较 ID 关系的变化,而不用关心具体位置。
最后,如果你需要支持框选多个箭头并集体删除,记得在视图上设置:
view->setDragMode(QGraphicsView::RubberBandDrag);这个模式会自动处理多个图元的框选,配合 ItemIsSelectable 标志,一次框选就能选中多条箭头。真正动手做的时候,图形的精细度、交互细节都可以按业务调整,但“QGraphicsItem 负责单图元绘制和事件、Scene 负责空间索引、外部管理器负责业务关系”这个分工架构,几乎适用于所有画布型工具。照这个思路写,后面再加什么功能都不会推翻重来。