ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Qt QGraphicsView绘图软件:图元交互、撤销重做与JSON序列化

Qt QGraphicsView绘图软件:图元交互、撤销重做与JSON序列化

简介:这份 Qt 绘图软件教程与配套源码面向具备 C++ 基础、希望系统掌握 Qt 二维绘图与桌面应用组织方式的开发者。内容围绕 QPainter 核心绘图类展开,结合重绘事件、绘图事件重写以及鼠标按下、移动、释放事件,演示自由绘制、颜色与画笔属性调整、绘图内容保存为图像并重新加载等常见功能,同时提及图形视图与场景框架在缩放、平移和图形对象交互方面的进阶用法。压缩包共 114 个文件,约 3.54MB,包含 52 个 png 界面与效果截图、18 个头文件、15 个 cpp 源文件、7 个 ui 界面文件以及 pro、qrc 等工程配置,便于在 Qt Creator 中对照阅读、编译调试并梳理项目结构。已有 706 人学习,读者可从中理解绘图区域封装、事件处理逻辑、画笔与画刷设置及图像存取流程,积累 Qt 图形应用二次开发经验。

1. Qt 写绘图软件到底在写什么:从一块空白画布说起

很多人搜索「Qt 编写绘图软件教程及相关源码」,心里想的是找一个能画出方块的例子,抄完就算入门。可真把那段代码跑起来,鼠标一拖就露馅:图形画到视图外面消失、缩放后坐标全错、删掉的图元撤不回来、存盘再打开位置偏移。Qt 绘图软件真正要解决的是三件事——画布怎么组织、图元怎么交互、数据怎么落地。主流且可靠的路线是 QGraphicsView 框架配自定义 QGraphicsItem,叠一套命令栈做撤销重做,再用 JSON 把场景序列化。这套结构能撑起流程图工具、图片标注器、简易白板、轻量 CAD,适合已经会写 Qt 窗口、想从“堆控件”进阶到“图形交互”的开发者。下面按定架构、建图元、接工具、存数据、排故障、做验证的顺序,把可复现的骨架和踩过的坑摊开讲。

2. 选 QGraphicsView 还是 QWidget 自绘:架构定错后面全是返工

在 QWidget 的 paintEvent 里直接 drawRect,前两百行写得很爽,写到“选中一个图元并拖动”时就开始还债:命中判定要自己遍历列表,重叠图元要自己算 Z 序,视图滚动要自己维护偏移量,局部刷新要自己算脏矩形。等你想加缩放和平移,会发现所有坐标换算散落在十几个函数里,改一处崩三处。QGraphicsView 把这些活接了过去:场景负责图元索引和事件分发,视图负责变换和视口裁剪,图元自己管自己的绘制和包围盒。选型的判断标准很朴素——图元数量会超过几十个、需要选中和多选、需要缩放平移、需要撤销重做,只要中两条,就该上 QGraphicsView。

2.1 场景、视图、图元三件套的职责边界

QGraphicsScene 是数据与逻辑层,持有所有图元,维护 Z 序和空间索引,把鼠标事件派发给命中的图元;QGraphicsView 是表现层,把场景映射到窗口,负责变换矩阵、滚动条、视口裁剪;QGraphicsItem 是单个对象,回答三个问题——我占多大范围(boundingRect)、我怎么画(paint)、我精确的形状是什么(shape)。新手最容易犯的错是把绘制逻辑写进 View 的 drawForeground,或者把业务数据挂在 View 上。正确的切法是:图元的模型数据(颜色、线宽、文本)存在图元自身,视图只管“怎么看”,场景只管“有什么”。

空间索引这块值得单独说。场景默认用 BSP 树索引,图元数量上千后,鼠标移动时的命中查询从 O(n) 降到接近 O(log n)。但 BSP 对频繁移动的图元不友好,每次 setPos 都可能触发索引重排。图元数量在几百以内、又需要频繁拖拽时,我一般会调用 setItemIndexMethod(QGraphicsScene::NoIndex) 关掉索引,用线性遍历换拖拽流畅度;等到图元上万、以静态展示为主,再打开 BSP。这是个典型的用场景换性能的取舍,没有标准答案。

