尧图精选

基于Qt和C++的绘画板开发:QPainter绘图与撤销重做实现

🕒 发布时间:2026/9/16 14:35:26 📁 来源:尧图网络
简介一套适合毕业设计、课程设计或课程作业也适合C初学者进阶的Qt绘画板项目源码基于Qt框架实现重点解决图形绘制、文件存储与交互操作等常见桌面应用开发问题。压缩包共27个文件其中包含8个cpp与7个h源文件、9个png图标资源、1个Qt工程文件及1份项目说明文档整体仅42KB代码量精简且结构清晰便于阅读和二次扩展。项目支持绘制点、直线、椭圆、矩形等基本几何图形具备绘图文件存取、撤回与重做、线宽调节、颜色选择以及鼠标坐标实时显示等功能能够完整覆盖简单绘图工具的核心交互流程。目前已有358人学习下载工程内还附带res图标资源文件夹程序采用绝对路径加载图标二次部署时稍作调整即可。整个项目模块划分明确适合作为C/Qt入门练手、课程设计或初期项目演示的实用参考。1. 为什么用 Qt 写一个绘画板而不是直接调 Windows GDI一个基于 Qt 的 C 绘画板程序压缩包十几个文件拆开后几乎是 Qt 客户端开发的迷你样板间Shape 基类、QPainter 绘制、鼠标事件状态机、撤销重做栈和自定义文件序列化全都有。如果你正找课程设计源码或者第一次用 C 接触 Qt这个项目很适合。刚学完 C 语法的人能在这里看到虚函数和设计模式怎么落到界面上工作后跳过来写 Qt 的工程师也能快速补上事件循环和绘图管线这一课。表面上是画点、画线、画矩形的玩具实际上把 Qt Widget 应用的主干都串起来了。2. QPainter 与 Shape 类先把图形抽象成可绘制对象2.1 Shape 基类设计为什么一定要有虚函数绘画板要支持点、直线、椭圆、矩形四种图元。最直白的做法是在鼠标事件里写一个 switch根据当前工具类型调用 QPainter 的对应方法。但这样每加一种图形就要改三处创建、绘制、文件读写。项目里的解法是定义一个 Shape 抽象基类把 draw、save、load 都设成纯虚函数让每种图元自己负责绘制和持久化。先看核心头文件// shape.h #pragma once #include QPainter #include QColor #include QDataStream class Shape { public: enum Type { PointShape, Line, Rectangle, Ellipse }; Shape(QPoint start, QPoint end, QColor color, int penWidth) : startPoint(start), endPoint(end), color(color), penWidth(penWidth) {} virtual ~Shape() default; virtual void draw(QPainter painter) const 0; virtual void save(QDataStream stream) const 0; virtual void load(QDataStream stream) 0; virtual Type type() const 0; protected: QPoint startPoint; QPoint endPoint; QColor color; int penWidth; };注意这里我把点图元类命名为PointShape避免和 Qt 的QPoint混淆。每个派生类只需要实现三个接口例如直线// line.cpp class Line : public Shape { public: using Shape::Shape; void draw(QPainter painter) const override { QPen pen(color, penWidth); painter.setPen(pen); painter.drawLine(startPoint, endPoint); } Type type() const override { return Line; } };这段代码的关键在于把“怎么画”下沉到每个类内部paintEvent 里只需要遍历图形列表挨个调用draw(painter)完全不用知道当前图元是哪一种。这就是面向对象里开放封闭原则的体现以后要加箭头、曲线新增一个子类即可原有代码一行都不用改。2.2 鼠标拖拽的三段式处理画板交互的核心在centerwidget里按下鼠标左键记录起点移动时更新终点并刷新界面松开时把临时图元提交到列表。事件处理可以拆成三段void CenterWidget::mousePressEvent(QMouseEvent *event) { if (event-button() ! Qt::LeftButton) return; startPoint event-pos(); drawing true; } void CenterWidget::mouseMoveEvent(QMouseEvent *event) { if (drawing) { endPoint event-pos(); update(); } } void CenterWidget::mouseReleaseEvent(QMouseEvent *event) { if (event-button() Qt::LeftButton drawing) { drawing false; Shape *shape createShape(); shapes.append(shape); update(); } }createShape()会根据当前工具栏选中的图元类型用 startPoint、endPoint、当前颜色和线宽构造对应的 Line、Rectangle、Ellipse 或 PointShape。注意mouseMoveEvent里那一句update()不能省否则拖拽过程中屏幕上看不到橡皮筋效果要等松开左键才一次性画出体验非常奇怪。event-pos()返回的是 QWidget 本地坐标如果没有滚动条和缩放可以直接用在 QPainter 上一旦画布需要滚动或缩放就得用mapToScene之类的方式转换这是从简单画板走向专业绘图工具时最容易踩的坐标坑。2.3 重绘策略全量重绘与 QPixmap 缓冲图元列表建好之后重绘有两种常见方案。第一种是在paintEvent里清空背景再遍历所有 shape 绘制实现最简单但图元超过几百个后每次 update 都会卡顿而且闪屏明显。第二种是维护一张 QPixmap 作为缓冲画布鼠标释放时把成品画上去paintEvent只执行一次drawPixmap拖动过程中的临时图形另画一层。两种方案对比如下策略内存占用拖拽流畅度实现复杂度全量重绘 遍历列表极低图元多时肉眼可见卡顿最简单QPixmap 缓冲画布画布面积 x 4 字节左右流畅但撤销时要重放历史中等QGraphicsView 场景中等高自带局部更新首次学习成本高这个项目用的就是第二种思路。缓冲画布的核心代码集中在撤销重做之后的重放里下一章会把这个栈讲透。3. 撤销重做与 QDataStream 文件存储别让画布只是一张位图3.1 用图元列表做撤销重做而不是位图快照如果每次操作都保存一张 800x600 的 QPixmap 快照内存会迅速膨胀画几十笔就吃掉几十 MB完全不可持续。常见做法是维护一个QVectorShape*和一个“已提交数量”索引currentIndex。每次新增图元时先把末尾可能存在的残留删除再 push 到列表并把索引指向列表末尾。撤销只需把索引减一重做加一然后重放整个列表到缓冲画布。void PaintModel::undo() { if (currentIndex 0) return; --currentIndex; redrawAll(); } void PaintModel::redo() { if (currentIndex shapes.size()) return; currentIndex; redrawAll(); } void PaintModel::redrawAll() { bufferPixmap.fill(Qt::white); QPainter painter(bufferPixmap); for (int i 0; i currentIndex; i) { shapes[i]-draw(painter); } }这里的currentIndex表示“已提交的图元数量”不是最后一个元素下标。撤销一次后shapes里可能还有后续图元重做时要把它们重新显示出来如果此时画了新图形就必须先销毁索引之后的残留对象否则内存泄漏且重做语义会乱。这种索引栈是 Qt 相关面试里经常问到的 C 八股为什么撤销用栈重做用索引而不是真的把元素 pop 出来。理解了这段代码再去看 QUndoStack 就觉得没什么神秘了。3.2 用 QDataStream 读写 .draw 文件保存画布不能只截一张图还要能重新编辑所以要持久化图元数据。QDataStream 是 Qt 自带的二进制序列化工具能自动处理字节序比手写 fread/fwrite 安全得多。保存代码void PaintModel::save(const QString path) { QFile file(path); if (!file.open(QIODevice::WriteOnly)) { qWarning() 无法打开文件 path; return; } QDataStream out(file); out.setVersion(QDataStream::Qt_5_15); out quint32(0x44724157); // 魔数 Draw out qint32(1); // 格式版本号 out qint32(shapes.size()); for (const Shape *s : shapes) { out qint32(s-type()); s-save(out); } }魔数0x44724157是 ASCII 字符串 “Draw” 的十六进制值用来识别文件是不是本程序画的。格式版本号是为将来功能扩展预留的如果以后增加箭头图元老阅读器读到版本号 2 时可以明确提示“文件格式过新”。每个图元保存前先写入类型枚举读取时根据类型 new 出对应对象再调用它的 load。要注意out.setVersion必须和读取端一致Qt 5.15 写出的数据如果用 Qt 6.4 默认版本读取可能因为浮点或字符串编码变化导致错位。让版本号统一写在文件头里就是最便宜的容错手段。3.3 扩展格式新图元类型的兼容设计之前的枚举类型就是文件里的“身份证”类型枚举值图元说明0PointShape点1Line直线2Rectangle矩形3Ellipse椭圆以后加箭头、贝塞尔曲线只要枚举值往后排读取端根据 type 分派构造。如果需要兼容旧文件可以在 Shape::save 里先写一个数据长度字段load 时先读长度遇到未知类型就 skipRawData 跳过去。这是为课程设计加分的细节也能让指导老师看到你对二进制格式有设计意识。4. 工具栏、QColorDialog 与坐标状态栏把画板做成正经程序4.1 用 QToolBar 和 QActionGroup 管理图元工具主窗口 QMainWindow 里放一个 QToolBar把新建、打开、保存、撤销、重做和四种图元都做成 QAction。图元工具之间必须互斥用 QActionGroup 就可以自动完成不需要手动维护状态auto *group new QActionGroup(this); QAction *lineAction new QAction(QIcon(:/res/line.png), 直线, this); lineAction-setCheckable(true); lineAction-setChecked(true); group-addAction(lineAction); toolBar-addAction(lineAction);把 lineAction 设置为 checkable 后用户点击时按钮保持按下状态。group 默认 exclusive 为 true同一时间只能有一个图元工具处于激活状态。connect 时只需要把 toolbar 上的 action 信号映射到 centerwidget 的 setTool 方法例如lineAction触发时把当前工具枚举改为Shape::Line。注意这里setShortcut也可以直接加比如按 L 切换到直线、按 R 切换到矩形对提高演示效果帮助很大。4.2 图标绝对路径的坑res 目录为什么会失踪项目说明里特意写了“程序图标使用绝对路径图标资源在 res 文件夹里”这说明代码里很可能出现了QIcon(D:/some/path/res/line.png)这种写法。在自己的机器上编译运行没问题把项目拷给别人或换一台电脑图标立刻全部消失。我不建议继续维护这种硬编码路径有两个更稳的替代方案方案优点缺点把 res 加进 .qrc 资源文件可执行文件自包含不易丢替换图标需要重新编译用applicationDirPath()拼相对路径图标可外部替换需要保证 exe 同级有 res 目录保留绝对路径本机最快换机器就崩不推荐如果不想改 UI 代码最小改动是把图标路径统一成一句QString iconPath QCoreApplication::applicationDirPath() /res/line.png; QAction *lineAction new QAction(QIcon(iconPath), 直线, this);开发调试时applicationDirPath()指向 build 目录所以需要在 .pro 里加一句QMAKE_POST_LINK xcopy /Y res $$OUT_PWD让编译器每次构建后自动复制资源目录。否则 IDE 里跑起来图标正常直接双击 build 目录里的 exe 又找不到资源这个问题在 Qt 教程里经常被一笔带过实际遇到会卡半天。4.3 状态栏实时显示鼠标坐标坐标显示虽然简单但有一个隐藏开关只有调用setMouseTracking(true)Qt 才会在鼠标不按下按键的情况下持续发送 mouseMoveEvent。很多新手直接重写mouseMoveEvent结果发现只有拖拽时才有坐标变化就是因为没开鼠标追踪。在CenterWidget构造函数里加一行setMouseTracking(true)然后通过信号把坐标传给主窗口statusLabel new QLabel(坐标: (0, 0), this); statusBar()-addPermanentWidget(statusLabel); connect(centerWidget, CenterWidget::mouseMoved, this, [this](QPoint p){ statusLabel-setText(QString(坐标: (%1, %2)).arg(p.x()).arg(p.y())); });信号mouseMoved在mouseMoveEvent里发送注意这里不要再判断drawing否则松开鼠标后坐标显示就冻结了。把状态栏放在永久区域窗口缩放时不会挤掉它。调试坐标错位时先在状态栏验证鼠标点位置和 QPainter 绘制点是否一致比打印日志更直观。5. 进阶从 QWidget 迁移到 QGraphicsView并完成打包验证5.1 QGraphicsScene 带来的范式变化当图元数量超过一千继续在 QWidget 里全量重绘会明显掉帧。这个时候应该考虑把画板迁移到 QGraphicsView 框架。迁移的关键是让每种图元变成 QGraphicsItem 子类例如class LineItem : public QGraphicsItem { public: QRectF boundingRect() const override { return QRectF(startPoint, endPoint).normalized(); } void paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) override { painter-drawLine(startPoint, endPoint); } private: QPoint startPoint, endPoint; };boundingRect()必须返回一个正确的矩形区域QGraphicsView 要靠它决定哪些区域需要重绘。如果返回的矩形太小图元会被相邻区域裁掉出现“画的线断了一截”的诡异现象。这是从 QWidget 方式迁移过来最容易犯的错。迁移后原来的撤销重做栈仍然有效只是redrawAll可以简化成对 scene 里 item 的显隐操作性能显著提升。5.2 用 windeployqt 打包验证无 Qt 环境可运行课程设计提交时一个常见成绩点是程序能否在他人的电脑上运行。Qt 程序默认静态链接 Qt DLL拷贝 exe 不够还要带上插件目录。Windows 下用 Qt 自带的 windeployqt 工具C:\Qt\5.15.2\mingw81_64\bin\windeployqt.exe --release --no-translations release\SimpleDraw.exe--no-translations可以跳过 qtbase 的翻译文件缩小发布体积。如果程序里用到 Qt 图像格式插件比如读取 jpg 图标还需要检查发布目录里有没有imageformats文件夹里的相应 DLL。部署完成后把整个发布目录打成 zip里面应该包含 exe、platforms、styles、iconengines 等目录。验证时找一台完全没有 Qt 的机器双击 exe画一条线、保存、重新打开确认图元不丢状态栏坐标有变化工具栏图标正常。如果在无 Qt 环境里图标消失优先检查 res 目录是否与 exe 同级。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →