简介:这是一份面向Qt初学者和C++桌面开发者的2D绘图入门教程及配套工程源码,围绕QPainter、QPaintEvent与QGraphicsView/QGraphicsScene等核心机制展开,帮助读者理解绘图区域搭建、鼠标事件处理、画笔与填充属性设置,以及图像保存与加载等常见需求。压缩包共114个文件,约3.54MB,以png截图、h头文件、cpp源文件为主,辅以ui界面文件、pro工程文件、qrc资源文件和pdf说明,便于对照界面、编译运行并梳理项目结构。已有704人学习,适合希望把Qt绘图知识点落到可运行示例中的读者。代码示例进一步展示主窗口与绘图部件的职责拆分、鼠标按下移动释放时的坐标记录与重绘触发时机,也涉及颜色和工具切换、将绘制结果导出为常见图像格式并重新载入的完整链路;读者可借助Qt Creator完成编辑、调试与项目管理,为后续开发更复杂的图形应用打基础。
1. Qt 写 2D 绘图软件:教程看三遍不如把一条线画活
很多人拿到一份 Qt 2D 绘图教程加配套源码,编译跑通,窗口里拖出一条直线、一个矩形,就以为收工了。真上手做一个能用的画板才发现问题全在后面:图形一缩放就糊,撤销一次退两步,导出的 PNG 打开是一片空白。这份资源真正值得拆的地方,不是那几行drawLine,而是它把 QPainter 手绘、图元模型、鼠标状态机、撤销栈、序列化这几段链路串成了一条完整可跑的路径。它适合已经会写 Qt 窗口、但没独立做过图形编辑器的开发者,也适合要给现有工具补一块画布、又不想引入重型图形库的人。下面按我拆包复现的顺序走一遍,中间该抄的代码、该改的参数、该躲的坑都摊开写。
2. 选型与骨架:QWidget 重绘和 QGraphicsScene 到底选哪个
2.1 两条路线的能力边界先划清
Qt 做 2D 绘图有两条主流路线,选错了后面每一步都在还债。第一条是继承QWidget、重写paintEvent,自己维护一个图元容器,每次重绘把容器里的图形挨个画一遍。第二条是QGraphicsScene+QGraphicsView,图形是场景里的QGraphicsItem对象,框架帮你做变换、碰撞、选中和层级。
这两条不是新旧关系,是能力边界不同。手绘路线的成本曲线很平,图元几百到几千时逻辑清晰、调试直观,所有绘制行为都在你眼皮底下;但它不提供任何现成的命中测试、图元级变换和 Z 序管理,全得自己写。Scene 路线把这些都内置了,代价是框架层更厚,出问题时排查路径长,图元数量上万后场景索引和 BSP 调优又是一门单独的功课。
| 维度 | QWidget 手绘 | QGraphicsScene |
|---|---|---|
| 图元规模 | 千级以内舒服 | 万级也能撑,需调索引 |
| 命中测试 | 自己写,可控 | 内置contains()/shape() |
| 缩放旋转 | 靠自己变换矩阵 | 图元级setTransform原生支持 |
| 撤销集成 | 直接操作容器,好接 | 要包一层命令,稍绕 |
| 上手成本 | 低,逻辑全暴露 | 中,要理解场景坐标系 |
| 可控性 | 极高 | 受框架约定约束 |
教程类源码绝大多数走手绘路线,原因很实际:它把「重绘」这件事完整暴露给读者,你能亲眼看到一次鼠标移动是怎么变成一次update()、再变成一次paintEvent的。我一般建议先用 QWidget 手绘把一条线画活,再决定要不要迁到 Scene。真要迁,图元结构那层是可以复用的。
2.2 QtCreator 工程骨架里必须写对的几行
新建工程时选 Qt Widgets Application,构建系统用 qmake 最省事。.pro文件里除了默认生成的,有几行值得手动确认:
QT += core gui widgets greaterThan(QT_MAJOR_VERSION, 4): QT += widgets CONFIG += c++17 TARGET = PainterDemo TEMPLATE = app # 需要导出 SVG 时再打开这一行 # QT += svg SOURCES += \ main.cpp \ canvaswidget.cpp HEADERS += \ canvaswidget.hQT += widgets是必须的,Qt5 之后 widgets 从 gui 里拆出来了,漏写会在链接阶段报一堆找不到QWidget符号的错误。CONFIG += c++17不是摆设,后面图元结构里用enum class、结构化绑定都靠它。QT += svg先注释掉,等做到导出那一步再打开,避免一开始就引入不必要的依赖。
如果用 CMake,对应的是find_package(Qt6 REQUIRED COMPONENTS Widgets)加target_link_libraries(PainterDemo PRIVATE Qt6::Widgets),逻辑等价,QtCreator 新建工程时直接选 CMake 也能生成。
提示:Qt5 和 Qt6 在鼠标事件、坐标类型上有 API 差异,工程里统一用条件编译包一层,后面迁移不会满地改代码。
2.3 最小可运行画布:paintEvent 里的第一笔
先把窗口跑起来,能画出一条线,再谈别的。头文件只留必要的东西:
// canvaswidget.h #pragma once #include <QWidget> #include <QVector> #include <QPainterPath> #include <QPen> #include <QBrush> class CanvasWidget : public QWidget { Q_OBJECT public: explicit CanvasWidget(QWidget *parent = nullptr); protected: void paintEvent(QPaintEvent *event) override; private: QVector<QPainterPath> m_paths; // 先用最简容器占位 QPen m_pen{Qt::black, 2.0}; };实现里两个细节决定后面的手感:
// canvaswidget.cpp #include "canvaswidget.h" #include <QPainter> CanvasWidget::CanvasWidget(QWidget *parent) : QWidget(parent) { setAttribute(Qt::WA_OpaquePaintEvent, true); // 告诉系统这块区域我们自己填满 setMouseTracking(true); // 想要悬停预览就必须开 setMinimumSize(640, 480); } void CanvasWidget::paintEvent(QPaintEvent *event) { QPainter painter(this); // 抗锯齿要开,但它是性能开销的大头,按需分级 painter.setRenderHint(QPainter::Antialiasing, true); painter.setRenderHint(QPainter::TextAntialiasing, true); // 只清空需要重绘的区域,别整窗刷 painter.fillRect(event->rect(), Qt::white); painter.setPen(m_pen); for (const auto &path : m_paths) painter.drawPath(path); // 一条示意线,确认坐标系原点在左上角 painter.drawLine(QPointF(20, 20), QPointF(220, 140)); }WA_OpaquePaintEvent的含义是「这个控件的每个像素都由我自己画」,系统就不会再帮你做背景擦除,能省一次全窗填充,也顺手消掉了一类闪烁。event->rect()是本次真正需要重绘的矩形,fillRect用它而不是rect(),是后面做局部刷新的基础。
抗锯齿这两行是玄学重灾区。开着画斜线确实顺眼,但每次重绘的耗时能翻两三倍;如果你的画布上有几千条自由笔迹,正确的做法是交互过程中关掉、落笔结束后再开,或者只对最终导出的图开。这条经验后面第 6 章还会用到。
drawLine那行只是用来确认坐标系的,Qt 的窗口坐标原点在左上角,y 轴朝下,和你中学课本里的直角坐标系是反的。所有涉及角度、旋转、SVG 导出的地方都要记住这一点,我第一次做旋转手柄时就是被这个坑了一下午。
3. 图元模型与鼠标交互:从能画到能改
3.1 一个结构装下所有图形
能画之后第一个要解决的问题是:图形得是个「东西」,不能是画完就丢的一笔。所有图形统一成一个结构,几何用QPainterPath承载,直线、矩形、椭圆、自由笔迹都是路径的不同构造方式,这样绘制、命中测试、序列化三处代码都能共用一套逻辑。
// shapeitem.h #pragma once #include <QPainterPath> #include <QPen> #include <QBrush> enum class ShapeType { Line, Rect, Ellipse, Freehand }; struct ShapeItem { int id = 0; ShapeType type = ShapeType::Line; QPainterPath path; QPen pen{Qt::black, 2.0}; QBrush brush{Qt::NoBrush}; // 包围盒要把笔宽算进去,否则粗线画到边上会被裁掉 QRectF bounds() const { const qreal half = pen.widthF() / 2.0 + 1.0; return path.boundingRect().adjusted(-half, -half, half, half); } void draw(QPainter &p) const { p.setPen(pen); p.setBrush(brush); p.drawPath(path); } };id这个字段看着多余,但它是撤销重做能对上号的关键,后面第 4 章会用到。bounds()里把笔宽的一半加进去,是因为QPainterPath::boundingRect()只算几何本身,不含描边宽度,线宽 10 的图形贴边画就会缺一条边。这个 1.0 的余量是留给抗锯齿的,少了它边缘会出现半透明毛刺。
画笔和画刷分开存,是为了后面做属性面板时能单独改颜色和线宽,而不用重建整个对象。
3.2 鼠标三段式状态机
交互的核心是把一次拖拽拆成按下、移动、抬起三个阶段,每个阶段只做一件事。画布类里加几个成员记录中间状态:
// canvaswidget.h 里补充 private: QVector<ShapeItem> m_shapes; ShapeItem m_preview; // 拖拽中的临时图形 bool m_drawing = false; QPointF m_startPos; int m_nextId = 1; ShapeType m_currentType = ShapeType::Freehand;按下时开一个新路径,移动时不断往路径里加点,抬起时把预览对象提交进容器:
// 兼容 Qt5 / Qt6 的取点写法 static inline QPointF eventPos(const QMouseEvent *e) { #if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) return e->position(); #else return e->localPos(); #endif } void CanvasWidget::mousePressEvent(QMouseEvent *e) { if (e->button() != Qt::LeftButton) { QWidget::mousePressEvent(e); return; } m_drawing = true; m_startPos = eventPos(e); m_preview = ShapeItem{}; m_preview.type = m_currentType; m_preview.pen = m_pen; m_preview.path.moveTo(m_startPos); } void CanvasWidget::mouseMoveEvent(QMouseEvent *e) { if (!m_drawing) { QWidget::mouseMoveEvent(e); return; } const QPointF pos = eventPos(e); switch (m_preview.type) { case ShapeType::Freehand: m_preview.path.lineTo(pos); // 自由笔迹逐点追加 break; case ShapeType::Line: m_preview.path = QPainterPath(m_startPos); m_preview.path.lineTo(pos); // 每次重建,避免累加 break; case ShapeType::Rect: m_preview.path = QPainterPath(); m_preview.path.addRect(QRectF(m_startPos, pos).normalized()); break; case ShapeType::Ellipse: m_preview.path = QPainterPath(); m_preview.path.addEllipse(QRectF(m_startPos, pos).normalized()); break; } update(m_preview.bounds().toAlignedRect()); // 只刷预览区域 } void CanvasWidget::mouseReleaseEvent(QMouseEvent *e) { if (!m_drawing || e->button() != Qt::LeftButton) return; m_drawing = false; if (m_preview.path.elementCount() >= 2) { m_preview.id = m_nextId++; m_shapes.append(m_preview); // 这里先直接入容器,第 4 章换成走撤销栈 } m_preview = ShapeItem{}; update(); }QRectF(m_startPos, pos).normalized()这个调用不能省。用户从右下往左上拖时,构造出的矩形宽高是负的,addRect画出来是空的,normalized()把负宽高翻正。我见过不止一份教程源码漏了这一步,结果就是「往左上拖画不出矩形」,然后被当成 Qt 的 bug。
update(rect)传矩形而不是空参,是让 Qt 只把这块区域排进重绘队列。注意paintEvent里必须相应地只清这块区域,不然局部刷新就白做了。
3.3 命中测试:包围盒糊弄不过去
图形能画能预览之后,选中就成了刚需。新手最容易犯的错是拿boundingRect().contains(pos)做命中判断,结果点在一个大矩形中间的空地上,它也被选中了——因为包围盒把整个矩形都算成了实心。
正确的做法是用QPainterPathStroker把路径「加粗」成一条有宽度的轮廓,再判断点是否落在轮廓里。这样细线也能点得中,空心矩形只有边框能选中:
bool hitTest(const ShapeItem &item, const QPointF &pos, qreal tolerance = 4.0) { // 有填充的图形,内部也算命中 if (item.brush.style() != Qt::NoBrush && item.path.contains(pos)) return true; // 描边命中:把路径按笔宽 + 容差膨胀成轮廓 QPainterPathStroker stroker; stroker.setWidth(qMax<qreal>(item.pen.widthF(), 1.0) + tolerance * 2.0); stroker.setCapStyle(item.pen.capStyle()); stroker.setJoinStyle(item.pen.joinStyle()); return stroker.createStroke(item.path).contains(pos); }tolerance是手感参数,4 像素大约对应「不用精准点线中心也能选中」的体验,触摸屏场景可以放大到 10 以上。遍历顺序要从后往前,因为后画的图形在视觉上压在上面,选中优先级也该更高:
int CanvasWidget::shapeAt(const QPointF &pos) const { for (int i = m_shapes.size() - 1; i >= 0; --i) if (hitTest(m_shapes.at(i), pos)) return i; return -1; }createStroke每次调用都会新建一条路径,图元多的时候它是热点函数,可以在ShapeItem里缓存一份描边路径、只在几何或笔宽变化时失效。这个优化在几百个图元时感知不到,上千之后拖动就会明显掉帧。
4. 撤销重做与文件落盘:让画布有后悔药
4.1 QUndoCommand 的拆分粒度
手写撤销栈最常见的翻车方式,是在mouseMoveEvent里每加一个点就压一条命令。拖一条两厘米的线能压进去几百条记录,按一次 Ctrl+Z 只回退一个像素,用户会以为撤销坏了。
正确的粒度是「一次完整的用户操作 = 一条命令」。按下到抬起算一次,命令里保存的是这次操作产生的完整图元:
// addshapecommand.h #pragma once #include <QUndoCommand> #include "shapeitem.h" class CanvasWidget; class AddShapeCommand : public QUndoCommand { public: AddShapeCommand(CanvasWidget *canvas, const ShapeItem &item, QUndoCommand *parent = nullptr) : QUndoCommand(parent), m_canvas(canvas), m_item(item) { setText(QStringLiteral("添加图形 #%1").arg(item.id)); } void redo() override; // 首次 push 时也会调用 void undo() override; private: CanvasWidget *m_canvas; ShapeItem m_item; };setText不是可选项。它决定了撤销菜单里显示什么文字,用户看到「撤销 添加图形 #12」和看到「撤销」是两种体验。redo()会在命令第一次被push时自动调用一次,所以新增图元的逻辑只写在redo()里就够了,别在 push 之前再手动加一遍,否则图形会重复。
画布这边的接线:
// canvaswidget.cpp void CanvasWidget::commitShape(const ShapeItem &item) { m_undoStack->push(new AddShapeCommand(this, item)); } void CanvasWidget::addShape(const ShapeItem &item) { m_shapes.append(item); update(item.bounds().toAlignedRect()); } void CanvasWidget::removeShape(int id) { for (int i = 0; i < m_shapes.size(); ++i) { if (m_shapes.at(i).id == id) { const QRect dirty = m_shapes.at(i).bounds().toAlignedRect(); m_shapes.removeAt(i); update(dirty); return; } } }用id而不是下标来定位,是因为撤销顺序可能和添加顺序交叉,下标会漂移。删除、移动、改颜色各对应一个命令类,结构完全一样,undo()和redo()互为逆操作。改属性的命令要同时保存旧值和新值,只保存新值的话撤销回去就没有目标状态了。
m_undoStack建议设一个上限,setUndoLimit(100)之类。不设的话长时间编辑会一直吃内存,每个命令都持有一份完整图元副本,几千次操作之后内存曲线很难看。
4.2 JSON 存档:版本号写在最外层
存档格式选 JSON 而不是QDataStream二进制,理由很朴素:出问题时能直接用文本编辑器打开看,字段对不对一眼就知道。代价是体积大一些、浮点精度略损,对绘图软件完全可接受。
外层结构里第一个字段必须是版本号:
QJsonObject CanvasWidget::toJson() const { QJsonObject root; root["version"] = 1; // 格式升级时靠它做分支 root["canvasWidth"] = width(); root["canvasHeight"] = height(); QJsonArray arr; for (const auto &s : m_shapes) arr.append(shapeToJson(s)); root["shapes"] = arr; return root; } QJsonObject shapeToJson(const ShapeItem &s) { QJsonObject o; o["id"] = s.id; o["type"] = static_cast<int>(s.type); o["penColor"] = s.pen.color().name(QColor::HexArgb); o["penWidth"] = s.pen.widthF(); o["brushColor"] = s.brush.color().name(QColor::HexArgb); o["brushStyle"] = static_cast<int>(s.brush.style()); // 路径按元素序列化,保留曲线控制点 QJsonArray elems; for (int i = 0; i < s.path.elementCount(); ++i) { const QPainterPath::Element &el = s.path.elementAt(i); elems.append(QJsonArray{ static_cast<int>(el.type), el.x, el.y }); } o["path"] = elems; return o; }QColor::HexArgb而不是name(),是因为带透明度的画笔用后者会把 alpha 丢掉,存回去变成不透明。路径按elementAt逐元素存,能保留贝塞尔曲线的控制点;如果只存包围盒和几个顶点,曲线读回来会变成折线。
读取端一定要做防御,文件可能是手改过的、旧版本存的、别的工具生成的:
bool CanvasWidget::loadFromJson(const QJsonObject &root, QString *err) { const int ver = root.value("version").toInt(-1); if (ver < MergePolicy_MinSupported) { // 例如宏定义为 1 if (err) *err = QStringLiteral("存档版本过低,无法读取"); return false; } // 逐字段带默认值读取,缺字段不崩 const QJsonArray arr = root.value("shapes").toArray(); QVector<ShapeItem> loaded; loaded.reserve(arr.size()); for (const auto &v : arr) { const QJsonObject o = v.toObject(); ShapeItem s; s.id = o.value("id").toInt(); s.type = static_cast<ShapeType>(o.value("type").toInt()); s.pen = QPen(QColor(o.value("penColor").toString("#ff000000")), o.value("penWidth").toDouble(1.0)); // path 元素重建略,按 type 分发 moveTo / lineTo / cubicTo loaded.append(s); } m_shapes = loaded; m_nextId = 1; for (const auto &s : m_shapes) m_nextId = qMax(m_nextId, s.id + 1); // 恢复 id 计数器 update(); return true; }m_nextId的恢复是必须的,否则读档后新建图形会和已有 id 撞号,撤销时按 id 查找会删错对象。这个 bug 藏得很深,通常要等用户读档、画三笔、再撤销两次才暴露。
4.3 导出 PNG 和 SVG:两条质量不同的路径
导出这一步是「画得好不好」和「存得好不好」的分水岭。PNG 走离屏渲染,要点是尺寸独立于窗口、必须自己p.end():
bool CanvasWidget::exportPng(const QString &file, const QSize &size) { QImage image(size, QImage::Format_ARGB32_Premultiplied); image.fill(Qt::white); QPainter p(&image); p.setRenderHint(QPainter::Antialiasing, true); // 按目标尺寸等比映射画布坐标 p.scale(size.width() / qreal(width()), size.height() / qreal(height())); for (const auto &s : m_shapes) s.draw(p); p.end(); // 必须先结束,再 save return image.save(file, "PNG"); }p.end()漏掉的典型现象是:image.save()返回 true,文件也有大小,打开却缺最后几个图形,甚至整个空白。原因是 QPainter 还处于活动状态,绘制指令没完全落到 QImage 上。这个坑没有任何报错提示,只能靠肉眼比对。
SVG 走QSvgGenerator,需要QT += svg。它和 PNG 最大的差别是矢量输出,缩放不失真,但笔宽在缩放时会被固定成绝对单位,如果画布坐标和 SVG 视口不一致,导出的线会明显偏粗或偏细:
#include <QSvgGenerator> bool CanvasWidget::exportSvg(const QString &file) { QSvgGenerator gen; gen.setFileName(file); gen.setSize(size()); gen.setViewBox(QRectF(0, 0, width(), height())); gen.setTitle(QStringLiteral("绘图导出")); gen.setDescription(QStringLiteral("Qt 2D canvas export")); QPainter p(&gen); p.setRenderHint(QPainter::Antialiasing, true); for (const auto &s : m_shapes) s.draw(p); p.end(); return true; }setViewBox要和setSize配对,前者决定坐标系范围,后者决定输出尺寸,两者比例不一致时图形会被拉伸。PNG 用离屏渲染还能顺手解决另一个问题:导出结果不受当前窗口缩放和遮挡影响,用户把窗口拖到屏幕外再导出,内容也是完整的。
5. 避坑与排查:五条血泪记录
5.1 现象:窗口一缩放,画好的图形全没了
原因基本是两个。一是图形存进了QPixmap缓存,resizeEvent里重建 pixmap 却没有把已有图形重绘进去;二是paintEvent里只在构造时画了一次示意线,没有遍历图元容器。前者更隐蔽,因为小窗口下看着正常,一放大就露馅。
解决方式很干脆:所有持久图形只存在QVector<ShapeItem>里,paintEvent每次完整遍历,pixmap 缓存只作为「重绘太慢」的优化手段,且必须在缓存失效时能从图元容器重建。判断标准是——把缓存全删掉,画面应该一模一样,只是慢一点。
5.2 现象:按一次撤销,退了两步
原因通常是撤销动作被接了两次。一个是QAction的triggered同时连到了QUndoStack::undo和自己写的槽函数;另一个是命令的redo()里调用了会再次 push 命令的方法,形成递归。
排查方法是在undo()和redo()里各打一条qDebug(),看一次操作输出几条。修复就是保证「图形容器的增删只发生在命令的redo()/undo()里」,UI 层只负责push命令,绝不再直接改容器。这条约束听起来啰嗦,但它能消掉此类问题的一大半。
5.3 现象:1 像素的线看着像 2 像素,还发虚
典型的高分屏表现。Qt 默认开启了设备像素比缩放,逻辑坐标 1 像素在 2 倍屏上会落在两个物理像素中间,抗锯齿一开就糊成两像素宽。同时整型坐标drawLine(QPoint)和浮点坐标drawLine(QPointF)的像素对齐规则也不一样。
解决分三步:画笔宽度用qreal不要用int;需要绝对清晰的 1px 线时,坐标加 0.5 偏移对齐像素中心;检查有没有误关Qt::AA_EnableHighDpiScaling。具体参数在第 6 章展开。
5.4 现象:程序崩溃,或控制台刷 “QPainter::begin: Painter already active”
在paintEvent里又构造了一个QPainter(this),或者在某处提前 return 导致局部的 QPainter 没析构就再次进入绘制。QPainter 是栈对象,靠析构调end(),任何可能提前返回的分支都要小心。
解决办法是把绘制逻辑收进一个独立函数,函数内构造 QPainter、函数结束自动析构,绝不在一个作用域里对同一个设备开两个 QPainter。导出函数里手动p.end()也是同理,别指望编译器帮你兜底。
5.5 现象:自由画笔越画越卡,鼠标移快了还断线
两个独立问题叠在一起。卡是因为每次mouseMoveEvent都调了无参update(),整窗重绘,图元一多就是灾难;断线是因为鼠标事件采样率有限,快速移动时两点间距可能有几十像素,直接lineTo画出来就是折线段。
卡的部分改成update(局部矩形),并且交互期间临时关掉抗锯齿。断线的部分做点插值:相邻两点距离超过阈值时,在中间补几个点,或者干脆用quadTo把连续三点拟合成曲线。阈值取 3 到 5 像素比较合适,太小起不到平滑作用,太大笔迹会「发飘」,失去手写感。
6. 高 DPI 与重绘性能:最后一百米的手感
高 DPI 这块最省事的做法是在main.cpp里、构造QApplication之前把属性设好:
int main(int argc, char *argv[]) { #if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling); QCoreApplication::setAttribute(Qt::AA_UseHighDpiPixmaps); #endif QApplication app(argc, argv); // ... }Qt6 默认就开了高 DPI 缩放,这两行加条件编译是为了兼容老项目。开了之后,所有坐标都是逻辑像素,不用自己乘devicePixelRatio。真正需要手动处理 ratio 的场合只有一处:缓存QPixmap时按devicePixelRatioF()放大再setDevicePixelRatio,否则缓存图在高分屏上会被拉糊。
1 像素线的对齐问题,我的习惯是写一个对齐函数,所有描边坐标过一遍:
// 把坐标对齐到物理像素中心,1px 线才清晰 static inline QPointF alignToPixel(const QPointF &p, qreal dpr = 1.0) { const qreal half = 0.5 / dpr; return QPointF(std::floor(p.x() * dpr) / dpr + half, std::floor(p.y() * dpr) / dpr + half); }注意这个函数只该用在「必须锐利」的场景,比如网格线、选中框、标尺。自由笔迹和曲线千万别对齐,一对齐就成了锯齿楼梯。
重绘性能得靠数据说话,不能凭感觉。加一个简单的耗时探针,用QElapsedTimer卡住paintEvent的主体:
void CanvasWidget::paintEvent(QPaintEvent *event) { QElapsedTimer timer; timer.start(); QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, m_antialias); painter.fillRect(event->rect(), Qt::white); for (const auto &s : m_shapes) if (s.bounds().intersects(event->rect())) // 视口裁剪 s.draw(painter); const qint64 ms = timer.nsecsElapsed() / 1000000; if (ms > 16) qDebug() << "paint slow:" << ms << "ms, shapes:" << m_shapes.size(); }16ms这个阈值对应 60 帧,超过就该动手了。探针一挂上去,通常会立刻发现两个大头:没有视口裁剪,以及抗锯齿全程开着。加上intersects判断之后,几千图元的画布在 1080p 下重绘能压到个位数毫秒;交互期间把m_antialias切成 false,落笔再切回来,拖动的手感差别非常直接。
我最早做这类画布时不信邪,觉得抗锯齿开着也没多慢,直到一份带网格背景和两百多条笔迹的图在低配机器上拖出了残影,才老老实实把这条降级路径加进去。从那以后我每次新起一个绘图窗口,第一件事就是先把耗时探针挂上、把局部刷新和裁剪写进paintEvent,再谈功能。这套顺序能省掉大量后期返工。希望帮到你。
本文还有配套的精品资源,点击获取