2.2 用 CMake 起一个最小可跑的绘图骨架

先搭一个能编译、能画、能滚轮缩放的空壳,后面所有功能都往这个壳里塞。用 CMake 而不是 qmake,是因为 Qt 5 和 Qt 6 的构建差异在 CMake 里能用同一份脚本兜住。

# CMakeLists.txt cmake_minimum_required(VERSION 3.16) project(PainterDemo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_AUTOMOC ON) # 有 Q_OBJECT 的类必须开 set(CMAKE_AUTORCC ON) find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Widgets) find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets) add_executable(PainterDemo main.cpp CanvasView.cpp CanvasView.h ) target_link_libraries(PainterDemo PRIVATE Qt${QT_VERSION_MAJOR}::Widgets)

这段脚本的关键在三处:CMAKE_AUTOMOC ON不能漏,漏了就是一堆 undefined reference to vtable 的链接错误,属于新手期最经典的翻车;find_package(QT NAMES Qt6 Qt5)让同一份工程在 Qt 5 和 Qt 6 上都能配置,QT_VERSION_MAJOR变量会自动填成实际找到的大版本;target_link_libraries只链 Widgets 模块,绘图不需要 Quick,别顺手把 Qml 拉进来增加体积。

// CanvasView.h #pragma once #include <QGraphicsView> class CanvasView : public QGraphicsView { Q_OBJECT public: explicit CanvasView(QWidget *parent = nullptr); protected: void wheelEvent(QWheelEvent *event) override; };
// CanvasView.cpp #include "CanvasView.h" #include <QWheelEvent> CanvasView::CanvasView(QWidget *parent) : QGraphicsView(parent) { setScene(new QGraphicsScene(this)); scene()->setSceneRect(-5000, -5000, 10000, 10000); // 给足画布范围 setRenderHint(QPainter::Antialiasing, true); // 抗锯齿 setRenderHint(QPainter::TextAntialiasing, true); setDragMode(QGraphicsView::RubberBandDrag); // 框选 setTransformationAnchor(QGraphicsView::AnchorUnderMouse); // 缩放锚点 setViewportUpdateMode(QGraphicsView::SmartViewportUpdate); } void CanvasView::wheelEvent(QWheelEvent *event) { const double step = 1.15; // 每格缩放倍率 const double factor = event->angleDelta().y() > 0 ? step : 1.0 / step; // 限制缩放范围,避免放大到像素化或缩小到看不见 const double next = transform().m11() * factor; if (next < 0.05 || next > 40.0) { event->accept(); return; } scale(factor, factor); event->accept(); }

逻辑说明:构造函数里做四件事——建场景、设场景矩形、开抗锯齿、定拖拽模式和缩放锚点。setSceneRect不能省,默认场景矩形会随图元自动扩展,导致滚动条乱跳、居中逻辑失真;给一个远大于可视区的固定矩形,滚动行为才稳定。AnchorUnderMouse是缩放体验的分水岭,它保证缩放时鼠标指向的那个点不动,缺了它就是“一缩放画面就飞走”的玄学现场。

参数说明:step = 1.15是每格滚轮的倍率,1.1 太肉、1.3 太跳,1.15 到 1.2 之间手感比较稳;0.05和40.0是缩放上下限,下限防止缩到亚像素后绘制发虚,上限防止放大到几十倍后图元线条粗得离谱。SmartViewportUpdate让 Qt 自己判断重绘区域,比FullViewportUpdate省,但如果你后面写了依赖全量重绘的背景网格,就得换回全量,这是后话。

2.3 坐标体系与缩放锚点:滚轮缩放不跑偏的写法

