简介:一份面向计算机相关专业学生与Qt初学者的简单绘画板程序源码包,适用于C++课程设计、毕业设计或项目初期演示。程序基于Qt框架实现,核心功能包括绘制点、直线、椭圆、矩形等基本几何图形,支持绘图文件的存储与读取、撤回与重做、线宽与颜色调节,并实时显示鼠标坐标位置,覆盖了Qt绘图程序的基础交互逻辑。资源共27个文件,以cpp源文件和h头文件为主体,搭配png图标资源、pro工程配置文件与md项目说明文档;其中png用于界面图标,cpp与h按窗口、中心画布、图形类分模块组织,pro为工程配置,md为使用说明,整体仅42KB,结构清晰便于查阅。代码已通过运行测试,功能稳定,可直接导入Qt Creator编译学习,也便于在此基础上扩展新工具或迁移到更大项目。目前已有358人学习下载,适合课设作业和Qt绘图机制入门。
1. “源码+项目说明”里的绘画板,核心是把绘制链路走通
拿到“C++程序开发基于Qt实现的简单绘画板程序源码+项目说明”这类压缩包,最容易出现的现象是:源码能编译,但运行后线条画不出来、窗口一拉伸画布全花、保存成图片后透明区域变黑。核心原因是绘制链路没有理顺。绘画板表面上看是鼠标事件和画线的组合,实际上关键点有三处:事件坐标如何映射到画布、用什么缓冲保存已有笔迹、重绘时如何避免闪烁。把这一条链路想清楚,再往上层加颜色、笔宽、橡皮擦、撤销重做,只是参数和状态的扩展。这篇文章从 QWidget + QPainter 这套最直接的技术栈入手,按“最小画板 → 交互增强 → 保存与发布 → 性能优化”的顺序把能直接抄的代码和参数都列出来,适合刚接触 Qt 的 C++ 开发者,也适合需要快速改造一个绘图 Demo 的工程师。
2. 用 QPainter 在 QWidget 上实现绘画板的最小可运行画板
2.1 继承 QWidget 并重写四个鼠标/绘制事件
绘画板最常见的载体是 QWidget,而不是 QMainWindow。让一个类继承 QWidget,重写paintEvent、mousePressEvent、mouseMoveEvent、mouseReleaseEvent,就完成了 90% 的输入输出闭环。QGraphicsView 和 QML 也能做,但事件映射和场景坐标对新手来说多了一层心智负担;QWidget + QPainter 是代码量最少、问题最直观的方案。
class PaintBoard : public QWidget { Q_OBJECT public: explicit PaintBoard(QWidget *parent = nullptr); protected: void paintEvent(QPaintEvent *event) override; void mousePressEvent(QMouseEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override; void mouseReleaseEvent(QMouseEvent *event) override; void resizeEvent(QResizeEvent *event) override; private: QPixmap canvas; // 离屏画布,保存已经画好的所有内容 QPoint lastPoint; // 上一次的鼠标位置 QColor penColor{Qt::black}; int penWidth{3}; };canvas是这里最关键的成员。它不直接显示在窗口上,而是作为绘制的目标;每次事件到来,先在canvas上画出新笔迹,然后调用update()触发一次窗口重绘,paintEvent里再把整张canvas拷贝到窗口上。这个机制保证了窗口被遮挡、最小化恢复、拉伸时内容不丢。
四个事件函数的职责按下表划分:
| 事件函数 | 职责 | 必须做的操作 |
|---|---|---|
| mousePressEvent | 开始一笔笔迹 | 记录 lastPoint,在 canvas 上画起始点 |
| mouseMoveEvent | 延续笔迹 | 从 lastPoint 画线到当前点,再更新 lastPoint |
| mouseReleaseEvent | 结束当前笔迹 | 复位绘制状态,把快照压入撤销栈 |
| paintEvent | 全量显示画布 | 将 canvas 绘制到 widget 上 |
mouseMoveEvent有一个隐蔽前提:默认情况下,只有按下鼠标按键才会持续收到 move 事件。对于“鼠标悬停时不落笔、按下才画”的需求,默认行为恰好合适;但如果后面要加“悬停预览笔迹”功能,需要在构造函数里调用setMouseTracking(true)。
2.2 在事件函数里完成坐标采集与画线落盘
QMouseEvent 的坐标需要统一用position()获取。position()返回 QPointF,在默认无缩放的 widget 上,它与 QPainter 的逻辑坐标一致。部分旧教程里的pos()在 Qt 5 中仍兼容,但 Qt 6 中会提示废弃,统一换成position().toPoint()更保险。
void PaintBoard::mousePressEvent(QMouseEvent *event) { if (event->button() != Qt::LeftButton) { QWidget::mousePressEvent(event); return; } lastPoint = event->position().toPoint(); QPainter painter(&canvas); painter.setRenderHint(QPainter::Antialiasing); painter.setPen(QPen(penColor, penWidth, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); painter.drawPoint(lastPoint); update(); }只点击不拖动时,mousePressEvent里画一个点,保证用户单击也能留下痕迹。QPen的第四个参数Qt::RoundCap很关键:笔划端点会被画成半圆,连续两笔之间的接头更平滑,不会出现明显的直角断裂。
mouseMoveEvent在左键按住时持续触发,坐标采集的频率由系统决定。对于常规鼠标,事件间隔大约在 5 到 15ms 之间。在这段时间里,不能直接drawLine(previous, current)就结束,正确逻辑是画完一段线后,把lastPoint更新为当前点,否则下一段线会从旧的起点连到新位置,出现重复拖尾。
void PaintBoard::mouseMoveEvent(QMouseEvent *event) { if (!(event->buttons() & Qt::LeftButton)) return; QPoint currentPos = event->position().toPoint(); QPainter painter(&canvas); painter.setRenderHint(QPainter::Antialiasing); painter.setPen(QPen(penColor, penWidth, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); painter.drawLine(lastPoint, currentPos); lastPoint = currentPos; update(); }这里用event->buttons()判断左键是否处于按下状态,而不是button()。button()只返回触发当前事件的那个按键,在 move 事件里可能返回Qt::NoButton;buttons()返回的是实时按键状态集合,二者区别常在触摸板和快捷键场景中导致逻辑错误。
2.3 paintEvent 中只用一句话完成重绘
paintEvent不需要关心“哪条线是新画的”,它只负责把canvas整个搬上屏幕:
void PaintBoard::paintEvent(QPaintEvent *event) { QPainter painter(this); painter.drawPixmap(0, 0, canvas); }如果直接把绘制逻辑写在paintEvent里,比如保存一个QPainterPath成员,每次事件重画一遍,就会出现两个问题:一是事件密集时重绘耗时线性增长,二是系统触发重绘(窗口遮挡恢复)时历史笔迹必须完整重建,一旦路径存储逻辑有遗漏,画面就会缺笔画。用QPixmap作为离屏缓存后,paintEvent永远只做一次位图拷贝,耗时稳定在毫秒级。
canvas的初始化放在构造函数里:
PaintBoard::PaintBoard(QWidget *parent) : QWidget(parent) { setMinimumSize(800, 600); canvas = QPixmap(size()); canvas.fill(Qt::white); // 白底画布 }值得注意的边界场景是:如果canvas在构造时用QPixmap(0, 0)初始化,后续QPainter绘制会静默失败而不报错。稳妥做法是先调用resize(800, 600)或setMinimumSize确保size()有效,再用QPixmap(1, 1)做兜底。
3. 给 Qt 绘画板加上橡皮擦、颜色笔宽与撤销重做
3.1 橡皮擦用 CompositionMode_Clear 而不是涂白色
橡皮擦是本项目里最容易写错的模块。直观做法是把画笔颜色临时设为白色,这样在白底画布上看起来确实像擦除。但画布一旦换成网格背景或加载图片,白块就会暴露。
正确的橡皮擦实现依赖 QPainter 的混合模式:
void PaintBoard::eraseLine(const QPoint &from, const QPoint &to, int eraserWidth) { QPainter painter(&canvas); painter.setCompositionMode(QPainter::CompositionMode_Clear); painter.setPen(QPen(Qt::transparent, eraserWidth, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); painter.drawLine(from, to); update(); }CompositionMode_Clear的作用是把目标区域像素清零,变成全透明。此时QPen的颜色参数失去意义,起作用的只有eraserWidth、RoundCap和RoundJoin。由于canvas初始是白底不透明位图,被擦区域变成透明后,窗口背景会透出来。如果希望擦除后仍然显示白色,可以在paintEvent里先画一层白色背景,再叠加canvas:
void PaintBoard::paintEvent(QPaintEvent *) { QPainter painter(this); painter.fillRect(rect(), Qt::white); painter.drawPixmap(0, 0, canvas); }这样“橡皮擦擦出白色”的行为才与视觉一致。注意保存 PNG 时,透明区域会真实写入 alpha 通道;如果保存 JPG,透明像素会自动变成黑色,这是两种格式的差异,不是代码 bug。
3.2 颜色选择、笔宽滑块如何接进绘制参数
在QMainWindow里通过工具栏对PaintBoard的公开接口传参。典型接口是 setter 方法,在类中增加:
public slots: void setPenColor(const QColor &color); void setPenWidth(int width); void setEraserMode(bool enabled);颜色选择最常见的交互是用 QColorDialog:
void MainWindow::chooseColor() { QColor color = QColorDialog::getColor(Qt::black, this, "选择画笔颜色"); if (color.isValid()) { board->setPenColor(color); board->setEraserMode(false); } }笔宽用 QSlider。这里有一个 Qt 的信号重载问题需要留意:QSpinBox 和 QSlider 的valueChanged在 Qt 5 之后有int和QString两个重载,直接connect(slider, &QSlider::valueChanged, board, &PaintBoard::setPenWidth)一般没问题,但使用旧式SIGNAL宏连接时,信号名必须写成SIGNAL(valueChanged(int)),否则运行时连接失败且不报错。
setEraserMode的典型逻辑是切换内部状态,在绘制函数里做分支:
void PaintBoard::setEraserMode(bool enabled) { eraserMode = enabled; if (enabled) { tempColor = penColor; tempWidth = penWidth; penColor = Qt::white; penWidth = eraserWidth; } else { penColor = tempColor; penWidth = tempWidth; } }这种“切换颜色字段”的做法实现简单,但会污染撤销快照里的颜色信息。更干净的做法是绘制时分发到不同的私有方法,不是改公共字段。实际操作中为减小改动量,绝大多数源码包采用字段切换;理解它和CompositionMode_Clear的区别即可。
3.3 用 QStack 实现 20 步撤销与重做
一个小型绘画板的撤销,不需要引入命令模式。每一笔结束时,把当前的canvas转成QImage快照压栈即可。QImage 是值类型,深拷贝代价与画布尺寸成正比,因此快照数量需要封顶,常见做法是限制 20 步。
void PaintBoard::mouseReleaseEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) { undoStack.push(canvas.toImage()); if (undoStack.size() > 20) { undoStack.removeFirst(); // 超出步数时删除最旧快照 } redoStack.clear(); } QWidget::mouseReleaseEvent(event); }撤销和重做的实现是对称的:
void PaintBoard::undo() { if (undoStack.isEmpty()) return; redoStack.push(canvas.toImage()); canvas = QPixmap::fromImage(undoStack.pop()); update(); } void PaintBoard::redo() { if (redoStack.isEmpty()) return; undoStack.push(canvas.toImage()); canvas = QPixmap::fromImage(redoStack.pop()); update(); }这里有两个细节直接决定撤销功能是否好使。第一,不要把初始空白画布放进 undoStack,否则第一次点击撤销会变成“无可撤销”而不是“清空为空白”,体验差别很大。第二,Qt 5.15 之前QStack::removeFirst是 O(n) 操作,20 步规模下无所谓;如果画布尺寸达到 4000×3000 以上,QImage 每次深拷贝耗时可能超过 30ms,此时建议改用QList<QImage>加头尾索引,避免频繁中间删除。
4. 图片保存、文件导出与 Qt 程序打包发布
4.1 用 QImageWriter 按后缀保存 PNG/JPG
保存功能最朴素的写法是canvas.save(fileName),效果可行,但遇到不被支持的后缀时失败信息不明确。稳定的保存路径是使用 QImageWriter,先判断目标格式是否被当前 Qt 插件支持。
void PaintBoard::saveToFile() { const QString initialPath = QStandardPaths::writableLocation(QStandardPaths::PicturesLocation); const QString fileName = QFileDialog::getSaveFileName( this, "保存绘画", initialPath, "PNG 图片 (*.png);;JPEG 图片 (*.jpg)"); if (fileName.isEmpty()) return; QImageWriter writer(fileName); writer.setQuality(95); QImage image = canvas.toImage(); // 如果是 JPG,透明区域需要先合成到白底 if (fileName.endsWith(".jpg", Qt::CaseInsensitive)) { QImage whiteImage(image.size(), QImage::Format_RGB32); whiteImage.fill(Qt::white); QPainter painter(&whiteImage); painter.drawImage(0, 0, image); image = whiteImage; } if (!writer.write(image)) { QMessageBox::warning(this, "保存失败", writer.errorString()); } }涉及两个值得说明的参数与边界:setQuality(95)只对 JPG 生效,对无损 PNG 无影响,所以统一设置是安全的。QImageWriter::errorString()返回的信息比save的布尔返回值更有排错价值,典型错误如插件缺失、路径无写权限都会直接暴露。
JPG 白底合成这一步经常被省略。透明 canvas 直接被 QImageWriter 输出成 JPG 时,透明区域会呈现为黑色,因为 JPG 没有 alpha 通道。先铺白底再绘制图像内容,是行业里最常见的兜底手法。
4.2 用 QMainWindow 组装完整界面
工程结构一般是两个类:PaintBoard负责画布逻辑,MainWindow负责菜单、工具栏和动作绑定。动作排列和图标可以简单,但七个动作必须可用:选择颜色、设置笔宽、橡皮擦、撤销、重做、清空画布、保存图片。
工具栏上的笔宽参数用一个 QSpinBox 加一个 QSlider 同步更合理。QSpinBox 设置范围 1 到 50,QSlider 设置范围 1 到 100,两者通过valueChanged互相同步,避免出现“界面上选了 8 号笔,实际画的却是 3 号”的失真。这里最容易被忽略的是 QSpinBox 的setValue在编程设置时也会触发valueChanged,如果两个控件直接互相连接,会形成一次冗余的信号循环;业界常见的处理是给一侧加QSignalBlocker:
void MainWindow::syncWidthSpin(int value) { QSignalBlocker blocker(widthSpin); widthSpin->setValue(value); }QSignalBlocker在构造时阻塞对象信号,析构时自动恢复,有效避免递归信号。
4.3 用 windeployqt 整理运行依赖并验证最小发布集
源码包在开发机编译运行正常,换一台机器就报“无法启动此程序,因为计算机中丢失 Qt5Widgets.dll”,这是 Qt 新手发布时会遇到的高频问题。Qt 程序在 Windows 上不是单个 exe 就能跑的,它动态依赖 Qt 库和平台插件。以 Qt 5.15 为例,完整依赖清单由windeployqt.exe自动收集,命令行在编译产物的目录执行:
windeployqt --release --no-translations --no-system-d3d-compiler .常见参数说明:
| 参数 | 作用 |
|---|---|
| --release | 只部署 release 版本依赖;不加则可能同时拷入 debug 库 |
| --no-translations | 跳过 Qt 自带翻译文件,减少体积 |
| --no-system-d3d-compiler | 不部署 D3D 编译器相关组件,纯 2D 应用不需要 |
| --compiler-runtime | 顺带部署 MSVC 运行时 |
执行完成后,exe 同级目录会出现platforms/qwindows.dll、Qt5Core.dll、Qt5Gui.dll、Qt5Widgets.dll 以及若干图像格式插件。验证是否干净的通用做法是:把整个目录压缩拷贝到一台没有装 Qt 的机器上运行,或者在本机临时改名 Qt 安装目录再运行 exe。如果提示缺少vcruntime140.dll或msvcp140.dll,说明编译器运行时没有带全;对 MSVC 构建的程序,安装官方 Visual C++ Redistributable 或用上面的--compiler-runtime参数都能解决。
提示:发布时不要把整个 Qt 安装目录拷给用户。安装目录包含 debug 库、源码和大量无用模块,体积膨胀明显;debug 版 DLL 还会让程序依赖对应的调试运行时,普通电脑上无法启动,排查起来容易误判为“程序坏了”。
5. 用事件合并与延迟提交优化 Qt 绘画板的笔迹流畅度
小画布上直接画线没有压力,但当画布达到 2000×1500、笔刷直径大于 30px 时,每帧都做贝塞尔插值并写回canvas,CPU 占用会明显上升。瓶颈不在 QPainter 本身的绘制,而在于mouseMoveEvent到paintEvent之间的重复劳动:一次移动事件就触发一次完整的 canvas 绘制。
优化的通用做法是“延迟提交”。移动过程中,不把临时笔迹直接写进canvas,而是保存在一个成员变量里,paintEvent中先绘制 canvas,再叠加临时笔迹;等到mouseReleaseEvent时才一次性把整条笔迹提交到 canvas。这样canvas的修改次数从“每事件一次”降为“每笔一次”,重绘开销大幅下降。
void PaintBoard::paintEvent(QPaintEvent *) { QPainter painter(this); painter.fillRect(rect(), Qt::white); painter.drawPixmap(0, 0, canvas); // 已提交的笔迹 if (!currentStroke.isEmpty()) { painter.setRenderHint(QPainter::Antialiasing); painter.setPen(QPen(penColor, penWidth, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); for (int i = 1; i < currentStroke.size(); ++i) { painter.drawLine(currentStroke[i - 1], currentStroke[i]); } } }提交动作发生在鼠标释放时:
void PaintBoard::mouseReleaseEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton && !currentStroke.isEmpty()) { QPainter painter(&canvas); painter.setRenderHint(QPainter::Antialiasing); painter.setPen(QPen(penColor, penWidth, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); for (int i = 1; i < currentStroke.size(); ++i) { painter.drawLine(currentStroke[i - 1], currentStroke[i]); } painter.end(); undoStack.push(canvas.toImage()); currentStroke.clear(); } QWidget::mouseReleaseEvent(event); }这个策略下,移动事件本身不会写canvas,只做两件事:把当前坐标 append 到currentStroke,然后调用update()。Qt 的update()会自动合并同一事件循环内的多次请求,所以高频鼠标移动不会对应高频重绘。
验证优化是否生效,用 QElapsedTimer 在移动事件里测时即可:
QElapsedTimer timer; timer.start(); // mouseMoveEvent 中的坐标采集与 update qint64 costMs = timer.nsecsElapsed() / 1000000; if (costMs > 16) { qDebug() << "move cost over 16ms:" << costMs << "ms"; }16ms 对应约 60 FPS 的刷新预算。如果 costMs 经常超过这个阈值,观察paintEvent里是否有不必要的canvas.toImage()调用。绘制过程中频繁toImage()是内存拷贝大户,改为仅在撤销快照需求出现时进行转换。若笔迹本身即折线效果,也可以用QPainter::drawPolyline一次性绘制整条路径,再配合QPainterPath::cubicTo做平滑,N 个点一次绘制也比 N 次 drawLine 的 CPU 占用低一个数量级。
本文还有配套的精品资源,点击获取