首页 / 资讯中心 / 文章详情

Qt 5.12画图工具开发实战:QPainter双缓冲与撤销重做全解析

Qt 5.12画图工具开发实战:QPainter双缓冲与撤销重做全解析 ★ FEATURED ARTICLE
手头要做一个小工具正好需要“类似系统自带画图软件”的那一套交互铅笔随手画、直线矩形椭圆、橡皮擦、油漆桶、色板、线宽、撤销重做、保存打开。我直接把一套基于 Qt 5.12 编写的源码工程翻出来过了一遍用 Qt Creator 4.8.0 新建项目、配置编译器、跑通编译再把每个工具底层怎么实现的逻辑捋顺了。这篇文章就是整理后的完整记录适合两类读者一类是刚学完 C 基础、想做第一个综合型 Qt 项目的同学另一类是手里已经有一套“画图软件”源码但不知道怎么改、不知道怎么扩展的人。我会沿着“环境准备 → 画布原理 → 工具实现 → 撤销重做 → 文件读写 → 踩坑总结”这条线走。每部分都会给出可以直接抄走的代码骨架和设计思路方便你对着自己的工程做增删。1. 环境与工程结构这套源码拿到手先做三件事1.1 Qt Creator 4.8.0 与 Qt 5.12 的组合注意点Qt Creator 4.8.0 对应的 Qt 版本正好是 5.12 LTS这个组合在 Windows、Linux、macOS 上都能稳定跑。源码里写的是 qmake 工程也就是.pro文件驱动构建不需要 CMakeLists.txt这点在打开前先分清。IDE 生成工程后第一件事看编译器套件。Windows 上通常选 MinGW 7.3.0 32/64bit 或 MSVC 2017 x64。同一份源码在 MinGW 和 MSVC 下的编译差异不大但要注意 MSVC 对代码中中文注释的编码要求更严格源码如果使用了中文注释且文件是 UTF-8 无 BOMMSVC 编译时可能警告甚至报错而 MinGW 一般不挑。想避免麻烦可以把源文件统一转存为 UTF-8 with BOM或者在代码中少写非 ASCII 注释用英文最省心。.pro 文件我建议把模块写全至少包含这几个QT core gui widgets CONFIG c11 TARGET SimplePainter TEMPLATE app SOURCES \ main.cpp \ mainwindow.cpp \ canvaswidget.cpp HEADERS \ mainwindow.h \ canvaswidget.h FORMS \ mainwindow.ui注意QT widgets在 Qt 5.12 里是必须的因为QMainWindow、QToolBar、QAction都归 widgets 模块管。只写core gui会导致编译直接报一堆未定义类型。CONFIG c11也建议加上源码里大多数会用到auto、nullptrC11 在 Qt 5.12 下默认就是开的但显式写上更保险换到别人机器上不会被老编译器连带炸了。1.2 打开工程后的目录结构认知画图软件这一类源码核心文件看起来很多但职责边界非常清晰。拿到工程先看文件列表通识结构长这样文件/目录职责你需要重点关注的内容main.cpp程序入口是否设置高DPI属性、是否需要注册全局对象mainwindow.h/.cpp主窗口菜单栏、工具栏、QAction 集合canvas.cpp/.h也叫 drawarea、paintwidget)画布控件鼠标事件、QPainter 绘制、撤销栈mainwindow.ui界面布局看看画布是直接放中央还是嵌在 QScrollArearesources.qrc资源文件工具栏按钮图标、程序图标drawing.proqmake 工程依赖模块、编译选项这种 MVC 式划分几乎是业界默认MainWindow只管 UI 信号和动作不碰任何绘图细节CanvasWidget只管画图不弹对话框。拿到源码后你先分辨这两层是否混在一起。很多网上流传的画图 Demo 会把这些全写在一个类里维护起来特别头疼。如果发现源码把功能都塞进了MainWindow我的建议是逐步往外搬而不是将就着改。1.3 编译期最容易卡住的两个错误常见的编译问题有两个。第一个是m_paint画布对象在某个头文件里声明为QPixmap但忘记在构造函数里初始化导致运行后画布区一片黑或整个程序崩溃指向QPainter::begin。第二个是绘图函数里用了QPainter painter(this)之后马上又 new 了一个QPainter对同一设备做绘制重复 begin 会触发告警QPainter::begin: A paint device is already being painted于是一部分图形画不出来。源码如果是从老项目迁移过来还会遇到Qt charts这类与画图无关的残留模块我在实际调试中甚至见过.pro里引入multimedia模块还找不到包的。处理方式很简单把与画布无关的QT xxx逐行测试注释遇到error: Unknown module(s) in QT: xxx就在.pro里删掉那一行。依赖剪干净比硬补库省时间得多。2. 不闪烁的秘密QPixmap 双缓冲与坐标换算2.1 为什么不能直接在控件上画线如果你第一次写画图工具会自然地在paintEvent里根据鼠标坐标调用painter.drawLine()。这样写十有八九会遇到画面闪烁、拖影、线条断断续续的问题。根本原因在于 widget 的重绘机制。系统收到update()后会先擦除控件旧内容再调用你的paintEvent。如果你的绘制过程依赖临时状态擦除后重绘的一瞬间屏幕就会出现一块空白高频拖动鼠标时人就看到了闪烁。画图软件需要的是“把内容画到一张纸上再整张贴出来”而不是“现场一笔一笔划在屏幕上”。所以源码里几乎必然会有这样一层一个固定大小的QPixmap m_canvas作为虚拟画布所有图形先画在这张QPixmap上控件收到重绘请求时只需要把m_canvas整体拷贝到屏幕上。这个思想就是双缓冲也是我读这套源码时最想先讲清楚的部分。void CanvasWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.drawPixmap(0, 0, m_canvas); }2.2 画布初始化和 TODO 都藏在哪里QPixmap创建后内容是不确定的必须主动 fill 背景。画图软件通常提供白底和透明底两种模式源码里一般默认m_canvas QPixmap(kCanvasW, kCanvasH); m_canvas.fill(Qt::white);如果不调用 fill画布初始化出来可能是黑底或者马赛克状随机数据新手很容易被吓到以为显卡坏了。另外如果你打算让用户导出透明背景 PNG就需要m_canvas.fill(Qt::transparent);并在保存时保留 Alpha 通道。默认设置成白色也没关系这个逻辑在“新建画布”对话框里做成选项即可。真正影响后续所有工具实现的是所有绘图操作的目标始终是m_canvas而不是 this。每个工具的mousePressEvent、mouseMoveEvent、mouseReleaseEvent里都要把 QPainter 绑定到m_canvas上。只有两种例外橡皮筋预览和选中虚线框需要额外一张m_preview图层叠加显示。2.3 鼠标坐标获取与高DPI隐藏问题Qt 5.12 里鼠标事件坐标最简单的获取方式是event-pos()返回的是相对收到事件控件的坐标。画布作为主窗口 centralWidget 上的子控件时这个值就已经是画布内的像素坐标了直接用即可。但高 DPI 场景下不要忽略缩放问题。如果你在main()里开启了高 DPIQApplication::setAttribute(Qt::AA_EnableHighDpiScaling);那么在 Windows 150% 缩放下鼠标传入的逻辑坐标与实际设备像素不是一个量级。若你的画布尺寸按逻辑像素设置而QPixmap的真实像素尺寸乘了devicePixelRatio()直线绘制就会出现鼠标点和落笔点肉眼可见的偏移。处理方式有两类要么让QPixmap的尺寸与event-pos()完全一致不开 DPI 缩放要么在事件函数里做event-pos() * devicePixelRatio()换算。源码取巧时通常会选第一种在main()里禁用高 DPI 缩放。小型画图工具完全可以接受。2.4 窗口大小变化时画布内容怎么留住另一个高频错点是从没考虑过resizeEvent。当用户拉大窗口时如果画布控件自动变大但内部m_canvas还是老尺寸就会出现边缘空白或者绘制区域只有左上角一小块。我以前见过有人直接在resizeEvent里m_canvas QPixmap(newSize); m_canvas.fill(Qt::white);结果一拉窗口之前画的图全没了。正确思路有两种。第一种让画布保持固定大小外面套一个QScrollArea这样窗口变大出现滚动条而不是裁剪第二种在缩放时把旧画布完整拷贝到新画布多出来的区域用背景色填充void CanvasWidget::resizeEvent(QResizeEvent *e) { QPixmap newCanvas(size()); newCanvas.fill(Qt::white); QPainter p(newCanvas); p.drawPixmap(0, 0, m_canvas); m_canvas newCanvas; QWidget::resizeEvent(e); }第二种方案适合窗口大小变化不频繁的场景每次 resize 都会做一次整图拷贝。最稳妥的还是第一种无论是真实画图软件还是这类源码固定画布加滚动区都是标准答案。3. 工具逐个拆解铅笔、橡皮、图形、油漆桶到底怎么实现3.1 工具状态机与枚举设计拿到源码后先去看控件里有没有一个enum Tool。我见过偷懒的写法每个鼠标事件做一串 if-else分支多了之后代码完全没法维护。正常做法定义成类似下面这样enum class Tool { None, Pen, Eraser, Line, Rect, Ellipse, Fill }; Tool m_currentTool Tool::Pen;每个工具的绘制流程差别很大但鼠标事件的基本状态可以抽象成一个微状态机IDLE → PRESSED → DRAGGING → RELEASED。按下时记住起点拖动时根据当前工具决定是“实时写进画布”还是“画预览”松开时把结果固化。之所以强调状态机是因为很多工具 Bug 都出在状态没有复位。比如按下后鼠标移出控件再松开mouseReleaseEvent接收不到导致状态一直停在 DRAGGING下次再画图起点就会飞掉。源码里遇到这类问题要么在leaveEvent里强制复位要么在每次mousePressEvent开头先忽略上一次未完成的拖拽状态。3.2 铅笔和橡皮擦画的是点、线还是圆形笔刷铅笔实现最简单每两个相邻鼠标点之间画一条线段线段宽度就是画笔宽度。为什么不能只画点因为鼠标移动事件的采样频率有限点与点之间如果直接连线快速画线时就会出现断断续续的一串圆点。void CanvasWidget::drawPenSegment(const QPoint start, const QPoint end) { QPainter painter(m_canvas); painter.setRenderHint(QPainter::Antialiasing, true); painter.setPen(QPen(m_brushColor, m_penWidth, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); painter.drawLine(start, end); }注意Qt::RoundCap很关键它让线段两端变成圆头连续画线时接头平滑不会出现折线段的尖角。这一行设置直接决定了你画出来的是“流畅笔迹”还是“锯齿折线”。橡皮擦源码里通常有两种做法。一种是把当前背景色当作画笔颜色用户擦除时其实是拿背景色覆盖这会导致透明背景模式下越擦越白更好的做法是使用QPainter::CompositionMode_Clear把目标区域真正清成透明QPainter painter(m_canvas); painter.setCompositionMode(QPainter::CompositionMode_Clear); painter.setPen(QPen(Qt::transparent, m_penWidth, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); painter.drawLine(start, end);很多老源码里没有这条你要扩展透明背景功能时一定会撞上。此外不要把橡皮擦实现成简单的线宽覆盖除非你的画笔不带透明度否则擦除的边缘会出现颜色残留视觉上像没擦干净。3.3 直线、矩形、椭圆橡皮筋预览与最终落笔直线、矩形、椭圆这一类“形状工具”难点在于拖动时用户需要看到形状的实时拉伸效果又不能把中间过程留下的旧预览残影永久画进画布。因此源码里几乎必然存在一个m_preview层。逻辑是这样按下时把当前画布内容作为底拖动时复制一份底在副本上画新形状作为预览松开时才把最终形状提交到m_canvas。void CanvasWidget::mousePressEvent(QMouseEvent *e) { m_startPoint e-pos(); m_lastPoint e-pos(); m_pressing true; if (m_currentTool Tool::Pen || m_currentTool Tool::Eraser) { pushUndoSnapshot(); // 按下时保存撤销点 } } void CanvasWidget::mouseMoveEvent(QMouseEvent *e) { if (!m_pressing) return; m_lastPoint e-pos(); if (m_currentTool Tool::Line || m_currentTool Tool::Rect || m_currentTool Tool::Ellipse) { m_preview m_canvas.copy(); QPainter painter(m_preview); drawShape(m_currentTool, m_startPoint, m_lastPoint, painter); update(); } } void CanvasWidget::mouseReleaseEvent(QMouseEvent *e) { if (!m_pressing) return; m_pressing false; if (m_currentTool Tool::Line || m_currentTool Tool::Rect || m_currentTool Tool::Ellipse) { pushUndoSnapshot(); QPainter painter(m_canvas); drawShape(m_currentTool, m_startPoint, m_lastPoint, painter); m_preview QPixmap(); update(); } }paintEvent里除了画m_canvas还要在最上层叠加m_previewQPainter painter(this); painter.drawPixmap(0, 0, m_canvas); if (!m_preview.isNull()) { painter.drawPixmap(0, 0, m_preview); }这里有一个容易踩的坑每次mouseMoveEvent都执行m_canvas.copy()如果画布尺寸是 1920x1080 且颜色复杂复制成本不低性能不够时鼠标移动会卡顿。优化方向是不复制整个画布而是只保存形状变化前的局部矩形区域但代码复杂度会显著上升。小尺寸画布直接 copy 完全够用。建议先跑通 copy 方案等确认性能瓶颈后再改成局部更新。3.4 油漆桶填充广搜比递归靠谱颜色比较有阈值油漆桶是很多画图源码里“意思一下”就完事的功能但真正实现时要小心。基于QImage做泛洪填充的标准办法是 BFSvoid CanvasWidget::floodFill(const QPoint startPos, const QColor targetColor) { QImage image m_canvas.toImage(); QColor replaceColor m_brushColor; QStackQPoint stack; stack.push(startPos); while (!stack.isEmpty()) { QPoint p stack.pop(); if (p.x() 0 || p.y() 0 || p.x() image.width() || p.y() image.height()) { continue; } if (colorDistance(image.pixelColor(p), targetColor) kThreshold) { continue; } if (image.pixelColor(p) replaceColor) { continue; } image.setPixelColor(p, replaceColor); stack.push(p QPoint(1, 0)); stack.push(p QPoint(-1, 0)); stack.push(p QPoint(0, 1)); stack.push(p QPoint(0, -1)); } m_canvas QPixmap::fromImage(image); update(); }两个关键点。第一为什么用QStack显式栈而不是递归因为 1920x1080 画布在最坏情况下递归深度会达到几十万层直接把函数调用栈跑爆程序闪退。第二为什么要有kThreshold和colorDistance因为用户点击的目标点未必是纯色区域顶部那个精确颜色画面边缘可能有抗锯齿像素颜色是渐变过渡的。不做阈值比较鼠标就点不准。在 Qt 5.12 里可以用qRed、qGreen、qBlue手动算 RGB 差也可以直接对 QColor 分量做曼哈顿距离。3.5 工具栏动作映射互相排斥的 QAction工具切换在源码里通常是通过QActionGroup实现的。给每个动作设置checkable true放到同一个QActionGroup里用户点选一个动作时其他动作自动取消勾选天然互斥。工具栏上还要有颜色选择按钮和线宽QSpinBox。这里有一个 UI 细节容易忽略颜色按钮最好显示当前颜色的小色块点击后弹出QColorDialog::getColor并把选中的颜色设置为按钮的 icon让用户一眼看到当前笔色。很多画图源码的工具栏按钮没有实时反馈用起来很别扭。4. 撤销重做快照栈与命令队列的取舍4.1 为什么撤销重做必须优先设计如果你只是把画图功能做到“能画出来”完全不考虑撤销后续加功能会非常痛苦。因为每次鼠标操作的完成点需要明确标记如果工具事件里随手在paintEvent里画很难界定“一步”的边界。这也是我读源码时最先去找撤销相关代码的原因。画图类的撤销重做大体有两种设计快照栈和命令模式。方案实现难度内存占用可操作性适用场景快照栈很低大只能按步骤回退小型画布、像素级绘制、源码教学命令模式中等小可精细化控制每条命令复杂编辑器、对象化图形快照栈的思路每次操作前把当前画布完整复制一份压入撤销栈撤销时把栈顶内容覆盖回当前画布。命令模式的思路每次操作封装成一个 Command 对象包含 do 和 undo 逻辑撤销时调用 undo重做时调用 do。对于这套源码里的像素型画布没有对象化图形命令模式的优势不大反而因为要保存大量 diff 区域显得繁琐所以我推荐用快照栈。4.2 快照栈的核心实现在CanvasWidget里维护两个栈QStackQPixmap m_undoStack; QStackQPixmap m_redoStack; const int kMaxUndoSteps 30;每次按下鼠标并准备开始一笔绘制时调用一次pushUndoSnapshot()。不要把每个mouseMoveEvent都压栈否则二十次拖动就产生一百多张画布快照内存会爆掉。当用户在同一个工具里多次连续绘制时每次 press 作为一个操作点即可。撤销函数这样写void CanvasWidget::undo() { if (m_undoStack.isEmpty()) return; m_redoStack.push(m_canvas.copy()); m_canvas m_undoStack.pop(); update(); }重做函数把顺序反过来void CanvasWidget::redo() { if (m_redoStack.isEmpty()) return; m_undoStack.push(m_canvas.copy()); m_canvas m_redoStack.pop(); update(); }关键细节撤销前要把当前画布压入重做栈否则撤销后内容就丢了无法重做。快照栈的缺点是内存占用高补救方式是限制栈深度。超过上限后丢弃最底部最早的快照if (m_undoStack.size() kMaxUndoSteps) { m_undoStack.removeFirst(); }4.3 快照栈与图元对象化的分水岭如果你的画图工具后续要把图形变成可拖拽移动的对象快照栈就不够用了。那时你更需要在画布数据结构上做文章而不是在 QPixmap 上做文章。对象化意味着把直线、矩形、椭圆存成结构体而不是像素点。比如直线对象保存起点、终点、颜色、线宽撤销时只需要从列表里删掉最后一个对象重做时再加回来。这种方式下快照栈仍有意义但快照粒度变小了你可以只保存每个对象加入前的列表长度而不是整张图。我的建议是一开始先按快照栈实现功能全部正常后再考虑是否对象化。反着来一上来就搞 Command 模式很容易被“泛型命令”绕进去项目迟迟跑不通。先解决“能画出完整流程”再提升架构。4.4 撤销边界处理新操作必须清空重做栈一个非常常见的 Bug用户画了几笔撤销了几次回到中间状态然后又画新操作此时重做栈里残留的内容还显示可重做点重做会把后画的内容覆盖掉。正确做法是每次pushUndoSnapshot()之前清空重做栈void CanvasWidget::pushUndoSnapshot() { m_undoStack.push(m_canvas.copy()); m_redoStack.clear(); }这个行为符合主流编辑器习惯重做分支被新操作截断。你可以在主窗口里同步调用actionUndo-setEnabled(!m_undoStack.isEmpty())和actionRedo-setEnabled(!m_redoStack.isEmpty())让菜单栏按钮的可用状态实时变化。不要等点击时才判断否则用户会看到一个可点但点了没反应的按钮。5. 保存打开与导出不只是存一张 PNG5.1 最简单的导出图片图片导出是所有内容里最没有技术门槛的一步但要注意 QPixmap 的 save 格式会自动根据文件后缀名识别编码格式bool ok m_canvas.save(filePath, PNG);如果画布初始fill(Qt::transparent)保存 PNG 会保留透明保存 JPG 会把透明区域填充成黑色因为 JPG 不支持 Alpha 通道。如果你想导出 JPG 但背景是白的记得先将画布白底化再进行保存否则用户看到的是黑底图片非常劝退。这个开关逻辑值得单独做一个菜单项导出 PNG 保留透明、导出 JPG 强制铺白。5.2 自定义文档格式QDataStream 才是正经路线画图软件只导出图片还不够用户需要保存可继续编辑的工程文件。工程文件要保存的内容有两种思路。第一种是基于像素的编辑器直接把当前画布图片写进去打开时读回来这种方案简单但打开后图形仍然是位图无法再做对象级别编辑。第二种是保存对象列表适合对象化编辑器。以像素型画布为例最简单的工程文件格式是“文件头标记 版本号 PNG 数据”。我用QDataStream实现过思路如下写文件bool saveDocument(const QString path) { QFile file(path); if (!file.open(QIODevice::WriteOnly)) return false; QDataStream out(file); out quint32(0x53415645); // 自定义魔数SAVE out quint16(0x0001); // 版本号 QByteArray bytes; QBuffer buffer(bytes); buffer.open(QIODevice::WriteOnly); m_canvas.save(buffer, PNG); out bytes; return true; }读文件bool openDocument(const QString path) { QFile file(path); if (!file.open(QIODevice::ReadOnly)) return false; QDataStream in(file); quint32 magic; quint16 version; in magic version; if (magic ! 0x53415645 || version ! 0x0001) return false; QByteArray bytes; in bytes; QPixmap pix; pix.loadFromData(bytes, PNG); m_canvas pix; update(); return true; }魔数的作用是在打开文件前先校验格式避免用户误选了任意图片却提示“打开失败”。版本号字段则是给未来的格式升级留余地。这套结构比直接把文件内容读进来再 setPixmap 可靠得多。5.3 打开文件时的错误处理与体验细节打开文件后执行m_canvas pix;前需要把撤销栈和重做栈全部清空。否则用户打开一张新图后点一下撤销画面会瞬间变回打开前的状态这个体验很诡异根源是两个栈里的快照属于另一个画布。还要考虑文件保护情况。某些系统文件或只读目录下保存时会弹权限错误源码里若不做判断QDataStream 写入失败但返回 true用户会以为保存成功关程序后数据全没了。所以file.open之后最好把保存失败和成功明确区分开弹QMessageBox::warning通知用户。5.4 自动保存的思路画图软件最怕画到一半崩溃源码没有自动保存时我踩过几次后写了一个轻量版。利用QTimer每隔若干分钟把当前画布以 PNG 格式保存到系统临时目录的固定路径文件名为autosave.png应用正常退出时删除它启动时检测到这个临时文件就提示是否恢复上一次未保存的内容QTimer *timer new QTimer(this); connect(timer, QTimer::timeout, this, []() { QFile f(QDir::tempPath() /my_painter_autosave.png); m_canvas.save(f.fileName(), PNG); }); timer-start(5 * 60 * 1000);这种自动保存不干扰用户流程也不需要额外格式设计因为 PNG 本身就含有全部位图数据。等以后做对象化自动保存格式再升级成自定义工程文件也不迟。6. 源码梳理时我踩过的坑与扩展方向6.1 新建文档前不重置画布菜单里“新建”如果只是m_canvas.fill(Qt::white); update();看起来没问题但撤销栈里还存着旧图快照。用户新建画布后立刻点撤销旧图又冒出来了。新建操作应当清空两个栈并把初始空白画布压入撤销栈这样用户能通过撤销回到空白画布。6.2 鼠标跟踪 setMouseTracking默认情况下QWidget 只在鼠标按键按下时才会持续响应mouseMoveEvent平时鼠标悬停移动是不触发的。画图软件里铅笔正常工作时因为按着鼠标感觉不到区别但如果你期望橡皮擦跳出图标跟随、或者鼠标悬停时高亮网格就必须在构造函数里setMouseTracking(true);不设置这个很多时候功能“看起来没反应”排查半天才发现是事件没来。6.3 拖影残影的源头m_preview 没有清空直线、矩形这类工具在鼠标松开后如果忘记把m_preview置为 nullpaintEvent会继续叠加绘制最后一次预览图画面看起来就像图形在拖动结束后被画了两遍边缘颜色深一层。排查时先检查 release 事件里是否执行了m_preview QPixmap();和update()。另一个来源是按住鼠标时不慎把上一个预览层的坐标继续使用画布尺寸变化后旧预览范围超出新画布也会出现边缘色块。6.4 画布控件放到 QScrollArea 里就对了前面提过画布固定大小加滚动区是最稳的方案。实际编码时setCentralWidget里放的是QScrollArea再把CanvasWidget作为滚动区的内容控件。要支持滚轮缩放画布时可以在wheelEvent里对尺寸做限制并更新滚动区。这个扩展方向比较适合后续做“放大镜”功能因为真实画图软件都有缩放查看直接改坐标换算就能实现。6.5 能继续深挖的四个方向如果你把当前源码完全吃透了还想继续扩展按投入产出比从低到高可以做这几个方向文字工具点击画布弹输入框输入内容渲染到画布关键是字体和背景透明处理喷枪/画笔纹理用 QBrush 的 texture 实现有纹理的笔刷效果图层面板每个图层是一张 QPixmap叠加显示复杂度主要在一个合理的图层管理接口导出 PDF 或 SVG像素画布导出 PDF 需要把 QPixmap 嵌入SVG 需要把所有图元对象化这一步很能考验架构。从我个人的经验来说这类画图小项目最值钱的东西其实不是某个绘图算法而是“状态管理”和“分层绘制”那套思维。铅笔、矩形、橡皮这些工具代码都很好写难的是让它们在同一个撤销栈、同一种绘制流程、同一套 UI 交互下和谐工作。把这套源码从头到尾拔一遍毛把每个鼠标事件在不同工具下的分支走通你对 Qt 事件循环、QPainter 绘制机制、QAction 交互的掌控都会有质变。最后分享一个我在调试过程中一直沿用的小技巧每实现一个工具就立刻在代码里加一个临时的qDebug()日志打印鼠标事件的按下点、移动点、释放点。这样你会发现很多看似“图形画歪了”的 Bug其实都是坐标换算错误跟工具逻辑没关系。调试日志和撤销栈一样最好一开始就设计进工程里而不是等出了问题再往代码里塞。
阅读完成 · 觉得有帮助?
咨询建站