4、Windows GDI 进阶:解决闪烁问题、多缓冲区(三缓冲)扩展、性能调优技巧
双缓冲解决了撕裂,但没解决所有问题。很多朋友做完双缓冲,发现窗口在 resize 或快速刷新时还是闪。嗯,这其实是另一个坑。今天我把 GDI 里跟闪烁、多缓冲、性能调优相关的经验一次性讲透。
4.1 闪烁的根源:不只是双缓冲的事
闪烁的本质是什么?说白了就是「背景擦除」和「前景绘制」之间出现了空白帧。你想想看,Windows 在收到 WM_PAINT 之前,会先发一个 WM_ERASEBKGND,把客户区刷成白色。如果你这时候再慢慢画,用户就会看到白屏一闪。
核心结论:闪烁 = 背景擦除 + 延迟绘制。双缓冲解决的是「绘制过程中的撕裂」,而闪烁需要额外处理 WM_ERASEBKGND。
我在项目中遇到过最典型的情况:一个实时波形显示控件,每秒刷新 60 次。双缓冲做好了,但波形边缘还是闪。查了半天,发现是默认的 DefWindowProc 在处理 WM_ERASEBKGND 时刷了白色背景。解决办法很简单——
// 方案一:返回非零值,阻止系统擦除背景
case WM_ERASEBKGND:
return 1; // 告诉系统:我已经处理了,你别擦了
// 方案二:注册窗口类时设置背景画刷为 NULL
WNDCLASS wc = {0};
wc.hbrBackground = NULL; // 不自动擦除
// 然后在 WM_PAINT 中用双缓冲全权负责背景填充
我的习惯:方案二更干净。把背景擦除和前景绘制全部交给双缓冲,一次 BeginPaint/EndPaint 搞定所有。这样连 WM_ERASEBKGND 都不会触发。
4.2 三缓冲:什么时候用?怎么用?
双缓冲还不够?对,某些场景下确实不够。比如你有一个主渲染线程和一个 UI 线程,主线程在往后台缓冲写数据,UI 线程想读这个缓冲去显示。这时候双缓冲就尴尬了——写和读不能同时进行。
三缓冲的思路:多一个后备缓冲,让读写彻底解耦。
| 缓冲区 | 角色 | 谁在操作 |
|---|---|---|
| Buffer 0(前台) | 当前显示的内容 | GDI 交换链管理 |
| Buffer 1(后台) | 正在准备的新帧 | 渲染线程写入 |
| Buffer 2(后备) | 已完成但未提交的帧 | 等待交换 |
实现上,三缓冲在 GDI 里没有原生支持,但我们可以用三个 HBITMAP 模拟:
// 伪代码:三缓冲管理器
class TripleBuffer {
HBITMAP buffers[3];
int front_idx; // 当前显示
int back_idx; // 正在写入
int ready_idx; // 已完成等待交换
void Swap() {
// 把 ready_idx 的内容交换到 front_idx
// 然后把旧的 front_idx 回收给渲染线程
int old_front = front_idx;
front_idx = ready_idx;
ready_idx = old_front;
}
void Render() {
// 渲染线程写入 buffers[back_idx]
// 完成后标记 back_idx 为 ready
ready_idx = back_idx;
back_idx = (back_idx + 1) % 3;
}
};
我曾经踩过的坑:三缓冲的内存开销是双缓冲的 1.5 倍。如果你的目标分辨率是 4K,每个缓冲 32 位色深,一个缓冲就要 32MB。三个就是 96MB。在嵌入式或低配机器上,这个代价要考虑清楚。
4.3 性能调优:让 GDI 跑得更快
双缓冲和三缓冲只是架构,真正让程序流畅的是细节。我总结了几条实战经验:
4.3.1 减少 GDI 对象创建
GDI 对象(画笔、画刷、字体)是系统资源。每次 WM_PAINT 都 CreatePen/CreateBrush,用完再 DeleteObject,这是最慢的做法。我的习惯:在窗口初始化时创建好,缓存起来,WM_PAINT 里只 SelectObject。
// 初始化时创建
HPEN hPen = CreatePen(PS_SOLID, 2, RGB(0, 120, 215));
HBRUSH hBrush = CreateSolidBrush(RGB(240, 248, 255));
// WM_PAINT 中只选入
case WM_PAINT: {
PAINTSTRUCT ps;
HDC hdc = BeginPaint(hWnd, &ps);
HDC memDC = CreateCompatibleDC(hdc);
HBITMAP memBM = CreateCompatibleBitmap(hdc, width, height);
SelectObject(memDC, memBM);
// 选入缓存的对象
SelectObject(memDC, hPen);
SelectObject(memDC, hBrush);
// ... 绘制逻辑 ...
BitBlt(hdc, 0, 0, width, height, memDC, 0, 0, SRCCOPY);
DeleteDC(memDC);
DeleteObject(memBM);
EndPaint(hWnd, &ps);
break;
}
4.3.2 只重绘脏区域
很多人一收到 WM_PAINT 就重绘整个客户区。其实 Windows 的 PAINTSTRUCT.rcPaint 已经告诉你了哪些区域需要重绘。只画这部分,能省掉大量不必要的像素操作。
// 只重绘无效区域
RECT dirty = ps.rcPaint;
// 在 memDC 上只裁剪到 dirty 区域
// 然后 BitBlt 也只传 dirty 的尺寸
我建议:配合 InvalidateRect 使用。只让需要变化的区域失效,而不是整个窗口。比如一个按钮按下,只 InvalidateRect 按钮区域,别动整个界面。
4.3.3 避免 BitBlt 全屏拷贝
双缓冲的最后一步是 BitBlt 把后台缓冲拷贝到前台。如果全屏 1920x1080,每次拷贝 8MB 数据。60fps 就是 480MB/s 的带宽。这个开销不小。
优化思路:
- 只拷贝脏区域:用 BitBlt 的宽高参数限制拷贝范围
- 用 ScrollDC 代替 BitBlt:如果只是内容滚动,ScrollDC 比 BitBlt 快得多
- 考虑 Direct2D:如果 GDI 性能实在不够,D2D 的硬件加速是更好的选择
4.4 知识体系总览
下面这张图把本章的核心逻辑串起来了。从闪烁的根源到三缓冲的架构,再到性能调优的三个方向,你可以对照着检查自己的实现。
4.5 实战避坑清单
最后,我把这些年遇到的 GDI 性能坑整理成清单。你写代码时对着检查一遍,能省不少调试时间。
| 问题 | 现象 | 解决方案 |
|---|---|---|
| 窗口 resize 时闪烁 | 缩放瞬间出现白屏 | WM_ERASEBKGND 返回 1 + 双缓冲全权绘制 |
| 频繁创建/销毁 GDI 对象 | 程序运行越久越卡 | 在 WM_CREATE 中创建,WM_DESTROY 中销毁 |
| 全屏 BitBlt 性能瓶颈 | CPU 占用高,帧率上不去 | 只拷贝脏区域,或改用 ScrollDC |
| 多线程读写同一缓冲 | 画面撕裂或内容错乱 | 引入三缓冲,读写彻底分离 |
| InvalidateRect 整窗口 | 不必要的全屏重绘 | 只 InvalidateRect 变化区域 |
我曾经犯过的错:在一个监控软件里,我用了三缓冲,但忘记在窗口最小化时暂停渲染线程。结果最小化后还在疯狂画三个缓冲,CPU 直接飙到 30%。后来加了 WM_SIZE 判断,窗口不可见时直接 Sleep。嗯,这个教训挺深刻的。
GDI 进阶的核心就这些。闪烁、多缓冲、性能调优,三个方向环环相扣。你只要把「减少无效绘制」和「合理管理缓冲区」这两条主线抓住,大部分问题都能迎刃而解。