Qt 渲染体系:从 QPainter 到像素——绘制管线、双缓冲与 GPU 合成

2026-08-25 · Qt6 × 性能优化 · C++17/Qt6 · 第 27 期
①

今日主题:为什么要把「画」这件事讲透

你在前端早已熟悉 style → layout → paint → composite 这条浏览器渲染管线,但桌面客户端的像素究竟是怎么产生的?Qt Widgets 的 paintEvent 与 Qt Quick 的 Scene Graph 是两条截然不同的渲染路线——不理解它们,就无法解释「为什么滚动会卡」「为什么窗口会闪烁」「为什么这个动画要交给 GPU」。今天我们把「从 QPainter 指令到屏幕像素」的整条管线讲透,这是从 Web 架构师走向客户端架构师必须补齐的核心拼图,也是昨天 profiler 知识的自然延伸:先知道瓶颈在哪里,再谈优化。

②

核心知识:两套渲染体系与一条绘制管线

两个世界:立即模式 vs 保留模式。Qt 同时存在两套渲染体系。QWidget 走「立即模式」:没有场景树,每个控件在事件循环派发的 paintEvent(QPaintEvent*) 里,用 QPainter 把内容直接画进一块离屏缓冲(backing store),全部画完后一次性提交给窗口系统。Qt Quick 走「保留模式」:QML 声明一棵场景图(Scene Graph)节点树,属性变化只更新节点,渲染线程负责遍历节点并合成。前者像 Canvas 2D 的立即绘制,后者像「DOM + CSS 合成」——这个类比请先记住,第六节会展开。

paintEvent 与 QPainter。paintEvent 是 Widgets 绘制的唯一入口,永远在 GUI 线程执行,由事件循环在帧边界派发。QPainter 是一个「绘制上下文 / 状态机」:pen(描边)、brush(填充)、transform(坐标变换)、render hints(如 Antialiasing),所有绘制指令(drawLine / drawRect / drawText / drawPixmap)最终交给 QPaintEngine 后端(raster 或 OpenGL)落到 QPaintDevice(QWidget / QPixmap / QImage)上。注意 QPainter 与 Canvas 2D context 几乎一一对应:save/restore、path、fill/stroke 的概念完全可迁移。

双缓冲与 backing store:为什么不会闪烁。单缓冲绘制时,「擦除旧内容 → 画新内容」之间的中间态会暴露给屏幕,造成闪烁。Qt Widgets 内部用 QBackingStore 维护一块与窗口等大的离屏位图:整个控件树的 paintEvent 都画在这块缓冲里,画完后通过 QPA(Qt Platform Abstraction)一次性 flush 到窗口表面,用户永远看不到半成品帧。代价是每帧都要提交整块(或损坏区域对应的)位图——这就是大窗口重绘贵的根源。

update() 合并与脏区域。QWidget::update() 不是「立即重绘」,而是「标记损坏区域 + 向事件循环投递一个绘制事件」。同一帧内多次 update() 会合并为一次 paintEvent,且可以传矩形缩小损坏范围;事件循环在合适的时机统一派发。而 repaint() 是同步、立即、直接调用 paintEvent 的——除极少数场景(如必须在 paintEvent 里继续画到 QPixmap)外不要用它。这套机制与 React 的 setState 批处理、浏览器的脏矩形重绘是同一个思想:把高频变更聚合到帧边界,最小化重绘面积。

高分屏与抗锯齿。Qt 6 默认启用 High-DPI:逻辑坐标按 devicePixelRatio 缩放成物理像素。模糊的根源通常是坐标没落在整数物理像素上;抗锯齿(QPainter::Antialiasing)用覆盖度采样消除锯齿,代价是每像素计算量上升。对 2x 屏,同样逻辑面积的工作量理论上是 4 倍——这是渲染预算的起点。

Qt Quick 与 GPU 合成。Qt Quick 的场景图把节点组织成可独立合成的层:纹理、变换、opacity 都可以在渲染线程由 RHI(Qt 6 的 Rendering Hardware Interface,统一封装 Vulkan / Metal / D3D / OpenGL)提交给 GPU。动画只需改节点的 transform/opacity,由 GPU 合成,不重绘内容——这正是浏览器「transform 动画走合成器线程」的桌面版。QOpenGLWidget 则是给 Widgets 应用开的 GPU 旁路:OpenGL 画到 FBO,再合成进窗口。

