简介:一套适合毕业设计、课程设计或课程作业,也适合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 扩展格式:新图元类型的兼容设计
之前的枚举类型就是文件里的“身份证”:
| 类型枚举值 | 图元 | 说明 |
|---|---|---|
| 0 | PointShape | 点 |
| 1 | Line | 直线 |
| 2 | Rectangle | 矩形 |
| 3 | Ellipse | 椭圆 |
以后加箭头、贝塞尔曲线只要枚举值往后排,读取端根据 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 同级。
本文还有配套的精品资源,点击获取