你在前端早已熟悉 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 合成(保留模式) |
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 细线伪影。
管线同构。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 视图的按需实例化。优化思路完全一致,只是名词不同。
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% 都能在这里找到答案,也是踩坑最少的捷径。
编译并运行下面的表盘程序,然后完成四个实验:
① 系统监视器观察 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();
}