Qt5 进阶【11】图形视图框架:用 QGraphicsView 搭一个流程图编辑器

目标读者:已经写过几版 Qt 桌面应用,知道 QWidget 布局怎么用,但一碰到"自由拖拽的拓扑图/流程图/示意图"就容易写崩溃的开发者。

示例环境:Qt 5.12/5.15 + Qt Creator + CMake(你习惯 .pro 也没问题,文中工程结构简单,改一下就能用)。

目录

[一、问题背景:WIDGET 布局很香,但真不适合流程图](#一、问题背景:WIDGET 布局很香,但真不适合流程图)

[二、核心知识点:QGraphicsScene / QGraphicsView / QGraphicsItem 三件套](#二、核心知识点:QGraphicsScene / QGraphicsView / QGraphicsItem 三件套)

[1. 场景 / 视图 / 图元之间是什么关系?](#1. 场景 / 视图 / 图元之间是什么关系?)

[2. 三种坐标系统与缩放/平移](#2. 三种坐标系统与缩放/平移)

[3. QGraphicsItem:boundingRect 与 paint 是"生死大事"](#3. QGraphicsItem:boundingRect 与 paint 是“生死大事”)

[4. Item 的选择、拖拽和连线](#4. Item 的选择、拖拽和连线)

[5. 碰撞检测和选框选择](#5. 碰撞检测和选框选择)

[6. 性能优化:OpenGL 视口 + 图元缓存](#6. 性能优化:OpenGL 视口 + 图元缓存)

三、代码实战:一个可用的"简单流程图编辑器"

[1. 工程结构与 CMakeLists.txt](#1. 工程结构与 CMakeLists.txt)

[2. NodeItem:流程节点图元](#2. NodeItem:流程节点图元)

[3. EdgeItem:带箭头的连线](#3. EdgeItem:带箭头的连线)

[4. DiagramScene:自定义场景(添加/删除/JSON)](#4. DiagramScene:自定义场景(添加/删除/JSON))

[5. MainWindow:视图 + 菜单 + Ctrl 滚轮缩放](#5. MainWindow:视图 + 菜单 + Ctrl 滚轮缩放)

[6. main.cpp](#6. main.cpp)

四、实战中的坑与优化(结合本例)

[1. 不给 Item 设置合适的 boundingRect](#1. 不给 Item 设置合适的 boundingRect)

[2. 在 paint() 里做复杂逻辑甚至访问数据库](#2. 在 paint() 里做复杂逻辑甚至访问数据库)

[3. 场景里塞数千图元卡顿:分层 / 简化绘制 / 视口缓存](#3. 场景里塞数千图元卡顿:分层 / 简化绘制 / 视口缓存)

[五、小结:凡是"自由布局 + 重交互",优先考虑图形视图框架](#五、小结:凡是“自由布局 + 重交互”,优先考虑图形视图框架)


一、问题背景:WIDGET 布局很香,但真不适合流程图

做后台管理界面、配置窗口时,Qt 的 QWidget 布局(QVBoxLayout、QHBoxLayout、QGridLayout...)用起来非常顺手:

控件从上到下、从左到右摆好,界面大小变化时自动伸缩,代码也相对整齐。

可一旦需求变成"能拖、能连、能任意摆"时,这套模型立刻显得别扭:

  • 画一个网络拓扑图:路由器、交换机、服务器等设备要随意拖来拖去;
  • 做一个流程图/状态机编辑器:节点能自由放置、连线跟着节点走;
  • 做一个简单的可视化画板:框图、箭头、注释随便画。

如果你用 QWidget 生搬硬套,常见症状是:

  1. 每个节点都是一个 QWidget ,用 move() 改位置;
  2. 连线要自己算起点终点,把 QPainter 画在背景里或单独的绘图层;
  3. 想支持缩放和平移就更痛苦------所有坐标、尺寸都要跟着算一遍。

结果是:

  • 代码充满"坐标黑魔法"和"像素偏移";
  • 性能惨不忍睹:几十个节点还行,上百就开始卡;
  • 一旦业务要改样式/换布局,你几乎只能推倒重写。

问题的本质在于:普通 QWidget + 布局管理器是为标准控件表单准备的,而流程图这类场景需要的是一个"可无限扩展的 2D 场景坐标系统"

Qt 早就给了答案:图形视图框架(Graphics View Framework)​


二、核心知识点:QGraphicsScene / QGraphicsView / QGraphicsItem 三件套

1. 场景 / 视图 / 图元之间是什么关系?

可以先把这三者理解为一套 MVC:

  • QGraphicsScene :模型 + 数据容器
    • 负责管理所有图元(QGraphicsItem)
    • 维护一个统一的"场景坐标系"
  • QGraphicsView :视图
    • 是一个 QWidget,用来"看"某个 QGraphicsScene 的一部分
    • 可以有多个视图看同一个场景(多视角、缩略图预览)
  • QGraphicsItem :图元
    • 场景中的一个个小对象:节点、连线、图标、文字...
    • 自己决定怎么画(paint)、自己提供自己的 boundingRect

简单关系图:

bash 复制代码
          ┌────────────────┐
          │  QGraphicsView │   ← 一个或多个视图
          └──────┬─────────┘
                 │
                 ▼
          ┌────────────────┐
          │ QGraphicsScene │   ← 统一的场景坐标系
          └──────┬─────────┘
        /         |         \
       ▼          ▼          ▼
   QGraphicsItem  QGraphicsItem  QGraphicsItem   ← 一个个图元

2. 三种坐标系统与缩放/平移

图形视图框架里有三种常见坐标:

  1. Item 坐标(局部坐标)​

    • 每个 QGraphicsItem 自己的局部坐标系
    • 通常左上角是 (0,0) 或居中是 (0,0)
  2. Scene 坐标(场景坐标)​

    • 整个 QGraphicsScene 的全局坐标系
    • 所有 Item 的 pos() 都是相对这个坐标系的
  3. View 坐标(视图坐标)​

    • QGraphicsView 自己的 widget 坐标
    • 左上角是 (0,0),右下角是 (width, height)

对应转换函数你会非常常用:

cpp 复制代码
// 视图坐标 -> 场景坐标(比如鼠标位置)
QPointF scenePos = view->mapToScene(viewPoint);

// 场景坐标 -> 视图坐标(比如把某个场景点移动到视图中心)
QPoint viewPos = view->mapFromScene(scenePoint);

// Item 坐标 -> 场景坐标
QPointF scenePos2 = item->mapToScene(QPointF(0, 0));

// 场景坐标 -> Item 坐标
QPointF itemPos = item->mapFromScene(scenePos2);

缩放和平移,实际上都是对视图做变换:

cpp 复制代码
// 放大 20%
view->scale(1.2, 1.2);

// 缩小 20%
view->scale(1/1.2, 1/1.2);

// 平移(滚动)
view->translate(dx, dy);

在我们的示例里,会实现:Ctrl + 滚轮 缩放视图,并且通过拖拽中键或按空格+左键来平移。

3. QGraphicsItem:boundingRect 与 paint 是"生死大事"

实现自定义图元时,最核心的两个函数:

cpp 复制代码
QRectF boundingRect() const override;
void paint(QPainter *painter,
           const QStyleOptionGraphicsItem *option,
           QWidget *widget = nullptr) override;
  • boundingRect()

    • 必须返回该图元可能绘制到的"最大矩形区域"
    • Qt 用它来做重绘范围、碰撞检测、选择框判断
    • 如果返回太小:部分内容画不出来/点不中
    • 如果返回太大:一操作就重绘半个场景 → 卡顿
  • paint()

    • 真正的绘制逻辑
    • 只能做绘图相关工作,绝不能在里面访问数据库、走网络、做复杂运算
    • 这是很多项目"画得越多越卡"的根源之一

4. Item 的选择、拖拽和连线

在图形视图框架中,图元是否可选、可拖动,由 setFlags() 决定:

cpp 复制代码
setFlags(QGraphicsItem::ItemIsMovable
       | QGraphicsItem::ItemIsSelectable
       | QGraphicsItem::ItemSendsGeometryChanges);

连线通常有两种实现方式:

  1. 连线也是一个 QGraphicsItem(比如自定义 LineItem)

    • 内部持有指向两个 NodeItem 的指针
    • paint() 里画一条从 source 到 dest 的线段
    • 节点位置变化时,更新连线几何
  2. 在场景级统一管理连线

    • 节点只管自己,连线由场景根据节点位置统一重算
    • 适合需要复杂路由(避障、弯折)的场景

本期示例采用第一种方式,更直观。

5. 碰撞检测和选框选择

  • 单点选择 :QGraphicsScene 内部会根据 boundingRect()shape() 做点选判断

    你只要给 Item 设置 ItemIsSelectable 标志即可。

  • 框选:QGraphicsView 已经内置好选框,只要设置:

cpp 复制代码
view->setDragMode(QGraphicsView::RubberBandDrag);
view->setRubberBandSelectionMode(Qt::IntersectsItemShape);

你也可以像本期示例那样在自定义 QGraphicsScene 里,实现自己的选框逻辑(比如按住 Ctrl 再框选)。

6. 性能优化:OpenGL 视口 + 图元缓存

图形视图框架有两个非常实用的优化点:

  1. 视口使用 QOpenGLWidget
cpp 复制代码
QGraphicsView *view = new QGraphicsView(scene);
view->setViewport(new QOpenGLWidget);
view->setRenderHint(QPainter::Antialiasing);
view->setViewportUpdateMode(QGraphicsView::FullViewportUpdate);

在图元多、线条多的场景下,OpenGL 可以显著减轻 CPU 压力。

  1. 图元缓存(Item Cache)​
cpp 复制代码
item->setCacheMode(QGraphicsItem::DeviceCoordinateCache);

对于不会频繁变化的图元(比如背景网格、只读节点),可以让 Qt 把绘制结果缓存成像素图,下次直接贴图,减少重绘成本。


三、代码实战:一个可用的"简单流程图编辑器"

下面的工程叫 DiagramEditorDemo ,你可以直接在 Qt Creator 中用 CMake 打开构建。

功能包括:

  • 图元类型:流程节点(矩形)、连线(带箭头的线段)
  • 支持:
    • 添加节点(在视图中心)
    • 拖拽移动节点
    • 选中、框选(RubberBand)
    • 删除选中节点/连线(Delete)
    • 简单连线(示例里用右键菜单或快捷键扩展很方便)
  • 视图操作:
    • Ctrl + 鼠标滚轮缩放
    • 中键拖动平移
  • 文件:
    • 将流程图保存为简单 JSON
    • 从 JSON 反序列化加载

为了篇幅和聚焦,下面的代码以"节点 + 线段 + JSON(节点+线的关系)+ 视图交互"为主,你可以在此基础上自由扩展 UI 皮肤和连线方式。

1. 工程结构与 CMakeLists.txt

工程结构说明:

bash 复制代码
DiagramEditorDemo/
├── CMakeLists.txt
├── include/
│   ├── diagramscene.h
│   ├── nodeitem.h
│   ├── edgeitem.h
│   └── mainwindow.h
└── src/
    ├── main.cpp
    ├── diagramscene.cpp
    ├── nodeitem.cpp
    ├── edgeitem.cpp
    └── mainwindow.cpp

CMakeLists.txt


2. NodeItem:流程节点图元

include/nodeitem.h

cpp 复制代码
#pragma once

#include <QGraphicsItem>
#include <QColor>
#include <QString>

class NodeItem : public QGraphicsItem
{
public:
    enum { Type = UserType + 1 };

    explicit NodeItem(const QString &text = QString(), QGraphicsItem *parent = nullptr);

    int type() const override { return Type; }

    // 几何与绘制
    QRectF boundingRect() const override;
    QPainterPath shape() const override;
    void paint(QPainter *painter,
               const QStyleOptionGraphicsItem *option,
               QWidget *widget = nullptr) override;

    // 文本与颜色
    void setText(const QString &text);
    QString text() const { return m_text; }

    void setColor(const QColor &color);
    QColor color() const { return m_color; }

    // 唯一 ID(用于 JSON 存储)
    int id() const { return m_id; }
    void setId(int newId) { m_id = newId; }

protected:
    // 交互
    void mousePressEvent(QGraphicsSceneMouseEvent *event) override;
    void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override;
    QVariant itemChange(GraphicsItemChange change,
                        const QVariant &value) override;

private:
    static int s_nextId;

    int m_id;
    QString m_text;
    QColor m_color;
    QRectF m_rect;
};

src/nodeitem.cpp

cpp 复制代码
#include "nodeitem.h"

#include <QPainter>
#include <QStyleOptionGraphicsItem>
#include <QGraphicsSceneMouseEvent>
#include <QRandomGenerator>
#include <QDateTime>


int NodeItem::s_nextId = 1;

NodeItem::NodeItem(const QString &text, QGraphicsItem *parent)
    : QGraphicsItem(parent),
    m_id(s_nextId++),
    m_text(text),
    m_color(Qt::cyan),
    m_rect(-60, -30, 120, 60)   // 居中矩形
{
    // 随机一点颜色,让多个节点看着不太一样
    static QRandomGenerator generator(QDateTime::currentMSecsSinceEpoch());
    int h = generator.bounded(360);
    m_color = QColor::fromHsl(h, 160, 180);

    setFlags(ItemIsMovable
             | ItemIsSelectable
             | ItemSendsGeometryChanges
             | ItemSendsScenePositionChanges);
    setAcceptHoverEvents(true);
}

QRectF NodeItem::boundingRect() const
{
    // 在基础矩形外部留一点边框空间
    const qreal margin = 4;
    return m_rect.adjusted(-margin, -margin, margin, margin);
}

QPainterPath NodeItem::shape() const
{
    // 精确的碰撞/选中区域
    QPainterPath path;
    path.addRoundedRect(m_rect, 8, 8);
    return path;
}

void NodeItem::paint(QPainter *painter,
                     const QStyleOptionGraphicsItem *option,
                     QWidget *widget)
{
    Q_UNUSED(widget);

    // 设置抗锯齿
    painter->setRenderHint(QPainter::Antialiasing, true);

    // 选中/悬停状态
    QColor fill = m_color;
    if (option->state & QStyle::State_MouseOver) {
        fill = fill.lighter(120);
    }
    if (option->state & QStyle::State_Selected) {
        fill = fill.darker(110);
    }

    // 绘制矩形
    QPen pen(Qt::black, (option->state & QStyle::State_Selected) ? 2.5 : 1.5);
    painter->setPen(pen);
    painter->setBrush(fill);
    painter->drawRoundedRect(m_rect, 8, 8);

    // 绘制文本
    if (!m_text.isEmpty()) {
        painter->setPen(Qt::black);
        painter->setFont(QFont("Microsoft YaHei", 9));
        painter->drawText(m_rect.adjusted(4, 4, -4, -4),
                          Qt::AlignCenter, m_text);
    }
}

void NodeItem::setText(const QString &text)
{
    m_text = text;
    update();
}

void NodeItem::setColor(const QColor &color)
{
    m_color = color;
    update();
}

void NodeItem::mousePressEvent(QGraphicsSceneMouseEvent *event)
{
    // 左键按下时可拖动,右键由场景处理上下文菜单
    if (event->button() == Qt::LeftButton) {
        QGraphicsItem::mousePressEvent(event);
    } else {
        event->ignore();
    }
}

void NodeItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
{
    QGraphicsItem::mouseMoveEvent(event);
}

QVariant NodeItem::itemChange(GraphicsItemChange change,
                              const QVariant &value)
{
    // 位置变化时,通知场景更新连线
    if (change == ItemPositionChange && scene()) {
        // 可在这里限制移动范围等
    }
    return QGraphicsItem::itemChange(change, value);
}

3. EdgeItem:带箭头的连线

include/edgeitem.h

cpp 复制代码
#pragma once

#include <QGraphicsItem>
#include <QLineF>

class NodeItem;

class EdgeItem : public QGraphicsItem
{
public:
    enum { Type = UserType + 2 };

    EdgeItem(NodeItem *source, NodeItem *dest, QGraphicsItem *parent = nullptr);

    int type() const override { return Type; }

    NodeItem *sourceNode() const { return m_source; }
    NodeItem *destNode() const { return m_dest; }

    void adjust();  // 重新计算起点终点

    QRectF boundingRect() const override;
    void paint(QPainter *painter,
               const QStyleOptionGraphicsItem *option,
               QWidget *widget = nullptr) override;

private:
    NodeItem *m_source;
    NodeItem *m_dest;
    QPointF m_sourcePoint;
    QPointF m_destPoint;
    qreal m_arrowSize;
};

src/edgeitem.cpp

cpp 复制代码
#include "edgeitem.h"
#include "nodeitem.h"

#include <QPainter>
#include <QStyleOptionGraphicsItem>
#include <QtMath>

EdgeItem::EdgeItem(NodeItem *source, NodeItem *dest, QGraphicsItem *parent)
    : QGraphicsItem(parent),
    m_source(source),
    m_dest(dest),
    m_arrowSize(10.0)
{
    setFlags(ItemIsSelectable | ItemSendsGeometryChanges);
    setZValue(-1); // 线在节点下面

    adjust();
}

void EdgeItem::adjust()
{
    if (!m_source)
        return;

    // 节点中心点
    QPointF src = m_source->scenePos();
    QPointF dst;
    
    if (m_dest) {
        dst = m_dest->scenePos();
    } else {
        // 如果目标节点为nullptr,使用源节点位置
        dst = src;
    }

    QLineF line(src, dst);
    if (qFuzzyCompare(line.length(), qreal(0.0))) {
        m_sourcePoint = m_destPoint = src;
        return;
    }

    // 从节点外缘开始画,简单起见,以中心点计算,不做精确边缘计算
    m_sourcePoint = src;
    m_destPoint   = dst;

    prepareGeometryChange();
}

QRectF EdgeItem::boundingRect() const
{
    if (!m_source)
        return QRectF();

    qreal extra = (m_arrowSize + 2.0);
    QRectF rect(m_sourcePoint, m_destPoint);
    return rect.normalized().adjusted(-extra, -extra, extra, extra);
}

void EdgeItem::paint(QPainter *painter,
                     const QStyleOptionGraphicsItem *option,
                     QWidget *widget)
{
    Q_UNUSED(option)
    Q_UNUSED(widget)

    if (!m_source)
        return;

    QLineF line(m_sourcePoint, m_destPoint);
    if (qFuzzyCompare(line.length(), qreal(0.0)))
        return;

    painter->setRenderHint(QPainter::Antialiasing, true);

    // 画线
    QPen pen(Qt::darkGray, isSelected() ? 2.5 : 1.5);
    painter->setPen(pen);
    painter->setBrush(Qt::NoBrush);
    painter->drawLine(line);

    // 只有当目标节点存在时才绘制箭头
    if (m_dest) {
        // 箭头
        double angle = std::atan2(-line.dy(), line.dx());
        QPointF arrowP1 = line.p2() + QPointF(std::sin(angle + M_PI / 3) * m_arrowSize,
                                              std::cos(angle + M_PI / 3) * m_arrowSize);
        QPointF arrowP2 = line.p2() + QPointF(std::sin(angle + M_PI - M_PI / 3) * m_arrowSize,
                                              std::cos(angle + M_PI - M_PI / 3) * m_arrowSize);

        QPolygonF arrowHead;
        arrowHead << line.p2() << arrowP1 << arrowP2;
        painter->setBrush(Qt::darkGray);
        painter->drawPolygon(arrowHead);
    }
}

4. DiagramScene:自定义场景(添加/删除/JSON)

include/diagramscene.h

cpp 复制代码
#pragma once

#include <QGraphicsScene>
#include <QVector>

class NodeItem;
class EdgeItem;

class DiagramScene : public QGraphicsScene
{
    Q_OBJECT
public:
    explicit DiagramScene(QObject *parent = nullptr);

    void addNodeAt(const QPointF &scenePos);
    void deleteSelectedItems();
    void clearAll();
    void updateEdgesForNode(NodeItem *node);

    // 简单 JSON 序列化
    bool saveToJson(const QString &filePath) const;
    bool loadFromJson(const QString &filePath);

protected:
    void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override;
    void mousePressEvent(QGraphicsSceneMouseEvent *event) override;
    void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override;
    void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override;

private:
    QVector<NodeItem*> m_nodes;
    QVector<EdgeItem*> m_edges;
    
    // 连线创建相关变量
    NodeItem *m_startNode; // 连线起点节点
    EdgeItem *m_tempEdge;  // 临时连线(预览用)
};

src/diagramscene.cpp

cpp 复制代码
#include "diagramscene.h"
#include "nodeitem.h"
#include "edgeitem.h"

#include <QGraphicsSceneMouseEvent>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QDebug>

DiagramScene::DiagramScene(QObject *parent)
    : QGraphicsScene(parent),
    m_startNode(nullptr),
    m_tempEdge(nullptr)
{
    setSceneRect(-2000, -2000, 4000, 4000);
    setBackgroundBrush(QColor(245, 245, 245));
}

void DiagramScene::addNodeAt(const QPointF &scenePos)
{
    auto *node = new NodeItem(QStringLiteral("节点 %1").arg(m_nodes.size() + 1));
    node->setPos(scenePos);
    addItem(node);
    m_nodes.append(node);
}

void DiagramScene::deleteSelectedItems()
{
    const auto itemsSel = selectedItems();
    for (QGraphicsItem *item : itemsSel) {
        if (item->type() == NodeItem::Type) {
            // 先移除相关连线
            for (int i = m_edges.size() - 1; i >= 0; --i) {
                EdgeItem *edge = m_edges[i];
                if (!edge) {
                    m_edges.removeAt(i);
                    continue;
                }
                if (edge->sourceNode() == item || edge->destNode() == item) {
                    removeItem(edge);
                    delete edge;
                    m_edges.removeAt(i);
                }
            }
            removeItem(item);
            m_nodes.removeOne(static_cast<NodeItem *>(item));
            delete item;
        } else if (item->type() == EdgeItem::Type) {
            removeItem(item);
            m_edges.removeOne(static_cast<EdgeItem *>(item));
            delete item;
        }
    }
}

void DiagramScene::clearAll()
{
    for (auto e : m_edges) {
        if (e) removeItem(e);
        delete e;
    }
    m_edges.clear();

    for (auto n : m_nodes) {
        if (n) removeItem(n);
        delete n;
    }
    m_nodes.clear();

    clear();
}

void DiagramScene::updateEdgesForNode(NodeItem *node)
{
    // 更新与指定节点相关的所有连线
    for (auto edge : m_edges) {
        if (edge && (edge->sourceNode() == node || edge->destNode() == node)) {
            edge->adjust();
        }
    }
}

bool DiagramScene::saveToJson(const QString &filePath) const
{
    QJsonObject root;
    QJsonArray nodesArr;
    for (auto n : m_nodes) {
        if (!n) continue;
        QJsonObject obj;
        obj["id"] = n->id();
        obj["text"] = n->text();
        obj["x"] = n->pos().x();
        obj["y"] = n->pos().y();
        obj["color"] = n->color().name();
        nodesArr.append(obj);
    }
    root["nodes"] = nodesArr;

    // edges: 简化处理,这里不真正保存,读者可按需求扩展
    QJsonArray edgesArr;
    for (auto e : m_edges) {
        if (!e) continue;
        QJsonObject obj;
        obj["source"] = e->sourceNode()->id();
        obj["dest"] = e->destNode()->id();
        edgesArr.append(obj);
    }
    root["edges"] = edgesArr;

    QJsonDocument doc(root);
    QFile f(filePath);
    if (!f.open(QIODevice::WriteOnly | QIODevice::Text)) {
        qWarning() << "无法写入文件:" << filePath;
        return false;
    }
    f.write(doc.toJson(QJsonDocument::Indented));
    return true;
}

bool DiagramScene::loadFromJson(const QString &filePath)
{
    clearAll();

    QFile f(filePath);
    if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
        qWarning() << "无法读取文件:" << filePath;
        return false;
    }
    QByteArray data = f.readAll();
    QJsonParseError err;
    QJsonDocument doc = QJsonDocument::fromJson(data, &err);
    if (err.error != QJsonParseError::NoError) {
        qWarning() << "JSON 解析失败:" << err.errorString();
        return false;
    }
    if (!doc.isObject()) return false;

    QJsonObject root = doc.object();
    QJsonArray nodesArr = root["nodes"].toArray();

    QMap<int, NodeItem*> idMap;
    for (const QJsonValue &v : nodesArr) {
        QJsonObject o = v.toObject();
        auto *node = new NodeItem(o["text"].toString());
        node->setId(o["id"].toInt());
        node->setColor(QColor(o["color"].toString()));
        node->setPos(o["x"].toDouble(), o["y"].toDouble());
        addItem(node);
        m_nodes.append(node);
        idMap[node->id()] = node;
    }

    QJsonArray edgesArr = root["edges"].toArray();
    for (const QJsonValue &v : edgesArr) {
        QJsonObject o = v.toObject();
        int sid = o["source"].toInt();
        int did = o["dest"].toInt();
        NodeItem *s = idMap.value(sid, nullptr);
        NodeItem *d = idMap.value(did, nullptr);
        if (s && d) {
            auto *e = new EdgeItem(s, d);
            addItem(e);
            m_edges.append(e);
        }
    }

    return true;
}

void DiagramScene::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event)
{
    // 双击空白处添加节点
    if (itemAt(event->scenePos(), QTransform()) == nullptr) {
        addNodeAt(event->scenePos());
    } else {
        QGraphicsScene::mouseDoubleClickEvent(event);
    }
}

void DiagramScene::mousePressEvent(QGraphicsSceneMouseEvent *event)
{
    // 检查是否点击在节点上
    QGraphicsItem *item = itemAt(event->scenePos(), QTransform());
    if (item && item->type() == NodeItem::Type) {
        // 开始连线创建
        m_startNode = static_cast<NodeItem*>(item);
        
        // 创建临时连线用于预览
        m_tempEdge = new EdgeItem(m_startNode, nullptr);
        m_tempEdge->setZValue(-1);
        addItem(m_tempEdge);
    }
    // 始终调用父类的鼠标按下事件处理函数,以确保节点可以正常移动
    QGraphicsScene::mousePressEvent(event);
}

void DiagramScene::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
{
    // 如果正在创建连线,更新临时连线的终点
    if (m_startNode && m_tempEdge) {
        // 更新临时连线的位置
        m_tempEdge->adjust();
    } else {
        // 检查是否有节点正在移动
        QGraphicsItem *item = mouseGrabberItem();
        if (item && item->type() == NodeItem::Type) {
            NodeItem *movingNode = static_cast<NodeItem*>(item);
            // 更新与移动节点相关的所有连线
            updateEdgesForNode(movingNode);
        }
    }
    // 始终调用父类的鼠标移动事件处理函数,以确保节点可以正常移动
    QGraphicsScene::mouseMoveEvent(event);
    
    // 每次鼠标移动后,更新所有连线
    // 这是一个简单但有效的解决方案
    for (auto edge : m_edges) {
        if (edge) {
            edge->adjust();
        }
    }
}

void DiagramScene::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
{
    // 如果正在创建连线
    if (m_startNode && m_tempEdge) {
        // 检查是否点击在另一个节点上
        QGraphicsItem *item = itemAt(event->scenePos(), QTransform());
        if (item && item->type() == NodeItem::Type) {
            NodeItem *endNode = static_cast<NodeItem*>(item);
            
            // 确保不是连接到自身
            if (endNode != m_startNode) {
                // 创建正式的连线
                EdgeItem *edge = new EdgeItem(m_startNode, endNode);
                addItem(edge);
                m_edges.append(edge);
            }
        }
        
        // 清理临时连线
        removeItem(m_tempEdge);
        delete m_tempEdge;
        m_tempEdge = nullptr;
        m_startNode = nullptr;
    }
    // 始终调用父类的鼠标释放事件处理函数,以确保节点可以正常移动
    QGraphicsScene::mouseReleaseEvent(event);
}

5. MainWindow:视图 + 菜单 + Ctrl 滚轮缩放

include/mainwindow.h

src/mainwindow.cpp


6. main.cpp

试着自己实现。


四、实战中的坑与优化(结合本例)

1. 不给 Item 设置合适的 boundingRect

在 NodeItem / EdgeItem 里,boundingRect 决定了:

  • 选中区域;
  • 刷新区域;
  • 碰撞检测。

如果写成:

cpp 复制代码
QRectF NodeItem::boundingRect() const
{
    return QRectF(0, 0, 0, 0); // 或者一个很小的区域
}

后果是:

  • 鼠标怎么点都点不中;
  • 画面虽然看见了,但拖拽、选中一律失效;
  • Qt 认为它"不占面积",在优化时会把它当空气。

正确做法是:根据你的实际绘制内容留出一点余量。本例中:

cpp 复制代码
QRectF NodeItem::boundingRect() const
{
    const qreal margin = 4;
    return m_rect.adjusted(-margin, -margin, margin, margin);
}

2. 在 paint() 里做复杂逻辑甚至访问数据库

在 paint() 里做昂贵操作是很多项目的性能地雷:

  • 每次重绘都会调用 paint();
  • 拖拽、缩放时会频繁重绘;
  • 一旦 paint() 做了数据库访问 / 网络请求 / 复杂计算,界面会肉眼可见地卡顿。

一定要坚持

  • paint() 只做与绘制强相关的简单逻辑;
  • 稍微复杂一点的准备工作都放到外面,画之前先算好;
  • 需要的数据显示放到成员变量里,paint() 只读取。

3. 场景里塞数千图元卡顿:分层 / 简化绘制 / 视口缓存

当图元数量从几十、几百飙到上千甚至上万,单纯的 QGraphicsView 也会吃力。这时可以考虑:

  1. 分层

    • 静态背景一层(缓存成图片)
    • 动态节点一层
    • 高亮/选框一层
      可以用多个场景 + 多个视图叠加实现,或用 Z 值区分。
  2. 简化绘制

    • 缩放很小(整体缩成 10%)时,没必要画那么精细,可以只画矩形轮廓,不画文字和细节;
    • 可以根据 view 的 transform 判断当前缩放级别,粗略绘制。
  3. 视口缓存 + OpenGL

    • setViewport(new QOpenGLWidget)
    • setCacheMode(QGraphicsView::CacheBackground)
    • 对大面积不变的背景使用 QPixmap 缓存。

五、小结:凡是"自由布局 + 重交互",优先考虑图形视图框架

把这一期的内容压缩成一段话就是:

只要场景里有很多可以任意拖拽的图形元素,而且这些元素之间还有大量连线/交互逻辑,就不要再用 QWidget 硬扛了,直接上 QGraphicsScene/QGraphicsView/QGraphicsItem。​

具体建议可以写进你的项目规范:

  1. 判断场景是否适合 Graphics View

    • 元素数量 > 50,位置自由,频繁拖动/缩放/连线 → 用
    • 简单表单/按钮/静态布局 → 继续用 QWidget/布局管理器。
  2. 自定义 Item 时,先写好 boundingRect 和 paint,再考虑交互

    • 这两个是"基础中的基础",踩稳才能往上堆功能。
  3. 视图缩放和平移尽量交给 QGraphicsView 处理

    • 不要自己改每个 Item 的坐标来模拟缩放/平移;
    • 正确姿势是对视图做 transform。
  4. 性能问题优先从"减少重绘 + 缓存 + OpenGL 视口"三方面考虑

    • 优化前先确认是不是 paint()/boundingRect() 写得太过头。
  5. 序列化逻辑(JSON/XML/DB)尽量和 Item 解耦

    • 让 Scene 提供 save/load,Item 只暴露属性,不亲自读写文件。

本期的 DiagramEditorDemo 已经是一个可以直接插入项目、快速改造的"流程图编辑器内核"。

你可以在这之上:

  • 加节点右键菜单(重命名、改变颜色、复制/粘贴);
  • 加连线交互(按快捷键进入"连线模式",点源节点再点目标节点);
  • 加左侧工具箱(不同形状/类型的节点);
  • 加属性面板(选中节点后显示属性,可编辑)。

等你把这套框架真正用在自己的项目中,你会非常直观地体会到:
图形视图框架,把原本一团乱麻的"坐标 + 绘图 + 事件",拆成了结构清晰的三层,让你能把更多精力放在业务逻辑和交互体验上。​

相关推荐
老骥伏枥~2 小时前
【C# 入门】程序结构与 Main 方法
开发语言·c#
xyq20242 小时前
Scala IF...ELSE 语句
开发语言
wengqidaifeng2 小时前
探索数据结构(二):空间复杂度
c语言·开发语言·数据结构
难得的我们2 小时前
单元测试在C++项目中的实践
开发语言·c++·算法
全栈师2 小时前
java和C#的基本语法区别
java·开发语言·c#
凯子坚持 c2 小时前
Qt常用控件指南(7)
服务器·数据库·qt
JHC0000002 小时前
智能体造论子--简单封装大模型输出审核器
开发语言·python·机器学习
【赫兹威客】浩哥2 小时前
可食用野生植物数据集构建与多版本YOLO模型训练实践
开发语言·人工智能·python
沐知全栈开发2 小时前
Java 封装
开发语言