Qt6 · 客户端架构 · 性能优化

Qt 绘制系统与自定义控件:从 paintEvent 到渲染管线

2026-08-17 · 每日技术学习 · 面向 Web 架构师 → C++/Qt 客户端架构师

①今日主题:为什么学绘制系统

从 Web 前端转向 Qt 桌面客户端,最大的认知跃迁之一就是"界面到底是怎么画出来的":在浏览器里你改一个 CSS 属性,重排、重绘、合成全部由 Chromium 兜底;而在 Qt 中,paintEvent() 就是你与像素之间的最后一道闸门。理解 Qt 绘制系统,你才能真正掌控自定义控件、性能优化与高分屏适配这三件客户端架构师的日常武器。

今天我们从 QPainter 与 paintEvent 出发,一路走到双缓冲、脏矩形、QSS 样式机制与 Qt Quick 的 Scene Graph,建立一份"从 DOM/CSS 到 QWidget/QPainter"的完整映射——你会发现,浏览器渲染管线的那套心智模型,在桌面端只是换了个名字。

②核心知识:Qt 绘制模型与 QPainter 原理

2.1 绘制模型:一切绘制都在 paintEvent 里

QWidget 的所有绘制都发生在 paintEvent(QPaintEvent*) 虚函数中。触发时机有四种:首次显示、尺寸变化(resize)、被遮挡后恢复、程序主动调用 update() 或 repaint()。关键差异在于:update() 是异步的——它只把区域标记为"脏"(dirty),真正重绘要等控制权回到事件循环后统一执行;Qt 会把多次 update() 合并成一次绘制(脏矩形合并,多个区域取并集)。这正是浏览器"增量重绘"在桌面端的对应物。而 repaint() 是同步立即重绘,除非有特殊需求(如与外部帧同步),否则应避免——它会阻塞事件处理,频繁调用还会造成闪烁。

2.2 QPainter:一个状态机,不是一只画笔

QPainter 可以在任何 QPaintDevice 上绘制:QWidget、QPixmap(内存图像)、QImage(像素级访问)、QPicture(录制重放)。它的本质是一个状态机:pen(描边)、brush(填充)、font、transform(坐标系变换)构成当前状态,save()/restore() 负责压栈与弹栈——这与 Canvas 2D 的 ctx.save()/restore() 如出一辙。你在 paintEvent 中拿到的 QPainter 已绑定目标设备,背后由 paint engine(光栅引擎或 OpenGL 引擎)执行真实绘制。

2.3 双缓冲与后备缓冲区(Backing Store)

自 Qt 4 起 QWidget 默认启用双缓冲:绘制先画到后备缓冲区,再一次性提交(flush)到平台窗口,从而彻底避免闪烁。这套机制由 QPA(Qt Platform Abstraction)层的 QBackingStore 管理,它同时维护脏区域列表,只把变化的部分提交到屏幕。这与浏览器把图层交给合成器(compositor)拼合是同一思路:先在离屏缓冲里画好,再一次性呈现。极端嵌入式场景可用 Qt::WA_PaintOnScreen 关闭双缓冲,但绝大多数情况下你不该碰它。

2.4 坐标系与高分屏(DPR)

paintEvent 中的坐标系是设备无关的逻辑坐标,Qt 会在提交时按 QWidget::devicePixelRatio() 缩放。Qt 5.6 引入 Qt::AA_EnableHighDpiScaling,Qt 6 则默认启用高 DPI 缩放。陷阱在于像素图:QPixmap 的尺寸是物理像素,若按逻辑尺寸创建再绘制,在 200% 缩放的屏幕上必然发虚——对应前端世界里 CSS 像素与物理像素的区别、window.devicePixelRatio 的含义。

2.5 QSS:运行时解析的"CSS 子集"

