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 知识体系总览

下面这张图把本章的核心逻辑串起来了。从闪烁的根源到三缓冲的架构,再到性能调优的三个方向,你可以对照着检查自己的实现。

Windows GDI 进阶知识体系 GDI 进阶核心 闪烁问题解决 三缓冲扩展 性能调优技巧 WM_ERASEBKGND 双缓冲全权负责 读写解耦 三缓冲管理器 减少GDI对象创建 脏区域重绘 核心原则:减少无效绘制 + 减少 GDI 对象操作 + 合理使用多缓冲 三缓冲不是银弹,只在读写冲突场景下才值得引入

4.5 实战避坑清单

最后,我把这些年遇到的 GDI 性能坑整理成清单。你写代码时对着检查一遍,能省不少调试时间。

问题 现象 解决方案
窗口 resize 时闪烁 缩放瞬间出现白屏 WM_ERASEBKGND 返回 1 + 双缓冲全权绘制
频繁创建/销毁 GDI 对象 程序运行越久越卡 在 WM_CREATE 中创建,WM_DESTROY 中销毁
全屏 BitBlt 性能瓶颈 CPU 占用高,帧率上不去 只拷贝脏区域,或改用 ScrollDC
多线程读写同一缓冲 画面撕裂或内容错乱 引入三缓冲,读写彻底分离
InvalidateRect 整窗口 不必要的全屏重绘 只 InvalidateRect 变化区域

我曾经犯过的错:在一个监控软件里,我用了三缓冲,但忘记在窗口最小化时暂停渲染线程。结果最小化后还在疯狂画三个缓冲,CPU 直接飙到 30%。后来加了 WM_SIZE 判断,窗口不可见时直接 Sleep。嗯,这个教训挺深刻的。

GDI 进阶的核心就这些。闪烁、多缓冲、性能调优,三个方向环环相扣。你只要把「减少无效绘制」和「合理管理缓冲区」这两条主线抓住,大部分问题都能迎刃而解。