news 2026/9/16 14:35:25

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于Qt和C++的绘画板开发:QPainter绘图与撤销重做实现

简介:一套适合毕业设计、课程设计或课程作业,也适合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,完全不可持续。常见做法是维护一个QVector<Shape*>和一个“已提交数量”索引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())); });

信号mouseMovedmouseMoveEvent里发送,注意这里不要再判断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 同级。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 14:34:40

基于Arm的车载Qt开发:交叉编译、信号槽与QML界面优化

简介&#xff1a;这是一套基于 Arm 与 Qt 的智能车载系统完整源码&#xff0c;面向嵌入式开发者和 Qt 应用工程师&#xff0c;可用于学习车载端功能集成与界面开发。资源共 115 个文件&#xff0c;压缩包约 26.54MB&#xff0c;涵盖 C/C 源文件、Qt 的 .ui 界面文件、qrc 资源文…

作者头像 李华
网站建设 2026/9/16 14:30:40

Node.js+Vue自习室预约系统架构设计与实践

1. 项目背景与核心价值自习室预约管理系统是当前高校和公共图书馆数字化转型中的刚需产品。传统的人工登记方式存在排队时间长、座位利用率低、管理成本高等痛点。我们团队基于Node.jsVue技术栈开发的这套系统&#xff0c;在3所高校实际运行中实现了95%以上的座位利用率&#x…

作者头像 李华