用Qt5.9做画图程序我见过太多人第一反应是重写paintEvent、维护一个QImage然后在上面刷颜色。这种写法做涂鸦Demo非常快但只要想加个撤销、加个缩放、加个图形选择就会发现自己其实在用Qt最吃力的一种方式。我整理一个小画板工具时把注意力全部放在Qt5.9的Graphics View框架上才发现画图这件事可以做得比想象中简单。这篇文章就按我当时的实现顺序分享如何用Scene、View、Item三个类打造一个能自由画线、画矩形、画椭圆、撤销、导出图片的画图程序。代码基于Qt5.9C核心思路对更高版本也一样适用。1. 先想清楚QPainter涂鸦和Graphics View画图到底差在哪里1.1 Graphics View三件套的职责划分Graphics View框架里最核心的三个类分别是QGraphicsScene、QGraphicsView和QGraphicsItem。很多人第一次接触会觉得头晕其实可以用很生活化的方式理解QGraphicsItem是一张张贴纸QGraphicsScene是一张可以无限铺开的桌面QGraphicsView是举在手里的一张放大镜。你往桌面上放多少张贴纸都行贴纸有自己的位置、大小、朝向放大镜负责把桌面上的某个区域映射到你的眼睛也就是屏幕。在一个画图程序里用户拿鼠标画出的每条线、每个矩形、每个椭圆本质上都是往“桌面”上放了一张“贴纸”。QGraphicsScene负责管理这些贴纸包括谁在上层谁在下层、哪些贴纸重叠、哪个被鼠标点中QGraphicsView负责把桌面上某个矩形区域按一定缩放比例画到窗口上。这种分工带来的最大好处是你不需要自己维护一个复杂的图形列表也不需要自己在paintEvent里逐条绘制所有图形。很多人一开始会问那我直接在QWidget的paintEvent里用QPainter画不也一样吗确实能画但区别在于所有图形信息是“一次性”的画完就丢。你想选中某条线、想拖走某个矩形、想撤销上一次操作都必须自己写一套数据结构来保存坐标和样式。而Graphics View从设计上就把这些能力内置了每个图形都是一个独立对象有生命周期可以被选择、移动、删除还可以发信号通知场景发生了变化。1.2 为什么“最简单”的画板仍值得用Graphics View如果只追求最低代码量直接在QWidget上画QImage确实更简单。但“最简单”不应该只看首版代码量而要看后续每次加功能时的成本。我整理过这两种方案的区别维度QWidget QPainterGraphics View涂鸦首版代码很少几十行就能画需要理解三个类稍多撤销/重做需要自己保存每步状态图形项本身可增删容易实现选择/拖拽已有图形基本要重写框架默认支持缩放/滚动自己要算坐标偏移坐标系自动映射大量图形性能每次全量重绘容易卡场景局部更新相对有优势所以我在选型时的判断标准很清楚如果只是给临时工具画几笔QWidget方案完全够用但如果想做一个能持续维护、未来可能加图层、加属性编辑、加导出功能的画图程序直接用Graphics View才是正路。这篇文章标题里说的“最简单制作画图”我理解的是“结构最简单的可用画图程序”而不是“代码行数最少的一键涂鸦”。2. 最小可运行骨架场景、视图、画布的装配顺序2.1 认识四个关键词Scene、View、Item、坐标搭建Graphics View画图程序最先要搞定的是场景和视图的配对。QGraphicsScene本身不负责显示它只是一个数据容器QGraphicsView才是真正继承自QWidget的控件可以嵌到窗口里显示。所以标准的装配顺序是先创建场景再创建视图并把场景设置进去然后往场景里添加各种Item。这里有一个新手特别容易忽略的点QGraphicsScene默认的sceneRect是空的视图不知道要显示多大区域结果就是窗口里可能一片空白或者滚动条出现得莫名其妙。最简单的做法是给场景一个明确的矩形范围比如一张1280乘800的“画纸”QGraphicsScene *scene new QGraphicsScene(this); scene-setSceneRect(0, 0, 1280, 800); QGraphicsView *view new QGraphicsView(scene, this); view-setRenderHint(QPainter::Antialiasing);setRenderHint(QPainter::Antialiasing)打开抗锯齿画出来的直线和椭圆边缘就不会出现明显的锯齿感。对画图程序来说这个开关基本是必须的否则线条效果会很粗糙。此外还要把view加入主窗口的布局比如setCentralWidget(view)这样才能在窗口里看到它。2.2 从空窗口到能接收鼠标的PaintView虽然可以直接用QGraphicsView这个类但画图必须在鼠标按下、移动、松开时做出不同响应所以最干净的做法是继承一个PaintView类在里面重写鼠标事件。为什么选择继承View而不是继承Scene因为鼠标事件首先到达的是View层的viewport你需要在View层把屏幕坐标变换成场景坐标再决定往场景里加什么Item。我新建工程时的结构很简单一个MainWindow负责放工具栏和按钮一个PaintView负责所有画图逻辑。PaintView构造函数里除了创建场景还要设置几个关键属性class PaintView : public QGraphicsView { Q_OBJECT public: explicit PaintView(QWidget *parent nullptr); void setDrawMode(DrawMode mode); void setPen(const QPen pen); protected: void mousePressEvent(QMouseEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override; void mouseReleaseEvent(QMouseEvent *event) override; private: QGraphicsScene *m_scene nullptr; };这几个属性就是我说的“最小可运行骨架”PaintView::PaintView(QWidget *parent) : QGraphicsView(parent) { m_scene new QGraphicsScene(this); m_scene-setSceneRect(0, 0, 1280, 800); setScene(m_scene); setRenderHint(QPainter::Antialiasing); setMouseTracking(true); setDragMode(QGraphicsView::NoDrag); }setMouseTracking(true)让鼠标在不按住任何按钮时也能移动事件后面做悬停预览会用到。setDragMode(QGraphicsView::NoDrag)则很重要一旦你把拖拽模式设置成RubberBandDrag鼠标左键拖拽就会被QGraphicsView拿去画框选区域你的绘图代码就无法生效。画图程序里一定要显式设成NoDrag这个坑我在后面还会再提。到这里一个能接收鼠标事件的画图框架就搭好了。接下来要解决的核心问题是鼠标按下后如何把一条条笔画真正变成场景里的图形Item。3. 自由画笔鼠标事件到QGraphicsPathItem的完整链路3.1 为什么选择QGraphicsPathItem而不是每条线addLine实现自由画笔时最容易想到的方式是每次mouseMove都往场景里addLine把上一个点和当前点连起来。这种方式写起来很快但有明显问题画一分钟就会产生成百上千个QGraphicsLineItem。Graphics View虽然对Item数量有一定优化但上千个图形项同时存在刷新时依然会明显变卡。合理的做法是使用QGraphicsPathItem。一条自由画笔对应一个QGraphicsPathItem内部用QPainterPath保存整条笔迹的路径。鼠标按下时创建Item并记录起点鼠标移动时不断向QPainterPath追加线段鼠标松开时这条笔画就完整了。这样一来无论你画多长场景里都只有一个Item撤销、移动、旋转都非常方便。从数据结构的角度看QPainterPath就是把多个点连成一条路径的容器非常契合手写笔迹和自由涂鸦。它还天然支持贝塞尔曲线后续如果想把笔画变平滑甚至不需要替换Item类型只要修改路径构造方式就行。3.2 PaintView核心实现按下、移动、松开三个阶段的处理我实现鼠标事件时先定义了一个绘图模式枚举用来区分当前要画的是自由画笔还是直线还是矩形、椭圆enum class DrawMode { None, Pen, Line, Rect, Ellipse };None表示当前不画图鼠标可以直接用来选择、拖动场景里已有的图形项。实际绘制时我在mousePressEvent里根据模式创建对应的Itemvoid PaintView::mousePressEvent(QMouseEvent *event) { if (event-button() Qt::LeftButton m_mode ! DrawMode::None) { m_startPoint mapToScene(event-pos()); m_drawing true; switch (m_mode) { case DrawMode::Pen: createPenItem(); break; case DrawMode::Line: createLineItem(); break; case DrawMode::Rect: createRectItem(); break; case DrawMode::Ellipse: createEllipseItem(); break; default: break; } } else { QGraphicsView::mousePressEvent(event); } }这里最关键的一行是mapToScene(event-pos())。鼠标事件里的pos()是视口坐标不是场景坐标。如果窗口没有滚动、没有缩放两者数值可能碰巧一样但只要用户拖动过滚动条直接用pos()就会出现“鼠标画线画笔飘到别处”的诡异现象。mapToScene就是为了把视口坐标正确转换到场景坐标。creatPenItem的实现如下void PaintView::createPenItem() { QPainterPath path; path.moveTo(m_startPoint); QGraphicsPathItem *pathItem new QGraphicsPathItem(); pathItem-setPath(path); pathItem-setPen(m_pen); m_scene-addItem(pathItem); m_pathItem pathItem; }移动阶段根据模式分别更新Itemvoid PaintView::mouseMoveEvent(QMouseEvent *event) { if (m_drawing (event-buttons() Qt::LeftButton)) { QPointF pos mapToScene(event-pos()); switch (m_mode) { case DrawMode::Pen: if (m_pathItem) { QPainterPath path m_pathItem-path(); path.lineTo(pos); m_pathItem-setPath(path); } break; case DrawMode::Line: if (m_lineItem) m_lineItem-setLine(QLineF(m_startPoint, pos)); break; case DrawMode::Rect: if (m_rectItem) m_rectItem-setRect(QRectF(m_startPoint, pos).normalized()); break; case DrawMode::Ellipse: if (m_ellipseItem) m_ellipseItem-setRect(QRectF(m_startPoint, pos).normalized()); break; default: break; } } QGraphicsView::mouseMoveEvent(event); }画自由画笔时我每次都在原来的QPainterPath基础上lineTo新点然后setPath回去。一定不要在move里新建Item否则性能会崩。松开鼠标时只需要把m_drawing置为false并把所有临时Item指针清空。Item本身已经通过m_scene-addItem交给场景管理不需要也不应该在Release里delete否则刚画好的图形会立刻消失void PaintView::mouseReleaseEvent(QMouseEvent *event) { if (event-button() Qt::LeftButton m_drawing) { m_drawing false; m_pathItem nullptr; m_lineItem nullptr; m_rectItem nullptr; m_ellipseItem nullptr; } QGraphicsView::mouseReleaseEvent(event); }到这里自由画笔已经能正常工作。后续加直线、矩形、椭圆其实是同一套逻辑按预判的图形在move里修改对应的几何属性。4. 几何形状直线、矩形、椭圆的临时预览Item管理4.1 用模式枚举统一形状工具有了DrawMode枚举扩展形状工具非常快。工具栏上放几个QAction分别把模式切换成Line、Rect、Ellipse。MainWindow里写这样的连接就够了connect(ui-actionLine, QAction::triggered, this, [this] { m_view-setDrawMode(DrawMode::Line); }); connect(ui-actionRect, QAction::triggered, this, [this] { m_view-setDrawMode(DrawMode::Rect); });setDrawMode内部只是把成员变量m_mode赋值。当模式是Pen时创建QGraphicsPathItem模式是Line时创建QGraphicsLineItem模式是Rect或Ellipse时创建QGraphicsRectItem或QGraphicsEllipseItem。它们的共同点是都需要设置m_pen画笔并且都通过m_scene-addItem加入场景。一个值得注意的细节是矩形和椭圆Item默认会带一个黑色填充对画图工具来说通常不希望这样。我创建时都会显式设置setBrush(Qt::NoBrush)让图形只保留边框用户可以更清楚地看到形状范围。当然如果你做的是填充图形工具这里也可以改成其他颜色取决于需求。4.2 预览item的正确收尾与点击穿透问题很多人第一次写形状工具时会想在鼠标移动过程中不断创建新Item、删除旧Item来做预览效果。这种思路需要频繁触发场景重新布局容易出现闪烁还会让代码变得很绕。正确的做法恰恰是只创建一次移动中修改这个Item的几何属性。具体来说mousePressEvent里创建一个矩形Item并加入场景移动时不断调用m_rectItem-setRect(QRectF(m_startPoint, pos).normalized())松开时清空m_rectItem指针。这个Item从创建到松开始终是同一个场景只需要在每次setRect时局部更新性能很好也没有闪烁。QRectF(m_startPoint, pos).normalized()是专门处理反向拖动的。如果用户从右下往左上拖不写normalized的话矩形的宽高是负数图形会不可见。调用normalized()后Qt会自动把左上角和右下角调换过来保证矩形永远显示正常。这个细节我在第一版实现里漏掉过后来被测试提醒才发现。另外还有一个交互问题当绘图模式是None时我们希望鼠标点击可以选中场景里的图形项当绘图模式非None时我们希望鼠标点击一定用来画新图形而不是去碰旧图形。我的做法是在mousePressEvent里判断只有非画图模式才调用基类QGraphicsView::mousePressEvent(event)把事件交给系统去做选择。画图模式下不调用基类从根源上避免“点一下旧图形结果没画上新线段”的问题。这种“模式分离”的思路几乎适用于所有画图工具。工具栏上明明白白分成两类编辑类动作和绘制类动作绘制时屏蔽编辑编辑时屏蔽绘制逻辑清晰很多。5. 实测里最容易踩的坑坐标、交互模式与绘图性能5.1 坐标错位排查一次“鼠标偏离画笔”的经验我在做第一版的时候犯过一个非常典型的错误直接在mousePressEvent里用event-pos()当作场景坐标。在窗口一开始没有滚动时一切正常但一旦用户拖动滚动条、或者把图像缩放一下再画线就会出现一个匪夷所思的现象鼠标在左边画线条却出现在右上方偏移量还会随滚动条位置发生变化。排查时我先怀疑是不是事件被谁拦截了后来又怀疑是Item坐标设错。最后才想到event-pos()返回的是View的视口坐标坐标系原点在viewport左上角而QGraphicsView有一个内部frame宽度和滚动位置这两者加在一起会产生偏移。解决办法就是全文一律使用mapToScene(event-pos())替换后问题立刻消失。这个经验后来帮我避免了很多次重复踩坑。只要涉及Graphics View的鼠标交互第一步永远是把坐标换到场景坐标系不要在视口坐标上直接建Item。后面如果继续用到右键菜单、悬停提示也都统一走mapToScene一套映射吃遍所有场景。5.2 画不出线先检查DragMode、MouseTracking和事件基类调用如果按照上面的代码写画笔画不出来常见原因有三个。一是setDragMode设置了RubberBandDrag。一旦设置了这个模式左键按下并拖拽时QGraphicsView会把它理解为框选鼠标move事件会被用来更新橡皮筋矩形你的绘图代码根本拿不到控制权。画图程序里应该明确设成NoDrag。二是没有对拖拽做出按键判断。我在mouseMoveEvent里写的条件是event-buttons() Qt::LeftButton也就是说必须按住左键移动才更新图形。如果条件写错成event-button()那只能检测到“当前按下的是哪个按钮”的状态无法判断多键组合效果就会很怪。三是基类事件调用顺序问题。如果你在mousePressEvent一开始就调用QGraphicsView::mousePressEvent(event)再把场景坐标转换后的绘制代码写在后面系统可能已经把这次按下的目标标记成某个Item或者触发了框架默认的拖拽逻辑导致你自己的画图代码被覆盖。最稳妥的做法是画图模式下不调用基类事件非画图模式才调用。为了快速定位这类问题可以保留这样一个排查顺序现象可能原因检查位置按住左键拖拽毫无反应拖拽模式抢事件setDragMode是否非NoDrag鼠标释放后图形消失松手时误删ItemRelease里是否delete了临时Item图形位置偏移坐标变换未正确实现是否用了mapToScene鼠标悬停时一直触发绘制未判断左键是否按下move里是否误用了button()5.3 性能与内存不要为每个移动点创建独立Item刚才说自由画笔用QGraphicsPathItem是一个Item承载一条完整路径这是性能的关键。如果画图工具还需要支持“橡皮擦”不要直接删除大量Item更好的做法是维护一个“当前笔画路径”橡皮擦模式时用背景色画一条更粗的线本质上也是往场景里加Item。这样虽然会留下很多背景色线段但对轻量工具来说可接受后续优化可以通过路径布尔运算实现真正的擦除。还有一个容易被忽视的画笔细节QPen的宽度默认是场景单位跟随视图缩放变化。如果用户用滚轮把画布放大到200%原来1像素宽的线会变成2像素宽。对画图工具来说有时我们希望线宽保持屏幕像素一致这种情况可以设置pen.setCosmetic(true)。这样做的好处是无论视口怎么缩放线段宽度都保持固定像素适合做标注类工具。代价是当视图缩放很小时线的视觉相对比例可能看起来不太协调所以具体用不用要看产品需要。最后是内存管理。场景里的Item由QGraphicsScene统一管理正常情况下不需要手动delete。但如果要撤销删除某个Item必须先调用scene()-removeItem(item)把它从场景移除再手动delete item否则会留下悬垂指针。这个顺序错了轻则内存泄漏重则程序崩溃。撤销功能里尤其要注意。6. 让画板更可用撤销、清空、保存图片与后续扩展6.1 撤销和清空的最小实现画图程序没有撤销会很难用。由于图形全部是场景里的Item撤销天然就是“删除最后一个Item”。我维护一个QListQGraphicsItem* m_items每次mouseReleaseEvent成功绘制完成后把对应的Item指针追加到列表里void PaintView::finishDraw(QGraphicsItem *item) { if (item) m_drawItems.append(item); }然后在MainWindow里提供一个undo方法void MainWindow::undo() { if (m_drawItems.isEmpty()) return; QGraphicsItem *item m_drawItems.takeLast(); m_view-scene()-removeItem(item); delete item; }这里尤其要注意必须先removeItem再delete。如果直接delete item场景里还会保留指向这块内存的指针下次重绘时访问已经释放的内存就会崩溃。清空画布用scene()-clear()即可。它会删除场景里所有Item包括正在编辑的临时项。所以如果PaintView里还持有m_pathItem、m_rectItem之类的成员指针清空后必须把它们全部置空否则下一次鼠标事件访问这些悬垂指针同样会崩溃。我习惯在PaintView里专门提供一个resetDrawingState()方法把所有临时指针和m_drawing状态一起重置。6.2 导出图片与后续优化方向Graphics View导出图片非常简单核心是QGraphicsScene::render。我们可以把场景内容画到一张QImage上然后保存成PNGvoid MainWindow::exportImage() { QRectF rect m_view-scene()-sceneRect(); QImage image(rect.size().toSize(), QImage::Format_ARGB32); image.fill(Qt::white); QPainter painter(image); m_view-scene()-render(painter); painter.end(); image.save(output.png); }这里有一个前提是场景坐标从(0,0)开始。如果sceneRect左上角不是原点render出来的位置会和期望不太一致。最简单的办法就是像我一样把sceneRect固定成QRectF(0, 0, width, height)后续所有坐标都建在这个矩形内导出逻辑就永远不用改。后续如果想把程序做得更完整方向其实很多能选中和移动已有图形、能缩放画布、能保存工程文件并重新打开、能把导出格式从PNG扩展成SVG。Graphics View本身都支持这些核心的鼠标事件模型我们已经搭好剩下的就是按功能需求添加工具栏选项。我自己在实际使用中最大的体会是Graphics View画图程序的复杂度不在框架本身而在“状态管理”。什么时候允许绘图、什么时候允许选择、坐标如何正确映射、释放Item后指针如何清理这几件事理清楚后续不管加多少种工具都顺手。如果你也在用Qt5.9做类似的画图工具建议直接在这个最小骨架上迭代比从零实现一套图形数据结构省力得多。