简介:这份资源面向具备一定 C++ 基础、希望入门 Qt 图形界面与 2D 绘图开发的程序员与在校学生,围绕 Qt 的 QPainter、QPaintEvent 以及 QGraphicsView/QGraphicsScene 等核心绘图机制,讲解如何从零构建一个可交互的绘图软件。压缩包共 114 个文件,约 3.54MB,包含 15 个 cpp 与 18 个 h 源文件、7 个 ui 界面文件、4 个 pro 工程文件及 4 个 qrc 资源文件,另有 52 张 png 与 4 张 jpg 截图辅助理解,并附 1 份 pdf 与若干 txt 说明。内容覆盖主窗口搭建、绘图区域重写 paintEvent、鼠标按下移动释放事件处理、颜色与画笔工具切换,以及将绘图结果保存为 BMP/PNG 并重新加载等完整流程。已有 704 人学习,适合对照源码梳理 Qt 项目组织方式与事件驱动绘图思路。
1. 从零写一个 Qt 绘图软件:为什么我劝你先别急着拖控件
很多人第一次接触 Qt 绘图,脑子里想的是「拖一个 QGraphicsView,放几个按钮,能画线就完事」。真动手才发现,画布缩放后坐标全乱、图元选中判定飘、撤销重做没处下手、几千个图元一刷新界面就卡成幻灯片。Qt 编写绘图软件教程及相关源码这个方向,真正难的不是「怎么画一条线」,而是怎么把坐标系、图元模型、视图刷新、交互状态这四件事拆干净。这篇笔记按我实际做过的桌面绘图工具路径来写:先讲清楚 QGraphicsScene/QGraphicsView 这套框架为什么适合做绘图软件,再落到最小可运行工程、图元封装、鼠标交互、撤销栈、性能优化,最后给一套能直接抄的源码结构。适合有 C++ 和 Qt 基础、想做一个能真正用起来的画板/标注工具/简易 CAD 的开发者,新手能跟着敲出可运行版本,熟手能对照检查自己的架构有没有埋雷。
2. Qt 绘图软件的框架选型:QGraphicsView 还是 QPainter 裸画
2.1 两种路线的本质差别
Qt 里做绘图,绕不开两条路。一条是继承 QWidget,重写 paintEvent,拿 QPainter 直接往窗口上画;另一条是用 QGraphicsScene + QGraphicsView + QGraphicsItem 这套图形视图框架。前者上手快,画个静态图表、波形图足够;后者前期要理解场景坐标系、视图变换、图元层级,但一旦跑通,缩放、平移、选中、碰撞检测、图元分组这些绘图软件的刚需全是现成的。
我一般这样判断:如果你的图形数量在几百以内、不需要用户交互编辑、不需要缩放平移,QPainter 裸画最省事。只要出现「用户要拖动图元」「要框选多个对象」「要无限画布缩放」这三条里的任意一条,就该上 QGraphicsView。原因是 QPainter 路线下,每一个交互你都得自己维护「屏幕坐标 ↔ 逻辑坐标」的换算,自己写命中测试,自己管重绘区域,代码量会指数级膨胀,而且极容易在缩放后出现坐标偏移这种玄学 bug。
QGraphicsView 的核心价值在于它把「数据」和「显示」分开了。QGraphicsScene 持有所有 QGraphicsItem,负责图元的逻辑组织和碰撞检测;QGraphicsView 是一个观察窗口,负责把场景映射到屏幕,并处理视图变换。你缩放视图,改的是 view 的变换矩阵,图元的逻辑坐标纹丝不动。这一点是后面所有交互能写干净的前提。
2.2 最小可运行工程:一个能画矩形的画板
先不追求功能,把工程骨架搭起来。用 Qt Creator 新建一个 Qt Widgets Application,基类选 QWidget,工程名随便,比如 MiniDraw。核心就三个文件:main.cpp、mainwindow.h、mainwindow.cpp。下面是最小可运行版本的关键代码。
// mainwindow.h #ifndef MAINWINDOW_H #define MAINWINDOW_H #include <QMainWindow> #include <QGraphicsScene> #include <QGraphicsView> class MainWindow : public QMainWindow { Q_OBJECT public: MainWindow(QWidget *parent = nullptr); ~MainWindow(); private slots: void addRectItem(); // 在场景中心添加一个矩形图元 private: QGraphicsScene *m_scene; // 场景:持有所有图元 QGraphicsView *m_view; // 视图:显示场景,处理缩放平移 }; #endif // MAINWINDOW_H// mainwindow.cpp #include "mainwindow.h" #include <QGraphicsRectItem> #include <QToolBar> #include <QAction> MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { // 1. 创建场景,指定场景逻辑范围 m_scene = new QGraphicsScene(this); m_scene->setSceneRect(-2000, -2000, 4000, 4000); // 逻辑坐标范围,可正可负 // 2. 创建视图,绑定场景 m_view = new QGraphicsView(m_scene, this); m_view->setRenderHint(QPainter::Antialiasing, true); // 抗锯齿 m_view->setDragMode(QGraphicsView::RubberBandDrag); // 框选模式 m_view->setTransformationAnchor(QGraphicsView::AnchorUnderMouse); // 缩放锚点跟随鼠标 setCentralWidget(m_view); // 3. 工具栏加一个动作,点一下加一个矩形 QToolBar *bar = addToolBar("tools"); QAction *act = bar->addAction("添加矩形"); connect(act, &QAction::triggered, this, &MainWindow::addRectItem); } void MainWindow::addRectItem() { // 在场景坐标 (0,0) 处创建一个 100x60 的矩形 QGraphicsRectItem *rect = m_scene->addRect(0, 0, 100, 60, QPen(Qt::black, 2), QBrush(Qt::cyan)); rect->setFlags(QGraphicsItem::ItemIsSelectable | QGraphicsItem::ItemIsMovable); // 可选中、可拖动 } MainWindow::~MainWindow() {}这段代码跑起来,你能得到一个带工具栏的窗口,点「添加矩形」会在场景原点叠一个矩形,鼠标能拖动它,按住左键在空白处拖能框选。逻辑说明:setSceneRect决定了场景的逻辑边界,绘图软件一般给一个很大的范围甚至负坐标,方便用户往任意方向画;setDragMode(RubberBandDrag)是框选的关键,不设的话拖拽会被图元吃掉;setTransformationAnchor(AnchorUnderMouse)让滚轮缩放时以鼠标位置为中心,这是绘图软件的手感基础,不设的话缩放会以视图中心为锚点,用起来很别扭。
参数上要注意QPen的宽度是逻辑坐标宽度,视图放大后线会跟着变粗。如果你希望线宽不随缩放变化,得用QPen::setCosmetic(true),这个坑后面避坑章节还会提。
2.3 坐标系的三层关系必须一次讲清
QGraphicsView 体系里有三套坐标:图元坐标(Item)、场景坐标(Scene)、视图坐标(View)。图元自己的 (0,0) 是它的局部原点,pos()返回的是图元原点在场景里的位置;场景坐标是全局逻辑坐标;视图坐标是屏幕像素坐标。鼠标事件传进来的event->pos()是视图坐标,要判断点到了哪个图元,得先m_view->mapToScene(event->pos())转成场景坐标,再交给场景做碰撞检测。
新手最容易翻车的地方,是在图元的paint()里用场景坐标画东西。paint()里的坐标系是图元局部坐标,原点在图元的 (0,0)。如果你在paint()里写painter->drawRect(scenePos().x(), ...),图元一移动,画出来的东西就飞了。正确做法是paint()里只画相对图元原点的内容,位置交给setPos()管。
3. 图元封装与鼠标交互:把「画线、画矩形、选中」写成可复用类
3.1 自定义图元的三个必写函数
内置的 QGraphicsRectItem、QGraphicsLineItem 能应付演示,但真实绘图软件里,每个图元往往要带自己的属性(线宽、颜色、图层、业务 ID),还要自定义绘制和命中区域。这时候就得继承 QGraphicsItem,至少重写三个函数:boundingRect()返回图元的外接矩形(场景坐标下的局部矩形),paint()负责绘制,shape()返回精确的命中形状。
boundingRect()必须把画笔宽度算进去,否则图元边缘会留下残影。常见错误是只返回几何矩形,忘了pen().widthF() / 2的扩展。shape()默认返回 boundingRect 的矩形,对于细线图元,这会导致点击判定范围过大,用户点空白处也能选中线。要精确判定,得用 QPainterPath 构造线的形状并加宽。
// mylineitem.h #ifndef MYLINEITEM_H #define MYLINEITEM_H #include <QGraphicsItem> class MyLineItem : public QGraphicsItem { public: MyLineItem(const QLineF &line, QGraphicsItem *parent = nullptr); QRectF boundingRect() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; QPainterPath shape() const override; void setLine(const QLineF &line); QLineF line() const { return m_line; } private: QLineF m_line; static constexpr qreal kHitWidth = 8.0; // 命中判定加宽,单位逻辑像素 }; #endif // MYLINEITEM_H// mylineitem.cpp #include "mylineitem.h" #include <QPainter> #include <QPainterPath> MyLineItem::MyLineItem(const QLineF &line, QGraphicsItem *parent) : QGraphicsItem(parent), m_line(line) { setFlags(ItemIsSelectable | ItemIsMovable); setAcceptHoverEvents(true); } QRectF MyLineItem::boundingRect() const { // 关键:把画笔宽度的一半扩进去,否则边缘残影 const qreal extra = 2.0; return QRectF(m_line.p1(), QSizeF(m_line.dx(), m_line.dy())) .normalized() .adjusted(-extra, -extra, extra, extra); } void MyLineItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) { Q_UNUSED(option); Q_UNUSED(widget); // 选中时用高亮色,这是绘图软件的通用反馈 QPen pen(isSelected() ? QPen(Qt::blue, 2) : QPen(Qt::black, 2)); painter->setPen(pen); painter->drawLine(m_line); } QPainterPath MyLineItem::shape() const { // 用加宽的路径做命中判定,细线也能点中 QPainterPath path; QPainterPathStroker stroker; stroker.setWidth(kHitWidth); path.moveTo(m_line.p1()); path.lineTo(m_line.p2()); return stroker.createStroke(path); } void MyLineItem::setLine(const QLineF &line) { prepareGeometryChange(); // 改几何前必须调用,通知场景更新索引 m_line = line; update(); }逻辑说明:prepareGeometryChange()是改 boundingRect 之前必须调的函数,它会让场景重新索引这个图元。漏掉它,图元移动或变形后,旧位置的残影会留在屏幕上,这是血泪经验。shape()里用 QPainterPathStroker 把线加宽到 8 像素做命中区域,用户点线附近就能选中,体验比默认矩形判定好得多。kHitWidth这个参数按你的最小图元尺寸调,一般取 6 到 10 之间。
3.2 鼠标交互:画线工具的状态机写法
绘图软件的核心交互是「选工具 → 按下 → 拖动 → 抬起 → 生成图元」。直接在 QGraphicsView 里重写 mousePressEvent 也能做,但工具一多,if-else 会失控。我一般用一个简单的状态机:视图持有当前工具类型和一个「正在绘制的临时图元」指针。
// drawview.h #ifndef DRAWVIEW_H #define DRAWVIEW_H #include <QGraphicsView> class DrawView : public QGraphicsView { Q_OBJECT public: enum Tool { SelectTool, LineTool, RectTool }; explicit DrawView(QGraphicsScene *scene, QWidget *parent = nullptr); void setTool(Tool tool) { m_tool = tool; } protected: void mousePressEvent(QMouseEvent *e) override; void mouseMoveEvent(QMouseEvent *e) override; void mouseReleaseEvent(QMouseEvent *e) override; private: Tool m_tool = SelectTool; QGraphicsItem *m_tempItem = nullptr; // 正在绘制的临时图元 QPointF m_startScenePos; // 起点(场景坐标) }; #endif // DRAWVIEW_H// drawview.cpp #include "drawview.h" #include "mylineitem.h" #include <QMouseEvent> #include <QGraphicsRectItem> DrawView::DrawView(QGraphicsScene *scene, QWidget *parent) : QGraphicsView(scene, parent) { setRenderHint(QPainter::Antialiasing, true); setDragMode(QGraphicsView::RubberBandDrag); setTransformationAnchor(QGraphicsView::AnchorUnderMouse); } void DrawView::mousePressEvent(QMouseEvent *e) { if (m_tool == SelectTool || e->button() != Qt::LeftButton) { QGraphicsView::mousePressEvent(e); // 交给基类处理选中/拖动 return; } m_startScenePos = mapToScene(e->pos()); // 视图坐标转场景坐标 if (m_tool == LineTool) { MyLineItem *line = new MyLineItem(QLineF(m_startScenePos, m_startScenePos)); scene()->addItem(line); m_tempItem = line; } else if (m_tool == RectTool) { QGraphicsRectItem *rect = new QGraphicsRectItem( QRectF(m_startScenePos, QSizeF(0, 0))); rect->setPen(QPen(Qt::black, 2)); scene()->addItem(rect); m_tempItem = rect; } } void DrawView::mouseMoveEvent(QMouseEvent *e) { if (!m_tempItem) { QGraphicsView::mouseMoveEvent(e); return; } QPointF cur = mapToScene(e->pos()); if (auto *line = qgraphicsitem_cast<MyLineItem *>(m_tempItem)) { line->setLine(QLineF(m_startScenePos, cur)); } else if (auto *rect = qgraphicsitem_cast<QGraphicsRectItem *>(m_tempItem)) { rect->setRect(QRectF(m_startScenePos, cur).normalized()); } } void DrawView::mouseReleaseEvent(QMouseEvent *e) { if (m_tempItem) { m_tempItem = nullptr; // 绘制完成,交还给场景管理 return; } QGraphicsView::mouseReleaseEvent(e); }逻辑说明:mapToScene是视图坐标转场景坐标的唯一正确入口,别自己算缩放比例。qgraphicsitem_cast是 Qt 提供的安全向下转型,比 dynamic_cast 快,但要求图元重写type()函数并返回自定义类型值,否则会转失败。临时图元在 release 时只是把指针置空,图元本身已经 addItem 到场景里,由场景负责生命周期,不要 delete。
参数上,setDragMode(RubberBandDrag)和自定义绘制工具会冲突:在 LineTool 下按下鼠标,基类的框选逻辑不能触发,所以 press 事件里判断工具类型后直接 return,不调基类。这个顺序写反了,会出现「画线的同时还在框选」的鬼畜现象。
3.3 撤销重做:QUndoStack 的最小接入
绘图软件没有撤销,用户画错一笔就想砸键盘。Qt 自带 QUndoStack + QUndoCommand,接入成本比想象中低。核心思路:每个会改变文档状态的操作封装成一个 QUndoCommand,redo()执行,undo()回滚。
// additemcommand.h #ifndef ADDITEMCOMMAND_H #define ADDITEMCOMMAND_H #include <QUndoCommand> #include <QGraphicsScene> class AddItemCommand : public QUndoCommand { public: AddItemCommand(QGraphicsScene *scene, QGraphicsItem *item, QUndoCommand *parent = nullptr) : QUndoCommand(parent), m_scene(scene), m_item(item) { setText("添加图元"); } void undo() override { m_scene->removeItem(m_item); } void redo() override { m_scene->addItem(m_item); } private: QGraphicsScene *m_scene; QGraphicsItem *m_item; }; #endif // ADDITEMCOMMAND_H用法是在绘制完成时,不要直接scene()->addItem(item),而是m_undoStack->push(new AddItemCommand(scene(), item))。注意 redo 会在 push 时被自动调用一次,所以图元只会在 redo 里被加进场景,别在 push 之前又手动 add 一遍,否则图元会被加两次。删除、移动、改属性的命令同理,移动命令要在构造时记录旧位置和新位置,undo 时 setPos 回旧位置。
4. 性能与刷新:图元上千后卡顿的排查与优化
4.1 卡顿的三个真实来源
图元数量上去后界面卡,原因通常不是「Qt 不行」,而是这三处:一是每个图元都开了抗锯齿和复杂画笔,paint 调用开销大;二是场景索引没建好,碰撞检测退化成线性遍历;三是视图刷新区域过大,一有变化就整屏重绘。
先确认瓶颈在哪。用 QElapsedTimer 在 paint 里打点,或者直接看 QGraphicsView 的viewport()->update()调用频率。如果拖动一个图元时所有图元都在重绘,说明 boundingRect 返回过大,场景的更新区域被撑爆了。
4.2 可落地的四条优化
第一条,给场景设置合理的setItemIndexMethod。默认是 BspTreeIndex,对静态图元友好;如果图元频繁增删移动,BspTreeIndex 的维护成本反而高,可以试QGraphicsScene::NoIndex,让碰撞检测走线性但省掉索引维护。这个要实测,没有银弹。
第二条,图元数量超过几千时,考虑用QGraphicsItemGroup分组,或者干脆把一批静态图元合并成一个自定义图元,在它的 paint 里一次性画完。合并后选中判定要自己实现,但渲染开销能降一个数量级。
第三条,视图开setViewportUpdateMode(QGraphicsView::SmartViewportUpdate),让 Qt 只重绘变化区域。默认的 MinimalViewportUpdate 在复杂场景下反而可能因为计算更新区域而变慢,Smart 是折中。
第四条,画笔用 cosmetic 模式,避免缩放时反复计算线宽。QPen pen(Qt::black); pen.setCosmetic(true);这样线宽固定为屏幕像素,缩放时视觉一致,也省掉一部分变换计算。
// 视图初始化时的性能相关设置 m_view->setViewportUpdateMode(QGraphicsView::SmartViewportUpdate); m_view->setOptimizationFlag(QGraphicsView::DontSavePainterState, true); m_view->setOptimizationFlag(QGraphicsView::DontAdjustForAntialiasing, true); m_scene->setItemIndexMethod(QGraphicsScene::NoIndex); // 频繁增删时试这个DontSavePainterState告诉 Qt 不用为每个图元保存/恢复 painter 状态,前提是你的 paint 里自己管好状态。DontAdjustForAntialiasing关掉抗锯齿的边界扩展,能减少重绘区域,但可能让边缘有轻微裁切,看你的精度要求。
4.3 大数据量下的取舍
如果图元上万,QGraphicsView 这套也会吃力。这时候常见做法是分层:把不参与交互的背景图元(网格、底图)画成一张 QPixmap 缓存,只把需要交互的图元放进场景。或者用 QGraphicsScene 只承载可视区域内的图元,滚动时动态增删,类似虚拟列表的思路。这个复杂度就上来了,除非确实有上万图元的需求,否则前面四条优化够用。
5. 避坑与排查:绘图软件开发中最容易翻车的五件事
5.1 现象:图元移动后旧位置留下残影
原因:修改了图元的几何(位置、大小、形状)但没有调用prepareGeometryChange(),场景的索引没更新,旧区域的刷新请求没发出。解决:任何会改变 boundingRect 的操作之前,先调prepareGeometryChange(),再改数据,最后update()。顺序不能反。
5.2 现象:滚轮缩放后鼠标点击位置和实际图元对不上
原因:在 mousePressEvent 里用了event->pos()直接当场景坐标,没有mapToScene。或者视图设置了setTransformationAnchor但缩放逻辑里手动改了变换矩阵,锚点失效。解决:所有鼠标坐标进场景前必须mapToScene,缩放统一用scale()配合 AnchorUnderMouse,别自己拼矩阵。
5.3 现象:细线图元点不中,或者点空白处也能选中
原因:shape()返回了默认的 boundingRect 矩形,细线的 boundingRect 很窄,命中区域要么太小要么因为画笔扩展变得很大。解决:重写shape(),用 QPainterPathStroker 按固定像素宽度构造命中路径,宽度取 6 到 10 逻辑像素,兼顾好点中和不误触。
5.4 现象:撤销后图元消失,重做后图元出现两个
原因:AddItemCommand 的 redo 在 push 时被自动调用,如果 push 之前已经手动 addItem,图元就被加了两次;undo 只 remove 一次,剩下一个。解决:图元的添加只放在 redo 里,push 之前不要碰场景。删除命令同理,undo 里 addItem,redo 里 removeItem,别搞反。
5.5 现象:程序运行一段时间后崩溃,堆栈指向图元析构
原因:图元被 removeItem 后没有 delete,或者被 delete 后场景里还留着悬空指针。QGraphicsScene 不拥有被 removeItem 的图元所有权,remove 之后要自己 delete,或者用 QScopedPointer 管理。解决:明确所有权,要么场景管(addItem 后不手动 delete),要么命令管(undo/redo 里成对处理),别两边都管。
6. 进阶技巧:用 QChart 做数据图层,以及一套可复用的源码结构
绘图软件做到后面,往往要叠加数据可视化,比如在画布上画折线图、柱状图。Qt 的 QChart 模块能直接嵌进 QGraphicsScene,因为 QChart 本身就是 QGraphicsWidget 的子类。把 QChart 作为一个图元加进场景,就能和手绘图元共存,缩放平移也统一。
#include <QtCharts/QChart> #include <QtCharts/QLineSeries> #include <QtCharts/QChartView> // 在场景里叠加一个折线图图层 QLineSeries *series = new QLineSeries(); series->append(0, 6); series->append(2, 4); series->append(4, 8); QChart *chart = new QChart(); chart->addSeries(series); chart->setTitle("数据图层"); // QChart 本身是 QGraphicsItem,直接加进场景 chart->setPos(200, 200); // 场景坐标定位 chart->resize(400, 300); // 逻辑尺寸 m_scene->addItem(chart);逻辑说明:QChart 加进场景后,它的交互(hover、点击数据点)会和你的绘图工具冲突,需要在工具切换时禁用 chart 的鼠标事件,或者用setAcceptHoverEvents(false)控制。resize用的是逻辑坐标尺寸,缩放视图时图表会跟着缩放,如果希望图表尺寸固定,得监听视图变换并反向调整。
关于源码结构,我一般这样组织:items/放所有自定义图元类,每个图元一个 .h/.cpp;commands/放 QUndoCommand 子类;tools/放工具状态机相关;view/放 DrawView;mainwindow只做工具栏和菜单的组装。这样加新图元类型时,只动 items 和 commands,视图和主窗口不用改。图元基类抽一个BaseItem,把type()、序列化、属性面板接口统一,后面做保存/加载会省很多事。
最后说个我自己的习惯:每加一个新图元类型,先写它的boundingRect和shape的单元测试,用 QTest 模拟点击和拖动,确认命中判定和边界正确,再往场景里加。绘图软件的 bug 大多出在坐标和边界上,提前测比事后查残影省时间。希望帮到你。
本文还有配套的精品资源,点击获取