通用GUI编程技术——Win32 原生编程实战(二十三)——GDI 双缓冲技术:消除闪烁完全指南¶
前言:为什么我的界面在闪烁¶
说实话,这个闪烁问题困扰了我很久。
当你刚接触 Win32 GDI 编程,写出一个可以响应窗口大小变化、可以绘制一些简单图形的程序时,一切看起来都很美好。直到某天你尝试在窗口中持续绘制动画,或者窗口需要频繁重绘时 —— 突然间,你的界面开始疯狂闪烁,看起来像是快要坏掉的老式显像管显示器。
这个问题不只是新手会遇到,很多有经验的开发者在一开始没处理好绘制逻辑时,同样会踩这个坑。闪烁问题的本质不是你代码写错了,而是你还没有理解 Windows 的绘制机制是如何工作的。
今天我们要深入探讨的是如何使用双缓冲技术来彻底解决这个闪烁问题。这不仅仅是一个技术技巧,更是理解 Windows 图形绘制机制的必经之路。
环境说明¶
在开始之前,先说明一下我的开发环境:
- 操作系统: Windows 11 Pro 10.0.26200
- 编译器: MSVC (Visual Studio 2022)
- 目标平台: Win32 API 原生开发
- 图形库: GDI (Graphics Device Interface)
闪烁的根源:Windows 绘制机制解析¶
要解决闪烁问题,首先得搞清楚它为什么会产生。Windows 的绘制机制设计上遵循"先擦除、后绘制"的原则,这个设计本身没错,但在某些场景下会变成灾难的源头。
WM_ERASEBKGND 的默认行为¶
当 Windows 需要重绘一个窗口时,它会先发送 WM_ERASEBKGND 消息。这个消息的目的很明确:给应用程序一个机会来擦除窗口的背景。如果你没有处理这个消息,DefWindowProc 会使用窗口类中注册的背景刷子来填充背景区域。
根据 Microsoft Learn 的官方文档,WM_ERASEBKGND 的返回值含义如下:
- 返回 TRUE(非零): 表示应用程序已经擦除了背景,系统不需要再做任何操作
- 返回 FALSE(零): 表示窗口仍然被标记为需要擦除
这里有个关键点:如果你在 WM_ERASEBKGND 中返回 TRUE,那么在后续处理 WM_PAINT 消息时,PAINTSTRUCT 结构的 fErase 成员会是 FALSE,表示系统已经完成了背景擦除。
BeginPaint 返回的背景刷子¶
当你调用 BeginPaint 函数时,事情会变得更加有趣。根据 Microsoft Learn 的文档,如果窗口类有一个背景刷子,BeginPaint 会自动使用这个刷子来擦除更新区域的背景。
这意味着即使你没有处理 WM_ERASEBKGND,BeginPaint 也会帮你完成背景擦除。具体来说,如果你在注册窗口类时设置了 hbrBackground 成员(比如设置为 (HBRUSH)(COLOR_WINDOW+1)),那么 BeginPaint 会用这个刷子填充背景。
两次绘制造成闪烁¶
现在你应该能看出问题所在了。每次重绘时,实际上发生了两次绘制操作:
- 第一次: 系统用背景刷子擦除背景(通常是白色或系统颜色)
- 第二次: 你的
WM_PAINT处理函数绘制实际内容
如果这两次操作之间有一定的时间间隔,或者绘制过程需要较长时间,人眼就能看到这个"擦除-重绘"的过程,表现为恼人的闪烁。
更糟糕的是,如果你的绘制内容很复杂,或者窗口大小变化频繁,这个闪烁会变得更加明显。这就是为什么直接在屏幕 DC 上绘制复杂图形时,闪烁问题会更加严重。
消除闪烁的基本方法¶
在深入双缓冲之前,我们先来看看一些简单但有效的方法来减轻闪烁问题。这些方法不需要实现完整的双缓冲,但在很多场景下已经足够了。
处理 WM_ERASEBKGND 返回 TRUE¶
最简单的方法就是阻止默认的背景擦除行为。你可以在窗口过程中这样处理:
这样做的好处是你的 WM_PAINT 处理函数会完全控制绘制过程,不会有先擦除背景的步骤。但前提是你必须在绘制之前自己填充背景,否则你可能会看到之前绘制的内容残留。
⚠️ 注意,如果你返回 TRUE 表示已经处理了背景擦除,但实际上没有擦除背景,你可能会看到视觉伪影。所以要确保在绘制之前正确填充背景。
使用 NULL 类背景刷¶
另一个方法是在注册窗口类时不设置背景刷子:
WNDCLASS wc = {0};
wc.lpfnWndProc = WindowProc;
wc.hbrBackground = NULL; // 不设置背景刷子
// ... 其他成员
RegisterClass(&wc);
当 hbrBackground 为 NULL 时,BeginPaint 不会自动擦除背景,WM_ERASEBKGND 也不会被发送。这把所有绘制控制权都交给了你的 WM_PAINT 处理函数。
InvalidateRect 的 bErase 参数¶
InvalidateRect 函数的第三个参数 bErase 控制是否在重绘时擦除背景:
当你传递 FALSE 时,系统不会在发送 WM_PAINT 之前擦除背景。这在需要频繁更新窗口内容时很有用,因为你可以在上一帧的内容基础上绘制新的内容。
InvalidateRect 和 InvalidateRgn 的主要区别在于前者处理矩形区域,后者可以处理任意形状的区域(通过 HRGN 句柄)。对于大多数情况,InvalidateRect 就足够了,而且使用起来更简单。
双缓冲技术原理¶
现在我们进入正题。双缓冲技术是解决闪烁问题的终极方案,它的核心思想是:所有的绘制操作先在内存中完成,然后再一次性将结果复制到屏幕上。
你可以把它理解为画画家的工作方式:画家不会直接在画布上作画,而是在草稿纸上先完成所有细节,确认无误后再一次性复制到正式画布上。在图形编程中,这个"草稿纸"就是一个内存 DC(Device Context)。
内存 DC 作为后端缓冲¶
内存 DC 是一个与屏幕 DC 兼容的内存设备上下文。它不像屏幕 DC 那样直接连接到显示器,而是关联到一个位图对象。你可以在内存 DC 上进行任何绘制操作,这些操作不会立即显示在屏幕上。
一次性绘制完成后拷贝¶
当你在内存 DC 上完成所有绘制后,可以使用 BitBlt 函数将整个位图内容一次性复制到屏幕 DC 上。因为 BitBlt 是一个高度优化的操作,通常在硬件层面完成,所以这个拷贝过程非常快,人眼无法察觉中间状态。
为什么这样能消除闪烁¶
双缓冲消除闪烁的关键在于:用户永远看不到绘制过程,只能看到最终结果。无论你在内存 DC 上绘制了多长时间,绘制过程有多么复杂,屏幕上只会发生一次更新,那就是 BitBlt 操作。
这就好比看电影:电影实际上是由一帧帧静止画面组成的,但因为播放速度足够快,我们感知到的是流畅的动画。同样,双缓冲技术通过确保只显示最终画面,避免了中间绘制状态带来的视觉干扰。
完整双缓冲实现¶
理论讲完了,现在我们来看看如何在代码中实现双缓冲。我们从一个基本的 WM_PAINT 处理函数开始,逐步改进。
创建兼容 DC 和位图¶
首先,我们需要创建一个与屏幕 DC 兼容的内存 DC:
case WM_PAINT:
{
PAINTSTRUCT ps;
HDC hdc = BeginPaint(hwnd, &ps);
// 获取客户区域尺寸
RECT rcClient;
GetClientRect(hwnd, &rcClient);
int cxClient = rcClient.right - rcClient.left;
int cyClient = rcClient.bottom - rcClient.top;
// 创建兼容 DC
HDC hdcMem = CreateCompatibleDC(hdc);
if (hdcMem == NULL) {
EndPaint(hwnd, &ps);
return 0;
}
// 创建兼容位图
HBITMAP hbmMem = CreateCompatibleBitmap(hdc, cxClient, cyClient);
if (hbmMem == NULL) {
DeleteDC(hdcMem);
EndPaint(hwnd, &ps);
return 0;
}
// ... 后续代码
}
这里我们使用 CreateCompatibleDC 创建一个与屏幕 DC 兼容的内存 DC。然后使用 CreateCompatibleBitmap 创建一个与屏幕 DC 兼容的位图。这个位图的尺寸与客户区域相同,确保能容纳整个窗口的内容。
在内存 DC 上绘制所有内容¶
接下来,我们需要将位图选入内存 DC,然后在其上进行绘制:
// 将位图选入内存 DC
HBITMAP hbmOld = (HBITMAP)SelectObject(hdcMem, hbmMem);
// 填充背景(因为我们禁用了默认背景擦除)
HBRUSH hbrBackground = CreateSolidBrush(RGB(255, 255, 255));
FillRect(hdcMem, &rcClient, hbrBackground);
DeleteObject(hbrBackground);
// 在内存 DC 上绘制实际内容
// 例如绘制一些图形
HPEN hPen = CreatePen(PS_SOLID, 2, RGB(255, 0, 0));
HPEN hPenOld = (HPEN)SelectObject(hdcMem, hPen);
Ellipse(hdcMem, 50, 50, 200, 200);
Rectangle(hdcMem, 150, 150, 300, 300);
SelectObject(hdcMem, hPenOld);
DeleteObject(hPen);
注意这里我们显式填充了背景。因为我们通常会处理 WM_ERASEBKGND 来阻止默认背景擦除,所以需要自己填充背景。
BitBlt 一次性拷贝到屏幕¶
现在内存 DC 上已经有了完整的绘制内容,我们将其拷贝到屏幕:
// 将内存 DC 的内容拷贝到屏幕
BitBlt(hdc,
rcClient.left, rcClient.top,
cxClient, cyClient,
hdcMem,
0, 0,
SRCCOPY);
BitBlt 的参数依次是:目标 DC、目标位置、宽度和高度、源 DC、源位置、光栅操作码。SRCCOPY 表示直接复制像素,这是最常用的操作。
资源清理的正确顺序¶
最后,我们需要正确清理所有 GDI 对象。这里有一个重要的顺序问题:
// 恢复原始位图
SelectObject(hdcMem, hbmOld);
// 删除我们创建的位图
DeleteObject(hbmMem);
// 删除内存 DC
DeleteDC(hdcMem);
// 结束绘制
EndPaint(hwnd, &ps);
return 0;
}
⚠️ 注意,在删除位图之前,必须先将其从 DC 中选出来。这是因为当一个位图被选入 DC 时,你不能删除它。这里我们通过选入原始位图(hbmOld)来实现这一点。资源管理的顺序很重要,如果搞反了可能会导致内存泄漏或者程序崩溃。
复杂场景下的双缓冲¶
基本的双缓冲实现已经能解决大部分闪烁问题,但在实际应用中,我们还需要考虑一些复杂场景。
处理窗口大小变化¶
当窗口大小改变时,我们需要重新创建缓冲位图以适应新的尺寸。一个常见的做法是将缓冲位图作为窗口类的一部分存储,在 WM_SIZE 消息中更新:
// 全局或窗口类成员变量
HBITMAP g_hbmBuffer = NULL;
int g_cxBuffer = 0;
int g_cyBuffer = 0;
case WM_SIZE:
{
int cxClient = LOWORD(lParam);
int cyClient = HIWORD(lParam);
// 如果尺寸变化,重新创建缓冲位图
if (cxClient > g_cxBuffer || cyClient > g_cyBuffer) {
if (g_hbmBuffer != NULL) {
DeleteObject(g_hbmBuffer);
}
HDC hdc = GetDC(hwnd);
g_hbmBuffer = CreateCompatibleBitmap(hdc, cxClient, cyClient);
ReleaseDC(hwnd, hdc);
g_cxBuffer = cxClient;
g_cyBuffer = cyClient;
}
return 0;
}
这样做的好处是避免在每次 WM_PAINT 时都重新创建位图,提高了性能。只在尺寸确实变化时才重新创建。
缓冲位图的重新创建¶
有时候你需要在某些条件下强制重新创建缓冲位图,比如当绘制内容发生重大变化时。你可以通过将缓冲位图句柄设置为 NULL 来触发重新创建:
void InvalidateBuffer(HWND hwnd) {
if (g_hbmBuffer != NULL) {
DeleteObject(g_hbmBuffer);
g_hbmBuffer = NULL;
}
InvalidateRect(hwnd, NULL, TRUE);
}
部分重绘优化(PAINTSTRUCT.rcPaint)¶
Windows 只会重绘被标记为"无效"的区域。这个区域信息存储在 PAINTSTRUCT 结构的 rcPaint 成员中。我们可以利用这个信息来优化双缓冲:
case WM_PAINT:
{
PAINTSTRUCT ps;
HDC hdc = BeginPaint(hwnd, &ps);
// 只重绘需要更新的区域
if (!IsRectEmpty(&ps.rcPaint)) {
int cxPaint = ps.rcPaint.right - ps.rcPaint.left;
int cyPaint = ps.rcPaint.bottom - ps.rcPaint.top;
HDC hdcMem = CreateCompatibleDC(hdc);
HBITMAP hbmMem = CreateCompatibleBitmap(hdc, cxPaint, cyPaint);
HBITMAP hbmOld = (HBITMAP)SelectObject(hdcMem, hbmMem);
// 在内存 DC 上绘制
// ... 绘制代码
// 只拷贝需要更新的区域
BitBlt(hdc,
ps.rcPaint.left, ps.rcPaint.top,
cxPaint, cyPaint,
hdcMem,
0, 0,
SRCCOPY);
SelectObject(hdcMem, hbmOld);
DeleteObject(hbmMem);
DeleteDC(hdcMem);
}
EndPaint(hwnd, &ps);
return 0;
}
这种优化对于大型窗口特别有用,因为它减少了需要复制的数据量。不过要注意,如果你的绘制逻辑依赖于整个窗口的状态(比如有一些全局布局计算),部分重绘可能会增加复杂度。
性能考量¶
双缓冲虽然能有效消除闪烁,但也带来了额外的内存和 CPU 开销。我们需要权衡这些因素。
何时需要双缓冲¶
不是所有情况都需要双缓冲。以下场景建议使用:
- 窗口内容频繁更新(如动画)
- 绘制操作复杂,耗时较长
- 用户明显感知到闪烁
- 需要平滑的视觉体验
对于简单的静态内容或者偶尔重绘的窗口,可能不需要完整的双缓冲实现。
位图尺寸与内存占用¶
缓冲位图的内存占用与分辨率成正比。一个 1920x1080 的 32 位位图大约需要 8MB 内存。对于大多数现代计算机来说,这个开销可以接受,但在极端情况下(如超高分辨率或多窗口)可能需要注意。
DWM 时代的现代方案¶
从 Windows Vista 开始,微软引入了桌面窗口管理器(DWM),它使用合成技术来渲染窗口。DWM 会为每个窗口创建一个离屏表面,然后由 DWM 负责最终的屏幕合成。
在 DWM 时代,传统的双缓冲技术的重要性有所下降,因为 DWM 本身就提供了一定程度的双缓冲效果。但这并不意味着双缓冲没有用 —— 对于频繁更新的内容,双缓冲仍然能显著改善用户体验。
现代 Windows 开发中,微软推荐使用硬件加速的 API 如 Direct2D 来替代 GDI。根据 Microsoft Learn 的文档,Direct2D 提供了更好的性能和更现代化的特性。
实战示例:平滑动画演示¶
让我们通过一个实际的动画例子来验证双缓冲的效果。这个例子会绘制一个在窗口中移动的圆形,使用双缓冲来确保动画流畅。
#include <windows.h>
#include <cmath>
// 全局变量
HWND g_hwnd = NULL;
int g_xPos = 0;
int g_yPos = 0;
int g_xDirection = 1;
int g_yDirection = 1;
const int g_radius = 30;
LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam);
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, PWSTR pCmdLine, int nCmdShow)
{
const wchar_t CLASS_NAME[] = L"AnimationWindow";
WNDCLASS wc = { };
wc.lpfnWndProc = WindowProc;
wc.hInstance = hInstance;
wc.lpszClassName = CLASS_NAME;
wc.hbrBackground = NULL; // 禁用默认背景刷
wc.hCursor = LoadCursor(NULL, IDC_ARROW);
RegisterClass(&wc);
g_hwnd = CreateWindowEx(
0, CLASS_NAME, L"Double Buffer Animation",
WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT,
800, 600, NULL, NULL, hInstance, NULL
);
if (g_hwnd == NULL) return 0;
ShowWindow(g_hwnd, nCmdShow);
// 启动动画定时器
SetTimer(g_hwnd, 1, 16, NULL); // 约 60 FPS
MSG msg = { };
while (GetMessage(&msg, NULL, 0, 0)) {
TranslateMessage(&msg);
DispatchMessage(&msg);
}
return 0;
}
LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
switch (uMsg) {
case WM_ERASEBKGND:
return 1; // 阻止默认背景擦除
case WM_TIMER:
{
// 更新位置
RECT rcClient;
GetClientRect(hwnd, &rcClient);
int cxClient = rcClient.right - rcClient.left;
int cyClient = rcClient.bottom - rcClient.top;
g_xPos += g_xDirection * 5;
g_yPos += g_yDirection * 5;
// 边界检测
if (g_xPos + g_radius > cxClient || g_xPos - g_radius < 0) {
g_xDirection *= -1;
}
if (g_yPos + g_radius > cyClient || g_yPos - g_radius < 0) {
g_yDirection *= -1;
}
// 触发重绘
InvalidateRect(hwnd, NULL, FALSE);
return 0;
}
case WM_PAINT:
{
PAINTSTRUCT ps;
HDC hdc = BeginPaint(hwnd, &ps);
RECT rcClient;
GetClientRect(hwnd, &rcClient);
int cxClient = rcClient.right - rcClient.left;
int cyClient = rcClient.bottom - rcClient.top;
// 创建双缓冲
HDC hdcMem = CreateCompatibleDC(hdc);
HBITMAP hbmMem = CreateCompatibleBitmap(hdc, cxClient, cyClient);
HBITMAP hbmOld = (HBITMAP)SelectObject(hdcMem, hbmMem);
// 填充背景
HBRUSH hbrBg = CreateSolidBrush(RGB(240, 240, 240));
FillRect(hdcMem, &rcClient, hbrBg);
DeleteObject(hbrBg);
// 绘制移动的圆形
HBRUSH hbrCircle = CreateSolidBrush(RGB(255, 100, 100));
HPEN hPen = CreatePen(PS_SOLID, 2, RGB(200, 50, 50));
HGDIOBJ hbrOld = SelectObject(hdcMem, hbrCircle);
HGDIOBJ hPenOld = SelectObject(hdcMem, hPen);
Ellipse(hdcMem,
g_xPos - g_radius, g_yPos - g_radius,
g_xPos + g_radius, g_yPos + g_radius);
// 恢复和清理
SelectObject(hdcMem, hbrOld);
SelectObject(hdcMem, hPenOld);
DeleteObject(hbrCircle);
DeleteObject(hPen);
// 一次性拷贝到屏幕
BitBlt(hdc, 0, 0, cxClient, cyClient, hdcMem, 0, 0, SRCCOPY);
SelectObject(hdcMem, hbmOld);
DeleteObject(hbmMem);
DeleteDC(hdcMem);
EndPaint(hwnd, &ps);
return 0;
}
case WM_SIZE:
// 初始化位置到中心
if (g_xPos == 0 && g_yPos == 0) {
g_xPos = LOWORD(lParam) / 2;
g_yPos = HIWORD(lParam) / 2;
}
return 0;
case WM_DESTROY:
PostQuitMessage(0);
return 0;
}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
这个例子展示了完整的双缓冲动画实现。你可以编译运行它,观察动画是否流畅。如果你移除双缓冲代码,直接在屏幕 DC 上绘制,你会明显看到闪烁现象。
常见问题与调试技巧¶
在实现双缓冲时,你可能会遇到一些常见问题。这里我来总结几个坑点和相应的解决方案。
问题1:双缓冲后仍然闪烁¶
如果你实现了双缓冲但仍然看到闪烁,可能的原因包括:
-
没有正确处理 WM_ERASEBKGND: 即使使用双缓冲,如果系统还在擦除背景,你仍然会看到闪烁。确保你的
WM_ERASEBKGND处理返回 TRUE。 -
缓冲位图尺寸不匹配: 如果缓冲位图比实际客户区域小,
BitBlt可能无法完全覆盖窗口。确保使用GetClientRect获取准确的尺寸。 -
部分重绘时的问题: 如果你只重绘部分区域,确保缓冲位图包含完整的内容,否则可能会看到残影。
问题2:内存泄漏¶
GDI 对象的内存泄漏是一个常见问题。你可以通过任务管理器查看 GDI 对象数量来检测泄漏。正常情况下,GDI 对象数量应该保持稳定,如果持续增长说明有泄漏。
确保每个 CreateCompatibleDC 都有对应的 DeleteDC,每个 CreateCompatibleBitmap 都有对应的 DeleteObject。
问题3:性能不如预期¶
如果双缓冲后性能反而下降,可能的原因包括:
-
频繁创建/销毁缓冲位图: 如前面所述,应该在
WM_SIZE时创建缓冲位图并缓存,而不是每次WM_PAINT都重新创建。 -
不必要的背景填充: 如果你的绘制内容会完全覆盖背景,可以跳过背景填充步骤。
-
过大或过小的缓冲位图: 缓冲位图应该与客户区域大小匹配。
调试技巧¶
为了验证双缓冲是否正常工作,你可以在内存 DC 绘制时使用不同的背景色,然后观察屏幕上是否能看到这个颜色。如果在 BitBlt 之前就能看到颜色变化,说明你的双缓冲没有正常工作。
另一个技巧是使用 GetTickCount 或高精度计时器来测量绘制时间,找出性能瓶颈。
总结¶
到这里,我们已经完整地介绍了 Win32 GDI 双缓冲技术。从闪烁问题的根源,到双缓冲的原理,再到完整的实现和优化,我们覆盖了所有关键知识点。
双缓冲技术虽然是一个"老"技术,但在理解图形绘制原理方面仍然很有价值。即使现代开发可能使用更高级的 API,但这些底层概念是通用的。
希望这篇文章能帮助你彻底解决 Win32 GDI 中的闪烁问题。如果你在实际应用中遇到其他问题,欢迎继续探索和实验 —— 毕竟,最好的学习方式就是动手实践。
参考资料: - WM_ERASEBKGND message - Microsoft Learn - BeginPaint function - Microsoft Learn - Comparing Direct2D and GDI - Microsoft Learn - Double buffering in Direct2D - Stack Overflow