简介:面向计算机相关专业在校生及Qt初学者的绘画板程序源码包,适用于课程设计、毕业设计或日常Qt图形编程练习。项目基于C++与Qt框架实现基本几何图形绘制,包含点、直线、椭圆、矩形等工具,并提供绘图文件存取、撤销重做、线宽与颜色调节、鼠标坐标实时显示等实用功能。整个包共27个文件,以cpp和h源码文件为主,配有png图标资源、pro工程文件和md项目说明文档,压缩后仅42KB,体量小巧便于阅读和二次开发。目前已有358人浏览学习,代码结构清晰,结合图标与工程文件可快速搭建运行环境,既能帮助初学者理解Qt绘图事件、坐标系与界面布局,也可作为毕业设计或课程作业的参考基础,在此基础上方便扩展更多图形编辑功能。
1. 为什么说 Qt 绘画板是 C++ 桌面开发的完整入口
一个 C++ 初学者把语法、指针和容器过完之后,常常在“窗口里画一条线”这一步卡住:控制台程序是流式输出,图形界面的画面却是对象在事件驱动下反复重绘出来的。基于 Qt 的简单绘画板,用一个最小闭环把 C++ 桌面开发的主线串起来:鼠标按下到松开发生了什么、窗口为什么需要主动刷新、画过的线存在哪、撤销该撤什么。它适合刚学完 C++ 想接触 Qt 事件编程的人,也适合面试前拿 Qt 八股里的绘制和内存管理问题做一次快速复盘。
2. Qt 绘画板的事件循环与 QPainter 双缓冲机制
简单绘画板代码量不大,却会把 Qt 的事件分发、绘制设备、对象生命周期三件事同时推到面前。先把这三件事说清楚,后面写 widget.cpp 才不会把所有逻辑塞进一个 paintEvent 里变成意大利面。
2.1 鼠标事件从 QMouseEvent 到 QWidget 的传递路径
Qt 程序进入 app.exec() 之后,就由 QEventLoop 驱动的事件循环接管程序。系统把鼠标、键盘、窗口重绘等变化包装成事件对象,按接收者分发给 QObject 子类。绘画板关心的 QMouseEvent 会在三个时刻被投递到 QWidget 的回调函数:按下时 mousePressEvent()、按住移动时 mouseMoveEvent()、松开时 mouseReleaseEvent()。这三个回调是绘画板全部的输入来源,所有形状参数都在这里完成“起点到预览再到提交”的转换。
// widget.h 中需要重写的事件,签名必须与基类完全一致 protected: void paintEvent(QPaintEvent *event) override; void mousePressEvent(QMouseEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override; void mouseReleaseEvent(QMouseEvent *event) override;override 的作用是让编译器检查基类确实存在同名同签名的虚函数,避免把 mousePressEvent 拼写成其他名字导致静默失效。event->pos() 返回的是窗口客户区坐标,画布左上角是 (0,0);这里不要用 globalPos,否则形状存入 QList 之后,只要窗口一移动,所有坐标就全部错位。
还要特别说明 setMouseTracking。QWidget 默认只在鼠标按键按住时持续收到 mouseMoveEvent,松开后鼠标移动不产生事件。对自由画笔工具来说,这样会丢失笔迹;但只做直线、矩形预览的话,默认行为反而省事。两种写法都能跑,只要在构造函数里调用 setMouseTracking(true),就进入常开跟踪模式。网上很多“为什么鼠标没按住就不动”的提问,问题都出在这一行。
2.2 双缓冲:为什么画布要存 QPixmap 而不是直接画在界面
新手最常见的写法是在 paintEvent 里直接把线画到 this 上。问题在于 paintEvent 会被系统频繁触发:窗口拉伸、最小化恢复、被其他窗口遮挡后再露出,都要重新执行一遍。QWidget 不持有绘制内容,每次 paintEvent 都是从头再画;“画了一笔,窗口一抖就消失”就是由此而来。
因此绘画板必须维护一个持久状态。常见做法是把每个已完成形状作为对象存在 QList 里,paintEvent 每次用这个状态重画整个画布;更细一步,可以把绘制结果缓存在 QPixmap 上,拖动预览时做增量绘制。两者取舍如下表:
| 持久化方式 | 数据规模 | 局部刷新 | 像素访问 | 撤销实现 |
|---|---|---|---|---|
| QList<Shape*> 全量重绘 | 数百个形状内体验良好 | 难,只能 update() 全屏 | 不能 | 栈操作即可 |
| QPixmap 画布增量绘制 | 几万笔涂鸦可用 | 容易,update(rect) | 难 | 需要额外存快照 |
| QImage 离屏离线渲染 | 与 QPixmap 相当 | 容易 | 可以逐像素读取 | 同样需要快照 |
本项目是简单绘画板,选 QList<Shape*> 最合适:形状少、撤销直观、代码量最小;等做到橡皮擦、滤镜或十万笔涂鸦时,再升级成 QPixmap 画布不迟。QImage 和 QPixmap 的区别值得记住:QImage 与平台无关,适合离屏构造和逐像素运算;QPixmap 由窗口系统托管,绘制到屏幕更快,但脱离 GUI 环境时不宜直接操作像素。后文导出 PNG 会用到这个区分。
2.3 撤销与重做的状态管理:两个栈就够了
绘画板的撤销重做是典型的 LIFO 问题:用户画的最后一条线最先被撤销,被撤销的线重做时最先回来。命令行式地引入完整命令模式在这里属于过度设计,两个 QList 就能完成全部工作:m_shapes 保存已完成形状,m_redoShapes 是撤销暂存区。
void Widget::undo() { if (m_shapes.isEmpty()) return; m_redoShapes.append(m_shapes.takeLast()); // takeLast 出栈并返回尾部指针 update(); } void Widget::redo() { if (m_redoShapes.isEmpty()) return; m_shapes.append(m_redoShapes.takeLast()); update(); }注意两个栈存的是 Shape*,undo 把指针从 m_shapes 搬进 m_redoShapes 时没有 delete,否则 redo 会拿到一块已经释放的内存。指针的释放只在清空画布或 Widget 析构时统一处理,用 qDeleteAll(m_shapes) 后 clear() 即可。另一个容易被忽略的状态是:用户在撤销之后画了新形状,此时 m_redoShapes 里的旧分支不可能再被重做,必须在 mouseReleaseEvent 提交新形状时立刻 clear(),否则重做会插入一条历史错位的线。
注意:撤销后画新一笔时必须清空 redo 栈。很多画板 bug 表现为“撤销两次后重做冒出来三条不存在的线”,根因就是没清理这一层历史分支。
3. 在 Qt 里写一个可复现的画笔最小闭环
前面的事件机制和状态栈是“为什么”,这一章落到“怎么写”。照着下面的结构组织代码,就能得到一个具备画笔、直线、矩形、椭圆和撤销重做的 Qt 桌面画线最小工程。
3.1 用 Shape 类把“画一笔”封装成对象
QPainter 一次只画一条线,而画布要回放任意一笔,所以把每一笔都变成一个可自我绘制的对象。基类保存形状类型、起点、终点和画笔,paint() 根据类型调用不同的绘图原语。
// shape.h #include <QPoint> #include <QPen> #include <QVector> #include <QPainter> class Shape { public: enum Type { Line, Rect, Ellipse, FreeHand }; Shape(Type type, const QPoint &start, const QPen &pen) : m_type(type), m_start(start), m_end(start), m_pen(pen) {} virtual ~Shape() {} void setEnd(const QPoint &end) { m_end = end; } void addPoint(const QPoint &pt) { m_points.append(pt); } virtual void paint(QPainter &painter) const { painter.setPen(m_pen); switch (m_type) { case Line: painter.drawLine(m_start, m_end); break; case Rect: painter.drawRect(QRect(m_start, m_end).normalized()); break; case Ellipse: painter.drawEllipse(QRect(m_start, m_end).normalized()); break; case FreeHand: if (m_points.size() > 1) painter.drawPolyline(m_points.constData(), m_points.size()); break; } } protected: Type m_type; QPoint m_start, m_end; QPen m_pen; QVector<QPoint> m_points; };QRect(m_start, m_end).normalized() 是关键:用户从右下往左上拖时,QRect 会得到宽高为负的矩形,normalized() 把它纠正成左上角到右下角的正矩形,否则矩形和椭圆会直接消失。drawPolyline 接收 const QPoint* 和点数两个参数,FreeHand 的采样点全部存在 m_points 中,这是自由画笔与固定形状在数据层面的唯一区别。
构造函数把 m_end 初始化为 start,保证只有一次点击而没有拖动的“零长度笔迹”也能被记录。基类的 paint 用 virtual 修饰,意味着后续可以派生一个虚线画笔子类,用void paint(QPainter&) const override覆盖实现;如果漏写 virtual,同名函数只是“隐藏”而不是“覆盖”,将来用 QList<Shape*> 调用它时依然走基类版本,这是 C++ 虚函数和对象切片最常见的坑。
3.2 鼠标三件套:按下、移动、松开的状态迁移
窗口类内部维护三个运行期变量:m_tempShape 表示正在绘制但尚未提交的形状;m_shapes 是已完成形状;m_currentPen 和 m_currentType 描述下一次落笔的样式。事件回调按阶段更新这些变量。
void Widget::mousePressEvent(QMouseEvent *e) { if (e->button() != Qt::LeftButton) return; m_start = e->pos(); m_tempShape = new Shape(m_currentType, m_start, m_currentPen); } void Widget::mouseMoveEvent(QMouseEvent *e) { if (!m_tempShape) return; if (m_currentType == Shape::FreeHand) m_tempShape->addPoint(e->pos()); // 逐点采样,笔画更顺滑 else m_tempShape->setEnd(e->pos()); // 预览终点跟随光标 update(); } void Widget::mouseReleaseEvent(QMouseEvent *e) { if (!m_tempShape) return; if (m_currentType != Shape::FreeHand) m_tempShape->setEnd(e->pos()); m_shapes.append(m_tempShape); // 对象所有权交给 m_shapes m_tempShape = nullptr; // 当前不再有“正在画”的形状 m_redoShapes.clear(); // 新操作使旧历史分支失效 update(); }三个事件各司其职:press 负责创建形状对象,start 先等于 end;move 对固定形状刷新 end,对 FreeHand 追加采样点,然后 update() 请求重绘;release 固定最终 end,把指针交给 m_shapes,并清空 redo 栈。update() 不会立刻执行 paintEvent,它只把窗口标记为需要更新,等 Qt 回到事件循环后再统一重绘,这样可以避免每次鼠标移动都触发一次绘制而造成闪烁。
release 里用的是 e->pos() 而不是 m_start:如果用户快速滑动,最后一次 move 事件可能没有落在 release 所在的位置,用 release 坐标收尾更准确。press 里那句e->button() != Qt::LeftButton判断也不能省,否则鼠标右键同样会落笔,这是很多画板 demo 会留的小 bug。
| 事件 | 新形状 | 预览 | 状态提交 | 常见坑 |
|---|---|---|---|---|
| press | 创建 | 否 | 否 | 不判断左键 |
| move | 否 | 更新 | 否 | 忘记 setMouseTracking 时不拖动也能触发 |
| release | 否 | 定稿 | 指针交给场景 | 不清理 redo 栈 |
3.3 颜色、线宽和形状选择工具栏
在主窗口里放一个 QToolBar,把控件用 addWidget 挂上去。Qt 5 之后推荐用 lambda 接槽函数:槽函数不需要返回值,连接成功与否只看信号参数与 lambda 形参是否匹配。
auto *colorBtn = new QToolButton(this); colorBtn->setText("颜色"); connect(colorBtn, &QToolButton::clicked, this, [this]() { QColor c = QColorDialog::getColor(m_currentPen.color(), this, "选择画笔颜色"); if (c.isValid()) m_currentPen.setColor(c); }); auto *widthSpin = new QSpinBox(this); widthSpin->setRange(1, 30); widthSpin->setValue(2); connect(widthSpin, QOverload<int>::of(&QSpinBox::valueChanged), this, [this](int w) { m_currentPen.setWidth(w); }); auto *typeBox = new QComboBox(this); typeBox->addItem("直线", Shape::Line); typeBox->addItem("矩形", Shape::Rect); typeBox->addItem("椭圆", Shape::Ellipse); typeBox->addItem("画笔", Shape::FreeHand); connect(typeBox, QOverload<int>::of(&QComboBox::currentIndexChanged), this, [this, typeBox](int) { m_currentType = static_cast<Shape::Type>( typeBox->currentData().toInt()); });QOverload<int>::of 是 currentIndexChanged 重载带来的麻烦:Qt 5 里这个信号同时存在 int 版本和 QString 版本,直接写&QComboBox::currentIndexChanged编译器不知道连接哪一个,必须用模板形式消歧。QSpinBox::valueChanged 也是同样的原因被包了一层。currentData() 在 addItem 时把 Shape::Type 的枚举值绑定到每一项,切换时用 toInt() 再转回枚举;如果写死下标,后面插入新形状时所有索引都会错位。
颜色对话框的默认色参数一定要传 m_currentPen.color(),不能写死 QColor(Qt::black);getColor 返回的 QColor 要判 isValid(),用户取消对话框时返回的是无效颜色,这时更新画笔会把颜色重置成黑色。
3.4 paintEvent 的全量重绘与绘制顺序
绘制分两层:已完成形状加正在预览的形状。预览对象在鼠标移动时被反复修改,每帧都要重新画;已完成形状则从 m_shapes 回放。
void Widget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.fillRect(rect(), Qt::white); // 先清成白底 painter.setRenderHint(QPainter::Antialiasing); // 抗锯齿,边缘平滑 for (const Shape *s : qAsConst(m_shapes)) // 避免 QList 隐式共享 detach s->paint(painter); if (m_tempShape) m_tempShape->paint(painter); }抗锯齿在构造 painter 后设置一次,后续所有绘制都会生效,代价是边缘会淡出几个灰度级,在低分辨率屏幕上可能像蒙了一层雾,在高 DPI 屏幕上反而自然。fillRect(rect(), Qt::white) 必须出现在所有绘制之前,否则上一帧残留会在新内容下面透出来。qAsConst 是 Qt 5.7 之后推荐的范围循环写法,防止 QList 隐式共享容器触发一次无意义的 detach 拷贝,Qt 6 里可以直接换成 std::as_const。
4. 源码+项目说明:工程结构、构建脚本与 README 怎么写
一个“源码+项目说明”压缩包的价值不在代码本身,而在别人拿到后能不能在 5 分钟内跑起来。这一章把工程文件和 README 的标准结构拆开讲。
4.1 目录布局与 qmake / CMake 两种构建脚本
简单绘画板的目录要短、文件少、职责清楚。一个可行的结构是七个文件:
painter/ ├── main.cpp ├── widget.h ├── widget.cpp ├── shape.h ├── shape.cpp ├── painter.pro └── README.mdqmake 工程文件是 Qt Creator 最常见的打开方式,三行关键配置决定构建行为:
QT += core gui greaterThan(QT_MAJOR_VERSION, 4): QT += widgets TARGET = painter TEMPLATE = app CONFIG += c++11 SOURCES += main.cpp widget.cpp shape.cpp HEADERS += widget.h shape.hQT += widgets 在 Qt5 中不可省略,漏掉它编译 main.cpp 时会直接报找不到 QApplication。greaterThan 是 qmake 的版本分支表达式,Qt4 没有 widgets 模块,Qt5、Qt6 才需要这一行。SOURCES 和 HEADERS 会触发 qmake 自动处理 moc 依赖;如果头文件里用了 Q_OBJECT 却忘了把 .h 写进 HEADERS,链接阶段会出现 vtable 相关的未定义符号。
CMake 是 Qt 官方这两年主推的构建方式,写法更显式:
cmake_minimum_required(VERSION 3.16) project(painter LANGUAGES CXX) set(CMAKE_AUTOMOC ON) set(CMAKE_CXX_STANDARD 11) find_package(Qt6 REQUIRED COMPONENTS Widgets) add_executable(painter main.cpp widget.cpp shape.cpp) target_link_libraries(painter PRIVATE Qt6::Widgets)AUTOMOC 一开,CMake 会扫描带 Q_OBJECT 的头文件并生成 moc_widget.cpp;关闭它就必须手写#include "moc_widget.cpp"才能链接通过。FindPackage 的 Qt6 可以整体换成 Qt5,Qt6::Widgets换成Qt5::Widgets,其余不用动。两种构建工具在这个规模上没有本质差别,主要是看团队后续有没有 CI、是否跨 IDE。
| 比较项 | qmake | CMake |
|---|---|---|
| 创建工程速度 | 快,配置短 | 稍多样板 |
| moc 自动化 | 自动 | 需显式开 AUTOMOC |
| 跨 IDE 兼容 | 主要是 Qt Creator | VS / CLion / Qt Creator 通用 |
| 依赖查找 | 不擅长 | find_package 精确 |
4.2 项目说明文档要覆盖什么,而不是写什么
README 是压缩包的“门面”。最常见的失败文本是写一大段“本项目支持画直线、画矩形、支持撤销”,却不写最核心的信息:在哪个 Qt 版本、用什么编译器能编过、需要哪些模块。下面这份模板可以直接套用:
# Qt Painter 绘画板 - 环境:Qt 5.15.2 / MSVC2019 64 位,或 MinGW 8.1.0 - 功能:画笔、直线、矩形、椭圆;颜色、线宽可调;撤销/重做/清空 - 构建(Qt Creator): 1. 打开 painter.pro 2. 选择 Qt 5.15.2 的 Kit,点击构建运行 - 构建(命令行): cd build qmake ../painter.pro mingw32-make # MSVC 环境用 nmake - 目录说明:shape.h 为形状基类,widget.* 为窗口与事件处理,main.cpp 为入口 - 扩展点:在 Shape::Type 增加枚举,并在 paint() 增加分支即可加入新形状版本要精确到“Qt 5.15.2 / MSVC2019”,而不是写“Qt 环境”:Qt 6 里 QMouseEvent 的部分接口行为与 Qt 5 不同,MSVC 与 MinGW 编出来的 exe 依赖的 DLL 文件也完全不同。命令行构建里写 mingw32-make 后,补一句“MSVC 环境用 nmake”,能省下读者反复试探的时间。扩展点这一段尤其重要,它告诉别人从哪个文件动手,项目说明才具备可演进属性。
我一般还会在 README 里放一张运行截图,图片用相对路径doc/screenshot.png,不要用 Qt Designer 生成的绝对路径。截图不大,但影响第一印象。
4.3 编译与运行时的三个高频错误
第一个错来自 moc。新增 Q_OBJECT 后编译报 vtable 相关错误或找不到 moc_widget.cpp,多半是构建系统没有重新扫描头文件。正确做法是删掉 build 目录后重新配置构建,而不是手写 include。qmake 用户执行下面第一段,CMake 用户执行第二段;同一个目录里不要混跑 qmake 和 cmake,两者生成的 Makefile 会互相覆盖。
# qmake 方式(Windows cmd 下把 rm -rf 换成 rmdir /s /q) rm -rf build && mkdir build && cd build qmake ../painter.pro && make # CMake 方式 cmake -S . -B build && cmake --build build第二个错是程序起不来,控制台输出qt.qpa.plugin: Could not find the Qt platform plugin "windows",有时还带一段qt_qpa_platform_plugin_path D:\qt\5.15.2\msvc2019_64的环境变量信息。这是 exe 在运行目录里找不到 Qt 的 platform plugin。开发机上 Qt Creator 会配置插件搜索路径,直接双击 exe 不经过那套环境,所以报错只在脱离 IDE 时出现。临时验证可以用:
set QT_QPA_PLATFORM_PLUGIN_PATH=D:\qt\5.15.2\msvc2019_64\plugins painter.exe这条命令只是调试手段,把开发机的绝对路径写死不能交付给用户;最终依赖清单要交给 windeployqt 处理。第三个错是把 Qt 的 debug 版 DLL 和 release 版 exe 混在一起,现象是运行后随机崩溃在某个 QPainter 调用上,解决方法是保证整个工程用同一套 configuration 构建。
5. 发布前的一件小事:windeployqt 部署与离屏导出 PNG
5.1 windeployqt 把 Qt 依赖收集成目录
开发机跑得欢,不代表换一台机器也能跑。Qt 程序编译出来依赖几十个 DLL,手工逐个拷贝一定会漏,Windows 上的标准做法是运行 Qt 官方部署工具 windeployqt。在 Qt 的 bin 目录下执行,或先把该目录加入 PATH:
windeployqt --no-translations --no-system-d3d-compiler painter.exe--no-translations 表示不需要 Qt 自带的语言翻译文件,本项目没有做 qt 国际化,不写这个参数会多拷一批 qtbase_*.qm;--no-system-d3d-compiler 在开发机具备较新 DirectX 时通常可以省略。运行完后,exe 同级目录会自动出现 Qt6Core.dll、Qt6Gui.dll、Qt6Widgets.dll、platforms/qwindows.dll、styles 目录等。验证部署是否完整,最省事的办法是把整个目录压缩,放到一台没有装 Qt 的干净机器双击运行;没有现成环境,也可以检查输出日志中是否出现 “Could not find”。
5.2 不打开窗口也能导出 PNG:QImage 离屏渲染
画好的画布需要保存成图片时,不要依赖 widget->grab(),那个方法受窗口可见性和 DPI 影响。更可控的做法是把所有 Shape 对象重放到 QImage 上,完全绕开屏幕:
bool exportToPng(const QString &path, const QSize &size) { QImage image(size, QImage::Format_ARGB32); image.fill(Qt::white); QPainter p(&image); p.setRenderHint(QPainter::Antialiasing); for (const Shape *s : qAsConst(m_shapes)) s->paint(p); p.end(); // 必须 end,否则 QImage 内部可能还持有未提交的缓冲 return image.save(path, "PNG"); }p.end() 这一步最容易丢:QPainter 析构时确实会自动 end,但依赖临时对象释放时机不安全,尤其是在循环里创建 painter 时。QImage 用 Format_ARGB32 支持透明通道,导出的 PNG 可以带 alpha;想要纯白底,把 fill 参数改成 Qt::white 即可,两种背景都保留。想继续扩展下一步,先把 m_shapes 换成 QList<QSharedPointer<Shape>>,再给 Shape 增加一个 boundingRect() 方法,撤销和局部重绘都以它为边界,这是把简单绘画板推向 QGraphicsView 架构之前最值得做的一步。
本文还有配套的精品资源,点击获取