QSS(Qt Style Sheets)是受 CSS 2 启发、支持盒模型(padding/border/background)与伪状态(:hover、:pressed、:checked)的样式系统,由 QStyleSheetStyle 在运行时解析,支持类型选择器、类选择器、ID 选择器、属性选择器、后代/子选择器。它的边界很明确:只管"样式",不管"内容"与"布局"(布局属于 QLayout 体系)。圆角、渐变、换肤用 QSS;进度环、波形、图表这类"内容型"视觉必须走 QPainter 自绘。

2.6 延伸:Qt Quick 与 Scene Graph

QML/Qt Quick 走的是另一条渲染路径:Scene Graph 场景图——控件树被编译成渲染节点树,在独立渲染线程上由 GPU 合成(QQuickWindow),动画在渲染线程直接驱动。若要在 QML 里复用 QPainter 能力,可用 QQuickPaintedItem。理解了 QWidget 的光栅绘制模型,再去看 Scene Graph,就是在看"软件渲染 → GPU 合成"的演进方向。

③实际案例:成熟客户端为什么都走向自绘

结论:自绘不是炫技,而是"跨平台一致性、复杂视觉、极致性能"三项需求的共同解。Qt 的价值在于把自绘的成本降到最低——paintEvent + QPainter 让你用几十行代码就能拥有自己的控件语言。

④常见错误:绘制代码的五个坑
错误 1:在 paintEvent 里做耗时操作(IO、网络、复杂计算)
绘制是同步执行在 GUI 线程上的,任何耗时操作都会直接卡住整个界面。原因:paintEvent 每帧都可能被调用(resize、遮挡恢复、update)。避免:把结果预计算并缓存为成员变量,paintEvent 只做"读状态 + 画",保持纯粹。
错误 2:忘记 update(),或滥用 update()
状态变了却不调 update(),界面纹丝不动;反之在循环里高频调用 update(),虽然 Qt 会合并脏矩形,但整窗口重绘依然昂贵。原因:不理解"绘制是事件驱动的"。避免:仅在状态变化点调用一次 update();只动局部时用 update(rect) 缩小重绘范围。
错误 3:paintEvent 里每帧堆分配对象(QPainterPath、QFont、QPen 在堆上 new)
高频绘制路径上的堆分配会带来内存碎片与性能抖动,这在 60fps 下会被放大 60 倍。原因:把 Web 侧"闭包里随手 new 对象"的习惯带了过来。避免:栈上创建临时对象(QPen pen(...)),复杂路径缓存为成员。
错误 4:忽略 DPR,高分屏模糊
按逻辑尺寸创建 QPixmap 缓存,在 200% 缩放的屏幕上绘制必然发虚。原因:混淆了逻辑坐标与物理像素。避免:pixmap.setDevicePixelRatio(devicePixelRatio()),并按 size() * devicePixelRatio() 创建缓存。
错误 5:误用 Qt::WA_OpaquePaintEvent 或跨线程操作控件
声明"整个区域不透明"却没画满每个像素,会出现残影/花屏;在非 GUI 线程调用 update() 或绘制 API 则是未定义行为(线程亲和性)。避免:只有确定每个像素都会被覆盖时才设 OpaquePaintEvent;跨线程一律通过信号槽/事件投递到 GUI 线程再操作控件。
⑤最佳实践:自绘控件的四条军规
  1. QSS 优先,自绘兜底。圆角、渐变、hover 态、换肤这类"样式"问题先问 QSS,QSS 表达不了的"内容"(进度环、波形、图表)才写 paintEvent。何时用:所有新控件先评估 QSS 可行性。Trade-off:QSS 是字符串运行时解析,复杂规则有解析与匹配开销,且调试不如代码直观;自绘则把成本前置到开发期。
  2. paintEvent 保持"纯函数"。只读取成员状态,不修改状态、不调 update()、不触发布局变更。何时用:所有自绘控件,无例外。收益:任意时刻都可以安全触发重绘,重绘多少次结果都一致,这是可测试性的前提。
  3. 静态内容缓存到 QPixmap。背景、网格、渐变、纹理这类不随帧变化的内容,离屏画一次,paintEvent 里只需一次 drawPixmap 位块拷贝。何时用:静态内容占比高的控件;不用:内容每帧都变或内存受限的嵌入式场景。Trade-off:这是典型的内存换 CPU 时间的交换。
  4. 用 update(rect) 缩小重绘范围。动画只动局部(进度条增长、光标闪烁)时只标记变化区域。何时用:局部动画、大控件小改动;不用:变化区域接近整个控件时,直接 update() 反而更省。Trade-off:脏矩形切得过多会退化为多次小绘制,必要时合并区域。
  5. 显式处理反锯齿与 DPR。每个自绘控件开头 setRenderHint(QPainter::Antialiasing, true);QPixmap 缓存按 DPR 创建。何时用:所有面向真实用户的桌面应用。不用:需要像素级精确对齐(如图像编辑器的缩放预览)时可关闭反锯齿。
