news 2026/10/23 1:30:45

Windows UI自绘编程:GDI/GDI+双缓冲打造圆角渐变按钮

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Windows UI自绘编程:GDI/GDI+双缓冲打造圆角渐变按钮

做桌面端项目的时候,谁没遇到过标准控件撑不起UI设计的尴尬场面。我最近跟着某小组一起排一个Windows界面UI自绘编程的模块,起因就是一枚渐变圆角按钮:需求写得很清楚,圆角、渐变色、鼠标悬停要变深、按下要有一点凹陷感。翻遍系统自带控件,没有一个能直接满足,最后只能老实走自绘这条路。

所谓UI自绘,通俗讲就是抛开标准控件的默认绘制逻辑,自己接管窗口或控件的全部外观输出,从背景到一个像素的圆角到文字阴影都自己画。这件事本身不复杂,难在背后牵扯的消息机制、绘图接口、刷新策略和资源管理。这篇是系列的上部,我会先把绘制原理、GDI/GDI+选型、双缓冲和基础控件绘制讲透,下部再展开无边框窗口、自定义标题栏和动画。如果你写过基本的Win32窗口程序,或者对自绘有印象但没系统梳理过Windows侧的做法,这篇应该对你有用。

1. 自绘之前,先搞懂窗口是怎么被画出来的

1.1 一条WM_PAINT消息的完整旅程

先澄清一个常见的误解:Windows并不会随时随地调用你的绘制函数。绝大多数时候,窗口上显示的内容都是收到WM_PAINT消息后才画出来的。这条消息什么时候来?简单归纳就四类:窗口被遮挡后重新露出、窗口大小发生变化、其他代码调用了InvalidateRect/RedrawWindow这类失效函数、窗口初始化后第一次显示。记住这四类,后面排错的时候会省很多时间。

WM_PAINT还有一个容易被忽略的特性:它是低优先级消息。只要窗口更新区域已经处于失效状态,WM_PAINT会等消息队列里优先级更高的消息处理完之后才送达。意思是你在某个消息里连续改了十次状态,最后也顶多触发一次WM_PAINT,系统还会把多次失效的区域自动合并成一个最小矩形。这个机制天然帮你减少了绘制次数,所以千万别在消息处理函数里直接调用绘制逻辑,那只会增加无谓的重复劳动。正常做法是:改完状态之后调用一次InvalidateRect,把绘制收敛到WM_PAINT里去。

还有一点新手特别容易栽:BeginPaint和GetDC都能拿到HDC,但两者语义完全不一样。BeginPaint返回的HDC会顺带把更新区域清空,标记为“有效”,同时把无效区域信息填进PAINTSTRUCT结构体的rcPaint字段;GetDC则只是拿一个客户区DC,根本不管更新区域的状态。如果你在WM_PAINT里用了GetDC而不是BeginPaint,那么下次系统依然认为窗口还是无效的,于是又会发一条WM_PAINT,结果就是绘制代码反复执行,CPU飙高,界面看着像卡死。

1.2 背景擦除在闪烁中扮演的角色

再往前半步,WM_PAINT之前系统通常还会发一条WM_ERASEBKGND。这条消息的目的就是让窗口把旧内容擦掉,窗口类注册时指定的背景画刷一般就在这里起作用。但擦除和绘制是两次独立操作,速度再快中间也会出现一帧“空白态”。为什么窗口拖动的时候会闪?就是因为这个:消息一来,系统先把背景擦成纯色,再去执行你画前景的代码,人眼在两帧之间就捕捉到了明暗交替。

处理方式非常直接:在窗体过程的WM_ERASEBKGND分支里直接返回TRUE,告诉系统“背景我自己管,你什么都别擦”。但这样有个前提,你自己在WM_PAINT里必须把整个客户区都覆盖到,否则之前的内容会残留,出现拖影。更精细的做法是只在背景确实需要被覆盖时才返回TRUE,不过一般自绘控件场景下,直接抑制默认擦除配合双缓冲就是最稳妥的方案。

1.3 客户区与非客户区的管理边界

窗口界面从绘制角度分成两大块:边框、标题栏、最小化最大化按钮属于“非客户区”,由系统来画;中间显示业务内容的部分是“客户区”,由应用程序自己负责。每块区域都有对应的绘制消息,客户区走WM_PAINT,非客户区走WM_NCPAINT。自绘如果只做业务控件,聚焦客户区就够了,但一旦想实现自定义标题栏、无边框窗口这些炫酷效果,就必须介入非客户区绘制。

