3、Windows GDI 实现:CreateCompatibleDC、BitBlt 函数详解、经典双缓冲代码模板

好,咱们进入实战环节。前面聊了双缓冲的原理,说白了就是在内存里先画好,再一次性贴到屏幕上。那在 Windows 的 GDI 时代,具体怎么干?核心就两个 API:CreateCompatibleDC 和 BitBlt。我把它们拆开讲透,再给出一套我用了十几年的模板代码。

3.1 CreateCompatibleDC:创建幕后画板

这个函数的名字挺直白——创建兼容的设备上下文。什么叫「兼容」?就是创建一个和你的屏幕(或者某个窗口)像素格式一模一样的内存 DC。

我个人习惯把它理解成「在内存里铺一张画布」。这张画布你看不见,但你可以往上面画任何东西,画错了擦掉重来也不影响屏幕显示。

函数签名:

HDC CreateCompatibleDC(
  HDC hdc  // 参考的设备上下文句柄,传 NULL 则兼容主屏幕
);

这里有个坑,我刚开始学的时候踩过:CreateCompatibleDC 创建出来的 DC,默认只有 1x1 像素的位图。你想想看,1x1 像素能画啥?啥也画不了。所以创建完 DC 之后,必须马上给它绑定一块真正大小的位图。

重要:创建兼容 DC 后,必须调用 CreateCompatibleBitmap 创建一块与目标窗口大小一致的位图,然后用 SelectObject 选入 DC。否则你画上去的东西根本存不住。

我曾经在一个项目里忘了这一步,结果调试了一下午,画出来的东西全是黑的。嗯,从那以后我写双缓冲代码,第一件事就是检查位图有没有创建。

3.2 BitBlt:内存到屏幕的快递员

BitBlt 是 GDI 里效率最高的位块传输函数。它的名字是 Bit Block Transfer 的缩写,说白了就是把一块内存里的像素数据,整块搬到另一个地方。

函数签名:

BOOL BitBlt(
  HDC hdcDest, // 目标 DC(通常是屏幕 DC)
  int xDest,    // 目标矩形左上角 x
  int yDest,    // 目标矩形左上角 y
  int wDest,    // 目标矩形宽度
  int hDest,    // 目标矩形高度
  HDC hdcSrc,   // 源 DC(通常是内存 DC)
  int xSrc,     // 源矩形左上角 x
  int ySrc,     // 源矩形左上角 y
  DWORD dwRop   // 光栅操作码,双缓冲用 SRCCOPY
);

参数看着多,其实核心就三个:源 DC、目标 DC、光栅操作码。双缓冲场景下,光栅操作码固定用 SRCCOPY,意思就是「原样复制,不做任何混合」。为什么?因为我们已经在内存里画好了最终结果,直接覆盖到屏幕上就行。

光栅操作码 含义 双缓冲场景
SRCCOPY 源像素直接复制到目标 ✅ 最常用
SRCAND 源与目标做 AND 运算 ❌ 不用于双缓冲
SRCPAINT 源与目标做 OR 运算 ❌ 不用于双缓冲
NOTSRCCOPY 源像素取反后复制 ❌ 不用于双缓冲

你可能会问:为什么不用 StretchBlt?那个还能缩放。我的建议是——双缓冲里千万别用。缩放会引入性能开销和像素走样,我们追求的就是 1:1 的像素级复制,BitBlt 是最快的。

3.3 经典双缓冲代码模板

下面这套模板,是我从 Windows 98 时代就开始用的,经过无数次项目检验。你直接复制到 WM_PAINT 消息处理里就能用。