QGraphicsView 里同时存在四套坐标:图元坐标(item)、场景坐标(scene)、视图坐标(view)、屏幕坐标(screen)。转换函数就那么几个——mapToScene、mapFromScene、mapToGlobal——但混用的后果很隐蔽。典型症状是:鼠标点击画一个点,缩放两次后再点,新图元落在了老图元旁边几百像素外。原因是用 view 坐标直接 setPos 给了图元,而图元要的是场景坐标。

正确姿势是统一在场景坐标里干活。鼠标事件进来先mapToScene(event->pos()),把视图坐标转成场景坐标;所有图元的 pos、boundingRect 都用场景坐标;只有在弹右键菜单、显示 tooltip 这种需要贴屏幕的位置,才做mapToGlobal。滚轮缩放本身不需要手动换算坐标,因为AnchorUnderMouse已经替你保持了锚点,但如果你自己实现“缩放后把视图中心拉回某个图元”,就必须先记住缩放前的场景中心点,缩放后再centerOn回去,顺序颠倒就会漂移。

还有一个高频坑:sceneRect设得比图元实际范围大很多时,itemsBoundingRect()和sceneRect()是两回事。做“适应窗口”功能要用itemsBoundingRect()并加一点边距,用sceneRect会导致缩放到一大片空白。写这段时我被这个差异坑过整整一个下午,图形明明只有巴掌大,一点“适应窗口”就缩成蚂蚁,血泪经验。

3. 图元与绘图工具:从鼠标落点到图形成形的完整链路

架构立住之后,第二个分水岭是“工具”这一层。很多人把绘制逻辑直接塞进 View 的 mousePressEvent,用一堆 if-else 判断当前是画矩形还是画圆,代码写到三百行就再也加不进新工具。更稳的做法是把“工具”抽象成一个状态机对象:按下时记录起点、移动时更新预览、松开时提交图元并销毁预览。View 只负责把事件转发给当前工具,工具之间互不知情,加一个“画箭头”只需要新增一个类,不动老代码。这一章把自定义图元的必备接口、工具状态机的写法、选中与控制点缩放三块拆开讲。

3.1 自定义 QGraphicsItem 的必备四件套

继承 QGraphicsItem 要重写的方法看着多,真正绕不开的是四个:boundingRect()、paint()、shape()、type()。前两个决定“画在哪、画什么”,后两个决定“点得中吗、你是谁”。

// RectItem.h #pragma once #include <QGraphicsItem> #include <QPen> #include <QBrush> class RectItem : public QGraphicsItem { public: enum { Type = UserType + 1 }; // 自定义类型从 UserType 起算 explicit RectItem(const QRectF &rect, QGraphicsItem *parent = nullptr); QRectF boundingRect() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; QPainterPath shape() const override; int type() const override { return Type; } void setRect(const QRectF &r); QRectF rect() const { return m_rect; } void setPen(const QPen &p) { m_pen = p; update(); } void setBrush(const QBrush &b) { m_brush = b; update(); } private: QRectF m_rect; QPen m_pen{Qt::black, 2.0}; QBrush m_brush{Qt::NoBrush}; };
// RectItem.cpp #include "RectItem.h" #include <QPainter> RectItem::RectItem(const QRectF &rect, QGraphicsItem *parent) : QGraphicsItem(parent), m_rect(rect.normalized()) { setFlags(ItemIsSelectable | ItemIsMovable | ItemSendsGeometryChanges); setAcceptHoverEvents(true); } QRectF RectItem::boundingRect() const { // 必须把画笔宽度算进去,否则描边会被裁掉一半 const qreal pad = m_pen.widthF() / 2.0 + 1.0; return m_rect.adjusted(-pad, -pad, pad, pad); } void RectItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) { Q_UNUSED(option); Q_UNUSED(widget); painter->setRenderHint(QPainter::Antialiasing, true); painter->setPen(m_pen); painter->setBrush(m_brush); painter->drawRect(m_rect); if (isSelected()) { // 选中态自己画,别指望默认虚线 QPen s(Qt::blue, 1.0, Qt::DashLine); painter->setPen(s); painter->setBrush(Qt::NoBrush); painter->drawRect(boundingRect()); } } QPainterPath RectItem::shape() const { QPainterPath path; path.addRect(m_rect); // 命中判定用实际矩形,不含 padding return path; } void RectItem::setRect(const QRectF &r) { prepareGeometryChange(); // 改包围盒前必须调,否则残影 m_rect = r.normalized(); update(); }