维度QWidget 绘制Qt Quick (Scene Graph)
模式立即模式,直接画入 backing store保留模式,场景图节点树
更新方式paintEvent + 脏区域(update 合并)属性变更 → 节点标记 → 渲染线程合成
线程仅 GUI 线程主线程提交 + 渲染线程 + RHI/GPU
适合场景表单、文档、工具类界面动效、大列表、数据可视化
前端类比Canvas 2D 立即绘制DOM + CSS 合成(保留模式)
渲染预算:60fps 意味着每帧 16ms。Widgets 的全部绘制都在 GUI 线程完成,paintEvent 超出预算就是肉眼可见的卡顿;Qt Quick 把合成卸载到渲染线程,主线程只负责提交场景变更。做性能优化前,先用 08-24 学的 profiler 定位瓶颈在「画」还是在「合成」。
③

实际案例:Chrome、VSCode、微信/QQ 与 JetBrains 的渲染选择

Chrome / Electron / VSCode:保留模式 + 合成的极致。Chrome 把页面切成 layer tree,滚动与 transform 动画只触发合成器用 GPU 纹理重新拼合,不需要重画 DOM。VSCode 的 Monaco 编辑器进一步做「虚拟化」:只渲染可视行,配合分层合成,十万行文件滚动依然流畅。这就是「保留模式 + 合成」的形态,也是 Qt Quick 想要达到的效果——理解了 Scene Graph 的节点与层,你就理解了 Electron 流畅动画的底层逻辑。

微信 / QQ 桌面端:Qt 下的自绘与缓存。公开资料显示微信 PC 版与 QQNT 均基于 Qt。聊天窗口的复杂气泡、图片九宫格、表情动画,用原生控件组合的代价太高,于是大量使用 QPainter 自绘 + QPixmap 缓存(头像、表情预渲染)。核心权衡:自绘获得像素级控制,代价是布局、命中测试、无障碍都要自己维护——这和前端「Canvas 全自绘 vs DOM」的取舍完全一致,只是换了个战场。

JetBrains 系列:接管绘制。IDEA 基于 Swing,但 JetBrains 用自研 Look&Feel 重写了几乎全部绘制路径,配合双缓冲与区域重绘,实现高度一致的跨平台观感。它说明一个规律:控件框架的默认绘制往往不够,大型客户端最终都会走向「接管绘制」——而接管的起点,就是今天讲的 paintEvent 与缓冲机制。

Qt 自己的选择。表单、表格、文档类界面用 Widgets(控件现成、成本低);仪表盘、动态图表、流畅动效用 Qt Quick。选错方向才是最大的性能事故——这不是编码问题,是架构决策,第七节会从管理者视角展开。

④

常见错误:五个让界面卡顿、闪烁的坑

1. 在 paintEvent 里做重活。网络、磁盘 IO、布局计算、new 对象全塞进 paintEvent。paintEvent 每帧可能被调用多次,任何耗时都会被放大,且直接阻塞 GUI 线程。避免:预计算并缓存到成员变量或 QPixmap,paintEvent 只做「把已有数据映射成像素」。

2. 用 repaint() 代替 update()。以为「更直接更快」,实际破坏了合并机制,同一帧多次同步重绘,甚至产生撕裂。原因:不理解异步合并的价值。避免:默认只用 update(),需要同步结果时重构逻辑而不是 repaint()。

3. 无脑全窗口 update()。大窗口每帧整窗重绘,CPU 消耗翻几倍。原因:没传损坏区域。避免:用 update(rect) 只标记脏区域;把静态内容画进 QPixmap 再 blit。

4. 跨线程碰绘制。在工作线程里调 widget->update() 或直接画 widget。QWidget 不是线程安全的,任何绘制必须发生在 GUI 线程。避免:用信号槽(QueuedConnection)把「数据变更」投递回 GUI 线程,在槽里 update()。

5. 忘了 WA_OpaquePaintEvent。不透明窗口默认仍会先擦背景(一次多余 fill),既浪费又可能闪烁。避免:完全不透明的自绘控件设置 setAttribute(Qt::WA_OpaquePaintEvent)。附加:高分屏下用未对齐的整数坐标硬画会导致模糊——画之前把坐标对齐到 devicePixelRatio 的整数倍。

