简介:这份资料包围绕 Qt 的 2D 绘图能力展开,面向具备基础 C++ 语法、希望用 Qt Creator 完成绘图软件原型的初学者与自学者。内容以 paintExample 为主线,串联 QPainter 绘图、QPaintEvent 重绘、鼠标按下移动释放事件处理,以及主窗口与绘图区域的组织方式;同时涉及画笔与画刷属性调整、颜色和工具选择、将绘图结果转为 QImage 保存为 BMP/PNG、重新加载图像显示等常见功能,便于理解一个完整 Qt 项目的模块划分与事件协作。压缩包共 114 个文件,约 3.54MB,包含 15 个 cpp 源文件、18 个 h 头文件、7 个 ui 界面文件、4 个 pro 工程文件及 qrc 资源文件,另有 png/jpg 预览图、txt 说明和 1 个 pdf 文档,可直接对照源码梳理实现思路。已有 704 人学习,适合用作课堂示例、课程设计或 Qt 绘图入门练习的参考。
1. Qt 2D 绘图从零到能画能存:一套可以直接拆改的源码
很多人装完 Qt Creator,第一个想写的并不是登录界面,而是一个能随手画线的画板。这套 Qt 2D 绘图软件教程配套源码走的正是这条路:从空窗口起步,一层层叠上直线、矩形、椭圆、自由画笔、撤销重做和文件保存,最后拼成一个能真正用起来的 2D 绘图 Demo。它不指望替代专业绘图工具,价值在于把 QGraphicsView 框架、鼠标事件、命令模式和 JSON 序列化这四件事串成一条完整的链路。适合手里有 C++ 基础、想把 Qt 图元体系一次吃透的人;每一层都能单独摘出来,塞进自己的项目里复用。
真正让新手卡住的往往不是「怎么画一条线」,而是画完之后线不见了、撤销一次图形没了、保存再打开位置全偏了。这套代码的结构就是围绕这几个坑设计的:图元负责自己长什么样,场景负责管理所有图元,命令负责记录每一次改动,IO 层只认 JSON 不认界面。下面按「跑起来 → 看懂画布 → 看懂交互 → 避开坑 → 动手改」的顺序拆。
2. 工程结构与构建:从 .pro 到能挂控件的空窗口
拿到一套 Qt 源码,第一件事不是读 main.cpp,而是看目录。目录乱的项目,后面每个功能都会长在主窗口类里,最后 MainWindow 变成两千行的黑匣子。这套代码的分层思路很明确:界面归界面,画布归画布,图元归图元,命令和 IO 各自独立,谁都不许越界去碰别人的成员。
2.1 目录规划:把「画」和「存」彻底分开
PaintBoard/ ├── PaintBoard.pro # qmake 工程文件 ├── CMakeLists.txt # CMake 构建(二选一) ├── main.cpp ├── mainwindow.h/.cpp/.ui # 主窗口:菜单、工具栏、状态栏 ├── canvas/ │ ├── canvasview.h/.cpp # 视图:缩放、平移、鼠标事件分发 │ └── canvasscene.h/.cpp # 场景:图元管理、选中、临时预览 ├── items/ │ └── shapeitem.h/.cpp # 自定义图元:线/矩形/椭圆/自由笔 ├── tools/ │ └── toolmanager.h/.cpp # 工具状态机:当前工具、拖拽中的临时图元 ├── commands/ │ ├── additemcommand.h/.cpp │ └── moveitemcommand.h/.cpp ├── io/ │ └── documentio.h/.cpp # JSON 存盘 / 载入 / 导出 PNG └── resources/ └── icons.qrc这个划分有个好处:items/和commands/完全不依赖mainwindow,你把它们拷到另一个 Qt 工程里,只要场景对象对得上就能编译。canvas/只依赖items/,mainwindow处在最外层负责组装。依赖方向是单向的,改一处不会连锁炸一片——这点在后期加工具的时候体会特别深。
toolmanager单独拎出来是我强烈建议的做法。新手常把「当前是画笔还是矩形」这种状态塞进 MainWindow 的成员变量,结果画布类要反向调用主窗口拿状态,循环依赖就出来了。工具状态集中在一个类里,画布只管问它「当前工具是谁」。
2.2 .pro 与 CMakeLists:两种构建方式怎么选
源码里两套构建文件都给了。qmake 的 .pro 短小直观,适合随手改;CMake 更接近现在 Qt 6 的默认工程模板,也方便接 IDE 和第三方库。
QT += core gui widgets CONFIG += c++17 TARGET = PaintBoard TEMPLATE = app # 头文件与源文件分开列,方便肉眼核对有没有漏加 HEADERS += \ mainwindow.h \ canvas/canvasview.h \ canvas/canvasscene.h \ items/shapeitem.h \ tools/toolmanager.h \ commands/additemcommand.h \ commands/moveitemcommand.h \ io/documentio.h SOURCES += \ main.cpp \ mainwindow.cpp \ canvas/canvasview.cpp \ canvas/canvasscene.cpp \ items/shapeitem.cpp \ tools/toolmanager.cpp \ commands/additemcommand.cpp \ commands/moveitemcommand.cpp \ io/documentio.cpp FORMS += mainwindow.ui RESOURCES += resources/icons.qrc几点说明:QT += widgets在 Qt 5 里必须显式写,只写gui是拿不到 QWidget 的;CONFIG += c++17决定了能不能用std::optional、结构化绑定这些语法,代码里用到了QJsonValue::toVariant之类的接口,低版本 C++ 也能编,但建议直接拉到 17。头文件和源文件都手工列出而不是用$$files()通配,好处是编译报错时能立刻定位到是哪个文件没进工程。
对应 CMake 版本大致是这样:
cmake_minimum_required(VERSION 3.16) project(PaintBoard LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTOUIC ON) set(CMAKE_AUTORCC ON) find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Widgets) find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets) add_executable(PaintBoard main.cpp mainwindow.cpp mainwindow.ui canvas/canvasview.cpp canvas/canvasscene.cpp items/shapeitem.cpp tools/toolmanager.cpp commands/additemcommand.cpp commands/moveitemcommand.cpp io/documentio.cpp resources/icons.qrc ) target_link_libraries(PaintBoard PRIVATE Qt${QT_VERSION_MAJOR}::Widgets)AUTOMOC、AUTOUIC、AUTORCC三个开关必须打开,否则带Q_OBJECT宏的类会报 undefined reference to vtable,这是 CMake 配 Qt 最常见的报错,看着像链接问题,实际是 moc 没跑。
# qmake 路线 mkdir build && cd build qmake ../PaintBoard.pro make -j8 # CMake 路线 cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug cmake --build build -j8编译通过后直接跑可执行文件,正常应该看到一个带菜单栏和左侧工具栏的空画布,网格背景是场景自己画的。如果窗口是黑的或者一片白,先确认setScene()有没有被调用——空场景和没场景,视觉上几乎一样,但鼠标事件去向完全不同。
2.3 主窗口只做组装,不写业务
MainWindow 的构造函数里做的事情很少:
MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), ui(new Ui::MainWindow) { ui->setupUi(this); m_scene = new CanvasScene(this); m_view = new CanvasView(m_scene, this); setCentralWidget(m_view); m_tools = new ToolManager(m_scene, this); m_undo = new QUndoStack(this); // 工具栏动作 -> 工具管理器,一行一个,不做多余逻辑 connect(ui->actionPen, &QAction::triggered, m_tools, [=]{ m_tools->setTool(ToolManager::Pen); }); connect(ui->actionLine, &QAction::triggered, m_tools, [=]{ m_tools->setTool(ToolManager::Line); }); connect(ui->actionRect, &QAction::triggered, m_tools, [=]{ m_tools->setTool(ToolManager::Rect); }); connect(ui->actionEllipse,&QAction::triggered, m_tools, [=]{ m_tools->setTool(ToolManager::Ellipse); }); connect(ui->actionSelect, &QAction::triggered, m_tools, [=]{ m_tools->setTool(ToolManager::Select); }); connect(ui->actionUndo, &QAction::triggered, m_undo, &QUndoStack::undo); connect(ui->actionRedo, &QAction::triggered, m_undo, &QUndoStack::redo); // 视图把鼠标事件转给工具管理器,主窗口不参与判断 connect(m_view, &CanvasView::sceneMousePressed, m_tools, &ToolManager::onPressed); connect(m_view, &CanvasView::sceneMouseMoved, m_tools, &ToolManager::onMoved); connect(m_view, &CanvasView::sceneMouseReleased, m_tools, &ToolManager::onReleased); }这里的关键设计是信号中转。CanvasView 不直接改图元,而是把「场景坐标下的按下 / 移动 / 松开」三个信号抛出去,由 ToolManager 决定干什么。好处是画布类不需要知道有多少种工具,新增工具只要改 ToolManager 的枚举和分支,CanvasView 一行不动。参数上sceneMousePressed(const QPointF &scenePos, Qt::MouseButton button)传的是场景坐标而不是控件坐标,因为图元都活在场景坐标系里,转换越早做越好,晚了就得在每个工具里重复mapToScene。
QUndoStack挂在 MainWindow 上而不是场景上,是因为撤销栈的生命周期应该和文档一致:新建文件就该清空。把栈交给场景管,新建文档时容易忘了clear(),用户一按 Ctrl+Z 就撤回到上一个文件的内容,这种 bug 挺玄学,复现步骤还特别长。
3. 画布与图元:QGraphicsScene 双层架构与 paint 重绘
Qt 做 2D 绘图有两条路:一条是在一个 QWidget 上重写paintEvent,自己拿 QPainter 从头画到尾;另一条是 QGraphicsView 框架,图元自己管自己。这套源码选的是后者。前者上手快,画十几个图形还行,一旦要选中、拖动、缩放、撤销,就得自己维护一个图形列表加命中检测,很快就写不动了。
3.1 Scene、View、Item 三层各管什么
一句话分工:Item 管「我长什么样、我占多大地方」,Scene 管「现在有哪些 Item、谁被选中」,View 管「怎么把 Scene 映射到屏幕上、滚轮缩放多少」。
理解这三层,最直接的方式是看坐标。鼠标点在 View 上的坐标是控件坐标,mapToScene()之后变成场景坐标;图元自己还有一个局部坐标,item->pos()决定它挂在场景的哪个位置,paint()里画的坐标是相对图元原点的局部坐标。三层坐标混着用,就是后面各种偏移 bug 的根源。
场景类的实现很薄,主要做两件事:网格背景和临时预览图元。
// canvasscene.h class CanvasScene : public QGraphicsScene { Q_OBJECT public: explicit CanvasScene(QObject *parent = nullptr); // 临时预览:拖拽过程中显示,松手后要么转正要么删掉 void setPreviewItem(QGraphicsItem *item); void clearPreviewItem(); protected: void drawBackground(QPainter *painter, const QRectF &rect) override; private: QGraphicsItem *m_preview = nullptr; int m_gridStep = 20; // 网格间距,单位是场景坐标 };void CanvasScene::drawBackground(QPainter *painter, const QRectF &rect) { painter->save(); painter->setPen(QPen(QColor(230, 230, 230), 0)); // 0 宽度 = cosmetic pen const qreal left = std::floor(rect.left() / m_gridStep) * m_gridStep; const qreal top = std::floor(rect.top() / m_gridStep) * m_gridStep; QVector<QLineF> lines; for (qreal x = left; x < rect.right(); x += m_gridStep) lines.append(QLineF(x, rect.top(), x, rect.bottom())); for (qreal y = top; y < rect.bottom(); y += m_gridStep) lines.append(QLineF(rect.left(), y, rect.right(), y)); painter->drawLines(lines); painter->restore(); }drawBackground每帧都会被调用,传进来的rect是「本次需要重绘的场景区域」,不是整个场景。所以网格必须按rect的边界循环,不能从 0 开始铺到无穷远,否则场景一大就卡死。QPen宽度传 0 表示 cosmetic pen,意思是无论视图怎么缩放,线在屏幕上永远是 1 像素粗,网格才不会在放大后糊成一团。这个细节很多教程不提,但不写就是放大到 400% 之后网格线变成四条灰带。
m_gridStep定成 20 是经验值:太密了视觉上吵,太疏了吸附不好用。如果要做网格吸附,这个值要跟着缩放级别动态调整,那是后话。
3.2 ShapeItem:boundingRect 和 paint 是一对
自定义图元继承QGraphicsItem,必须实现两个纯虚函数:boundingRect()告诉框架我占多大,paint()告诉框架我长什么样。这两个函数必须严格自洽——paint画到的地方,boundingRect必须框得住。框不住的后果非常直观:拖动图形时屏幕上留下一条条残影,因为你画出去的部分没有被标记为「需要重绘」。
class ShapeItem : public QGraphicsItem { public: enum ShapeType { Line, Rect, Ellipse, FreeHand }; ShapeItem(ShapeType type, const QPen &pen, const QBrush &brush, QGraphicsItem *parent = nullptr); void setStartPoint(const QPointF &p); // 起始点(局部坐标) void setEndPoint(const QPointF &p); // 结束点,矩形/椭圆用它算范围 void appendPathPoint(const QPointF &p); // 自由画笔专用 QRectF boundingRect() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; ShapeType shapeType() const { return m_type; } QJsonObject toJson() const; // 序列化,第 4 章展开 private: ShapeType m_type; QPen m_pen; QBrush m_brush; QPointF m_start, m_end; QPainterPath m_path; // 自由画笔的轨迹 };boundingRect()的实现有个必须注意的点:要加上画笔宽度的一半。
QRectF ShapeItem::boundingRect() const { // 线宽的一半向外扩,另外再加 1 像素容差,避免抗锯齿边缘被裁 const qreal margin = m_pen.widthF() / 2.0 + 1.0; switch (m_type) { case Line: case Rect: case Ellipse: { QRectF r = QRectF(m_start, m_end).normalized(); return r.adjusted(-margin, -margin, margin, margin); } case FreeHand: return m_path.boundingRect().adjusted(-margin, -margin, margin, margin); } return QRectF(); }QRectF(m_start, m_end).normalized()把任意两个对角点规整成左上-右下形式,用户从右下往左上拖也能得到正的矩形。adjusted四个参数是左、上、右、下各扩多少,这里统一扩 margin。少扩这半个线宽,画粗线条的时候起笔那一段就会被裁掉,看着像被刀切了一样。
paint()里做三件事:开抗锯齿、设笔刷、按类型画。
void ShapeItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) { painter->setRenderHint(QPainter::Antialiasing, true); painter->setPen(m_pen); painter->setBrush(m_brush); switch (m_type) { case Line: painter->drawLine(m_start, m_end); break; case Rect: painter->drawRect(QRectF(m_start, m_end).normalized()); break; case Ellipse: painter->drawEllipse(QRectF(m_start, m_end).normalized()); break; case FreeHand: // 自由画笔只描边不填充,填充会变成一团色块 painter->setBrush(Qt::NoBrush); painter->drawPath(m_path); break; } }注意自由画笔那条分支单独把 brush 设成Qt::NoBrush。如果统一用用户选的填充色画路径,快速划圈的时候QPainterPath会自己闭合区域,出来就是一片实心色块,跟预期完全不符。
还有一个常被忽略的优化点:QGraphicsItem有个setCacheMode(QGraphicsItem::DeviceCoordinateCache),对静态图元能显著减少重绘开销。但这个缓存一旦开启,图元在缩放时会先拉伸缓存再重画,放大后线条发虚。项目的做法是:绘制中的临时图元不开缓存,松手转正之后再开。这个切换放在ToolManager::onReleased里做。
另外图元要支持选中和拖动,构造时得设两个标志:
setFlags(ItemIsSelectable | ItemIsMovable | ItemSendsGeometryChanges); setAcceptedMouseButtons(Qt::LeftButton);ItemSendsGeometryChanges是给itemChange()用的,移动时能收到ItemPositionHasChanged通知,第 4 章记录移动命令就靠它。不设这个标志,itemChange根本不会被调用,你会以为是自己重写错了。
3.3 视图层:缩放、平移和抗锯齿
CanvasView 继承 QGraphicsView,主要重写三处:滚轮、中键拖拽、把鼠标事件转成场景坐标抛信号。
CanvasView::CanvasView(CanvasScene *scene, QWidget *parent) : QGraphicsView(scene, parent), m_scene(scene) { setRenderHint(QPainter::Antialiasing, true); setRenderHint(QPainter::SmoothPixmapTransform, true); // 关键:锚点设在鼠标下方,缩放时鼠标指着的那个点不动 setTransformationAnchor(QGraphicsView::AnchorUnderMouse); setResizeAnchor(QGraphicsView::AnchorViewCenter); setDragMode(QGraphicsView::NoDrag); // 拖拽由我们自己控制 setViewportUpdateMode(QGraphicsView::SmartViewportUpdate); setMouseTracking(true); // 不按键也要收到 mouseMoveEvent }AnchorUnderMouse这一行是缩放体验的分水岭。默认锚点是视图中心,滚轮放大的时候画面会朝中心飘,用户要反复平移找回刚才看的位置。设成鼠标锚点后,鼠标指着哪里就放大哪里,手感立刻正常。这个属性在 QGraphicsView 里是现成的,但很多示例代码没写,新手就自己算变换矩阵,越算越歪。
setMouseTracking(true)也是必须的:不按下鼠标也要收到mouseMoveEvent,自由画笔的实时预览和十字光标都依赖它。默认是 false,只在按键按下时才发移动事件。
缩放逻辑:
void CanvasView::wheelEvent(QWheelEvent *event) { // 1.15 是经验倍率:再小滚半天没反应,再大一格就飞出去 const qreal factor = event->angleDelta().y() > 0 ? 1.15 : 1.0 / 1.15; const qreal next = m_zoom * factor; // 限制在 10% ~ 1600%,超出就忽略,避免浮点累加到无穷 if (next < 0.1 || next > 16.0) { event->accept(); return; } m_zoom = next; scale(factor, factor); emit zoomChanged(m_zoom); event->accept(); }m_zoom单独记一份而不是每次去问变换矩阵,是因为transform().m11()反复乘除之后会有浮点误差,几次缩放下来数值就对不上,最后限制判断失效。自己存一份乘出来的比例,只要因子固定,误差是可控的。
中键平移用「记录上次位置 + 累积平移量」的方式:
void CanvasView::mousePressEvent(QMouseEvent *event) { if (event->button() == Qt::MiddleButton) { m_panning = true; m_lastPanPos = event->pos(); setCursor(Qt::ClosedHandCursor); event->accept(); return; } if (event->button() == Qt::LeftButton) { emit sceneMousePressed(mapToScene(event->pos()), event->button()); event->accept(); return; } QGraphicsView::mousePressEvent(event); }中键平移和工具的左键操作分开处理,互不干扰。这里用event->pos()而不是mapToScene,因为平移只需要控件的相对位移,不需要场景坐标。如果这里混用场景坐标,缩放之后平移速度会跟着缩放比例失真,放大到 800% 时鼠标动一像素画面飞出去很远。
4. 工具交互与撤销重做:QUndoCommand 串起每一步操作
画布能显示只是第一步。真正的分水岭在「按 Ctrl+Z 能不能退回去」和「画到一半能不能切工具不留下垃圾图元」。这两件事本质是同一个问题:每一次对文档的修改,都必须是一个可记录、可撤销的原子操作。
4.1 工具状态机:一次鼠标拖拽的完整生命周期
ToolManager 里维护一个当前工具枚举和三个事件回调。一次完整的绘制流程固定是三步:按下创建临时图元、移动更新终点、松开提交命令。
// toolmanager.h class ToolManager : public QObject { Q_OBJECT public: enum Tool { Select, Pen, Line, Rect, Ellipse }; ToolManager(CanvasScene *scene, QObject *parent = nullptr); void setTool(Tool t); public slots: void onPressed(const QPointF &pos, Qt::MouseButton button); void onMoved(const QPointF &pos); void onReleased(const QPointF &pos); signals: void requestCommand(QUndoCommand *cmd); // 交给主窗口 push 进撤销栈 private: CanvasScene *m_scene; QUndoStack m_localStack; // 仅为占位,实际用外部栈 Tool m_tool = Pen; ShapeItem *m_drawing = nullptr; // 正在绘制的临时图元 QPointF m_startPos; };按下时的逻辑:
void ToolManager::onPressed(const QPointF &pos, Qt::MouseButton button) { if (button != Qt::LeftButton) return; if (m_tool == Select) return; // 选择工具交给 QGraphicsView 自己处理 m_startPos = pos; // 用当前画笔配置造一个还没显示出来的图元 ShapeItem::ShapeType type = ShapeItem::Line; switch (m_tool) { case Pen: type = ShapeItem::FreeHand; break; case Line: type = ShapeItem::Line; break; case Rect: type = ShapeItem::Rect; break; case Ellipse: type = ShapeItem::Ellipse; break; default: return; } m_drawing = new ShapeItem(type, m_pen, m_brush); m_drawing->setStartPoint(pos); m_drawing->setEndPoint(pos); if (type == ShapeItem::FreeHand) m_drawing->appendPathPoint(pos); m_scene->setPreviewItem(m_drawing); // 只挂到预览槽,不进撤销栈 }预览图元和正式图元的区别就在这里:预览只是挂在场景上让用户看见,不进撤销栈,也不参与选中。松开时才决定它的命运。
void ToolManager::onReleased(const QPointF &pos) { if (!m_drawing) return; // 起点终点几乎重合,说明是误点击,直接丢掉,不产生一条无意义的撤销记录 const QRectF r = QRectF(m_startPos, pos).normalized(); if (r.width() < 2.0 && r.height() < 2.0 && m_drawing->shapeType() != ShapeItem::FreeHand) { m_scene->clearPreviewItem(); delete m_drawing; m_drawing = nullptr; return; } m_scene->clearPreviewItem(); // 转正:从预览变成正式图元,同时生成一条可撤销命令 ShapeItem *finalItem = m_drawing; m_drawing = nullptr; finalItem->setCacheMode(QGraphicsItem::DeviceCoordinateCache); emit requestCommand(new AddItemCommand(m_scene, finalItem)); }r.width() < 2.0这个阈值是血泪经验。用户在画布上习惯性点一下不拖,如果不判断,每次误点都会留下一个零尺寸图元,撤销栈里也堆一条记录,画十分钟堆了几百条空命令,按撤销得按到手酸。阈值设 2.0 像素刚好能过滤掉抖动,又不至于把真正的小图元误杀。
自由画笔走的是另一条路径,在onMoved里持续追加点:
void ToolManager::onMoved(const QPointF &pos) { if (!m_drawing) return; if (m_drawing->shapeType() == ShapeItem::FreeHand) { m_drawing->appendPathPoint(pos); } else { m_drawing->setEndPoint(pos); } m_scene->update(); // 触发预览图元重绘 }自由画笔这里有个性能隐患,第 5 章展开讲:每次移动都追加一个点,鼠标采样率高的时候一条线能积累上万个点,QPainterPath节点一多,重绘就卡。
4.2 QUndoCommand:每一步操作都要能回退
Qt 的撤销框架核心就两个方法:undo()和redo()。坑在于push()的时候 Qt 会自动调用一次redo()——也就是说,命令对象在构造时不应该自己执行动作,动作全部写在redo()里,靠 push 触发。
class AddItemCommand : public QUndoCommand { public: AddItemCommand(CanvasScene *scene, ShapeItem *item, QUndoCommand *parent = nullptr) : QUndoCommand(parent), m_scene(scene), m_item(item) { setText(QStringLiteral("添加图元")); // 注意:这里绝对不能把 item 加进 scene,push 时会自动调 redo() } void undo() override { m_scene->removeItem(m_item); // 移除但不销毁,允许再次 redo } void redo() override { if (m_item->scene() != m_scene) m_scene->addItem(m_item); } ~AddItemCommand() override { // 命令从栈里被挤掉(超过上限)且当前处于 undo 状态时,图元没人管了 if (!m_item->scene()) delete m_item; } private: CanvasScene *m_scene; ShapeItem *m_item; };这段代码里有三个细节值得单独说。
第一,构造函数里什么都不做。新手最容易写成构造函数里scene->addItem(item),然后在redo()里又 add 一次,结果每画一笔出来两个图元,叠在一起看着像线条变粗了,其实是重影。这种 bug 肉眼极难发现,得拖开才看得见。
第二,undo()用removeItem而不是delete。删掉之后redo()就没东西可以再加回来了,撤销一次图形就永久丢失,用户按 Ctrl+Y 什么也不会发生。
第三,析构函数里补删。QUndoStack 默认有撤销步数上限(setUndoLimit,默认是 0 表示不限,但很多项目会设成 100),超限时最旧的命令会被销毁。如果这条命令当前处于「已撤销」状态,图元已经不在场景里,析构时没人 delete 就是内存泄漏。反过来,如果命令处于「已执行」状态,图元挂在场景上,场景销毁时会连带删掉,这时再 delete 就是二次释放。所以判断条件是!m_item->scene()。
移动图元的命令思路类似,但要在图元移动前后分别记位置:
class MoveItemCommand : public QUndoCommand { public: MoveItemCommand(ShapeItem *item, const QPointF &oldPos, const QPointF &newPos) : m_item(item), m_old(oldPos), m_new(newPos) { setText(QStringLiteral("移动图元")); } void undo() override { m_item->setPos(m_old); } void redo() override { m_item->setPos(m_new); } private: ShapeItem *m_item; QPointF m_old, m_new; };位置的采集靠图元的itemChange:
QVariant ShapeItem::itemChange(GraphicsItemChange change, const QVariant &value) { if (change == ItemPositionChange && scene()) { // 拖动开始时记下初始位置,松手时才知道移动了多少 if (!m_dragging) { m_dragging = true; m_dragStartPos = pos(); } } if (change == ItemPositionHasChanged && m_dragging) { // 这里只做状态更新,真正的命令在 mouseRelease 时生成 } return QGraphicsItem::itemChange(change, value); }命令在哪里 push 进栈?主窗口连一根线:
connect(m_tools, &ToolManager::requestCommand, m_undo, &QUndoStack::push); connect(ui->actionUndo, &QAction::triggered, m_undo, &QUndoStack::undo); connect(ui->actionRedo, &QAction::triggered, m_undo, &QUndoStack::redo);push之后redo()自动跑一次,图元进场景,撤销栈里多一条。撤销栈的canUndoChanged信号还能顺便驱动菜单项的启用状态,省得手动维护。
4.3 文档序列化:JSON 存盘和版本号
保存做的是把所有图元转成一个 JSON 数组,加载时再反向解析。选 JSON 不选二进制,是因为绘图文件出问题时人眼能直接打开看,调试成本差一个量级。
QJsonObject ShapeItem::toJson() const { QJsonObject obj; obj["type"] = static_cast<int>(m_type); obj["pen"] = m_pen.color().name(QColor::HexArgb); // 带 alpha,半透明笔也能存 obj["width"] = m_pen.widthF(); obj["brush"] = m_brush.style() == Qt::NoBrush ? QString() : m_brush.color().name(QColor::HexArgb); obj["x"] = pos().x(); obj["y"] = pos().y(); if (m_type == FreeHand) { QJsonArray pts; for (int i = 0; i < m_path.elementCount(); ++i) { const auto e = m_path.elementAt(i); pts.append(QJsonArray{ e.x, e.y }); } obj["points"] = pts; } else { obj["p1"] = QJsonArray{ m_start.x(), m_start.y() }; obj["p2"] = QJsonArray{ m_end.x(), m_end.y() }; } return obj; }文档层再包一层,带上版本号:
bool DocumentIO::save(const QString &path, CanvasScene *scene) { QJsonArray items; for (QGraphicsItem *it : scene->items()) { if (auto *shape = dynamic_cast<ShapeItem *>(it)) items.append(shape->toJson()); } QJsonObject root; root["version"] = 2; // 格式版本,加字段时必须递增 root["gridStep"] = scene->gridStep(); root["items"] = items; QFile f(path); if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) return false; f.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); return true; }version字段现在看着多余,但等到你半年后想给格式加个「图层」属性,没有版本号就只能在解析时猜字段在不在。加个2的成本是一行代码,不加的成本是以后所有老文件都得特殊处理。dynamic_cast用来过滤掉场景里可能存在的其它类型图元(比如以后加的文字图元、临时预览图元),比type()判断安全。
加载时按version分支处理,items数组遍历,每个对象按type分派到不同的构造分支。QJsonDocument::fromJson失败时记得检查isNull(),这一点很多示例代码直接跳过,结果打开一个损坏文件就静默显示空画布,用户以为自己的图丢了。
5. 避坑与排查:绘图软件最容易翻车的五个地方
前面几章写的是「应该怎么写」,这一章写「写完为什么不按预期跑」。下面五条都是这套源码在迭代过程中真实撞上的,每条按现象、原因、解决来。
5.1 每画一笔出来两个图元
现象:画一条线,屏幕上看着正常,但用选择工具去点它,发现选中框只框住了其中一段,或者拖动时两个东西一起动。删掉一个另一个还在。
原因:QUndoCommand的push会自动调用redo(),而命令的构造函数里又把图元加进了场景,等于加了两次。第二次addItem对同一个指针是无害的,但如果你在构造函数里new了一个拷贝就不一样了,而且从撤销栈角度看,一次操作变成了两次。
解决:命令构造函数只存指针和参数,任何对文档的修改都只写在redo()里。判断条件加上if (item->scene() != targetScene)做幂等保护。这套写法加上之后,「画一笔出两个」的问题再没出现过——代价只是每次redo多一次指针比较,可以忽略。
5.2 拖动图形时留一串残影
现象:用选择工具拖一个粗线条矩形,鼠标移动过程中,后面的位置留下一串深浅不一的旧边框,松手之后才干净。
原因:boundingRect()没算上画笔宽度。paint()里画了一个宽度 8 的矩形,描边向外扩了 4 像素,但boundingRect()返回的还是几何矩形本身。框架认为那 4 像素不属于这个图元,重绘时不擦除,旧帧就留在了屏幕上。线越粗,残影越明显。
解决:boundingRect统一按pen.widthF() / 2.0 + 1.0外扩,+1是给抗锯齿边缘留的容差。另外场景开SmartViewportUpdate比FullViewportUpdate省很多,但前提是boundingRect必须准确,否则会留下没刷新到的区域。两者是配套的,只改一个还会出别的怪问题。
5.3 保存再打开,图形整体偏移
现象:存盘之前图形在网格左上角,重新打开之后整体向右下挪了几十像素,而且越靠边的图形偏得越多。
原因:坐标混用。绘制时用的mapToScene(event->pos())是场景坐标,但构造图元时把场景坐标直接当成了图元的局部坐标塞进m_start、m_end,同时又调了setPos()。于是局部坐标里含了一份场景偏移,pos()又加了一份,打开时按pos还原,偏移被算了两次。
解决:统一约定——图元内部所有点都用局部坐标,且局部坐标的原点就是图元的左上角;pos()只用来表示这一个偏移量。绘制时先算场景坐标的包围盒,setPos(box.topLeft()),然后把m_start、m_end各自减去这个左上角,转成局部坐标。序列化时存的x、y是pos(),存的p1、p2是局部坐标,两边都按这个约定来。这条定死之后,坐标相关的 bug 少了八成。
5.4 导出的 PNG 在高分屏上模糊
现象:在 4K 屏上编辑,画布看着很清楚,导出成 PNG 之后文字和线条糊成一团,尺寸也不对。
原因:QGraphicsScene::render()用QImage接收输出,而QImage的devicePixelRatio默认是 1。高分屏下控件实际是按 2 倍或 1.5 倍渲染的,导出时没告诉 QImage 这个比例,它按逻辑像素分配了缓冲区,结果就是拉升后的模糊图。
解决:先在QImage构造时按比例放大物理尺寸,再setDevicePixelRatio:
void DocumentIO::exportPng(const QString &path, CanvasScene *scene, const QRectF &area) { const qreal dpr = qApp->devicePixelRatio(); QImage img(area.size().toSize() * dpr, QImage::Format_ARGB32_Premultiplied); img.setDevicePixelRatio(dpr); img.fill(Qt::white); // 不留透明底,方便直接发出去 QPainter p(&img); p.setRenderHint(QPainter::Antialiasing, true); scene->render(&p, QRectF(QPointF(0, 0), area.size()), area); p.end(); img.save(path); }scene->render的第二个参数是目标矩形,第三个是源场景区域,两者坐标系不同,写反了会导出一块空白。fill这一步也别省,不带填充的 PNG 在深色背景的看图器里打开是黑底透明图,用户会以为导错了。
5.5 自由画笔画久了越来越卡
现象:前几笔流畅,画到后面每画一笔都有明显延迟,尤其是画过一条很长的曲线之后,整个画布都开始卡。
原因:自由画笔把每个mouseMoveEvent的坐标都追加进QPainterPath。鼠标采样率 1000Hz 的鼠标,一秒能推上千个点,三秒钟的曲线就是三千个节点。每个节点都要参与抗锯齿绘制和包围盒计算,复杂度是线性累加,而且还只是单个图元。画布上十条这样的曲线,重绘一次就是三万次节点运算。
解决:在appendPathPoint里做距离过滤,只有当新点和上一个点距离超过 1.5 像素才追加;同时在绘制结束后用QPainterPath::simplified()合并共线点。1.5 像素的间隔在视觉上完全看不出来,节点数却能砍掉一半以上。
void ShapeItem::appendPathPoint(const QPointF &p) { if (m_path.elementCount() > 0) { const auto last = m_path.elementAt(m_path.elementCount() - 1); const QPointF d = p - QPointF(last.x, last.y); // 距离太近的点直接丢弃,避免路径节点爆炸 if (d.manhattanLength() < 1.5) return; } if (m_path.elementCount() == 0) m_path.moveTo(p); else m_path.lineTo(p); prepareGeometryChange(); // 包围盒变了必须通知场景 }prepareGeometryChange()这行很容易漏。图元的包围盒是会变的(路径在长大),不调这个函数,场景缓存的包围盒永远是最初那个小点,超出的部分就不刷新,表现为画笔只有第一小段是可见的。这个坑比节点爆炸更隐蔽,因为它在图元刚创建时看起来完全正常。
6. 二次开发:加一个新工具只动三个文件
想把画笔颜色和粗细做成可配置,改ToolManager里的m_pen、m_brush两个成员,再从主窗口连一个QColorDialog进来就够了,不需要碰画布。真正需要动手扩结构的是加一种新工具,比如文字工具或者油漆桶填充。
按这套架构,加一个文字工具的完整改动只有三处。第一处,ShapeItem::ShapeType加一个Text,paint()里加一条分支用drawText,toJson()里多存一个text和字号字段。第二处,ToolManager::onPressed里遇到文字工具时不进入拖拽流程,而是直接弹一个QInputDialog拿字符串,构造图元后立刻 emit 一条AddItemCommand。第三处,主窗口工具栏加一个 action 连过去。
有个细节值得单独提醒:文字图元的boundingRect不要用字符串长度估算。得先用QFontMetricsF::boundingRect()拿到实际排版范围,再按前面说的加 margin。用text.length() * 8这种估算方式,中文和英文混排时框会明显偏窄,选中框和文字对不上,后期做对齐吸附全乱套。
再往下就是文件格式的向后兼容。加载时按version分支,不要试图用「字段存在与否」来推断版本,一旦某个字段恰好为空就会被误判:
static ShapeItem *createFromJson(const QJsonObject &obj, int docVersion) { const int type = obj["type"].toInt(); if (type == ShapeItem::Text && docVersion < 3) { return nullptr; // 老版本文件不可能有文字图元,防御性跳过 } // ... 正常构造 }老版本的字段一律给默认值,宁可显示得朴素一点,也别让程序崩在打开文件这一步。用户丢一次文件,这套源码在他心里就报废了,跟代码写得多漂亮没关系。
我自己的习惯是:每次给格式加字段,先跑一遍「用新版本存、用旧版本读、再用新版本读」这三步,确认老代码读到新文件时能降级而不是抛异常。那次给画笔加透明度没做这一步,结果同事发来的文件在我这边打开全是实心黑块,排查了半天才发现是 alpha 通道被吃掉了。从那以后我每次动序列化代码,都强制走一遍这三步,再顺手把仓库里那份示例文件用新版本重存一次。希望帮到你。
本文还有配套的精品资源,点击获取