⑥与 Web 技术的联系:一张对照表完成迁移

把 Web 渲染管线里的每一个概念在 Qt 里找到对应物,你就完成了 80% 的迁移:

维度Web 前端Qt 桌面
重绘调度浏览器脏矩形 + 增量布局,异步批量处理update() 标记脏区域,事件循环中合并为一次 paintEvent
绘制 APICanvas 2D:save/restore、translate、strokeStyleQPainter:save/restore、transform、pen/brush 状态机
样式系统CSS(选择器 + 盒模型 + 伪类)QSS(CSS2 子集:选择器/盒模型/伪状态,无布局能力)
双缓冲/合成合成器线程拼合图层,will-change 提升图层QBackingStore 后备缓冲 + 脏区域 flush;Qt Quick 的 Scene Graph 更接近合成器模型
高分屏devicePixelRatio,CSS 像素 vs 物理像素QWidget::devicePixelRatio(),逻辑坐标 vs QPixmap 物理像素
动画驱动requestAnimationFrame 每帧回调QTimer(16ms)/QVariantAnimation 驱动状态更新 + update()

最需要警惕的差异是线程模型:浏览器把布局、绘制、合成分散在多个线程(甚至多个进程),而 QWidget 的绘制默认全部在 GUI 线程同步执行——所以"绘制成本"在 Qt 里是更稀缺的资源,这也是为什么"纯函数式 paintEvent + 缓存 + 脏矩形"比 Web 侧更接近刚需。而 Qt Quick 的 Scene Graph 之所以把渲染挪到独立线程,本质是在向浏览器的合成器模型靠拢。

⑦管理者视角:Team Leader 如何把绘制能力变成团队资产

Code Review 关注点:paintEvent 里有没有 IO/堆分配/业务逻辑;update() 调用是否克制、是否只重绘了变化区域;DPR 是否处理;控件是否通过 QSS/调色板接口取色(可换肤);自绘逻辑是否沉淀为可复用组件而非散落各处。

指导团队:先定"绘制分层"约定——哪些层用 QSS、哪些层自绘、哪些场景用 QML,形成团队共识文档;为自绘控件建立性能预算(60fps 帧预算 16.6ms,自绘控件单帧建议 < 2ms),并把耗时绘制纳入性能回归检查。

架构层面:把主题/调色板抽象为统一设计源(对应前端的 design token),QSS 变量与 Q_PROPERTY 联动,实现换肤不改业务代码;同时警惕"自绘上瘾"——能用框架能力解决的不要自研渲染引擎,自研渲染的维护成本是团队级负债。

