
简介面向计算机相关专业在校生及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 上拖动预览时做增量绘制。两者取舍如下表持久化方式数据规模局部刷新像素访问撤销实现QListShape* 全量重绘数百个形状内体验良好难只能 update() 全屏不能栈操作即可QPixmap 画布增量绘制几万笔涂鸦可用容易update(rect)难需要额外存快照QImage 离屏离线渲染与 QPixmap 相当容易可以逐像素读取同样需要快照本项目是简单绘画板选 QListShape* 最合适形状少、撤销直观、代码量最小等做到橡皮擦、滤镜或十万笔涂鸦时再升级成 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; QVectorQPoint 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同名函数只是“隐藏”而不是“覆盖”将来用 QListShape* 调用它时依然走基类版本这是 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 先等于 endmove 对固定形状刷新 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, QOverloadint::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, QOverloadint::of(QComboBox::currentIndexChanged), this, [this, typeBox](int) { m_currentType static_castShape::Type( typeBox-currentData().toInt()); });QOverloadint::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 c11 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 可以整体换成 Qt5Qt6::Widgets换成Qt5::Widgets其余不用动。两种构建工具在这个规模上没有本质差别主要是看团队后续有没有 CI、是否跨 IDE。比较项qmakeCMake创建工程速度快配置短稍多样板moc 自动化自动需显式开 AUTOMOC跨 IDE 兼容主要是 Qt CreatorVS / 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_PATHD:\qt\5.15.2\msvc2019_64\plugins painter.exe这条命令只是调试手段把开发机的绝对路径写死不能交付给用户最终依赖清单要交给 windeployqt 处理。第三个错是把 Qt 的 debug 版 DLL 和 release 版 exe 混在一起现象是运行后随机崩溃在某个 QPainter 调用上解决方法是保证整个工程用同一套 configuration 构建。5. 发布前的一件小事windeployqt 部署与离屏导出 PNG5.1 windeployqt 把 Qt 依赖收集成目录开发机跑得欢不代表换一台机器也能跑。Qt 程序编译出来依赖几十个 DLL手工逐个拷贝一定会漏Windows 上的标准做法是运行 Qt 官方部署工具 windeployqt。在 Qt 的 bin 目录下执行或先把该目录加入 PATHwindeployqt --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 不打开窗口也能导出 PNGQImage 离屏渲染画好的画布需要保存成图片时不要依赖 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 换成 QListQSharedPointerShape再给 Shape 增加一个 boundingRect() 方法撤销和局部重绘都以它为边界这是把简单绘画板推向 QGraphicsView 架构之前最值得做的一步。本文还有配套的精品资源点击获取