通用GUI编程技术——Win32 原生编程实战(二十三)——GDI 双缓冲技术:消除闪烁完全指南
通用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_ERASEBKGNDBeginPaint也会帮你完成背景擦除。具体来说如果你在注册窗口类时设置了hbrBackground成员比如设置为(HBRUSH)(COLOR_WINDOW1)那么BeginPaint会用这个刷子填充背景。两次绘制造成闪烁现在你应该能看出问题所在了。每次重绘时实际上发生了两次绘制操作第一次: 系统用背景刷子擦除背景通常是白色或系统颜色第二次: 你的WM_PAINT处理函数绘制实际内容如果这两次操作之间有一定的时间间隔或者绘制过程需要较长时间人眼就能看到这个擦除-重绘的过程表现为恼人的闪烁。更糟糕的是如果你的绘制内容很复杂或者窗口大小变化频繁这个闪烁会变得更加明显。这就是为什么直接在屏幕 DC 上绘制复杂图形时闪烁问题会更加严重。消除闪烁的基本方法在深入双缓冲之前我们先来看看一些简单但有效的方法来减轻闪烁问题。这些方法不需要实现完整的双缓冲但在很多场景下已经足够了。处理 WM_ERASEBKGND 返回 TRUE最简单的方法就是阻止默认的背景擦除行为。你可以在窗口过程中这样处理caseWM_ERASEBKGND:return1;// 告诉系统我们已经处理了背景擦除这样做的好处是你的WM_PAINT处理函数会完全控制绘制过程不会有先擦除背景的步骤。但前提是你必须在绘制之前自己填充背景否则你可能会看到之前绘制的内容残留。⚠️ 注意如果你返回 TRUE 表示已经处理了背景擦除但实际上没有擦除背景你可能会看到视觉伪影。所以要确保在绘制之前正确填充背景。使用 NULL 类背景刷另一个方法是在注册窗口类时不设置背景刷子WNDCLASS wc{0};wc.lpfnWndProcWindowProc;wc.hbrBackgroundNULL;// 不设置背景刷子// ... 其他成员RegisterClass(wc);当hbrBackground为 NULL 时BeginPaint不会自动擦除背景WM_ERASEBKGND也不会被发送。这把所有绘制控制权都交给了你的WM_PAINT处理函数。InvalidateRect 的 bErase 参数InvalidateRect函数的第三个参数bErase控制是否在重绘时擦除背景InvalidateRect(hwnd,NULL,FALSE);// bErase FALSE当你传递 FALSE 时系统不会在发送WM_PAINT之前擦除背景。这在需要频繁更新窗口内容时很有用因为你可以在上一帧的内容基础上绘制新的内容。InvalidateRect和InvalidateRgn的主要区别在于前者处理矩形区域后者可以处理任意形状的区域通过 HRGN 句柄。对于大多数情况InvalidateRect就足够了而且使用起来更简单。双缓冲技术原理现在我们进入正题。双缓冲技术是解决闪烁问题的终极方案它的核心思想是所有的绘制操作先在内存中完成然后再一次性将结果复制到屏幕上。你可以把它理解为画画家的工作方式画家不会直接在画布上作画而是在草稿纸上先完成所有细节确认无误后再一次性复制到正式画布上。在图形编程中这个草稿纸就是一个内存 DCDevice Context。内存 DC 作为后端缓冲内存 DC 是一个与屏幕 DC 兼容的内存设备上下文。它不像屏幕 DC 那样直接连接到显示器而是关联到一个位图对象。你可以在内存 DC 上进行任何绘制操作这些操作不会立即显示在屏幕上。一次性绘制完成后拷贝当你在内存 DC 上完成所有绘制后可以使用BitBlt函数将整个位图内容一次性复制到屏幕 DC 上。因为BitBlt是一个高度优化的操作通常在硬件层面完成所以这个拷贝过程非常快人眼无法察觉中间状态。为什么这样能消除闪烁双缓冲消除闪烁的关键在于用户永远看不到绘制过程只能看到最终结果。无论你在内存 DC 上绘制了多长时间绘制过程有多么复杂屏幕上只会发生一次更新那就是BitBlt操作。这就好比看电影电影实际上是由一帧帧静止画面组成的但因为播放速度足够快我们感知到的是流畅的动画。同样双缓冲技术通过确保只显示最终画面避免了中间绘制状态带来的视觉干扰。完整双缓冲实现理论讲完了现在我们来看看如何在代码中实现双缓冲。我们从一个基本的WM_PAINT处理函数开始逐步改进。创建兼容 DC 和位图首先我们需要创建一个与屏幕 DC 兼容的内存 DCcaseWM_PAINT:{PAINTSTRUCT ps;HDC hdcBeginPaint(hwnd,ps);// 获取客户区域尺寸RECT rcClient;GetClientRect(hwnd,rcClient);intcxClientrcClient.right-rcClient.left;intcyClientrcClient.bottom-rcClient.top;// 创建兼容 DCHDC hdcMemCreateCompatibleDC(hdc);if(hdcMemNULL){EndPaint(hwnd,ps);return0;}// 创建兼容位图HBITMAP hbmMemCreateCompatibleBitmap(hdc,cxClient,cyClient);if(hbmMemNULL){DeleteDC(hdcMem);EndPaint(hwnd,ps);return0;}// ... 后续代码}这里我们使用CreateCompatibleDC创建一个与屏幕 DC 兼容的内存 DC。然后使用CreateCompatibleBitmap创建一个与屏幕 DC 兼容的位图。这个位图的尺寸与客户区域相同确保能容纳整个窗口的内容。在内存 DC 上绘制所有内容接下来我们需要将位图选入内存 DC然后在其上进行绘制// 将位图选入内存 DCHBITMAP hbmOld(HBITMAP)SelectObject(hdcMem,hbmMem);// 填充背景因为我们禁用了默认背景擦除HBRUSH hbrBackgroundCreateSolidBrush(RGB(255,255,255));FillRect(hdcMem,rcClient,hbrBackground);DeleteObject(hbrBackground);// 在内存 DC 上绘制实际内容// 例如绘制一些图形HPEN hPenCreatePen(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);// 删除内存 DCDeleteDC(hdcMem);// 结束绘制EndPaint(hwnd,ps);return0;}⚠️ 注意在删除位图之前必须先将其从 DC 中选出来。这是因为当一个位图被选入 DC 时你不能删除它。这里我们通过选入原始位图hbmOld来实现这一点。资源管理的顺序很重要如果搞反了可能会导致内存泄漏或者程序崩溃。复杂场景下的双缓冲基本的双缓冲实现已经能解决大部分闪烁问题但在实际应用中我们还需要考虑一些复杂场景。处理窗口大小变化当窗口大小改变时我们需要重新创建缓冲位图以适应新的尺寸。一个常见的做法是将缓冲位图作为窗口类的一部分存储在WM_SIZE消息中更新// 全局或窗口类成员变量HBITMAP g_hbmBufferNULL;intg_cxBuffer0;intg_cyBuffer0;caseWM_SIZE:{intcxClientLOWORD(lParam);intcyClientHIWORD(lParam);// 如果尺寸变化重新创建缓冲位图if(cxClientg_cxBuffer||cyClientg_cyBuffer){if(g_hbmBuffer!NULL){DeleteObject(g_hbmBuffer);}HDC hdcGetDC(hwnd);g_hbmBufferCreateCompatibleBitmap(hdc,cxClient,cyClient);ReleaseDC(hwnd,hdc);g_cxBuffercxClient;g_cyBuffercyClient;}return0;}这样做的好处是避免在每次WM_PAINT时都重新创建位图提高了性能。只在尺寸确实变化时才重新创建。缓冲位图的重新创建有时候你需要在某些条件下强制重新创建缓冲位图比如当绘制内容发生重大变化时。你可以通过将缓冲位图句柄设置为 NULL 来触发重新创建voidInvalidateBuffer(HWND hwnd){if(g_hbmBuffer!NULL){DeleteObject(g_hbmBuffer);g_hbmBufferNULL;}InvalidateRect(hwnd,NULL,TRUE);}部分重绘优化PAINTSTRUCT.rcPaintWindows 只会重绘被标记为无效的区域。这个区域信息存储在PAINTSTRUCT结构的rcPaint成员中。我们可以利用这个信息来优化双缓冲caseWM_PAINT:{PAINTSTRUCT ps;HDC hdcBeginPaint(hwnd,ps);// 只重绘需要更新的区域if(!IsRectEmpty(ps.rcPaint)){intcxPaintps.rcPaint.right-ps.rcPaint.left;intcyPaintps.rcPaint.bottom-ps.rcPaint.top;HDC hdcMemCreateCompatibleDC(hdc);HBITMAP hbmMemCreateCompatibleBitmap(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);return0;}这种优化对于大型窗口特别有用因为它减少了需要复制的数据量。不过要注意如果你的绘制逻辑依赖于整个窗口的状态比如有一些全局布局计算部分重绘可能会增加复杂度。性能考量双缓冲虽然能有效消除闪烁但也带来了额外的内存和 CPU 开销。我们需要权衡这些因素。何时需要双缓冲不是所有情况都需要双缓冲。以下场景建议使用窗口内容频繁更新如动画绘制操作复杂耗时较长用户明显感知到闪烁需要平滑的视觉体验对于简单的静态内容或者偶尔重绘的窗口可能不需要完整的双缓冲实现。位图尺寸与内存占用缓冲位图的内存占用与分辨率成正比。一个 1920x1080 的 32 位位图大约需要 8MB 内存。对于大多数现代计算机来说这个开销可以接受但在极端情况下如超高分辨率或多窗口可能需要注意。DWM 时代的现代方案从 Windows Vista 开始微软引入了桌面窗口管理器DWM它使用合成技术来渲染窗口。DWM 会为每个窗口创建一个离屏表面然后由 DWM 负责最终的屏幕合成。在 DWM 时代传统的双缓冲技术的重要性有所下降因为 DWM 本身就提供了一定程度的双缓冲效果。但这并不意味着双缓冲没有用 —— 对于频繁更新的内容双缓冲仍然能显著改善用户体验。现代 Windows 开发中微软推荐使用硬件加速的 API 如 Direct2D 来替代 GDI。根据 Microsoft Learn 的文档Direct2D 提供了更好的性能和更现代化的特性。实战示例平滑动画演示让我们通过一个实际的动画例子来验证双缓冲的效果。这个例子会绘制一个在窗口中移动的圆形使用双缓冲来确保动画流畅。#includewindows.h#includecmath// 全局变量HWND g_hwndNULL;intg_xPos0;intg_yPos0;intg_xDirection1;intg_yDirection1;constintg_radius30;LRESULT CALLBACKWindowProc(HWND hwnd,UINT uMsg,WPARAM wParam,LPARAM lParam);intWINAPIwWinMain(HINSTANCE hInstance,HINSTANCE hPrevInstance,PWSTR pCmdLine,intnCmdShow){constwchar_tCLASS_NAME[]LAnimationWindow;WNDCLASS wc{};wc.lpfnWndProcWindowProc;wc.hInstancehInstance;wc.lpszClassNameCLASS_NAME;wc.hbrBackgroundNULL;// 禁用默认背景刷wc.hCursorLoadCursor(NULL,IDC_ARROW);RegisterClass(wc);g_hwndCreateWindowEx(0,CLASS_NAME,LDouble Buffer Animation,WS_OVERLAPPEDWINDOW,CW_USEDEFAULT,CW_USEDEFAULT,800,600,NULL,NULL,hInstance,NULL);if(g_hwndNULL)return0;ShowWindow(g_hwnd,nCmdShow);// 启动动画定时器SetTimer(g_hwnd,1,16,NULL);// 约 60 FPSMSG msg{};while(GetMessage(msg,NULL,0,0)){TranslateMessage(msg);DispatchMessage(msg);}return0;}LRESULT CALLBACKWindowProc(HWND hwnd,UINT uMsg,WPARAM wParam,LPARAM lParam){switch(uMsg){caseWM_ERASEBKGND:return1;// 阻止默认背景擦除caseWM_TIMER:{// 更新位置RECT rcClient;GetClientRect(hwnd,rcClient);intcxClientrcClient.right-rcClient.left;intcyClientrcClient.bottom-rcClient.top;g_xPosg_xDirection*5;g_yPosg_yDirection*5;// 边界检测if(g_xPosg_radiuscxClient||g_xPos-g_radius0){g_xDirection*-1;}if(g_yPosg_radiuscyClient||g_yPos-g_radius0){g_yDirection*-1;}// 触发重绘InvalidateRect(hwnd,NULL,FALSE);return0;}caseWM_PAINT:{PAINTSTRUCT ps;HDC hdcBeginPaint(hwnd,ps);RECT rcClient;GetClientRect(hwnd,rcClient);intcxClientrcClient.right-rcClient.left;intcyClientrcClient.bottom-rcClient.top;// 创建双缓冲HDC hdcMemCreateCompatibleDC(hdc);HBITMAP hbmMemCreateCompatibleBitmap(hdc,cxClient,cyClient);HBITMAP hbmOld(HBITMAP)SelectObject(hdcMem,hbmMem);// 填充背景HBRUSH hbrBgCreateSolidBrush(RGB(240,240,240));FillRect(hdcMem,rcClient,hbrBg);DeleteObject(hbrBg);// 绘制移动的圆形HBRUSH hbrCircleCreateSolidBrush(RGB(255,100,100));HPEN hPenCreatePen(PS_SOLID,2,RGB(200,50,50));HGDIOBJ hbrOldSelectObject(hdcMem,hbrCircle);HGDIOBJ hPenOldSelectObject(hdcMem,hPen);Ellipse(hdcMem,g_xPos-g_radius,g_yPos-g_radius,g_xPosg_radius,g_yPosg_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);return0;}caseWM_SIZE:// 初始化位置到中心if(g_xPos0g_yPos0){g_xPosLOWORD(lParam)/2;g_yPosHIWORD(lParam)/2;}return0;caseWM_DESTROY:PostQuitMessage(0);return0;}returnDefWindowProc(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 LearnBeginPaint function - Microsoft LearnComparing Direct2D and GDI - Microsoft LearnDouble buffering in Direct2D - Stack Overflow
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2486242.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!