非客户区的绘制和客户区完全是另一套玩法。系统默认用DefWindowProc去画标题栏和边框,你可以在WM_NCPAINT里自己接管,也可以配合DwmExtendFrameIntoClientArea这类API做窗口边框延伸。这部分涉及更底层的坐标计算和系统交互,属于进阶内容,系列的下篇我会单独展开。这里你先建立一个概念:你在客户区画的东西永远只能算窗口“内容”,进不了标题栏和边框的范围。

2. GDI还是GDI+:选型决定代码长什么样

2.1 GDI的核心对象:DC、画笔、画刷、字体、位图

GDI是Windows最古老的绘图接口,核心概念是设备上下文DC。你可以把DC想象成一块画布,画布上还挂着一堆当前选中的绘图工具。画笔决定线条的颜色和线宽,画刷决定填充区域的颜色和纹理,字体决定文字怎么渲染,位图决定图像数据怎么存放。自绘一个控件,大部分时间就是在跟这几个对象打交道。

使用GDI有条死规矩:创建出来的GDI对象用完必须删除,删除之前必须先把它从DC里换出来,换回之前选入的旧对象。顺序不能乱。我见过很多程序跑着跑着界面突然全面卡白,最后查下来就是GDI对象泄漏到了上限。Windows对单个进程的默认GDI对象数是有上限的,超了之后所有绘制直接罢工,而且没有任何错误提示,只能靠任务管理器里的“GDI对象”列肉眼观察。

2.2 GDI+带来哪些增量价值

GDI+可以看成GDI之上的一个更现代的封装层。它带来的最直观好处有三个:抗锯齿、渐变画刷、透明度混合。GDI里画斜线或圆角边缘会有明显锯齿,GDI+开启SmoothingModeAntiAlias之后边缘平滑很多;GDI没有线性渐变画刷,要实现渐变得自己一行一行算颜色插值,用GDI+一个LinearGradientBrush就搞定了;透明混合在GDI里几乎是灾难级麻烦,GDI+用Alpha值就能控制。

另外GDI+的对象模型比GDI更省心。画刷、画笔、路径、位图基本都是C++类,出了作用域自动析构,不用一个个DeleteObject。对于快速开发自绘控件来说,这种资源管理方式极大减少了出错概率。代价是性能——GDI+的绘制速度比GDI慢,尤其是涉及复杂路径和大量图像处理时差距会更明显,高频刷新的场景需要谨慎。

2.3 实际项目中的选型标准

我自己的选型原则很简单:高频重绘、图形简单的场景用GDI;静态展示、复杂形状、需要渐变和抗锯齿的场景用GDI+;两者混用也完全可行。

对比维度GDIGDI+
绘制速度快一般,复杂路径有压力
抗锯齿原生不支持支持
渐变画刷没有,需要自算支持线性、路径渐变
资源管理手动,容易泄漏类封装,自动析构
坐标模型逻辑单位世界、页面、设备三层变换
适合场景图表、高速滚动列表静态复杂控件、现代风格UI

举个例子,自绘一个普通按钮,把背景渐变和圆角外框交给GDI+,中间的文字仍由GDI的DrawText完成。GDI绘制文字速度快、渲染效果稳定,GDI+处理图形形状更省力,各取所长。只要不把两者的句柄混用、各自资源各自管理,完全不会出问题。选型这件事没有绝对答案,但方向清楚,写出来的代码就会顺很多。

3. 双缓冲绘图:把闪烁问题按死在源头

3.1 闪烁的根因到底在哪

闪烁的本质,是用户看到了“背景被擦掉”和“新内容画上”之间的中间状态。过程很像老师擦黑板:先把旧内容擦干净,再去写新内容,如果擦和写之间隔得稍微久一点,全班同学都会看到一块空黑板。屏幕刷新频率比黑板演示快得多,每秒几十帧,一旦擦除和绘制不同步,人眼就会捕捉到一亮一暗的交替。

自绘控件里最容易触发这个问题的动作是调整窗口大小。窗口一变尺寸,系统先发送WM_ERASEBKGND把背景擦掉,等到WM_PAINT执行时,窗口露出的就是一段空白底色。如果你在WM_PAINT里的绘制逻辑又比较重,空白时间会进一步拉长,闪烁体感就更明显。所以双缓冲不是可选项,而是自绘控件的必需品。

3.2 双缓冲的三步流程与代码实现

双缓冲的核心思想很直接:不要直接在屏幕上“擦旧画新”,而是先在内存里准备一张完整的新画面,然后一次性拷贝到屏幕上。这样用户永远看不到绘制过程的中间状态,只看到结果从旧画面切换到新画面。