⑤

最佳实践:五条经过验证的绘制准则

1. 静态内容缓存到 QPixmap。何时用:内容低频变化(背景、表盘、头像、九宫格)。何时不用:每秒都在变的动态内容——缓存本身也要重绘,白白占用显存。Trade-off:内存换时间,注意 2x 屏下 pixmap 也要按物理尺寸缓存,否则放大后模糊。

2. 帧驱动动画用 QTimer(16ms) + update(),或直接用 QVariantAnimation。何时用:连续动画。何时不用:一次性状态变化,直接 update() 即可。Trade-off:QTimer 不对齐 vsync,极端情况会丢帧;Qt Quick 的动画框架由渲染线程插值,更省心——这正是选择 Qt Quick 的理由之一。

3. 选型先问「内容会不会变」。表单、CRUD、工具类界面用 Widgets(开发效率最高);数据可视化、动效、超长列表用 Qt Quick。Trade-off:Widgets 生态成熟但渲染模型陈旧;Qt Quick 性能好但引入 QML/JS 心智成本与工具链复杂度。

4. 需要 GPU 时再上 GPU。QOpenGLWidget 或 Qt Quick 的 RHI。何时用:视频、图像处理、粒子、超大画布。何时不用:普通控件界面——驱动兼容性是真实且持续的运维成本。

5. 绘制与数据分离,高分屏对齐。模型持有业务数据,paintEvent 只做「数据 → 像素」的纯映射:可单测、可 Code Review、可复用。绘制前把坐标乘以 devicePixelRatioF() 并对齐整数物理像素,避免模糊与 1px 细线伪影。

⑥

与 Web 技术的联系:把前端渲染知识迁移过来

管线同构。DOM → Style → Layout → Paint → Composite 与 QLayout → paintEvent → backing store → flush 是同一条流水线;Qt Quick 的 Scene Graph 更接近浏览器的保留模式,连「transform/opacity 走合成器」的优化技巧都一模一样。你在前端调试 GPU 加速的经验可以直接复用。

setState 批处理 ≈ update() 合并。React 在帧边界聚合状态变更、只 diff 需要更新的部分;QWidget 在同一帧合并多次 update()、只重绘脏区域。思想同源:高频变更聚合,最小化重绘面积。Vue 的依赖追踪则对应 Qt 的信号槽与模型通知(08-14/08-16 已讲)。

requestAnimationFrame ≈ 帧驱动。rAF 对齐 vsync;Qt 侧用 QTimer(16ms) 近似,或用 QVariantAnimation。区别在于浏览器帮你对齐了屏幕刷新,QTimer 没有——这正是 Qt Quick 动画更流畅的底层原因之一。

Canvas 2D ≈ QPainter。save/restore、path、fill/stroke、transform、抗锯齿开关几乎一一对应。写过 Canvas 的前端学 QPainter 是降维打击,API 都能猜中。

虚拟滚动 ≈ 部分重绘。前端虚拟列表只渲染可视行;Qt 侧等价物是 update(rect) 只画脏区域、或 Qt Quick 视图的按需实例化。优化思路完全一致,只是名词不同。

⑦

管理者视角:Team Leader 如何把关渲染质量

Code Review 清单:paintEvent 里有没有 IO、业务逻辑、new;update/repaint 用得对不对;缓存策略是否考虑 2x 屏;有没有跨线程碰 UI;动画是否帧驱动。这五条可以在 Review 模板里固化成 check-list。

技术决策:「自绘 vs 原生控件 vs Qt Quick」是架构决策而非编码决策。要求团队先写 PoC,用 profiler 出基准数据再拍板(08-24 的 profiling 技能在这里是决策工具,不是事后救火工具)。

团队成长:把「渲染预算」变成团队共识——每帧 16ms、GUI 线程内完成绘制、性能问题先定位再优化。前端团队的 Rendering 面板 / paint flashing 经验可以平移到 Qt Creator 的 FPS 指示与 profiler,让转岗同学感到熟悉而不是陌生,这是降低转型摩擦的杠杆点。

⑧

延伸阅读

