简介:面向Qt初学者与桌面图形应用开发者的绘图软件教程及源码包,围绕跨平台C++框架的2D绘图能力展开,帮助读者理解QPainter、QPaintEvent及鼠标事件驱动的交互式绘图流程。压缩包共114个文件,约3.54MB,其中15个cpp与18个头文件承载核心逻辑,7个ui文件对应界面布局,52张png便于对照运行效果,另有pro、qrc等工程配置和pdf、txt说明,可用Qt Creator直接打开学习。已有706人学习,内容覆盖主窗口与绘图区域组织、画笔和填充属性调整、颜色与工具选择、图形保存加载等环节,并展示事件处理与重绘机制如何配合,对绘图坐标、重绘触发条件与画笔状态切换也有较直观的演示。读者可据此梳理完整Qt项目的目录结构与模块分工,掌握基础绘图、交互响应和图像导出的实现思路,适合课程设计、桌面绘图工具原型开发参考。
1. 从一张空白画布说起:Qt 2D 绘图软件到底能做成什么样
很多人第一次打开 Qt Creator,拖两个按钮进去,感觉这玩意儿就是个“加壳工具”;直到要做真正的画布程序,才发现 QPainter、QGraphicsScene、坐标变换这些概念全都会冒出来。这份资源的核心,就是一套用 Qt 做 2D 绘图软件的完整教程和配套源码:从窗口骨架、绘图事件分发,到图层管理、图元选取、撤销重做、导出 PNG,把“能画线”一步一步推到“能当一个小型画板用”。它适合两类人:一类是刚学完 Qt 控件基础、想找一个中型 Demo 把信号槽、事件、模型视图串起来的开发者;另一类是手头要做工业标注、曲线绘制、思维导图、白板协作这类功能,需要一个能直接改的基线工程。教程和源码配套,意味着不用从零猜结构,但也不等于复制粘贴就能上线,后面几章会把该补的边界说清楚。
2. 先把绘图骨架立住:QPainter、画布控件与坐标系统
Qt 做 2D 绘图有两条主流路线,一份源码值不值得看,先看它选的是哪条。第一条是 QWidget + QPainter 的重绘路线,直接在自己的 paintEvent 里画,轻量、好理解、性能可控;第二条是 Graphics View 框架,用 QGraphicsScene 当数据模型、QGraphicsView 当视口、QGraphicsItem 当图元,天然支持选取、拖拽、缩放和碰撞检测。这份教程在前期用第一种方式讲原理,后期切到第二种做完整功能,这个顺序对新手很友好:先看清“一笔怎么画出来”,再去理解“图元怎么被管理”。
选型上不用纠结哪个更高级。纯 QPainter 路线适合图形数量固定、交互简单的场景,比如实时曲线、仪表盘、标注框;Graphics View 适合图形数量上百、需要单独选中和拖动、要做图层排序的场景,比如白板、流程图、矢量编辑器。判断标准可以粗暴一点:如果你的每个图形都需要被“点中”然后单独改属性,直接上 Graphics View,否则后面补选中逻辑会付出很高代价。
2.1 最小可绘制的画布窗口
不管后面多复杂,第一步永远是让鼠标按下去能留下一笔。常见做法是继承 QWidget,维护一个笔画容器,在 mousePressEvent 里开新笔画、mouseMoveEvent 里追加点、paintEvent 里统一重绘。
// canvaswidget.h #pragma once #include <QWidget> #include <QVector> #include <QPointF> class CanvasWidget : public QWidget { Q_OBJECT public: explicit CanvasWidget(QWidget *parent = nullptr); protected: void paintEvent(QPaintEvent *event) override; // 所有绘制集中在这里 void mousePressEvent(QMouseEvent *event) override; // 开始一笔 void mouseMoveEvent(QMouseEvent *event) override; // 追加点 void mouseReleaseEvent(QMouseEvent *event) override; // 结束一笔 private: QVector<QVector<QPointF>> m_strokes; // 每个元素是一笔,元素内是采样点 bool m_drawing = false; };// canvaswidget.cpp #include "canvaswidget.h" #include <QPainter> #include <QMouseEvent> CanvasWidget::CanvasWidget(QWidget *parent) : QWidget(parent) { setAttribute(Qt::WA_OpaquePaintEvent); // 告诉系统不用先擦背景,减少闪烁 setMouseTracking(true); // 未按键时也上报移动,便于做悬停预览 } void CanvasWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); // 抗锯齿,线条才不毛糙 QPen pen(Qt::black, 2.0, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin); painter.setPen(pen); for (const auto &stroke : m_strokes) { if (stroke.size() < 2) continue; painter.drawPolyline(stroke.constData(), stroke.size()); // 一笔整体绘制,避免断点 } } void CanvasWidget::mousePressEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) { m_strokes.append({event->pos()}); m_drawing = true; } } void CanvasWidget::mouseMoveEvent(QMouseEvent *event) { if (!m_drawing) return; m_strokes.last().append(event->pos()); update(); // 触发重绘;简单 Demo 够用,后面会换成局部刷新 } void CanvasWidget::mouseReleaseEvent(QMouseEvent *event) { Q_UNUSED(event) m_drawing = false; }这段代码的关键点有三个。第一,数据与绘制分离:m_strokes是唯一数据源,paintEvent 只负责把它翻译成像素,这样后面加撤销重做、加缩放,都只需要改数据层。第二,drawPolyline一次画整笔,而不是逐点 drawPoint,否则在高 DPI 屏上线条会断裂、粗细不均。第三,WA_OpaquePaintEvent配合完整重绘能减少闪烁,但代价是每次 update 都会重画全部笔画,笔画一多就会卡,这个坑第 5 章会展开。
参数上最值得改的是 QPen:width决定线宽,Qt::RoundCap让笔头圆润,Qt::RoundJoin让转折处不出现尖角锯齿。抗锯齿一定要开,除非你在做像素画工具。
2.2 坐标系与缩放:先想清楚谁是“世界”,谁是“屏幕”
新手最容易翻车的地方是坐标。鼠标事件给的event->pos()是控件坐标(设备无关像素),而你要保存的往往是“画布坐标”。一旦支持缩放和平移,这两者就不再相等。
常见的做法是引入一个 QTransform 作为视图变换,保存时把屏幕坐标反变换回画布坐标:
// 缩放状态下,把鼠标位置映射回画布坐标 QTransform inverse = m_viewTransform.inverted(); QPointF canvasPos = inverse.map(event->pos()); // 绘制时先把画笔宽度换算成“屏幕像素”,否则放大后线条会变粗 QPen pen(Qt::black, m_penWidth / m_zoom, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin);不这么做的后果非常直观:放大到 400% 之后,用 2px 画出来的线在屏幕上变成 8px,用户会觉得软件“变糊了”。所以线宽要么按屏幕像素恒定(除以缩放比),要么按画布单位真实放大,这属于产品决策,必须在动手前定死,中途改会牵动整个数据结构。
绘图软件的基础参数建议集中管理,别散落在各处:
| 参数 | 常见取值 | 说明 |
|---|---|---|
| 抗锯齿 | 开启 | 关闭后斜线有明显阶梯 |
| 线宽单位 | 画布单位或屏幕像素 | 决定缩放时线条是否变粗 |
| 采样方式 | 逐点或插值平滑 | 插值平滑让手写曲线更顺,但会增加点数 |
| 撤销栈上限 | 50~200 步 | 太多吃内存,太少用户体验差 |
| 画布尺寸 | 固定或无限 | 无限画布需要视口裁剪,复杂度上一个台阶 |
2.3 从 QPainter 迁到 Graphics View 的时机
当需求出现“选中某条线并改颜色”,纯 QPainter 路线就得自己写命中检测:遍历所有笔画,算点到线段距离,判断是否小于阈值。这活儿能写,但很快会演化成一坨。此时迁到 Graphics View 更划算:每个图元自己实现boundingRect()和paint(),框架帮你做命中检测、Z 序、选中状态。
迁移不是推倒重来,已有的数据结构可以保留,把“一笔”包装成一个 QGraphicsItem 子类即可。注意两个细节:boundingRect()要把画笔宽度的一半算进去,否则选中框会切边;shape()建议重写成带一定宽度的路径,否则细线极难点中。
3. 图元、图层与撤销重做:把 Demo 变成能用的编辑器
能画线只是起点。一个绘图软件真正的分水岭,是它有没有“对象”概念:图形能被选中、能改属性、能分层、能撤销。这一章讲的就是从“一坨点集”升级到“图元模型”的过程,也是这份源码里信息密度最高的部分。
3.1 自定义图元:boundingRect 与 paint 的配合
Graphics View 里每个图元必须回答两个问题:我占多大地方、我怎么画。前者错了会导致刷新残影和选中异常,后者错了只是难看。所以先保证 boundingRect 正确。
class StrokeItem : public QGraphicsItem { public: explicit StrokeItem(QVector<QPointF> points, QColor color, qreal width) : m_points(std::move(points)), m_color(color), m_width(width) {} QRectF boundingRect() const override { QRectF r = QPolygonF(m_points).boundingRect(); // 关键:把线宽的一半外扩,否则抗锯齿边缘和选中框都会溢出 qreal pad = m_width / 2.0 + 2.0; return r.adjusted(-pad, -pad, pad, pad); } void paint(QPainter *p, const QStyleOptionGraphicsItem *, QWidget *) override { QPen pen(m_color, m_width, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin); p->setPen(pen); p->setRenderHint(QPainter::Antialiasing, true); p->drawPolyline(m_points.constData(), m_points.size()); } // 命中区域用“描边路径”而非外接矩形,细线才点得中 QPainterPath shape() const override { QPainterPath path; path.addPolygon(QPolygonF(m_points)); QPainterPathStroker stroker; stroker.setWidth(qMax(m_width, 8.0)); // 最小 8px 容错,照顾触屏和手抖 return stroker.createStroke(path); } private: QVector<QPointF> m_points; QColor m_color; qreal m_width; };boundingRect里的 pad 是血泪经验:只返回多边形外接矩形的话,画笔宽度大于 1 时边缘会被裁掉,拖动时还会留下残影。shape()用 QPainterPathStroker 生成描边区域,比矩形精确得多,代价是命中检测稍慢,图形数量在几千以内基本无感。注意shape()返回的是图元本地坐标,别把场景坐标混进去。
另一个必须设置的属性是setFlag(QGraphicsItem::ItemIsSelectable, true)和ItemIsMovable,不设的话鼠标怎么点都没反应,新手常在这里怀疑人生。选中后要显示控制点,就在 paint 里判断isSelected()画八个方块,或者用 QGraphicsItem 的itemChange监听选中变化再通知场景。
3.2 图层管理:Z 值与场景树的两种做法
图层在绘图软件里通常有两种含义:视觉上的前后顺序,和逻辑上的分组(比如“背景层”“标注层”)。前者用setZValue()就能解决,后者需要自己维护一棵树或者一个列表。
常见的轻量做法是给每个图元打一个 layerId,渲染时按 layerId 排序设置 Z 值,切换图层可见性时遍历隐藏。更正规的做法是用 QGraphicsItemGroup 分组,但 group 会改变坐标体系,拖动时容易出现子项坐标漂移,新手建议先用 layerId 方案,稳。
图层操作要落到命令上,才能被撤销。下面这个命令模式的结构在源码里反复出现,值得单独理解。
class AddItemCommand : public QUndoCommand { public: AddItemCommand(QGraphicsScene *scene, QGraphicsItem *item, QUndoCommand *parent = nullptr) : QUndoCommand(parent), m_scene(scene), m_item(item) { setText(QStringLiteral("添加图形")); } void undo() override { m_scene->removeItem(m_item); } // 只摘除,不 delete void redo() override { m_scene->addItem(m_item); } // 重复添加同一对象 private: QGraphicsScene *m_scene; QGraphicsItem *m_item; };这里有个必须讲清的所有权问题:removeItem之后场景不再持有该图元,但对象仍然存在,undo/redo 才能反复切换。如果 undo 里写成delete m_item,redo 时就是野指针,程序直接崩。如果命令对象在析构时要负责释放,那得保证命令本身不会被重复执行,逻辑会更绕。稳妥做法是让场景或文档对象统一持有所有图元,命令只做挂载和摘除。
3.3 撤销重做栈:QUndoStack 的接入方式
Qt 自带 QUndoStack 和 QUndoView,接入成本很低,但有两个坑。第一,所有会改变文档状态的操作都必须走命令,包括改颜色、改线宽、移动图元;漏一个,用户就会遇到“撤销一次跳两步”的玄学现象。第二,连续的小操作要合并,比如拖动图元时的每一次 move 都压栈会瞬间塞满。
移动图元的合并写法:
// 在 mouseReleaseEvent 里只压一次命令 auto *cmd = new MoveItemCommand(item, m_dragStartPos, item->pos()); m_undoStack->push(cmd); // MoveItemCommand 内部 void undo() override { m_item->setPos(m_oldPos); } void redo() override { m_item->setPos(m_newPos); }QUndoStack::setUndoLimit(100)控制栈深度,默认是 0 表示不限,长时间使用会吃内存。对于带大图片的绘图软件,建议显式设成 50~100。另外,保存文件后记得调setClean(),标题栏的“未保存”星号就能跟着状态自动更新,这个细节很多教程会漏。
4. 文件保存、导出与性能:让画板真正能交付
画得出来、改得动之后,第三个问题是“存得下、导得出”。绘图软件的输出通常有两类:可再编辑的工程文件,和给外部看的图片。这两条路的数据组织方式完全不同,混在一起写会很难受。
4.1 工程文件格式:JSON 还是自定义二进制
常见做法是用 JSON 存图元列表,可读性好、调试方便、版本迁移容易。缺点是文件大、加载慢,笔画点多了尤其明显。自定义二进制省空间,但一旦字段改动就得写版本兼容逻辑。
比较务实的方案是分层:文档元信息和小图元用 JSON,超长笔画点集做压缩(比如把 QPointF 量化成 int16 差分编码)。这份源码在教程里用的是 JSON 版本,先跑通再说。
QJsonObject StrokeItem::toJson() const { QJsonArray pts; for (const QPointF &p : m_points) { pts.append(QJsonArray{p.x(), p.y()}); // 坐标成对存,避免浮点字符串膨胀 } QJsonObject obj; obj["type"] = "stroke"; obj["points"] = pts; obj["color"] = m_color.name(QColor::HexArgb); // 带 alpha,半透明笔迹才不丢 obj["width"] = m_width; obj["z"] = zValue(); return obj; }导出的关键在QColor::HexArgb,用name()默认的 HexRgb 会丢掉透明度,半透明荧光笔保存后变成实心,这是很典型的翻车点。加载时对应用QColor::fromString或setNamedColor,注意 Qt 版本差异,老版本只认#AARRGGBB顺序。
4.2 导出 PNG:分辨率与背景色的两个陷阱
导出图片最容易被忽略的是分辨率和背景。QGraphicsScene::render()默认按场景坐标 1:1 输出,导出 4K 图时如果直接放大,线条会糊;正确做法是用QImage指定目标尺寸,再通过 painter 的 scale 变换渲染。
QImage exportImage(const QGraphicsScene *scene, const QSize &target, bool transparent) { QImage image(target, QImage::Format_ARGB32_Premultiplied); image.fill(transparent ? Qt::transparent : Qt::white); // 背景必须显式填,否则是脏内存 QPainter painter(&image); painter.setRenderHint(QPainter::Antialiasing, true); painter.setRenderHint(QPainter::SmoothPixmapTransform, true); QRectF source = scene->itemsBoundingRect(); // 按内容裁剪,而不是整块场景 qreal scale = qMin(target.width() / source.width(), target.height() / source.height()); painter.scale(scale, scale); painter.translate(-source.topLeft()); // 把内容平移到原点 scene->render(&painter); return image; }Format_ARGB32_Premultiplied是渲染性能最好的格式,别用Format_ARGB32。背景一定要调fill,QImage 构造后内容是未初始化的,不填会出现随机的彩色噪点。按itemsBoundingRect()裁剪则能避免导出大片空白,但如果场景里有隐藏图元,它也会被算进去,需要先过滤。
4.3 大量图元下的刷新策略
笔画一多就卡,根源通常是每次鼠标移动都触发全量重绘。优化顺序建议是:先局部刷新(update(rect)而不是update()),再考虑把静态内容缓存成 QPixmap,最后才是分块渲染。
判断是否需要优化有个简单指标:图形超过 500 个、或者单笔点数超过 2000 时,全量重绘的延迟就能被肉眼感知。此时可以给场景设setItemIndexMethod(QGraphicsScene::BspTreeIndex)(默认值),但图元频繁增删时 BSP 重建开销反而大,可临时切成NoIndex再切回来。
5. 避坑与排查:绘图软件最容易翻的五个地方
这一章全部来自实际调试,现象、原因、解决按顺序写,遇到对应症状可以直接对照。
5.1 拖动图元时留下虚线残影
现象:拖动一个细线图元,原位置残留一条淡淡的影子,松手后还在。
原因:boundingRect()没有把画笔宽度和抗锯齿溢出算进去,场景只刷新了旧矩形范围内的区域,边缘像素没被覆盖。
解决:在 boundingRect 里按width/2 + 2外扩,并确认图元使用了ItemUsesExtendedStyleOption或者场景开启了QGraphicsView::FullViewportUpdate作为兜底。后者性能差,只建议排错时临时开。
5.2 撤销后图元消失,再撤销程序崩溃
现象:第一次撤销正常,第二次撤销直接退出。
原因:命令的undo()里对图元做了delete,第二次执行时访问了已释放内存;或者同一个 QGraphicsItem 被加进了两个命令,引用计数混乱。
解决:明确所有权,让文档对象统一持有图元,命令只负责 addItem/removeItem。命令析构时不要释放图元,除非能保证该命令已从栈中移除。
5.3 高 DPI 屏上线条发虚、粗细不对
现象:在缩放 150% 的显示器上,1px 的线变成 2px 且模糊。
原因:Qt 5 之后默认开启高 DPI 缩放,event->pos()返回的是逻辑坐标,而像素对齐没处理。
解决:绘图时对坐标做 0.5 偏移让线落在像素中心,或者直接按devicePixelRatioF()放大画布。线宽按屏幕像素恒定时记得除以缩放比,前文已经给过写法。
5.4 保存的工程文件重新打开后顺序错乱
现象:图元位置对,但遮挡关系变了。
原因:JSON 里没存zValue(),加载时按添加顺序重新分配 Z 值,顺序自然改变。
解决:序列化时把 z 值一起存,加载后setZValue恢复。图层可见性同理,别只存图元本身。
5.5 鼠标快速划动时笔画出现折线
现象:快速画圆,结果是多边形。
原因:mouseMoveEvent 的采样率受系统和事件合并影响,点不够密。
解决:在两个采样点之间做插值(Catmull-Rom 或二次贝塞尔),或者在移动事件里用event->pos()加上QApplication::mouseButtons()判断持续绘制。插值会增加点数,存文件时要考虑压缩。
6. 进阶技巧:用 QPainterPath 做平滑笔迹与命中优化
基础功能跑通后,最影响手感的就是笔迹平滑。逐点 drawPolyline 在慢速绘制时还行,快速划动会有明显折角。把点集转成 QPainterPath,用二次贝塞尔以相邻点的中点为锚,是性价比最高的做法。
QPainterPath buildSmoothPath(const QVector<QPointF> &pts) { QPainterPath path; if (pts.size() < 2) return path; path.moveTo(pts.first()); for (int i = 1; i < pts.size() - 1; ++i) { QPointF mid = (pts[i] + pts[i + 1]) / 2.0; // 用中点做锚,避免过冲 path.quadTo(pts[i], mid); } path.lineTo(pts.last()); return path; }这段逻辑的要点是:控制点取原始采样点,终点取中点,曲线就不会在拐弯处甩出去。代价是路径对象比点数组重,建议在绘制时缓存到图元内部,别每次 paint 都重建。我一般会在图元里存一份m_cachedPath,点集变化时置脏,paint 时按需重建。
平滑之后,命中检测也要跟着换。shape()直接用平滑路径生成描边,比多边形更贴合视觉。如果图元数量上千,还可以给场景加一层空间索引,或者按图层分场景(QGraphicsScene 支持嵌套addItem到 group),把命中范围缩小。
最后说验证。改完平滑和命中,别只看“能不能画”,要跑三组用例:快速画大圆弧看折角、慢速画小字看抖动、在 400% 缩放下重复前两项看线宽是否恒定。我吃过一次亏,平滑算法在缩放状态下没换算坐标,结果导出图片时整条曲线偏移了半个画布,查了两天才定位到变换矩阵的乘法顺序。从那以后我每次改绘制链路,都强制走一遍“画、选、拖、撤销、保存、重导”这六步回归,再谈新功能。希望这套教程和源码能帮你少走几段弯路。
本文还有配套的精品资源,点击获取