Qt Graphics View Framework(图形视图框架)深度解析:从 Scene/View/Item 到工业级 2D 场景

1. 背景与设计动机:为什么需要 Graphics View

1.1 QPainter 直接绘制的痛点

在 Graphics View 出现之前(Qt3 时代叫 QCanvas,Qt4 起正式命名为 Graphics View),在 Qt 里画 2D 图形基本只有一条路:重写 QWidget::paintEvent(),拿到 QPainter 后一顿 drawLine / drawRect / drawText。

这套"直接绘制"(Immediate Painting)模式有三个绕不开的痛点:

痛点一:画完即忘,图形不是对象。 你用 painter->drawRect(rect) 画了个矩形,这个矩形只是屏幕上的几个像素,程序里没有一个对象代表它。你想让用户点一下这个矩形------做不到,你得自己记录矩形的坐标,然后在 mousePressEvent 里手动做"点是否在矩形内"的判断。图形越多,这种手工命中测试(hit-testing)的代码越爆炸。

痛点二:重绘是"全量重画",性能随复杂度直线下降。 任何局部变化(比如一条线变色),你都得让整个 widget 重绘。图形一多、图形一复杂,界面就开始卡。

痛点三:交互能力全靠手搓。 选中、拖拽、缩放、旋转、批量操作......这些都是图形编辑器的基础需求,但 QPainter 直绘时代每一项都要自己实现,而且每次实现都会遇到"坐标换算"这堵墙。

1.2 Graphics View 架构解决什么问题

Graphics View 的核心思想是:把"画布上的每一个图形"提升为"框架托管的一等公民对象"

  • 每个图形是一个 QGraphicsItem(图元),它有位置、尺寸、层级、变换,框架自动完成命中测试 (点哪个图元)和局部重绘(哪个图元变了只重画它);
  • 图元放在 QGraphicsScene(场景)这个"世界容器"里,场景负责管理所有图元、分发事件、执行碰撞检测、做空间索引加速;
  • 用户看到的是 QGraphicsView(视图),它是"观察世界的窗口"------同一个场景可以挂多个视图(多窗口、鹰眼导航),每个视图可以独立缩放、平移、旋转观察。

💡 通俗类比:把 QPainter 直绘想象成"用粉笔在黑板上画一幅画"------画完就是粉笔灰,想改一处就得擦掉重画;Graphics View 则像"用乐高搭世界"------每个积木都是一个对象,你可以单独拿起、放下、换位置,还有一架可以拉近拉远的望远镜(View)在观察这个世界(Scene)。

一句话总结:QPainter 解决"怎么画",Graphics View 解决"画出来之后怎么管、怎么交互"

1.3 Graphics View 与 QCustomPlot、Model/View 的边界

维度 QPainter 直绘 Graphics View QCustomPlot Qt Model/View
定位 底层绘制 API 通用 2D 场景框架 统计图表专用库 数据展示架构
图形是对象吗 否,画完即像素 是,QGraphicsItem 部分(曲线/轴对象) 否,数据在 Model
命中测试/选中 手搓 框架内置 部分内置 通过索引选中
批量图元性能 优(BSP 空间索引) N/A(网格控件)
典型场景 自定义控件绘制 流程图/地图/画板/游戏 曲线图/柱状图 表格/树/列表
与本文关系 被替代/互补 本篇主角 图表场景可用它实现 完全不同问题域

2. 核心概念:Scene / View / Item 三角架构

2.1 三角色的职责划分

复制代码
┌─────────────────────────────────────────────────┐
│                  QGraphicsScene                 │
│  世界的容器:管理所有图元、事件分发、碰撞检测   │
│  (无边框的抽象世界,坐标单位是"场景坐标")       │
└──────────────────────┬──────────────────────────┘
                       │ 观察/渲染
         ┌─────────────┼─────────────┐
         ▼             ▼             ▼
   ┌──────────────┐  ┌──────────────┐  ┌──────────────┐
   │QGraphicsView │  │QGraphicsView │← |可以有多个视图|
   └──────────────┘  └──────────────┘  └──────────────┘

QGraphicsScene(场景)

  • 职责:管理图元的"世界容器",维护图元列表、执行碰撞检测、把事件转发给图元、决定场景背景/网格如何绘制。
  • 它不是一个 widget,没有可见边框,坐标系是抽象的"场景坐标"(可以理解为"世界坐标")。
  • 内部默认用 BSP(二叉空间分割)树索引图元位置,用于加速"点选/框选/碰撞"查询。

QGraphicsView(视图)

  • 职责:把场景渲染到屏幕上,处理滚动/缩放/平移等"镜头"操作,把鼠标键盘事件翻译成场景坐标后转给场景。
  • 它是真正的 QWidget(或 QGraphicsView 子类),是用户唯一能"看到摸到"的东西。
  • 同一个 scene 可以被多个 view 观察,各自独立缩放平移------经典用途:主视图 + 右上角"鹰眼导航图"。

QGraphicsItem(图元)

  • 职责:场景里的"一个图形对象",如一个矩形、一张图片、一段文字、一个节点。
  • 框架内置了大量现成图元:QGraphicsRectItem、QGraphicsEllipseItem、QGraphicsLineItem、QGraphicsPathItem、QGraphicsPixmapItem、QGraphicsTextItem、QGraphicsPolygonItem、QGraphicsSimpleTextItem 等。
  • 复杂业务图形(流程图节点、电路元件、CAD 零件)需要你继承 QGraphicsItem 自定义,见第 6 章。

2.2 三者如何协同(一条完整链路)

  1. 用户鼠标点下 → QGraphicsView 收到 widget 事件;
  2. View 把 widget 坐标换算为场景坐标,交给 Scene;
  3. Scene 用空间索引找到"命中的图元",把事件转成 QGraphicsSceneMouseEvent 分发给图元;
  4. 图元处理事件(如 mousePressEvent 里标记"我被选中")→ 调用 update() 请求重绘;
  5. Scene 只对发生变化的图元区域发出重绘请求,View 把该区域重画到屏幕。

⚠️ 新手最容易犯的第一个错误 :试图在 View 上直接 addItem。记住------图元只属于 Scene,View 只是观察者。所有 addItem / removeItem 都是 Scene 的方法。


