2026-08-17 · 每日技术学习 · 面向 Web 架构师 → C++/Qt 客户端架构师
从 Web 前端转向 Qt 桌面客户端,最大的认知跃迁之一就是"界面到底是怎么画出来的":在浏览器里你改一个 CSS 属性,重排、重绘、合成全部由 Chromium 兜底;而在 Qt 中,paintEvent() 就是你与像素之间的最后一道闸门。理解 Qt 绘制系统,你才能真正掌控自定义控件、性能优化与高分屏适配这三件客户端架构师的日常武器。
今天我们从 QPainter 与 paintEvent 出发,一路走到双缓冲、脏矩形、QSS 样式机制与 Qt Quick 的 Scene Graph,建立一份"从 DOM/CSS 到 QWidget/QPainter"的完整映射——你会发现,浏览器渲染管线的那套心智模型,在桌面端只是换了个名字。
QWidget 的所有绘制都发生在 paintEvent(QPaintEvent*) 虚函数中。触发时机有四种:首次显示、尺寸变化(resize)、被遮挡后恢复、程序主动调用 update() 或 repaint()。关键差异在于:update() 是异步的——它只把区域标记为"脏"(dirty),真正重绘要等控制权回到事件循环后统一执行;Qt 会把多次 update() 合并成一次绘制(脏矩形合并,多个区域取并集)。这正是浏览器"增量重绘"在桌面端的对应物。而 repaint() 是同步立即重绘,除非有特殊需求(如与外部帧同步),否则应避免——它会阻塞事件处理,频繁调用还会造成闪烁。
QPainter 可以在任何 QPaintDevice 上绘制:QWidget、QPixmap(内存图像)、QImage(像素级访问)、QPicture(录制重放)。它的本质是一个状态机:pen(描边)、brush(填充)、font、transform(坐标系变换)构成当前状态,save()/restore() 负责压栈与弹栈——这与 Canvas 2D 的 ctx.save()/restore() 如出一辙。你在 paintEvent 中拿到的 QPainter 已绑定目标设备,背后由 paint engine(光栅引擎或 OpenGL 引擎)执行真实绘制。
自 Qt 4 起 QWidget 默认启用双缓冲:绘制先画到后备缓冲区,再一次性提交(flush)到平台窗口,从而彻底避免闪烁。这套机制由 QPA(Qt Platform Abstraction)层的 QBackingStore 管理,它同时维护脏区域列表,只把变化的部分提交到屏幕。这与浏览器把图层交给合成器(compositor)拼合是同一思路:先在离屏缓冲里画好,再一次性呈现。极端嵌入式场景可用 Qt::WA_PaintOnScreen 关闭双缓冲,但绝大多数情况下你不该碰它。
paintEvent 中的坐标系是设备无关的逻辑坐标,Qt 会在提交时按 QWidget::devicePixelRatio() 缩放。Qt 5.6 引入 Qt::AA_EnableHighDpiScaling,Qt 6 则默认启用高 DPI 缩放。陷阱在于像素图:QPixmap 的尺寸是物理像素,若按逻辑尺寸创建再绘制,在 200% 缩放的屏幕上必然发虚——对应前端世界里 CSS 像素与物理像素的区别、window.devicePixelRatio 的含义。
QSS(Qt Style Sheets)是受 CSS 2 启发、支持盒模型(padding/border/background)与伪状态(:hover、:pressed、:checked)的样式系统,由 QStyleSheetStyle 在运行时解析,支持类型选择器、类选择器、ID 选择器、属性选择器、后代/子选择器。它的边界很明确:只管"样式",不管"内容"与"布局"(布局属于 QLayout 体系)。圆角、渐变、换肤用 QSS;进度环、波形、图表这类"内容型"视觉必须走 QPainter 自绘。
QML/Qt Quick 走的是另一条渲染路径:Scene Graph 场景图——控件树被编译成渲染节点树,在独立渲染线程上由 GPU 合成(QQuickWindow),动画在渲染线程直接驱动。若要在 QML 里复用 QPainter 能力,可用 QQuickPaintedItem。理解了 QWidget 的光栅绘制模型,再去看 Scene Graph,就是在看"软件渲染 → GPU 合成"的演进方向。
will-change: transform 把元素提升为独立图层,动画只动合成层、跳过重绘。Qt Widgets 的对应物就是 backing store + 脏矩形:update(rect) 只重绘局部区域,把"绘制成本"压到最小。结论:自绘不是炫技,而是"跨平台一致性、复杂视觉、极致性能"三项需求的共同解。Qt 的价值在于把自绘的成本降到最低——paintEvent + QPainter 让你用几十行代码就能拥有自己的控件语言。
update(),界面纹丝不动;反之在循环里高频调用 update(),虽然 Qt 会合并脏矩形,但整窗口重绘依然昂贵。原因:不理解"绘制是事件驱动的"。避免:仅在状态变化点调用一次 update();只动局部时用 update(rect) 缩小重绘范围。QPen pen(...)),复杂路径缓存为成员。pixmap.setDevicePixelRatio(devicePixelRatio()),并按 size() * devicePixelRatio() 创建缓存。update() 或绘制 API 则是未定义行为(线程亲和性)。避免:只有确定每个像素都会被覆盖时才设 OpaquePaintEvent;跨线程一律通过信号槽/事件投递到 GUI 线程再操作控件。setRenderHint(QPainter::Antialiasing, true);QPixmap 缓存按 DPR 创建。何时用:所有面向真实用户的桌面应用。不用:需要像素级精确对齐(如图像编辑器的缩放预览)时可关闭反锯齿。把 Web 渲染管线里的每一个概念在 Qt 里找到对应物,你就完成了 80% 的迁移:
| 维度 | Web 前端 | Qt 桌面 |
|---|---|---|
| 重绘调度 | 浏览器脏矩形 + 增量布局,异步批量处理 | update() 标记脏区域,事件循环中合并为一次 paintEvent |
| 绘制 API | Canvas 2D:save/restore、translate、strokeStyle | QPainter: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 之所以把渲染挪到独立线程,本质是在向浏览器的合成器模型靠拢。
Code Review 关注点:paintEvent 里有没有 IO/堆分配/业务逻辑;update() 调用是否克制、是否只重绘了变化区域;DPR 是否处理;控件是否通过 QSS/调色板接口取色(可换肤);自绘逻辑是否沉淀为可复用组件而非散落各处。
指导团队:先定"绘制分层"约定——哪些层用 QSS、哪些层自绘、哪些场景用 QML,形成团队共识文档;为自绘控件建立性能预算(60fps 帧预算 16.6ms,自绘控件单帧建议 < 2ms),并把耗时绘制纳入性能回归检查。
架构层面:把主题/调色板抽象为统一设计源(对应前端的 design token),QSS 变量与 Q_PROPERTY 联动,实现换肤不改业务代码;同时警惕"自绘上瘾"——能用框架能力解决的不要自研渲染引擎,自研渲染的维护成本是团队级负债。
目标:实现一个自绘圆形进度环,验证本课全部要点——反锯齿、状态驱动重绘(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 成员,体会"缓存换性能"。