逻辑说明:boundingRect里的 padding 是整个类最容易漏的一行。如果只返回m_rect,2 像素宽的描边有一半会落在包围盒外,被视图裁剪掉,表现为“矩形边框缺一条边”或者移动时留下残影——这是 Qt 绘图里传播最广的翻车案例。shape反过来要返回精确形状,它决定鼠标点不点得中;把 padding 也算进去,会导致点图形外面 2 像素也能选中,手感很怪。setRect里先调prepareGeometryChange()再改数据,顺序颠倒就是拖拽时满屏残影,Qt 的文档写得很清楚但极容易忽略。

参数说明:ItemSendsGeometryChanges这个 flag 决定itemChange()会不会被调用,后面做“移动时吸附网格”必须开它;ItemIsMovable让框架接管拖动,省掉自己写 mouseMoveEvent;m_pen.widthF() / 2.0 + 1.0里的+1.0是给抗锯齿留的余量,纯数学算下来只要一半宽度,但抗锯齿会把边缘羽化出去一像素,加 1 省事。

3.2 矩形、椭圆、自由画笔:一套工具状态机

工具基类只暴露三个虚函数:按下、移动、松开。View 在事件里判断当前工具是否为空,非空就转发并accept(),避免事件继续往下传给已经存在的图元。

// DrawTool.h #pragma once #include <QPointF> class QGraphicsScene; class DrawTool { public: virtual ~DrawTool() = default; virtual void press(const QPointF &scenePos, QGraphicsScene *scene) = 0; virtual void move(const QPointF &scenePos) = 0; virtual void release(const QPointF &scenePos, QGraphicsScene *scene) = 0; virtual bool isDrawing() const = 0; };
// RectTool.cpp(节选) void RectTool::press(const QPointF &p, QGraphicsScene *scene) { m_start = p; m_preview = new RectItem(QRectF(p, p)); m_preview->setPen(QPen(Qt::gray, 1.0, Qt::DashLine)); // 预览用虚线 scene->addItem(m_preview); } void RectTool::move(const QPointF &p) { if (!m_preview) return; // 允许反向拖拽,normalized 在 setRect 里做 m_preview->setRect(QRectF(m_start, p)); } void RectTool::release(const QPointF &p, QGraphicsScene *scene) { if (!m_preview) return; QRectF r(m_start, p); scene->removeItem(m_preview); delete m_preview; m_preview = nullptr; if (r.width() < 3 && r.height() < 3) return; // 误点不生成图元 auto *item = new RectItem(r.normalized()); scene->addItem(item); // 这里接命令栈,见第 4 章 }

逻辑说明:预览图元和最终图元是两个对象,不是同一个。预览用虚线、不参与选中,松开时先删预览再建正式图元。这种“一进一出”的写法看起来浪费,但换来两个好处:预览的样式和正式图元彻底解耦,不会出现预览虚线和成品实线互相污染;撤销栈里只记录正式图元的创建,预览不进历史。误点阈值< 3是防手抖,单击一下不拖也会触发 press 和 release,不加判断就会在画布上撒一堆 0 尺寸图元,用户看不见但删不掉。

参数说明:m_start要存场景坐标而不是视图坐标,原因在 2.3 节说过;阈值 3 是按像素估的,高 DPI 屏上可以取到 4 到 5,太低会让小尺寸图形画不出来。自由画笔工具的结构一样,区别是 move 里不更新矩形而是往 QPainterPath 里lineTo,松开时把整条路径塞给一个 PathItem。