3. 坐标系统三件套:scene / view / item 与 Z 值层级

3.1 三套坐标系,各管一摊

坐标系 属于谁 单位 通俗理解
Item 坐标 每个图元自己 图元本地单位 "我身体内部"的坐标,(0,0) 是图元的本地原点(通常取中心或左上角)
Scene 坐标 场景 场景单位 "整个世界"的坐标,图元的位置 setPos() 就填场景坐标
View 坐标 视图窗口 像素 "望远镜里看到"的坐标,即屏幕上的 widget 坐标

三套坐标的关系一句话:Item 坐标 →(图元变换 + 位置)→ Scene 坐标 →(视图变换 + 缩放平移)→ View 坐标

3.2 三个重量级映射 API

API 作用 典型用法
item->scenePos() 图元(局部某点)→ 场景坐标 想知道节点在世界里的绝对位置
view->mapToScene(QPoint) 视图像素点 → 场景坐标 鼠标点击处对应世界哪个位置(最常用
view->mapFromScene(QPointF) 场景坐标 → 视图像素 把图元世界位置换算成屏幕位置画标记
item->mapToScene(QPointF) / item->mapFromScene(...) 图元局部 ↔ 场景 在自定义 item 内部换算
item->mapToItem(other, pt) / mapFromItem(...) 图元 ↔ 图元 判断两个图元局部坐标互相转换
cpp 复制代码
// 例:在 view 的 mousePressEvent 里拿到"点在哪个图元上"
void MyView::mousePressEvent(QMouseEvent *e)
{
    QPointF scenePos = mapToScene(e->pos());   // 1. 视图像素 → 场景坐标(关键一步)
    QGraphicsItem *hit = scene()->itemAt(scenePos, QTransform());
    // 2. 用场景坐标去问场景"这里有什么图元"(自动做命中测试)
    if (hit) qDebug() << "点到图元:" << hit;
}

⚠️ 避坑预警:itemAt() 的第二个参数是"视图变换"。如果视图做了缩放/旋转,这里必须传 view->transform()(通常写 QTransform() 空变换也行,但涉及非等比缩放时会不精确,标准写法是 itemAt(pos, view->transform()))。

3.3 Z 值层级:谁压谁

场景里多个图元重叠时,谁画在上面?由 item->setZValue(double z) 决定:

  • Z 值大的画在上面(后绘制),Z 值小的画在下面;
  • 默认所有图元 Z 值都是 0;
  • Z 值相同(包括同为 0)时,按 addItem 的加入顺序绘制,后加入的在上
  • Z 值只影响绘制顺序和命中优先级,不影响图元在场景中的位置。
cpp 复制代码
QGraphicsRectItem *bg = scene.addRect(0, 0, 100, 100, pen, brush);
QGraphicsRectItem *fg = scene.addRect(20, 20, 50, 50, pen, brush);
bg->setZValue(0);
fg->setZValue(10);   // fg 盖在 bg 上面

💡 实战经验:给"选中态高亮框""拖拽手柄"这类永远在顶层的装饰图元设置很大的 Z 值(如 1000+),避免被业务图元遮挡。


4. 环境搭建:CMake 工程 + Qt6 Widgets

先决条件:已安装 Qt 6.x(含 Widgets 模块)与 CMake 3.16+,且 Qt 已加入 CMAKE_PREFIX_PATH(或用 Qt Creator 打开时自动检测)。

项目结构

复制代码
graphics_view_demo/
├── CMakeLists.txt
└── main.cpp

CMakeLists.txt(关键行已注释):

复制代码
cmake_minimum_required(VERSION 3.16)
project(GraphicsViewDemo VERSION 1.0 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# Qt6 官方推荐:AUTOMOC 自动处理 Q_OBJECT 元对象代码
set(CMAKE_AUTOMOC ON)

# 找 Qt6 的 Widgets 模块(Graphics View 随 Widgets 一起提供)
find_package(Qt6 REQUIRED COMPONENTS Widgets)

qt_add_executable(graphics_view_demo
    main.cpp
)

target_link_libraries(graphics_view_demo PRIVATE Qt6::Widgets)

💡 如果没装 Qt6 而是 Qt5:find_package(Qt5 REQUIRED COMPONENTS Widgets) + Qt5::Widgets,其余代码几乎通用。本系列其余示例均按 Qt6 书写。

配置 Qt 路径(命令行方式)

bash 复制代码
cmake -S . -B build -DCMAKE_PREFIX_PATH="C:/Qt/6.7.2/mingw_64"
cmake --build build

5. 第一个最小示例:场景-视图-图元三步走

把"三个角色 + 一次坐标换算"跑通的最短完整程序(main.cpp):

cpp 复制代码
#include <QApplication>
#include <QGraphicsScene>
#include <QGraphicsView>
#include <QGraphicsRectItem>

int main(int argc, char *argv[])
{
    QApplication app(argc, argv);

    // 第 1 步:创建场景(世界的容器)
    QGraphicsScene scene;

    // 第 2 步:往场景里放图元(画布上的对象)
    // addRect 返回一个 QGraphicsRectItem*,我们把它存下来以便后续操作
    QGraphicsRectItem *rect = scene.addRect(
        -50, -50, 100, 100,       // 位置和大小(场景坐标,中心在原点)
        QPen(Qt::blue, 3),        // 边框:蓝色、3 像素
        QBrush(Qt::yellow));      // 填充:黄色

    // 图元还可以有自己的变换:旋转 45 度(绕自身原点)
    rect->setRotation(45);

    // 第 3 步:创建视图并显示(观察世界的窗口)
    QGraphicsView view(&scene);
    view.setRenderHint(QPainter::Antialiasing);  // 抗锯齿,边缘更平滑
    view.resize(400, 300);
    view.show();

    return app.exec();
}

编译运行后你应该看到一个蓝色边框的黄色菱形(矩形旋转 45°)出现在窗口中央。

逐行解读

  • scene.addRect(...) 返回图元指针,同时把图元加入场景------不需要手动 new + addItem 两步;
  • setRotation(45) 是图元自带的变换属性,只影响这一个图元,这正是对象化图形与 QPainter 直绘的本质区别;
  • view(&scene) 构造时传入场景指针,View 就"看"上了这个场景。

⚠️ 注意:scene 和 view 在这里是栈对象,main 返回时自动析构,析构顺序:view 先于 scene(后构造先析构),这是安全的;反过来如果手动 new 而不管理生命周期,就是第 11 章要讲的内存泄漏坑。


6. 自定义 QGraphicsItem:boundingRect / paint / shape 三件套

内置图元只能覆盖矩形/圆/线/图片等基础形状。真实业务(流程图节点、电路符号、CAD 零件)几乎都要子类化 QGraphicsItem。自定义图元的核心是三个虚函数,理解它们等于掌握了 Graphics View 的自定义契约。

6.1 三个虚函数的分工

虚函数 返回/作用 通俗理解
boundingRect() 返回图元的外接矩形(Item 坐标) "我占多大地方"------框架用它做裁剪 (只重绘这个区域)和命中测试的粗略判断
paint() 真正绘制图形(拿到 QPainter) "我长什么样"------在里面画圆、画线、画文字
shape() 返回图元的精确外形(QPainterPath) "我的真实轮廓"------默认是 boundingRect 矩形,复杂图形要重写,否则点图元空白角也算命中

⚠️ 最核心的避坑规则 :boundingRect() 必须永远返回一个包含全部绘制内容的矩形,且要比实际图形至少大一点点(常用 adjust(-2,-2,2,2)) 。如果画的内容超出 boundingRect,超出部分会被裁剪掉;如果画的内容小于 boundingRect,会产生无谓的重绘区域导致性能浪费。paint 里画的一切都必须在 boundingRect 范围内。

6.2 完整示例:一个可缩放显示文本的"节点"图元

cpp 复制代码
#include <QGraphicsItem>
#include <QPainter>

class NodeItem : public QGraphicsItem
{
public:
    // 构造:记录节点文字;setFlag 开启"可移动"和"可选中"
    explicit NodeItem(const QString &text)
        : m_text(text)
    {
        // ItemIsMovable:允许鼠标拖拽移动
        // ItemIsSelectable:允许被点击选中(配合 selectionChanged 信号使用)
        // ItemSendsGeometryChanges:位置变化时触发 itemChange 回调(用于拖拽时联动)
        setFlags(QGraphicsItem::ItemIsMovable
               | QGraphicsItem::ItemIsSelectable
               | QGraphicsItem::ItemSendsGeometryChanges);
    }

    // ① 外接矩形:返回图元占用的矩形(Item 坐标,以 (0,0) 为中心)
    // 注意:必须覆盖所有绘制内容,且留出 2px 余量避免裁剪/闪烁
    QRectF boundingRect() const override
    {
        return QRectF(-60, -30, 120, 60).adjusted(-2, -2, 2, 2);
    }

    // ② 精确外形:默认返回 boundingRect;圆角矩形类图形建议给出精确 path,
    //    否则点击圆角外的"透明角"也会命中本图元
    QPainterPath shape() const override
    {
        QPainterPath path;
        path.addRoundedRect(-60, -30, 120, 60, 8, 8);  // 圆角矩形路径
        return path;
    }

    // ③ 绘制:所有绘图代码都在这里,QPainter 用法与 QPainter 直绘完全一致
    void paint(QPainter *painter, const QStyleOptionGraphicsItem *option,
               QWidget *widget) override
    {
        Q_UNUSED(widget);

        // 选中态高亮:option->state 里有 Qt 提供的选中信息
        if (option->state & QStyle::State_Selected) {
            painter->setBrush(QColor(255, 235, 120));  // 选中时浅黄底
        } else {
            painter->setBrush(QColor(200, 220, 255));  // 未选中浅蓝底
        }
        painter->setPen(QPen(Qt::black, 2));
        painter->drawRoundedRect(-60, -30, 120, 60, 8, 8);  // 画圆角矩形

        // 画文字:居中显示
        painter->drawText(QRectF(-60, -30, 120, 60),
                          Qt::AlignCenter, m_text);
    }

private:
    QString m_text;
};

6.3 加入场景并交互

cpp 复制代码
// 在主窗口/视图初始化处
NodeItem *node = new NodeItem("开始");
scene->addItem(node);      // 场景接管所有权,不需要手动 delete
node->setPos(100, 100);    // 放到场景坐标 (100,100)

// 选中状态监听:图元选中/取消选中时打印
connect(scene, &QGraphicsScene::selectionChanged, [](){
    qDebug() << "选中状态变化!";
});

因为第 6.2 节里设置了 ItemIsMovable,现在鼠标按住节点就能直接拖动------你没写一行拖拽代码,框架全包了,这就是对象化图形的威力。


7. 交互:鼠标键盘事件、碰撞检测、拖拽与动画

7.1 鼠标与键盘事件

自定义图元里重写事件虚函数即可:

cpp 复制代码
class InteractiveItem : public QGraphicsItem
{
protected:
    // 鼠标按下:只在图元内部区域触发(框架已做命中测试,不需要自己判断)
    void mousePressEvent(QGraphicsSceneMouseEvent *event) override
    {
        // event->pos() 是 Item 坐标
        // event->scenePos() 是场景坐标
        qDebug() << "按下,局部坐标:" << event->pos()
                 << "场景坐标:" << event->scenePos();
        // 注意:这里可以不调用基类,但想保持默认行为(如触发 movable)
        // 或让事件继续传递,就调用基类版本
        QGraphicsItem::mousePressEvent(event);
    }

    // 双击
    void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override
    {
        qDebug() << "双击了图元";
        QGraphicsItem::mouseDoubleClickEvent(event);
    }

    // 键盘事件:需要先 setFlag(ItemIsFocusable) 才能收到键盘事件
    void keyPressEvent(QKeyEvent *event) override
    {
        if (event->key() == Qt::Key_Delete) {
            qDebug() << "删除键:准备删除自己";
            // 注意:不能在事件处理里直接 delete 图元(可能仍在栈上)
            // 标准做法:deleteLater()
            this->deleteLater();
        } else {
            QGraphicsItem::keyPressEvent(event);
        }
    }
};

键盘事件要点:图元默认不接收键盘事件,必须:

  1. setFlag(QGraphicsItem::ItemIsFocusable, true);
  2. 场景里至少有一个图元获得焦点(item->setFocus(),或在图元被点击后由框架自动授予);
  3. 之后图元才能收到 keyPressEvent。

7.2 碰撞检测

QGraphicsScene 提供两个碰撞检测入口:

API 语义 典型用法
scene->items()(可传矩形/路径/多边形) 找出与某区域相交的所有图元 框选、区域查询
scene->collidingItems(item) 找出与指定图元碰撞的所有图元 游戏撞墙、连线吸附、元素重叠检测
cpp 复制代码
// 例:检测矩形选区内的所有图元(框选)
QList<QGraphicsItem*> hitList = scene->items(
    QRectF(10, 10, 200, 150),      // 场景坐标下的矩形区域
    Qt::IntersectsItemShape);      // 相交判定方式:与图元精确外形相交才算

// 例:游戏里子弹图元撞到了谁
QList<QGraphicsItem*> collided = scene->collidingItems(bullet);
for (QGraphicsItem *other : collided) {
    if (other->data(0).toString() == "enemy") {  // data() 可挂自定义属性,见 9 章
        qDebug() << "命中敌人!";
    }
}

7.3 拖拽:图元拖到视图、视图外部文件拖进来

Graphics View 有两层拖拽,别混淆:

第一层:图元自身的移动(第 6 章已覆盖)------设置 ItemIsMovable 即可,无需任何代码。

第二层:拖放(Drag & Drop)------把图元拖出/拖入视图,或从文件管理器拖文件到视图上。实现要点:

cpp 复制代码
// 视图子类里启用拖放接收
class DropView : public QGraphicsView
{
public:
    DropView(QWidget *parent = nullptr) : QGraphicsView(parent)
    {
        setAcceptDrops(true);               // 视图接受拖放
        setDragMode(QGraphicsView::RubberBandDrag); // 顺便支持橡皮筋框选
    }

protected:
    void dragEnterEvent(QDragEnterEvent *e) override
    {
        // 只接受文件拖入
        if (e->mimeData()->hasUrls()) e->acceptProposedAction();
    }

    void dropEvent(QDropEvent *e) override
    {
        // 把拖入的文件路径换算成场景坐标,创建图元
        QPointF scenePos = mapToScene(e->position().toPoint());
        for (const QUrl &url : e->mimeData()->urls()) {
            auto *pix = new QGraphicsPixmapItem(QPixmap(url.toLocalFile()));
            pix->setPos(scenePos);
            scene()->addItem(pix);  // 场景接管所有权
        }
    }
};

💡 若想让图元自己支持"可被拖出"(比如拖到别的 widget),要重写图元的 mouseMoveEvent + 启动 QDrag,并配合 ItemIsMovable 关闭默认移动。这是进阶话题,多数场景用不到。

7.4 动画:QGraphicsItemAnimation 与 QPropertyAnimation

方式一:QGraphicsItemAnimation(经典,配合 QTimeLine)

cpp 复制代码
QTimeLine *timeline = new QTimeLine(1000);   // 动画时长 1000ms
timeline->setFrameRange(0, 100);             // 0~100 帧

QGraphicsItemAnimation *anim = new QGraphicsItemAnimation;
anim->setItem(nodeItem);
anim->setTimeLine(timeline);

// 第 0 帧在 (0,0),第 50 帧移动到 (200,0),第 100 帧到 (200,200)
anim->setPosAt(0.0, QPointF(0, 0));
anim->setPosAt(0.5, QPointF(200, 0));
anim->setPosAt(1.0, QPointF(200, 200));

timeline->start();

方式二:QPropertyAnimation(现代推荐,图元需要 QGraphicsObject)

QGraphicsObject 是「QObject + QGraphicsItem」的合体(见第 9 章),它让图元拥有属性系统,从而可以被 QPropertyAnimation 直接动画:

cpp 复制代码
// MyGraphicsObject : public QGraphicsObject
QPropertyAnimation *posAnim = new QPropertyAnimation(myObj, "pos");
posAnim->setDuration(800);
posAnim->setStartValue(QPointF(0, 0));
posAnim->setEndValue(QPointF(300, 150));
posAnim->setEasingCurve(QEasingCurve::OutBounce);  // 弹性出射,效果更生动
posAnim->start();

对比:QGraphicsItemAnimation 需要手动管 QTimeLine,适合"轨道动画"(预先定义多关键帧);QPropertyAnimation 更符合现代 Qt 声明式动画习惯,配合 QGraphicsObject 还能直接用信号槽(如 itemChange 通知状态)。

⚠️ 动画避坑:动画期间频繁修改图元位置是高频更新,务必确保 boundingRect() 精确且不夸张,否则每帧都触发大区域重绘,动画会卡。


8. 视图导航:缩放与平移

这是"地图/画板/监控大屏"类应用必备能力。三种常用手段:

8.1 setTransform 手动缩放(最灵活)

cpp 复制代码
void zoomView(QGraphicsView *view, qreal factor)
{
    // 以鼠标当前位置为锚点缩放,体验更贴近"放大镜跟手"
    view->setTransformationAnchor(QGraphicsView::AnchorUnderMouse);
    view->scale(factor, factor);   // 等比缩放
}

setTransformationAnchor 三个选项:

  • AnchorViewCenter(默认):以视图中心为锚点;
  • AnchorUnderMouse:以鼠标位置为锚点(地图类应用首选);
  • AnchorNoAnchor:不保持锚点(变换后内容会跑位,一般不推荐)。

8.2 滚轮缩放(重写 wheelEvent)

cpp 复制代码
void MyView::wheelEvent(QWheelEvent *e)
{
    // 每格滚轮 15°,放大 1.15 倍 / 缩小 1/1.15 倍
    const qreal factor = std::pow(1.15, e->angleDelta().y() / 120.0);
    setTransformationAnchor(QGraphicsView::AnchorUnderMouse);
    scale(factor, factor);
}

⚠️ 必须给缩放设置上下限,否则无限放大到数值溢出、无限缩小到图元消失:

cpp 复制代码
QTransform t = transform();
if (t.m11() < 0.1 && factor < 1.0) return;  // 已最小,拒绝继续缩小
if (t.m11() > 10.0 && factor > 1.0) return; // 已最大,拒绝继续放大

8.3 平移(拖拽画布 / 空格+拖拽)

cpp 复制代码
// 方案 A:ScrollHandDrag 模式------按住左键拖拽整张画布
view->setDragMode(QGraphicsView::ScrollHandDrag);
// 缺点:此模式下左键无法选中图元;常见做法是空格按下时切到该模式

// 方案 B:手动记录平移量(用 scrollbars)
void MyView::mouseMoveEvent(QMouseEvent *e)
{
    if (m_panning) {   // m_panning 由 mousePress/Release 维护
        QPoint delta = e->pos() - m_lastPos;
        horizontalScrollBar()->setValue(horizontalScrollBar()->value() - delta.x());
        verticalScrollBar()->setValue(verticalScrollBar()->value() - delta.y());
        m_lastPos = e->pos();
    }
    QGraphicsView::mouseMoveEvent(e);
}

8.4 鹰眼导航(一 Scene 多 View 的经典应用)

cpp 复制代码
// 主视图 + 鹰眼视图共用一个场景
QGraphicsView *mainView = new QGraphicsView(&scene);
QGraphicsView *miniView = new QGraphicsView(&scene);

// 鹰眼:固定小尺寸、只读、随主视图联动
miniView->setFixedSize(180, 120);
miniView->scale(0.1, 0.1);          // 鹰眼缩小比例
miniView->setInteractive(false);     // 鹰眼不做交互
miniView->setDragMode(QGraphicsView::NoDrag);

// 主视图滚动时同步鹰眼视野(伪代码示意)
connect(mainView->horizontalScrollBar(), &QScrollBar::valueChanged,
        miniView, [&](){ miniView->centerOn(mainView->mapToScene(mainView->viewport()->rect().center())); });

9. 核心 API 详解

9.1 QGraphicsScene ------ 世界的容器

API 语义 备注
addItem(QGraphicsItem*) 加入图元并接管所有权 图元删除用 removeItem 或直接 delete
addRect / addEllipse / addLine / addPath / addPixmap / addText / addSimpleText / addPolygon 快速创建内置图元并加入 返回对应图元指针,方便链式设置
removeItem(item) 把图元从场景移除(不删除对象 移除后需自行管理内存
clear() 删除并清空所有图元 会真正 delete 图元,注意别再用旧指针
items() 返回场景内所有/某区域图元 可传矩形/路径/多边形做区域查询
itemAt(pos, transform) 返回指定场景坐标处最顶层图元 传视图 transform 更精确
collidingItems(item) 返回与指定图元碰撞的图元列表 默认按 shape 相交判定
setSceneRect(rect) 设置场景"世界边界" 影响视图滚动范围
selectionChanged 信号 选中集合变化时发射 常用于状态栏显示选中数
changed(rects) 信号 场景某区域重绘时发射 可用于自定义同步
setItemIndexMethod(BspTreeIndex/NoIndex) 设置图元空间索引方式 性能调优关键,见 11.4
setBackgroundBrush() 设置背景(纯色/网格纹理) 画板/网格地图常用
focusItem() 当前拥有焦点的图元 配合键盘事件

9.2 QGraphicsView ------ 观察世界的窗口

API 语义 备注
setScene(QGraphicsScene*) 绑定/切换场景 一个 view 同时只绑一个 scene
scale(sx, sy) / rotate(a) / translate(dx, dy) 视图级变换(镜头操作) 多次调用会叠加到 transform()
centerOn(item/point) 让场景某点/某图元居中 跳转定位神器
ensureVisible(item/rect) 确保某区域可见(必要时滚动) 自动滚动到指定图元
setRenderHint(QPainter::Antialiasing) 开启抗锯齿 图形圆润的关键开关
setDragMode(NoDrag/ScrollHandDrag /RubberBandDrag) 左键拖拽行为 橡皮筋框选 / 抓手平移
setTransformationAnchor(...) 缩放锚点 见第 8 章
setInteractive(bool) 是否把事件转给场景交互 false 时纯展示,常用鹰眼
setOptimizationFlag (DontSavePainterState/DontAdjustForAntialiasing) 性能优化标志 大批量图元时酌情开启
mapToScene / mapFromScene 视图↔场景坐标映射 核心坐标换算
viewport() 返回内部视口 widget 自定义重绘/事件拦截点
setViewportUpdateMode(...) 重绘策略(Full/Minimal/BoundingRect) 性能调优,见 11.4
setAlignment(...) 场景比视图小时的对齐方式 默认居中

9.3 QGraphicsItem ------ 图元的基类契约

API 语义 备注
setPos / pos() 图元在场景中的位置 存的是父图元坐标系(无父时=场景坐标)
setZValue / zValue() Z 层级 见 3.3
setRotation / setScale / setTransform / setTransformOriginPoint 图元级变换 影响自身绘制与命中
setParentItem(parent) 建立父子图元 父移动子跟随,变换叠加,见 11.6
boundingRect() 外接矩形(必须重写 见 6.1
paint() 绘制(必须重写 见 6.1
shape() 精确外形 默认=boundingRect,复杂图形重写
contains(pt) 判断点是否在图元内 基于 shape
setFlag(Flag, bool) 开关图元能力 见下方 Flag 列表
setFlags(flags) 批量设置能力 常用组合见 6.2
setSelected / isSelected 选中状态 配合 selectionChanged
setFocus / hasFocus 键盘焦点 需 ItemIsFocusable
setCursor() 悬停光标 如十字光标/手型
setToolTip() 悬停提示 零成本体验提升
setData(key, value) 挂任意自定义数据 免建子类的轻量方案
setAcceptHoverEvents(bool) 开启悬停事件 不开启收不到 hoverEnter/Move/Leave
itemChange(Change, value) 属性变化回调 需 ItemSendsGeometryChanges 等 flag
update() 请求重绘 属性变化后手动调用
mapToScene / mapFromScene / mapToItem / mapFromItem 坐标转换族 见 3.2
scenePos() 图元在场景中的绝对位置 等价 mapToScene(0,0)

QGraphicsItem::GraphicsItemFlag 常用枚举

Flag 效果 常见组合场景
ItemIsMovable 可被鼠标拖动 流程图节点
ItemIsSelectable 可被选中 配合框选
ItemIsFocusable 可接收键盘焦点 快捷键删除节点
ItemSendsGeometryChanges 位置/尺寸变化时触发 itemChange 拖拽时实时联动连线
ItemIgnoresTransformations 忽略视图缩放(保持屏幕尺寸不变) 标注文字/图钉,见 11.5
ItemIsPanel 作为"面板"接收键盘事件 复杂面板图元
ItemAcceptsInputMethod 接收输入法(中文输入) 图元内文本编辑

9.4 QGraphicsObject ------ 图元 + QObject

QGraphicsObject : public QObject, public QGraphicsItem,把两棵类树合一,获得:

  • 信号槽能力(图元之间直接 connect);
  • Q_PROPERTY 属性系统(可被 QPropertyAnimation 动画、被 QML 使用);
  • QObject 父子/定时器。
cpp 复制代码
class MyNode : public QGraphicsObject
{
    Q_OBJECT
    Q_PROPERTY(QPointF pos READ pos WRITE setPos)  // 暴露给属性动画
public:
    // 必须实现这两个纯虚函数(来自 QGraphicsItem)
    QRectF boundingRect() const override;
    void paint(QPainter *painter, const QStyleOptionGraphicsItem *o, QWidget *w) override;
signals:
    void clicked();          // 图元也能发信号!
protected:
    void mousePressEvent(QGraphicsSceneMouseEvent *e) override
    {
        emit clicked();
        QGraphicsObject::mousePressEvent(e);
    }
};

与 QGraphicsItem 的选择:纯绘制无交互逻辑用 QGraphicsItem(轻量);需要信号槽/属性动画/定时器/嵌入 QML 时用 QGraphicsObject(代价:多一个 vtable + QObject 开销)。

9.5 QGraphicsSceneEvent ------ 事件对象家族

场景给图元分发事件时,鼠标事件不是裸 QMouseEvent,而是包了一层场景专用的子类:

携带的额外信息 触发时机
QGraphicsSceneMouseEvent pos()(Item 坐标)、scenePos()(场景坐标)、screenPos()(屏幕坐标)、button()、buttons()、modifiers()、lastPos/lastScenePos(上一次位置,拖拽增量计算神器) 鼠标按下/移动/释放/双击
QGraphicsSceneWheelEvent delta()、scenePos()、modifiers() 滚轮
QGraphicsSceneHoverEvent pos()、scenePos()、modifiers() 悬停进入/移动/离开
QGraphicsSceneDragDropEvent mimeData()、scenePos()、dropAction() 拖放
QGraphicsSceneContextMenuEvent scenePos()、screenPos()、modifiers() 右键菜单
QGraphicsSceneHelpEvent scenePos()、screenPos() 帮助(Shift+F1)
QGraphicsSceneMoveEvent / ResizeEvent oldPos/newPos、oldSize/newSize 图元移动/缩放后

关键理解 :event->pos() 是 Item 坐标 (图元本地),event->scenePos() 是场景坐标。在 paint 里写局部坐标,在事件里若想"图元局部位置",用 pos();若想"世界位置",用 scenePos()。

9.6 QGraphicsItemAnimation ------ 关键帧动画

见 7.4 方式一。核心 API:setItem(item)、setTimeLine(timeline)、setPosAt(step, pos)、setRotationAt(step, angle)、setScaleAt(step, scale)、setTranslationAt(step, dx, dy)。step 为 0.0~1.0 的时间比例。

9.7 其他常用类

用途
QGraphicsRectItem / EllipseItem / LineItem / PathItem / PixmapItem / TextItem / SimpleTextItem / PolygonItem 内置图元,直接可用
QGraphicsProxyWidget 把普通 QWidget 嵌入场景当图元(如场景内放 QLineEdit)
QGraphicsEffect(含 QGraphicsDropShadowEffect 等) 图元特效(阴影/模糊/透明)
QGraphicsItemGroup 把多个图元打包成组(可整体移动)
QGraphicsPathItem 任意路径图元,画折线/贝塞尔最灵活
QGraphicsSimpleTextItem 轻量文本图元(比 TextItem 便宜,无富文本)
QGraphicsDropShadowEffect 阴影特效,卡片感
QStyleOptionGraphicsItem paint 里携带的选项(选中态/细节级别/变换)

10. 使用场景与选型建议

10.1 典型场景一览

场景 为什么用 Graphics View 关键能力组合
流程图 / 拓扑编辑器(节点-连线) 节点是图元(可拖拽选中),连线是 LineItem/PathItem,拖拽节点时联动更新连线 ItemIsMovable + ItemSendsGeometryChanges + itemChange 重算连线
地图与画板 图元=地物/笔画;平移缩放导航 + 鹰眼 滚轮缩放 + ScrollHandDrag + 多 View
监控大屏 / 工业组态 设备图标是图元,告警变色=改 paint,状态上报=信号槽 QGraphicsObject + data() 挂设备 ID + 定时刷新
游戏地图(2D 俯视) 地图块/单位都是图元,碰撞检测内置 collidingItems + itemAt + 动画
CAD 轻量查看器 矢量图元可缩放不失真,选中/拖拽是基础编辑能力 高精度变换 + ItemIgnoresTransformations 处理标注
图表标注 / 截图标注 画矩形/箭头/文字标注,可拖可删 内置图元 + 自由坐标系统
关系图谱 / 思维导图 节点+连线图元,力导向布局配合动画 父子图元 + QGraphicsItemAnimation

10.2 选型决策树

复制代码
要画的是什么?
├─ 统计数据图表(折线/柱状/饼)→ 优先 QCustomPlot / QtCharts(现成图表库)
│     └─ 只是简单曲线?→ QPainter 直绘就够了
├─ 表格/树/列表式数据展示 → Qt Model/View
├─ 自由画布上的对象管理(可选中/拖拽/碰撞/多层)→ Graphics View ★
│     └─ 图元数量巨大(>10 万)且都是简单形状?→ 考虑自定义 OpenGL/Vulkan 渲染
└─ 一次性静态绘制(不交互)→ QPainter 直绘即可

核心判断标准是否需要"图形是对象、可交互"。是 → Graphics View;否 → 考虑更轻的方案。


11. 常见问题与避坑指南

11.1 坐标转换错误(最常见的坑)

症状 :鼠标点击位置和图元实际位置对不上,越缩放越偏。 原因 :没有把 View 像素坐标换算成 Scene 坐标就直接使用,或用了错误的映射 API。 正确姿势

cpp 复制代码
// 在 QGraphicsView 子类里:e->pos() 是视图像素坐标
QPointF scenePos = mapToScene(e->pos());   // 必须换算!
// 在 QGraphicsItem 事件里:
QPointF localPos = event->pos();           // 已是 Item 坐标,直接用
QPointF worldPos = event->scenePos();      // 已是场景坐标

进阶坑:视图旋转/非等比缩放后,itemAt() 的 transform 参数传错会导致命中偏移。统一用 scene()->itemAt(scenePos, view->transform())。

11.2 boundingRect 不准确导致重绘异常

症状 :图元边缘有残影、闪烁、内容被裁剪、移动后留"鬼影"。 原因 :paint 画的内容超出 boundingRect(),框架只重绘 boundingRect 区域。 修复

  1. boundingRect 必须完全覆盖所有绘制内容;
  2. 留 adjusted(-2, -2, 2, 2) 余量(抵消抗锯齿和 pen 宽度);
  3. 图元内坐标变化(如文本变长)后主动 update()
  4. 不要在 paint 里依赖外部可变状态却不触发 update。

11.3 图元数量大性能差

症状 :几千个图元后,缩放/拖动明显卡顿。 原因 :命中测试和重绘范围过大。 优化三板斧(按性价比排序):

cpp 复制代码
// ① 索引方式:大量图元用 BSP(默认);图元常变(频繁 add/remove)时用 NoIndex 反而更快
scene.setItemIndexMethod(QGraphicsScene::NoIndex);

// ② 重绘策略:静态场景用 Minimal,动态大批量用 Full(或不更新)
view.setViewportUpdateMode(QGraphicsView::BoundingRectViewportUpdate);
// 或 QGraphicsView::FullViewportUpdate(简单粗暴但可能更流畅)

// ③ 图元本身瘦身
//   - boundingRect 精确(避免大空白区域被当作重绘区)
//   - 避免每帧 new QPen/QBrush(复用成员变量)
//   - 非必要不开抗锯齿的优化标志
view.setOptimizationFlag(QGraphicsView::DontAdjustForAntialiasing);

终极方案:若单场景图元数达到数十万级别且形状简单,考虑 QGraphicsScene::NoIndex + 视图层 setViewport() 换成 QOpenGLWidget,或干脆脱离 Graphics View 用自绘/GPU 方案。

11.4 缩放后线条/文字变粗变大

症状 :视图放大后,本来 1px 的线变成粗线,文字被撑大变形。 原因 :视图变换作用于所有图元,线宽/字号都被缩放。 修复:给需要"屏幕恒定尺寸"的图元加 Flag:

cpp 复制代码
textItem->setFlag(QGraphicsItem::ItemIgnoresTransformations, true);
// 效果:位置随场景缩放平移,但自身尺寸/线宽/字号保持不变(始终屏幕大小)

⚠️ 注意:ItemIgnoresTransformations 让图元忽略视图缩放(还有视图旋转也会被忽略)。若只想"线宽恒定"而文字/图形仍缩放,需要自己写 setCosmetic 风格笔:QPen pen(QColor(0,0,0), 1, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin); pen.setCosmetic(true);------cosmetic pen 是真正的"屏幕像素恒定线宽",与 ItemIgnoresTransformations 无关,两者可组合。

11.5 父子图元变换叠加

规则 :子图元的 setPos/rotation/scale 都是相对父图元坐标系 的;父图元的变换会叠加到子图元上。因此:

  • 移动父图元 → 子图元跟随移动(无需手动);
  • 子图元的 scenePos() 是"父变换 + 自身变换"后的绝对位置;
  • 经典坑:把子图元 setPos 到"场景坐标"的值,但父图元有偏移/缩放,子图元位置就会"跑偏"------因为 setPos 相对父系。要么确认父系坐标,要么用 mapToScene/mapFromScene 换算后再 setPos。
cpp 复制代码
// 正确示例:把子图元放到场景坐标 (500, 500)
QPointF targetScene = QPointF(500, 500);
QPointF localInParent = child->mapFromScene(targetScene); // 场景坐标 → 父系坐标
child->setPos(localInParent);

11.6 内存泄漏与场景清理

规则一:场景接管所有权。 scene->addItem(p) 之后,不要再手动 delete p(可能引发双重释放);删除图元统一用 scene->removeItem(p); delete p; 或直接 delete p;(QGraphicsScene 析构会自动 delete 所有图元,但手动 delete 单个图元时它会自动从场景移除)。

规则二:注意删除时序。 在 keyPressEvent 等事件处理中 delete 图元(尤其 delete 自己)有风险,用 deleteLater():

cpp 复制代码
// 危险:事件还在分发中 delete 自己
// delete this;   ❌
// 安全:
this->deleteLater();   // ✅ 事件循环空闲后安全销毁

规则三:场景清空用 clear()。 scene->clear() 会删除所有图元并清空列表;清空后不要再引用旧图元指针。

规则四:Scene/View 生命周期。 谁 new 谁负责。局部栈对象自动析构(注意析构顺序:View 先于 Scene)。堆对象建议 view->setParent(widget) + scene 作为成员或由 view 管理。

11.7 线程安全

铁律:QGraphicsScene / QGraphicsView / QGraphicsItem 不是线程安全的,必须只在其所属 GUI 线程(主线程)操作。

  • 在子线程更新图元属性、增删图元 → 未定义行为,轻则闪烁,重则崩溃;
  • 正确姿势:子线程计算数据,通过信号槽(QueuedConnection) 投递到主线程,在主线程槽函数里更新图元;
  • 后台持续计算(如仿真)→ 用 QTimer(主线程节拍)或 QThread + 信号投递,避免直接跨线程摸图元。
cpp 复制代码
// 正确:子线程发信号 → 主线程槽更新图元
// class Worker : QObject { signals: void newDataReady(QPointF pos); };
// class UiBridge : QObject { public slots: void updateNode(QPointF pos){ node->setPos(pos); } };
connect(worker, &Worker::newDataReady, bridge, &UiBridge::updateNode, Qt::QueuedConnection);

11.8 其他高频坑

现象 解法
图元不响应鼠标 点击没反应 检查是否设置了对应 flag(ItemIsSelectable/ItemIsMovable);没设任何交互 flag 时图元默认只接收 hover 提示
收不到 hover 事件 hoverEnter 不触发 setAcceptHoverEvents(true)
收不到键盘事件 按 Delete 没反应 setFlag(ItemIsFocusable) + 确保图元有焦点(点击后 setFocus())
图元透明区域也触发点击 点矩形外圈也选中 重写 shape() 返回精确路径
缩放后命中不准 放大后点不中/点错图元 itemAt(pos, view->transform()) 传正确变换
文字图元缩放模糊 放大后文字糊 设置 view->setRenderHint(QPainter::Antialiasing) + TextAntialiasing;或对文字图元加 ItemIgnoresTransformations
视图不显示内容 只有空白窗口 检查 scene rect(scene->setSceneRect)是否覆盖图元区域;view 默认按 scene rect 滚动
图元被裁剪 图元一部分消失 检查 boundingRect 是否覆盖全部绘制内容;检查 scene rect

12. FAQ 速查表

Q1:Graphics View 和 QPainter 有什么区别? QPainter 是底层绘制 API(怎么画),Graphics View 是场景化对象框架(画出的东西怎么管、怎么交互)。Graphics View 内部也是用 QPainter 渲染的,二者是层次关系不是替代关系。

Q2:怎么判断一个项目该用 Graphics View? 核心看"图形是否需要作为对象被选中/拖拽/碰撞/单独更新"。需要 → Graphics View;只是画个静态图表 → QPainter 或 QCustomPlot/QtCharts。

Q3:Graphics View 和 Qt Model/View 有什么关系? 没有直接关系,名字像但问题域不同:Model/View 管"数据如何在表格/树/列表展示",Graphics View 管"图形对象如何在自由画布上摆放交互"。二者可以结合(Model 存数据,Graphics View 画图),但不是同一层的东西。

Q4:图元数量多大需要优化? 经验值:几千个简单图元正常流畅;上万图元开始要开 NoIndex/调整重绘策略;10 万+ 简单图元需考虑 GPU 视口或放弃 Graphics View。

Q5:怎么实现"图钉/标注文字"不被缩放? setFlag(QGraphicsItem::ItemIgnoresTransformations, true),图元保持屏幕尺寸不变。

Q6:怎么让线宽不随缩放变粗? 用 cosmetic pen:QPen pen(...); pen.setCosmetic(true);。

Q7:拖动图元时怎么实时更新连到它的连线? 图元设置 ItemSendsGeometryChanges,重写 itemChange(ItemPositionHasChanged, ...),在其中重算连线端点并 update()。

Q8:怎么实现框选? view->setDragMode(QGraphicsView::RubberBandDrag),然后监听场景 selectionChanged;配合图元 ItemIsSelectable。

Q9:怎么让场景支持"无限画布"? 设置 scene->setSceneRect 为足够大的范围,或动态扩展(在图元移动越界时扩大 rect)。注意 BSP 索引在超大 rect 下命中性能会下降。

Q10:图元上的文本怎么支持中文输入? 图元需要 setFlag(ItemAcceptsInputMethod) 并实现 inputMethodEvent;或者把 QGraphicsTextItem 设为可编辑(setTextInteractionFlags(Qt::TextEditorInteraction))。

Q11:多个视图观察同一场景会冲突吗? 不会。同一场景多视图各自独立缩放平移(鹰眼导航原理);事件分发时视图自动换算坐标,互不干扰。

Q12:图元可以嵌入普通 QWidget 吗? 方向反了:是把 QWidget 嵌入场景------用 scene->addWidget(new QLineEdit(...)) 返回 QGraphicsProxyWidget。反向(在 widget 上放图元)不是 Graphics View 的职责。

Q13:Graphics View 支持 GPU 加速吗? 视图可以 setViewport(new QOpenGLWidget) 走 OpenGL 渲染管线(Qt5.4+ 稳定可用),但图元命中/管理仍是 CPU 的;纯 GPU 大批量绘制建议自定义 OpenGL 方案。

Q14:QGraphicsItem 和 QGraphicsObject 怎么选? 需要信号槽/属性动画/定时器/QML 集成 → QGraphicsObject;纯绘制轻量图元 → QGraphicsItem。


结语

Graphics View 是 Qt 里"性价比"极高的框架:学习成本不高,却能把流程图编辑器、地图查看器、监控大屏、游戏地图这类项目从"手搓几千行命中测试代码"解放成"几十行配置 + 自定义图元"。

记住三条主线,你就掌握了 80%:

  1. 三角架构:Scene 管世界、View 管镜头、Item 管图形;
  2. 坐标三件套:Item → Scene → View,一切交互先换算;
  3. 自定义三虚函数:boundingRect 定疆域、paint 定长相、shape 定轮廓。
相关推荐
程与留1 小时前
09_Qt 布局管理系统——QHBoxLayout、QVBoxLayout、QGridLayout
c++·qt
神仙别闹1 小时前
基于QT(C++)实现旅游线路规划系统
c++·qt·旅游
2601_966949651 小时前
Python 量化数据质量校验:如何确保股票历史日线数据不存在缺失交易日?
开发语言·人工智能·爬虫·python·量化策略·量化·quantdash
aikopen1 小时前
高可用 API 网关限流与微服务过载保护实战:从 Sentinel 动态阈值到 DB 连接池舱壁
数据库·微服务·sentinel
whcyhhh1 小时前
头歌实践教学平台:数据科学与大数据技术导论(七下)
大数据·数据库·python
广东帝工智能安防1 小时前
BCAS桥梁防撞预警系统五层架构深度解析:从感知层到对接层的技术实现与选型指南
开发语言·人工智能·架构·边缘计算·桥梁防撞预警系统
迪康coolmu1 小时前
企业IT运维闭环——工单管理与远程协助一体化实践
java·大数据·运维·网络·数据库·人工智能·安全
树码小子1 小时前
Skill的格式 & 开发语言和工具
开发语言·skill
Java后端的Ai之路1 小时前
05、Python单例模式完全指南
开发语言·人工智能·python·单例模式·oracle