// 经典 GDI 双缓冲模板
case WM_PAINT:
{
    PAINTSTRUCT ps;
    HDC hdc = BeginPaint(hWnd, &ps);

    // 1. 获取客户区尺寸
    RECT rc;
    GetClientRect(hWnd, &rc);
    int width = rc.right - rc.left;
    int height = rc.bottom - rc.top;

    // 2. 创建内存 DC 和兼容位图
    HDC hdcMem = CreateCompatibleDC(hdc);
    HBITMAP hbmMem = CreateCompatibleBitmap(hdc, width, height);

    // 3. 将位图选入内存 DC
    HBITMAP hbmOld = (HBITMAP)SelectObject(hdcMem, hbmMem);

    // 4. 在内存 DC 上绘制所有内容
    //    用白色背景填充,避免残留
    HBRUSH hbrWhite = CreateSolidBrush(RGB(255, 255, 255));
    FillRect(hdcMem, &rc, hbrWhite);
    DeleteObject(hbrWhite);

    //    在这里画你的所有图形、文字、控件
    Rectangle(hdcMem, 50, 50, 200, 150);
    TextOut(hdcMem, 60, 60, L"Hello Double Buffer", 19);
    // ... 其他绘制代码

    // 5. 一次性将内存 DC 内容复制到屏幕
    BitBlt(hdc, 0, 0, width, height, hdcMem, 0, 0, SRCCOPY);

    // 6. 清理资源
    SelectObject(hdcMem, hbmOld);
    DeleteObject(hbmMem);
    DeleteDC(hdcMem);

    EndPaint(hWnd, &ps);
    return 0;
}

我的经验:第 4 步里的 FillRect 很多人会忘。如果不先清空背景,内存 DC 里残留的是随机数据,第一次绘制时会出现花屏。我习惯用白色填充,如果你的界面有背景图,可以用 PatBlt 或者直接画背景。

3.4 核心流程可视化

为了让你更直观地理解整个流程,我画了一张图。这张图把上面代码的 6 个步骤串起来了。

GDI 双缓冲核心流程 GDI 双缓冲核心流程 1. BeginPaint 获取 hdc 2. 创建内存 DC + 兼容位图 3. 在内存 DC 上绘制所有内容 4. BitBlt 一次性复制到屏幕 5. 删除位图、释放 DC 关键:所有绘制在内存完成,屏幕只做一次 BitBlt,彻底消除闪烁

3.5 避坑指南与性能建议

模板给你了,但实际项目里还有几个细节要注意。我一个个说。

  • 位图尺寸变化:窗口大小改变时,必须重新创建位图。我习惯在 WM_SIZE 消息里销毁旧位图、创建新位图,然后在 WM_PAINT 里直接用。
  • 频繁创建销毁:如果每帧都 CreateCompatibleDC 再 DeleteDC,性能会受影响。对于高频刷新(比如 60fps 的游戏),我建议在窗口初始化时创建好 DC 和位图,只在窗口尺寸变化时重建。
  • SelectObject 恢复:记得在清理前把原来的位图 SelectObject 回去。不恢复的话,DeleteObject 会失败,造成 GDI 对象泄漏。Windows 的 GDI 对象数量有限,泄漏多了整个系统会变卡。

我曾经踩过的坑:在一个监控软件项目里,我忘了恢复 SelectObject,结果程序运行 8 小时后,整个屏幕开始闪烁,所有窗口都画不出来了。后来用任务管理器一看,GDI 对象数飙到了 9800 多(上限 10000)。从那以后,我每次写双缓冲代码,都会在函数末尾加一个资源清理的断言检查。

3.6 什么时候该用 GDI 双缓冲?

说实话,GDI 双缓冲不是万能的。我列个表,帮你快速判断。

场景 推荐方案 原因
简单 2D 绘图、图表、编辑器 ✅ GDI 双缓冲 实现简单,兼容性好,XP 到 Win11 都能跑
高频刷新(60fps+) ⚠️ 考虑 Direct2D GDI 的 BitBlt 在大量像素复制时 CPU 占用高
3D 渲染、游戏 ❌ Direct3D / OpenGL GDI 不支持硬件加速,性能瓶颈明显
跨平台需求 ❌ 用 Skia / Cairo GDI 是 Windows 专属,无法移植

我个人习惯是:只要是 Windows 桌面端的 2D 工具类软件,比如截图工具、流程图编辑器、监控面板,我都用 GDI 双缓冲。够用、稳定、调试方便。但如果要做动画编辑器或者游戏,我会直接上 Direct2D。