3.3 选中、移动与控制点缩放

选中交给框架(ItemIsSelectable),移动交给框架(ItemIsMovable),但“拖右下角控制点缩放”框架不管,得自己写。思路是在hoverMoveEvent里判断鼠标是否落在八个控制点之一,是就换光标形状,在mousePressEvent里记录当前拖的是哪个点,mouseMoveEvent里按方向改 rect。

// 控制点判定(RectItem 内新增) enum Handle { None, Left, Right, Top, Bottom, TopLeft, TopRight, BottomLeft, BottomRight }; Handle RectItem::hitHandle(const QPointF &p) const { const qreal t = 6.0; // 控制点判定半径,随缩放变化需再除 view 的 scale const QRectF r = m_rect; if (QRectF(r.topLeft() - QPointF(t,t), QSizeF(2*t,2*t)).contains(p)) return TopLeft; if (QRectF(r.bottomRight() - QPointF(t,t), QSizeF(2*t,2*t)).contains(p)) return BottomRight; // 其余六个方向同理,此处省略 return None; } QVariant RectItem::itemChange(GraphicsItemChange change, const QVariant &value) { if (change == ItemPositionChange && scene()) { QPointF np = value.toPointF(); const int grid = 10; // 网格吸附步长 np.setX(qRound(np.x() / grid) * grid); np.setY(qRound(np.y() / grid) * grid); return np; } return QGraphicsItem::itemChange(change, value); }

逻辑说明:hitHandle返回枚举而不是布尔值,因为拖左上角和拖右下角对 rect 的修改方向完全相反。判定半径t = 6.0是场景坐标下的值,视图放大到 4 倍时,屏幕上看起来控制点只有 1.5 像素,用户根本点不中;成熟做法是用6.0 / view->transform().m11()把半径换算成当前缩放下的等效值,或者干脆在 paint 里把控制点画成固定屏幕尺寸。itemChange里的吸附是可选项,网格步长 10 对流程图类应用很合适,对自由绘图就是干扰,做成开关更稳妥。

参数说明:ItemPositionChange在位置真正改变之前触发,返回的 QVariant 就是最终位置,所以吸附逻辑写在这里;写成ItemPositionHasChanged就只能事后修正,会看到图元先跳到鼠标位置再弹回网格,视觉上抖一下。吸附取整用qRound而不是强转 int,负数坐标下强转会朝零截断,网格在原点两侧不对称,这个细节不实测很难发现。

4. 撤销重做与文件序列化:让用户敢按下 Ctrl+Z

绘图软件有没有“后悔药”,是业余和专业的分界线。用户画错一笔就想撤,撤不了就会骂。Qt 自带的 QUndoStack 加 QUndoCommand 能把这块做得很干净,难点不在 API,而在“命令的粒度怎么切”。切太细,拖一次图元产生几十条移动命令,用户按十次 Ctrl+Z 才挪回原位;切太粗,把“画矩形 + 改颜色 + 移动”打包成一条,撤销时用户一脸茫然。经验法则是:一次完整的用户意图对应一条命令,拖拽从按下到松开算一次,属性面板改一次值算一次。

4.1 QUndoCommand 的粒度怎么切

// AddItemCommand.h #pragma once #include <QUndoCommand> class QGraphicsScene; class QGraphicsItem; class AddItemCommand : public QUndoCommand { public: AddItemCommand(QGraphicsScene *scene, QGraphicsItem *item, QUndoCommand *parent = nullptr) : QUndoCommand(parent), m_scene(scene), m_item(item) { setText(QStringLiteral("添加图元")); } ~AddItemCommand() override { if (m_owned) delete m_item; } void undo() override { m_scene->removeItem(m_item); m_owned = true; } void redo() override { if (!m_item->scene()) m_scene->addItem(m_item); m_owned = false; } private: QGraphicsScene *m_scene; QGraphicsItem *m_item; bool m_owned = false; // 关键:谁负责 delete };

