资讯动态

Qt 2D绘图软件实战:从QPainter到Graphics View完整教程

发布时间:2026/10/28 1:13:01 来源:尧图企业网站定制
简介面向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: QVectorQVectorQPointF 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 章会展开。参数上最值得改的是 QPenwidth决定线宽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用户会觉得软件“变糊了”。所以线宽要么按屏幕像素恒定除以缩放比要么按画布单位真实放大这属于产品决策必须在动手前定死中途改会牵动整个数据结构。绘图软件的基础参数建议集中管理别散落在各处参数常见取值说明抗锯齿开启关闭后斜线有明显阶梯线宽单位画布单位或屏幕像素决定缩放时线条是否变粗采样方式逐点或插值平滑插值平滑让手写曲线更顺但会增加点数撤销栈上限50200 步太多吃内存太少用户体验差画布尺寸固定或无限无限画布需要视口裁剪复杂度上一个台阶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(QVectorQPointF 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: QVectorQPointF 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_itemredo 时就是野指针程序直接崩。如果命令对象在析构时要负责释放那得保证命令本身不会被重复执行逻辑会更绕。稳妥做法是让场景或文档对象统一持有所有图元命令只做挂载和摘除。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 表示不限长时间使用会吃内存。对于带大图片的绘图软件建议显式设成 50100。另外保存文件后记得调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。背景一定要调fillQImage 构造后内容是未初始化的不填会出现随机的彩色噪点。按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 QVectorQPointF 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% 缩放下重复前两项看线宽是否恒定。我吃过一次亏平滑算法在缩放状态下没换算坐标结果导出图片时整条曲线偏移了半个画布查了两天才定位到变换矩阵的乘法顺序。从那以后我每次改绘制链路都强制走一遍“画、选、拖、撤销、保存、重导”这六步回归再谈新功能。希望这套教程和源码能帮你少走几段弯路。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