⑧延伸阅读
⑨今日思考题
1. 为什么 update() 是异步的而 repaint() 是同步的?如果在动画循环里改用 repaint(),会发生什么?
2. Qt 的脏矩形合并与 Chromium 的增量布局/增量绘制,各自解决什么问题?两者在"何时合并、何时分开绘制"上的策略有何不同?
3. QSS 与 CSS 的选择器体系高度相似,但 Qt 官方建议优先使用类型选择器而非 ID/类选择器,这与 QStyleSheetStyle 的匹配实现有什么关系?
4. 一个实时音频频谱控件,你会选 QPainter 自绘还是 QML?请列出至少三条权衡因素(刷新率、数据传递、线程模型)。
5. 高分屏下 QPixmap 缓存为什么会模糊?设计一个 DPR 自适应的绘制缓存方案,并说明何时该失效重建。
⑩今日实践任务:圆形进度环 GaugeWidget(约 35 行,30-45 分钟)

目标:实现一个自绘圆形进度环,验证本课全部要点——反锯齿、状态驱动重绘(update 而非 repaint)、QPainter 状态机、角度语义、DPR 适配。

// 单文件可编译:Qt 5.15+ / Qt 6,C++17
// 编译:g++ -std=c++17 -fPIC main.cpp -o gauge $(pkg-config --cflags --libs Qt6Widgets)
//      (Qt 5 则将 Qt6Widgets 换为 Qt5Widgets)
#include <QApplication>
#include <QWidget>
#include <QPainter>
#include <QTimer>

class GaugeWidget : public QWidget {
public:
    explicit GaugeWidget(QWidget* parent = nullptr)
        : QWidget(parent), m_progress(0.0) {
        auto* timer = new QTimer(this);
        connect(timer, &QTimer::timeout, this, [this] {
            m_progress += 0.01;                    // 1) 只改状态
            if (m_progress > 1.0) m_progress = 0.0;
            update();                              // 2) 异步请求重绘(脏矩形合并)
        });
        timer->start(16);                          // ~60fps
    }

protected:
    void paintEvent(QPaintEvent*) override {       // 3) 纯函数:只读状态 + 绘制
        QPainter p(this);
        p.setRenderHint(QPainter::Antialiasing);   // 反锯齿

        const qreal side = qMin(width(), height());          // 居中正方形
        const QRectF ring((width() - side) / 2.0,
                          (height() - side) / 2.0, side, side);

        p.setPen(QPen(QColor(225, 228, 235), side * 0.08)); // 背景圆环
        p.drawArc(ring, 0, 360 * 16);                        // 整圆 = 5760(1/16 度)

        // 角度语义:0° 在 3 点钟方向,正值逆时针、负值顺时针
        // 从 12 点(90*16)起,负跨度 = 顺时针前进
        p.setPen(QPen(QColor(52, 120, 246), side * 0.08,
                      Qt::SolidLine, Qt::RoundCap));        // 圆头端点
        p.drawArc(ring, 90 * 16, -int(360 * 16 * m_progress));

        p.setPen(QColor(80, 90, 110));                      // 中央百分比
        QFont f = p.font();
        f.setPixelSize(int(side * 0.18));
        f.setBold(true);
        p.setFont(f);
        p.drawText(ring, Qt::AlignCenter,
                   QString::number(int(m_progress * 100)) + QLatin1String("%"));
    }

private:
    qreal m_progress = 0.0;   // 唯一状态源;无需 Q_OBJECT/moc(本类未声明信号槽)
};

int main(int argc, char** argv) {
    QApplication app(argc, argv);
    GaugeWidget w;
    w.resize(260, 260);
    w.show();
    return app.exec();
}

验证点:① 运行后蓝色圆环从 12 点方向顺时针匀速增长并循环;② 拖拽缩放窗口,圆环始终居中且不变形(逻辑坐标 + 反锯齿生效);③ 在 200% 缩放的屏幕上文字与圆环依然清晰(Qt 6 默认高 DPI,可对比设置前后);④ 思考:把 update() 换成 repaint() 运行,观察 CPU 占用与流畅度差异。进阶:把背景圆环缓存到 QPixmap 成员,体会"缓存换性能"。

⑪一句话总结
paintEvent 就是你的合成器线程:把绘制当纯函数、把更新当脏标记、把缓存当图层——Web 那套渲染心智模型,在 Qt 里换个名字继续生效。