1. Qt 官方文档「Paint System」(QPainter / QPaintDevice / QPaintEngine)——权威定义,写绘制代码前先通读,避免被二手博客带偏。

2. Qt 官方「Qt Quick Scene Graph」与 QRhi 文档——理解保留模式与 GPU 抽象的入口,也是选型判断的依据。

3. Chrome 博客 RenderingNG 系列——现代渲染管线的黄金参考,作为知识迁移的坐标系,很多 Qt 概念能在这里找到对应物。

4. 《Qt 6 Cadaques》在线书绘制章节——免费、示例多,适合边读边改代码验证。

5. Qt 官方 High DPI 文档——高分屏模糊问题 90% 都能在这里找到答案,也是踩坑最少的捷径。

⑨

今日思考题

1. Widgets 的「重绘」与 Qt Quick 的「节点更新 + 合成」本质差异是什么?什么场景下 Widgets 反而更合适?
2. 双缓冲消除了闪烁,代价是什么?估算 2560×1440 全窗口重绘一帧要搬运多少字节(RGBA)?
3. 2x 高分屏下,同样逻辑尺寸的绘制工作量是 1x 的几倍?你的 QPixmap 缓存策略该如何调整?
4. 长聊天列表滚动时,怎样做到只重绘可见区域?与前端虚拟列表的实现差异在哪里?
5. 如果把微信表情面板用 Widgets 实现,你的缓存与失效策略怎么设计?
⑩

今日实践任务:60fps 秒针表盘(约 40 分钟)

编译并运行下面的表盘程序,然后完成四个实验:
① 系统监视器观察 CPU;
② 把 paintEvent 改为只 update(指针所在小矩形),对比 CPU 变化——体会脏区域的价值;
③ 去掉 WA_OpaquePaintEvent,观察性能差异;
④ 用 Qt Creator Profiler 或 perf 看 paintEvent 耗时(对照 08-24)。
加码题:把表盘背景预画到 QPixmap,paintEvent 只 blit 背景 + 画指针。

// dial.cpp — 60fps 秒针表盘:体会 update() 合并、双缓冲与 paintEvent
// 编译: g++ -fPIC dial.cpp -o dial $(pkg-config --cflags --libs Qt6Widgets)
#include <QApplication>
#include <QWidget>
#include <QPainter>
#include <QTimer>
#include <QTime>
#include <QtMath>

class DialWidget : public QWidget {
public:
    DialWidget() {
        setAttribute(Qt::WA_OpaquePaintEvent);   // 不透明:跳过背景擦除
        setFixedSize(360, 360);
        connect(&m_timer, &QTimer::timeout, this,
                [this] { update(); });            // 异步合并,绝不 repaint()
        m_timer.start(16);                        // ≈60fps 帧驱动
    }
protected:
    void paintEvent(QPaintEvent *) override {
        QPainter p(this);                         // RAII:析构自动 end()
        p.setRenderHint(QPainter::Antialiasing);
        p.setBrush(QColor(28, 28, 34));
        p.drawEllipse(rect().adjusted(8, 8, -8, -8));   // 表盘
        const qreal sec = QTime::currentTime().second()
                        + QTime::currentTime().msec() / 1000.0;
        const qreal ang = qDegreesToRadians(sec * 6.0 - 90.0);
        const QPointF c = rect().center();
        p.setPen(QPen(Qt::white, 4, Qt::SolidLine, Qt::RoundCap));
        p.drawLine(c, c + QPointF(qCos(ang), qSin(ang)) * 150.0);  // 秒针
        p.setPen(QColor(150, 150, 160));
        for (int i = 0; i < 60; ++i) {            // 刻度
            const qreal a = qDegreesToRadians(i * 6.0 - 90.0);
            const QPointF d(qCos(a), qSin(a));
            p.drawLine(c + d * 140.0, c + d * (i % 5 == 0 ? 125.0 : 132.0));
        }
    }
private:
    QTimer m_timer;
};

int main(int argc, char *argv[]) {
    QApplication app(argc, argv);
    DialWidget w;
    w.show();
    return app.exec();
}
⑪

一句话总结

把「重绘」当成一次受控的帧提交:update() 聚合变更、缓存消灭重复绘制、paintEvent 只做像素映射——像优化 React 渲染一样优化你的 Qt 界面。