标准流程分三步:创建与屏幕DC兼容的内存DC和位图;在内存DC上完成所有绘制;用BitBlt把整张内存位图一次性拷贝到屏幕DC。配合前面说的抑制WM_ERASEBKGND,闪烁基本能彻底消除。

下面这段代码是一个标准的双缓冲WM_PAINT处理,可以直接套用到自己的控件里:

case WM_PAINT: { PAINTSTRUCT ps; HDC hdc = BeginPaint(hwnd, &ps); RECT rcClient; GetClientRect(hwnd, &rcClient); // 1. 创建内存DC和兼容位图 HDC memDC = CreateCompatibleDC(hdc); HBITMAP memBmp = CreateCompatibleBitmap(hdc, rcClient.right - rcClient.left, rcClient.bottom - rcClient.top); HGDIOBJ oldBmp = SelectObject(memDC, memBmp); // 2. 在内存DC上完成全部绘制 HBRUSH bgBrush = CreateSolidBrush(RGB(30, 120, 200)); FillRect(memDC, &rcClient, bgBrush); DeleteObject(bgBrush); SetBkMode(memDC, TRANSPARENT); SetTextColor(memDC, RGB(255, 255, 255)); DrawText(memDC, L"双缓冲示例", -1, &rcClient, DT_CENTER | DT_VCENTER | DT_SINGLELINE); // 3. 一次性拷贝到屏幕 BitBlt(hdc, 0, 0, rcClient.right, rcClient.bottom, memDC, 0, 0, SRCCOPY); // 4. 清理对象 SelectObject(memDC, oldBmp); DeleteObject(memBmp); DeleteDC(memDC); EndPaint(hwnd, &ps); return 0; }

代码里有一个非常容易被忽视的细节:内存位图必须用CreateCompatibleBitmap创建,而不是CreateBitmap。CreateBitmap创建的纯内存位图颜色格式不一定和屏幕匹配,拷贝到屏幕时经常出现颜色偏绿、偏暗等奇怪现象。CreateCompatibleBitmap会按照传入DC的格式创建位图,保证最终视觉效果一致。这一步我踩过好几次,颜色不对先检查这里。

3.3 两个容易出错的内存位图细节

内存位图的大小必须跟随窗口客户区动态变化。很多初版代码图省事,在窗口初始化时创建一次位图,窗口一放大,绘制内容就被裁掉一大块。我的做法是在WM_PAINT或WM_SIZE里重新获取客户区大小,每次绘制前都确认位图尺寸正确。虽然多了一次重建位图的开销,但换来的是逻辑简单可靠。

另一个细节是内存位图的清理时机。有人为了省事,把内存DC和位图作为成员变量反复使用,只在析构时统一释放。这样会因为窗口尺寸变化需要重新创建位图,容易忘记释放旧对象,在窗口频繁调整大小时造成GDI对象泄漏。我的经验是:每次WM_PAINT都创建、清理一遍,虽然看起来多了一点开销,但内存绘制本身很快,这点消耗完全值得,换来的是决不让GDI对象数量持续增长的安全感。

4. 手写一个带悬停和按下效果的自绘按钮

4.1 自绘按钮的消息处理骨架

原理讲完,来点实际能跑的东西。我要实现一个圆角按钮,带悬停变色、按下变深、松开恢复这三种状态。不要标准BUTTON控件,丢开按钮父类的绘制逻辑,自己写一个子类化窗口出来。

思路是先创建一个空窗口作为按钮,窗口类注册时不用标准按钮类,而用自绘窗口类,然后在窗口过程里接管绘制相关消息和鼠标消息。下面是按钮窗口的消息分发框架:

LRESULT CALLBACK RoundButtonWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { RoundButton* btn = reinterpret_cast<RoundButton*>( GetWindowLongPtr(hwnd, GWLP_USERDATA)); switch (msg) { case WM_PAINT: btn->OnPaint(); return 0; case WM_ERASEBKGND: return 1; // 告诉系统背景自己处理 case WM_MOUSEMOVE: btn->OnMouseMove(); return 0; case WM_MOUSELEAVE: btn->OnMouseLeave(); return 0; case WM_LBUTTONDOWN: btn->OnLButtonDown(); return 0; case WM_LBUTTONUP: btn->OnLButtonUp(); return 0; default: return DefWindowProc(hwnd, msg, wp, lp); } }

创建这个窗口后,用SetWindowLongPtr把自己的类指针挂到GWLP_USERDATA上,消息处理里就能拿到按钮对象。自绘控件想接鼠标事件,必须自己处理这些消息。没有父类帮你做状态管理,这点和标准BUTTON控件有本质区别。

4.2 状态管理:悬停、按下、禁用

自绘按钮的状态管理是整个交互的核心。悬停要用TrackMouseEvent注册鼠标离开通知,否则鼠标移出窗口时你根本不知道,状态会永远卡在Hover。按下的关键字是捕获鼠标SetCapture,这样即使用户按下按钮后把鼠标拖到窗口外面再松开,你也能收到WM_LBUTTONUP。不捕获的话,鼠标一旦移出窗口,松开事件就丢失了,按钮会一直卡在Pressed状态。

下面是状态管理的实现:

void RoundButton::OnMouseMove() { if (!m_bTracking) { TRACKMOUSEEVENT tme = { sizeof(tme), TME_LEAVE, m_hwnd, 0 }; TrackMouseEvent(&tme); m_bTracking = true; } if (m_state != StateHover && !m_bPressed) { m_state = StateHover; InvalidateRect(m_hwnd, nullptr, FALSE); } } void RoundButton::OnMouseLeave() { m_bTracking = false; if (m_state == StateHover && !m_bPressed) { m_state = StateNormal; InvalidateRect(m_hwnd, nullptr, FALSE); } } void RoundButton::OnLButtonDown() { SetCapture(m_hwnd); m_bPressed = true; m_state = StatePressed; InvalidateRect(m_hwnd, nullptr, FALSE); } void RoundButton::OnLButtonUp() { if (m_bPressed) { ReleaseCapture(); m_bPressed = false; // 如果鼠标仍在按钮内,回到Hover状态 POINT pt; GetCursorPos(&pt); ScreenToClient(m_hwnd, &pt); RECT rc; GetClientRect(m_hwnd, &rc); m_state = PtInRect(&rc, pt) ? StateHover : StateNormal; InvalidateRect(m_hwnd, nullptr, FALSE); } }

每次状态变更后都要调用InvalidateRect,把重绘工作交给WM_PAINT。这里再次体现前面说的原则:消息处理只改状态,真正做绘制的地方只有WM_PAINT。这样逻辑清晰,也方便后续调试。

4.3 分层绘制函数的好处

绘制部分我建议拆成三个方法:DrawBackground负责画背景和圆角,DrawContent负责画文字或图标,DrawBorder负责画边框和高光效果。每个方法内部根据当前状态选择不同的颜色参数。这样做的好处是状态变化只影响颜色选择,绘制流程完全复用,以后想加动画、加阴影,改动也非常集中。

下面是用GDI+画圆角背景的核心代码。先把内存DC取出来,再用Graphics封装它,设置抗锯齿,最后用路径画出圆角矩形填充:

void RoundButton::DrawBackground(HDC hdc, const RECT& rc) { Gdiplus::Graphics graphics(hdc); graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); COLORREF color = RGB(60, 120, 200); switch (m_state) { case StateNormal: color = RGB(60, 120, 200); break; case StateHover: color = RGB(40, 100, 190); break; case StatePressed: color = RGB(20, 80, 170); break; } Gdiplus::Color gdColor(255, GetRValue(color), GetGValue(color), GetBValue(color)); Gdiplus::SolidBrush brush(gdColor); Gdiplus::GraphicsPath path; int radius = 10; int w = rc.right - rc.left - 1; int h = rc.bottom - rc.top - 1; path.AddArc(0, 0, radius * 2, radius * 2, 180, 90); path.AddLine(radius, 0, w - radius, 0); path.AddArc(w - radius * 2, 0, radius * 2, radius * 2, 270, 90); path.AddLine(w, radius, w, h - radius); path.AddArc(w - radius * 2, h - radius * 2, radius * 2, radius * 2, 0, 90); path.AddLine(w - radius, h, radius, h); path.AddArc(0, h - radius * 2, radius * 2, radius * 2, 90, 90); path.AddLine(0, h - radius, 0, radius); path.CloseFigure(); graphics.FillPath(&brush, &path); DrawContent(hdc, rc); }

DrawContent部分继续沿用GDI的DrawText绘制文字并设置透明背景模式,这样可以和GDI+的图形混合存放。文字就别用GDI+的DrawString了,中文字体渲染没GDI的DrawText省心,效果还不一定好。我把整块绘制放进双缓冲后再统一BitBlt到窗口,和前面讲的双缓冲流程保持一致,最后得到的按钮既平滑又不闪。

5. 资源泄漏和绘制开销:自绘代码最容易翻车的两个地方

5.1 GDI对象泄漏:症状、检测、防范

GDI对象泄漏是自绘开发者绕不过去的坎。刚开始写自绘代码,追求的都是画面效果,很少有人去数自己到底创建了多少个HBRUSH、HBITMAP、HPEN。程序刚跑的前几分钟一切正常,界面越用越卡,最后直接白屏,这时候多半就是GDI对象触顶了。

系统为每个进程维护的默认GDI对象句柄数是有上限的,超过之后GDI绘制会直接失败。我排查这个问题的办法分两步:先在任务管理器的“详细信息”里打开“GDI对象”列,观察数值是否在稳定上涨;再用GetGuiResources获取当前进程的GDI对象数量,在关键函数入口打印出来,定位泄漏发生在哪个函数里。最恶心的泄漏场景通常是窗口大小变化时重建位图,旧位图没有DeleteObject,每次调整窗口就少一个GDI对象。

从根上解决问题要靠资源管理习惯。我的做法是写一套简单的工具类,用RAII包装常见GDI对象,保证创建之后离开作用域一定释放。比如内存DC用这个类包一遍,SelectObject和DeleteObject都在析构函数里自动完成,代码写起来也更简洁。

5.2 用更新区域控制绘制量

自绘控件的性能瓶颈很多时候不在绘制算法本身,而在重复绘制了不该画的内容。WM_PAINT里的代码是全量绘制,但窗口的更新区域可能只是一个小角落。如果每次InvalidateRect都传nullptr表示“整个窗口都重画”,那绘制开销就是全量级的。

优化手段就一句话:尽量携带精确的矩形或区域去失效。例如悬停状态变化只影响按钮背景,矩形范围就不该是整个窗口。代码里把那种不带参数的重绘调用全部替换成指定矩形区域的版本,在高密度列表或频繁刷新场景下收益非常大。另外窗口类注册时如果带上了CS_HREDRAW和CS_VREDRAW标志,窗口大小变化时会触发全量重绘,对性能敏感的自绘控件不太友好,窗口尺寸变化本来就需要重排布局,全量重绘可以通过自己控制WM_SIZE里的失效范围来优化。

5.3 几个容易被忽视的细节

文字底色是新手最容易忽略的点。在内存DC上绘制文字,如果之前用背景色填充过画布,文字周围会带出一个底色方框,非常丑。解决办法是在DrawText之前调用SetBkMode(hdc, TRANSPARENT),把背景模式设成透明。这是老生常谈,但几乎每批自绘代码里都会有人忘记。

另一个细节是字体对象。如果自己创建了HFONT,把它SelectObject进DC修改完后,记得在收尾时把旧字体换回来,然后DeleteObject。这和GDI画刷的规则一模一样,很多人只注意画刷和位图,字体却总被漏掉——但字体也是GDI对象,同样占句柄。

关于DPI这块也想提醒一句。如果程序没有声明DPI感知,Windows会对窗口进行虚拟化缩放,自绘内容会被拉伸或模糊,所有坐标计算都可能错位。我是在程序启动时就调用SetProcessDPIAware,并且按实际DPI缩放窗口和字体大小。不同DPI下绘制效果的一致性,属于自绘控件“看得见但很容易忽略”的隐藏需求,下篇讲无边框窗口时会一起深入。

最后再分享一个我自己的调试技巧:想快速确认某个区域有没有被重复绘制,可以在绘制函数入口用GetRandomColor随机填充背景,跑几下程序就能通过颜色闪烁直观看到哪些区域在频繁重绘。这个方法比看日志和数据直观得多,尤其适合自绘控件的性能排查。DWM合成器开启几十年了,桌面程序性能瓶颈已经不再是刷新率,更多是无效绘制带来的CPU浪费。把消息处理、双缓冲、状态管理这几块基础打扎实,自绘这条路才算真正走顺了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/23 1:29:32

陕西公路SHP数据坐标系识别与清洗转换实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/23 1:26:43

Windows下用diskpart挂载ESP分区:UEFI引导维护实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/23 1:26:34

IC验证成长手册:从零搭建UVM验证环境与覆盖率驱动实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/23 1:26:32

编辑器CPU 100%排查:bookmarks插件与AI流式渲染优化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/23 1:26:22

显卡代码43故障排查:HDMI无信号、驱动与硬件问题一次解决

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/23 1:26:22

嵌入式ST单元测试:仿真输入与现场数据双轨验证方法论

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华