逻辑说明:这段代码的核心不是 undo/redo 两个函数,而是m_owned这个所有权标记。命令第一次被 push 时,redo 会被自动调用一次,此时图元已经在场景里,不能再 addItem;undo 后图元脱离场景,但对象还活着,等命令析构时才 delete。如果省掉这个标记,要么重复 add 导致图元被场景接管两次直接崩,要么 undo 里直接把图元 delete 掉,redo 时拿着野指针再 add,崩溃点离现场十万八千里,属于典型的需要“黑匣子”式排查的内存问题。

参数说明:setText不只是好看,QUndoView 会把它显示在历史列表里,写成“添加矩形”比“Command”对用户友好得多;析构函数里 delete 图元要求命令的生命周期覆盖图元,如果图元可能被用户手动删除,就得换成更复杂的引用计数方案,简单项目里限制“图元只能通过命令删除”最省心。移动图元要单独写 MoveCommand,在 mousePress 记录起始 pos、mouseRelease 时压栈,中途的 mouseMove 直接 setPos 不产生命令,这就是“一次拖拽一条命令”的落地写法。

4.2 用 JSON 存场景:字段设计与版本号

序列化最容易犯的错是“照着内存结构存”,图元有什么成员就存什么,结果改了类的字段,旧文件全打不开。稳妥做法是给每种图元定义一个扁平的 JSON 结构,带type字段区分,带version字段兜底,坐标统一用场景坐标。

// 保存:遍历场景,按类型分派 QJsonArray SceneSerializer::save(QGraphicsScene *scene) { QJsonArray arr; const auto items = scene->items(Qt::AscendingOrder); // Z 序从底到顶 for (QGraphicsItem *it : items) { QJsonObject o; o["type"] = it->type(); o["x"] = it->pos().x(); o["y"] = it->pos().y(); o["z"] = it->zValue(); if (it->type() == RectItem::Type) { auto *r = static_cast<RectItem *>(it); o["w"] = r->rect().width(); o["h"] = r->rect().height(); o["penColor"] = r->pen().color().name(QColor::HexArgb); o["penWidth"] = r->pen().widthF(); } // 其他类型在此追加分支,互不影响 arr.append(o); } QJsonObject root; root["version"] = 2; // 结构版本,改了字段就加一 root["items"] = arr; return arr; // 实际返回 root,此处为节选 }

逻辑说明:Qt::AscendingOrder保证存盘顺序和 Z 序一致,读回来时按数组顺序 addItem,层叠关系自然还原。每种图元存“位置 + 自身几何”两部分,位置用pos()而不是boundingRect().topLeft(),因为图元内部的 rect 可能不以原点为左上角,存 pos 加相对几何,读回来先 setPos 再 setRect,才和内存里一致。version字段现在没用,等你半年后加了个“旋转角度”字段,老文件缺这个键,读取时按版本号补默认值 0,就能平滑升级——这就是那粒后悔药。

参数说明:颜色存HexArgb不存HexRgb,带透明度的填充色才不丢;线宽存widthF()存浮点不存整数,画笔宽度在缩放场景下经常是 1.5 这种值,取整会让线宽在存读一轮后偷偷变细。JSON 用 QJsonDocument 输出时选Indented方便人眼调试,正式文件可以换Compact省体积,几十兆的场景文件差别很明显。

4.3 打开旧文件的容错读取

读取侧的原则是“缺字段给默认值,未知类型跳过并计数”,绝不让一个坏字段把整份文件废掉。

