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 个步骤串起来了。
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。