bool SceneSerializer::load(const QJsonObject &root, QGraphicsScene *scene) { const int ver = root.value("version").toInt(1); // 老文件没版本号按 1 处理 const QJsonArray arr = root.value("items").toArray(); int skipped = 0; for (const QJsonValue &v : arr) { const QJsonObject o = v.toObject(); const int t = o.value("type").toInt(0); QGraphicsItem *item = nullptr; if (t == RectItem::Type) { auto *r = new RectItem(QRectF(0, 0, o.value("w").toDouble(10), o.value("h").toDouble(10))); QPen p(QColor(o.value("penColor").toString("#ff000000"))); p.setWidthF(o.value("penWidth").toDouble(2.0)); r->setPen(p); item = r; } if (!item) { ++skipped; continue; } // 未知类型不中断 scene->addItem(item); item->setPos(o.value("x").toDouble(0), o.value("y").toDouble(0)); item->setZValue(o.value("z").toDouble(0)); if (ver < 2 && item->type() == RectItem::Type) { // v2 才有的旋转字段,老文件补 0 item->setRotation(0); } } return skipped == 0; }

逻辑说明:toInt(1)和toDouble(10)这些带默认值的接口是容错的关键,缺字段不会得到 0 尺寸图元。先 addItem 再 setPos 和 setZValue,顺序不能反,setZValue 在图元未入场景时虽然也能设,但某些场景索引的更新时机依赖加入时的事件,稳妥起见按“加入 → 定位 → 设层”来。skipped计数返回给调用方,UI 上提示“有 3 个对象无法识别,已跳过”,比静默丢数据诚实得多。

参数说明:默认宽高 10 是个占位,真实项目里应该用“上次使用的尺寸”;默认颜色给不透明黑,透明默认会让用户以为文件读坏了。ver < 2那段是示例,实际做版本兼容时把每个新增字段的补默认值逻辑集中在一个函数里,比散落在分支里好维护。

5. 踩坑排查:绘图软件最容易翻车的五个地方

下面五条都是实打实调试出来的,现象、原因、解决按顺序写,遇到同类问题可以当检查清单用。

5.1 图元移动后留下“鬼影”

现象:拖动矩形,原位置残留一条边或一整块模糊的旧图形,鼠标停住才慢慢消失。

原因:boundingRect()没有把画笔宽度算进去,框架按返回的矩形刷新脏区,描边落在区域外的部分永远刷不掉;或者改了图元几何却没调prepareGeometryChange(),场景缓存的包围盒还是旧的。

解决:包围盒按pen.widthF() / 2.0 + 1.0外扩,所有修改几何的 setter 第一行调prepareGeometryChange()。改完还残留,就临时把setViewportUpdateMode设成FullViewportUpdate验证,全量刷新下不残留,说明问题一定出在包围盒,方向就锁定了。

5.2 缩放之后鼠标点不中图元

现象:100% 时点击选中正常,缩小到 30% 后要点好几次才中,放大到 500% 又变成点哪都选中。

原因:shape()或boundingRect()里用了固定像素的容差,缩放后这个容差在场景坐标里被同步放大或缩小;或者命中判定写在视图层的mousePressEvent里,用的是视图坐标去比对场景坐标。

解决:命中判定统一交给shape(),里面只返回真实几何,容差的事情交给框架;确实需要额外容差的场景(比如细线图元),容差值要除以当前视图的缩放系数。检查所有mapToScene/mapFromScene的调用点,凡是把视图坐标直接赋给setPos的都是 bug。

5.3 撤销一次,图元被删了两次导致崩溃

现象:画几个图形后连续 Ctrl+Z,程序在某个时刻段错误,调试栈停在析构函数里。

原因:命令对象析构时 delete 图元,但图元同时被场景或者别的命令持有,重复释放;或者 undo 里直接 delete 了图元,redo 时拿野指针去 addItem。

解决:明确所有权,命令只在“图元确定不会再回到场景”时才 delete,用m_owned这类标记管理,别在 undo 里 delete。QUndoStack 的setUndoLimit也要设一个上限(比如 100),无限历史在长时间使用后既吃内存又让每次操作变慢。

5.4 打开自己保存的文件,图形整体偏移

现象:存盘前图形在画布中央,重新打开后跑到左上角或者偏出可视区。

原因:保存时存的是boundingRect().topLeft(),读取时又用setPos设置,等于把“图元位置 + 内部几何偏移”加了两遍;或者保存用了视图坐标,读取用了场景坐标。

解决:保存pos()加图元自身的相对几何(rect、path),读取时先setPos再设置内部几何。存盘前打印几个图元的pos()和scenePos(),两者相等才说明坐标体系没混。

5.5 图元上千后拖拽明显卡顿

现象:图形数量到几百以后,拖动一个图元时鼠标跟手性变差,任务管理器里 CPU 单核跑满。

原因:场景开着 BSP 索引,每次setPos都触发索引项删除和重插;或者每个图元的paint里都新建 QPen、QBrush,频繁堆分配;再或者视图更新模式是FullViewportUpdate,拖一下重绘整个视口。

解决:图元数量中等且频繁拖拽时,setItemIndexMethod(QGraphicsScene::NoIndex);把 QPen、QBrush 提到图元成员里复用,paint里只做 set 和 draw;更新模式换SmartViewportUpdate或BoundingRectViewportUpdate。这三刀下去,几百图元的拖拽一般能从明显卡顿回到跟手。

6. 导出与渲染验证:PNG 与 SVG 两条路的取舍

功能做完总得导出,很多人直接截屏,结果分辨率一塌糊涂。正确做法是用QGraphicsScene::render把场景画到 QImage 或 QSvgGenerator 上,输出尺寸和内容范围都可控。

// 导出 PNG:按内容范围渲染,带 2 倍超采样 bool exportPng(QGraphicsScene *scene, const QString &path) { const QRectF src = scene->itemsBoundingRect().adjusted(-20, -20, 20, 20); const int scale = 2; // 超采样倍数 QImage img(src.size().toSize() * scale, QImage::Format_ARGB32_Premultiplied); img.fill(Qt::white); // 透明底按需改成 Qt::transparent QPainter p(&img); p.setRenderHint(QPainter::Antialiasing, true); scene->render(&p, QRectF(QPointF(0,0), img.size()), src); p.end(); return img.save(path, "PNG"); }

逻辑说明:范围用itemsBoundingRect()而不是sceneRect(),前者贴合实际内容,后者是那个一万见方的画布,导出会得到一张大比例空白图。render的目标矩形是整张图,源矩形是内容范围,Qt 自动完成缩放映射,所以超采样只需要把目标尺寸乘倍数,不需要额外写缩放逻辑。

参数说明:scale = 2适合屏幕展示,印刷需求可以给 4;adjusted(-20,-20,20,20)是给内容留边距,不留边的话图形描边会紧贴图片边缘,看起来像被裁了。PNG 适合位图交付,SVG 适合需要二次编辑的场合,但 SVG 导出对渐变、图案填充的支持有限,复杂图元建议先用 PNG 验证渲染逻辑,再切 SVG。

导出格式适用场景注意点
PNG截图、文档插图、汇报固定分辨率,放大后模糊,超采样补
SVG矢量交付、印刷、二次编辑渐变色和图案填充可能丢失
PDF打印、多页文档通过 QPdfWriter,字体要嵌入

验证导出是否正确,我习惯做一次“往返比对”:导出 PNG 后重新读回 QImage,和场景在屏幕上截图的像素做抽样比对,偏差超过阈值就说明有图元没渲染或者被裁剪。这个土办法帮我抓到过好几次“某个图元的包围盒漏算导致导出缺边”的问题,比肉眼盯图可靠。

我自己写这类工具时有个固定习惯:每加一种新图元,先写序列化的存和读,再写绘制和交互,最后才写工具入口。顺序反过来的话,功能堆到一半才发现存盘恢复不了,那种返工比一开始多花的两小时难受十倍。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进