☰
MFC自绘按钮实战:CButton派生、GDI+圆角与双缓冲防闪烁
2026/10/26 13:39:39 网站建设 项目流程

简介:面向MFC/VC++桌面开发者的自定义图片按钮示例工程,围绕CButton派生类、OnPaint自绘、位图加载与对话框控件绑定等常见界面需求展开,适合需要为按钮增加图标、状态外观或自绘效果的初中级开发者参考。压缩包共47个文件,约28.91MB,包含h/cpp源码、rc资源与bmp位图素材,以及sln、dsp、dsw等工程文件和obj、pdb、exe等编译运行产物,便于直接打开对照或改造现有MFC项目。已有335人学习下载,说明该示例在MFC按钮美化、控件扩展与界面自绘场景中具有一定参考价值,适合对照现有工程查漏补缺。读者可从中了解从标准CButton控件扩展到图像按钮的完整组织方式,包括类声明、消息映射、绘制流程、资源加载及在对话框中的创建与绑定思路,也可借此排查自绘按钮重绘、位图显示和工程配置等问题,快速形成可复用的界面控件实现方案。

1. MFC自定义按钮:界面上那排灰块,其实能救

接手一个上位机项目的时候,功能已经跑通了,客户验收前只提了一条:按钮太丑。MFC 自带的 CButton 用系统主题画出来还是那个 3D 凸起的方块,放在深色背景的 Tab 页上像贴了张上世纪的老膏药。想换掉它,又不愿意引 Qt 或者整一套第三方控件库,最省事的办法就是从 CButton 派生一个类,把绘制权抢回来。这个项目做的就是这件事:一套可复用的自绘按钮,支持圆角、渐变、悬停、按下、禁用、焦点框几种状态,代码量不大,但把 MFC 自绘里最容易翻车的点都踩了一遍。适合手上有 MFC 老项目、想低成本美化界面的开发者。

2. 工程结构与类设计:一个 CButton 派生类该有哪些零件

2.1 文件组织与我惯用的拆分方式

一个自绘按钮的体量看着不大,真要往项目里塞的时候,文件怎么拆挺影响后面改起来顺不顺手。我一般把它拆成三部分:控件本身的头文件和实现文件、一个 GDI+ 的启动/关闭辅助类、一个演示用的对话框。控件的头文件只暴露外观参数和两个对外接口(设置配色、设置圆角),绘制细节全压在 cpp 里,别的模块引入的时候不牵扯 GDI+ 的头文件污染。

具体的文件清单大概是这样:

文件作用是否需要改动
CustomButton.h类声明、状态枚举、对外属性接口新增
CustomButton.cpp消息映射、绘制实现、鼠标与焦点处理新增
GdiPlusHelper.hGDI+ 的启动与关闭封装新增
MainDlg.cpp演示对话框,把原来的 CButton 换成 CCustomButton修改
resource.h控件 ID 不变,无需新增一般不动

控件的类声明里,成员变量分成两组:一组是运行状态(是否悬停、是否按下、是否已经挂了鼠标离开跟踪),另一组是外观参数(各种状态下的颜色、圆角半径、文字颜色)。这两组分开写,后面做换肤或者做主题切换的时候就知道好处在哪了——只要动外观那一组,状态机那组一行都不用碰。

// CustomButton.h #pragma once #include <afxwin.h> #include <gdiplus.h> #pragma comment(lib, "gdiplus.lib") class CCustomButton : public CButton { DECLARE_DYNAMIC(CCustomButton) public: CCustomButton(); virtual ~CCustomButton(); // 对外接口:换配色、调圆角,不影响绘制逻辑 void SetColors(COLORREF crNormal, COLORREF crHover, COLORREF crPressed, COLORREF crText); void SetCornerRadius(int nRadius) { m_nRadius = nRadius; Invalidate(FALSE); } protected: // ---- 运行状态 ---- bool m_bHover = false; // 鼠标是否悬停 bool m_bPressed = false; // 左键是否按下 bool m_bTracking = false; // 是否已注册 TME_LEAVE // ---- 外观参数 ---- COLORREF m_crNormal = RGB(0, 122, 204); COLORREF m_crHover = RGB(28, 151, 234); COLORREF m_crPressed = RGB(0, 90, 158); COLORREF m_crDisabled = RGB(190, 190, 190); COLORREF m_crText = RGB(255, 255, 255); int m_nRadius = 6; // 圆角半径,单位像素 virtual void PreSubclassWindow(); virtual void DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct); afx_msg void OnMouseMove(UINT nFlags, CPoint point); afx_msg LRESULT OnMouseLeave(WPARAM wParam, LPARAM lParam); afx_msg void OnLButtonDown(UINT nFlags, CPoint point); afx_msg void OnLButtonUp(UINT nFlags, CPoint point); afx_msg BOOL OnEraseBkgnd(CDC* pDC); afx_msg BOOL OnSetCursor(CWnd* pWnd, UINT nHitTest, UINT message); DECLARE_MESSAGE_MAP() };

PreSubclassWindow是整个类能不能生效的关键钩子。对话框模板上的按钮,DDX 绑定时会走SubclassDlgItem,进而触发这个虚函数,在这里补一句ModifyStyle(0, BS_OWNERDRAW),按钮才会把绘制权交出来,系统才会去调DrawItem。动态Create出来的按钮也一样走这条路。忘了这一句,后面所有绘制代码全是死代码,界面还是那个灰块,新手最容易在这儿卡半天。

SetColors和SetCornerRadius这两个接口故意做得简单,不引入任何主题对象。参数直接就是COLORREF,需要换肤的时候调一次,内部Invalidate(FALSE)重绘,别用Invalidate(TRUE),那个会连带擦背景,闪一下很明显。

2.2 状态机:按钮到底有几种脸

自绘按钮最容易写乱的地方就是状态判断散落在绘制代码里,if套if,加一个"禁用"状态就得动三处。我的做法是把状态收敛成一个枚举,画之前先算一次当前该用哪张脸,绘制函数里只做 switch。这个状态机包含五种视觉态:Normal、Hover、Pressed、Disabled、Focused。焦点态和另外四种是叠加关系,不是互斥关系——一个按钮可以既悬停又有焦点,所以焦点框单独画,不进 switch。

状态判断的顺序很重要,必须是"禁用优先级最高,其次按下,再次悬停,最后普通"。反过来写的话,一个被EnableWindow(FALSE)掉的按钮,鼠标划过仍然会进 Hover 分支,画出来是一个亮着的、点不动的按钮,用户会以为程序卡了。这个顺序错一次,排查起来是很玄学的,因为代码逻辑看着完全通顺。

// 状态判定的集中入口,绘制前调用一次 enum class BtnState { Normal, Hover, Pressed, Disabled }; BtnState CCustomButton::GetCurrentState() const { // 禁用优先级最高,先判掉,避免禁用按钮还响应悬停 if (!IsWindowEnabled()) return BtnState::Disabled; if (m_bPressed) return BtnState::Pressed; if (m_bHover) return BtnState::Hover; return BtnState::Normal; }

这个函数是const的,不修改任何成员,纯粹做判断。IsWindowEnabled()是 CWnd 的成员,比自己去查WS_DISABLED样式位安全,因为控件可能被父窗口连带禁用,样式位和实际状态不一定一致。

对应的颜色取法也抽成一个函数,switch 四个分支返回四个COLORREF。这样以后加"警告按钮""危险按钮"只需要多一个外观参数和一条分支,绘制代码完全不动。这套拆法看着啰嗦,但在一个界面上有几十个按钮的项目里,后面改配色的时候能省下大量时间。

2.3 消息映射:哪些消息必须自己接

MFC 的消息映射是一张静态表,用宏声明要处理哪些消息。自绘按钮需要接的消息不多,但每一条都不能少:鼠标移动(悬停判定)、鼠标离开(清悬停)、左键按下和抬起(按下态)、擦背景(防闪烁)、设置光标(手型)、绘制(DrawItem是虚函数不是消息,但依赖WM_DRAWITEM的分发)。

// CustomButton.cpp IMPLEMENT_DYNAMIC(CCustomButton, CButton) BEGIN_MESSAGE_MAP(CCustomButton, CButton) ON_WM_MOUSEMOVE() ON_MESSAGE(WM_MOUSELEAVE, &CCustomButton::OnMouseLeave) ON_WM_LBUTTONDOWN() ON_WM_LBUTTONUP() ON_WM_ERASEBKGND() ON_WM_SETCURSOR() END_MESSAGE_MAP()

WM_MOUSELEAVE不是标准窗口消息,是TrackMouseEvent注册之后系统往窗口投的,所以必须用ON_MESSAGE而不是ON_WM_XXX系列宏。宏的第二个参数要写成&CCustomButton::OnMouseLeave这种带取地址符号的形式,而且这个成员函数必须是afx_msg LRESULT、两个参数(WPARAM, LPARAM)的签名,签名不对编译期不一定报错,运行时会莫名其妙地崩在消息分发里。

另外要留意OnEraseBkgnd在映射表里排在ON_WM_MOUSEMOVE之后并不影响执行顺序,消息映射是按消息 ID 查找的,源码里的书写顺序只是给人看的。但为了后面维护的人能一眼找到,我还是习惯按"绘制 → 鼠标 → 焦点 → 键盘"这样分组写。

3. 核心绘制链路拆解:DrawItem 里到底发生了什么

3.1 双缓冲与背景擦除:闪烁问题的根源就在这两行

自绘按钮不闪的前提,是搞清楚 Windows 的重绘流程:系统先发WM_ERASEBKGND让窗口擦背景,再发WM_PAINT让窗口画内容。两次绘制之间有一段极短的时间差,用户眼睛捕捉到的就是一下白闪。自绘控件的背景多半是父窗口的底色,擦完再画一遍,闪烁几乎不可避免。

标准解法是两步:OnEraseBkgnd直接返回 TRUE,告诉系统"背景我自己负责,你别管了";然后在内存 DC 上把整个按钮画完,一次性BitBlt到屏幕。这样屏幕上只会看到一次完整的绘制结果。

// 关键一:阻断系统的背景擦除 BOOL CCustomButton::OnEraseBkgnd(CDC* /*pDC*/) { return TRUE; // 背景由 DrawItem 在内存 DC 里一次性画完 } void CCustomButton::DrawItem(LPDRAWITEMSTRUCT lpDIS) { CDC* pDC = CDC::FromHandle(lpDIS->hDC); CRect rc = lpDIS->rcItem; // 关键二:所有绘制先落到内存位图 CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rc.Width(), rc.Height()); CBitmap* pOldBmp = memDC.SelectObject(&bmp); // 用父窗口背景色打底,解决圆角外的透明区域变黑 memDC.FillSolidRect(rc, GetParent() ? GetParent()->GetBkColor() : RGB(240, 240, 240)); DrawContent(&memDC, rc); // 真正的圆角、渐变、文字都在这里 // 一次性贴回屏幕,避免中间态可见 pDC->BitBlt(0, 0, rc.Width(), rc.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }

lpDIS->rcItem是这个按钮的矩形区域,单位是客户区坐标,左上角一般是(0,0),所以后面BitBlt的源坐标也传 0。CreateCompatibleBitmap必须用pDC(屏幕 DC)而不是memDC,用memDC创建出来的是单色位图,画上去全是黑白块,这个坑我见过不止一次。

中间那行FillSolidRect是拿来兜底透明区域的。圆角按钮四个角是空的,不填的话内存位图里是随机的黑色,贴到屏幕上就是四个黑角。填父窗口的GetBkColor()是最简单的做法,如果父窗口本身是渐变或者贴图,那就得往父窗口抓图,代价高不少,一般项目里不值得。

DrawContent是抽取出来的绘制主体,把 GDI+ 那部分隔离开,方便单独调试。参数就两个:内存 DC 和矩形,不需要关心状态,因为它内部会调GetCurrentState()。

3.2 圆角、渐变与文字:GDI+ 的绘制三件套

MFC 自带的CDC画不了抗锯齿圆角,硬画出来的圆角边缘全是锯齿,放在高分屏上非常明显。这里用 GDI+ 的Graphics接管绘制,SmoothingModeAntiAlias打开之后边缘是平滑的。

GDI+ 使用前要初始化,GdiplusStartup必须在任何 GDI+ 对象创建之前调用,GdiplusShutdown在程序退出时调用。我一般用一个全局辅助对象管理生命周期,构造时启动、析构时关闭,避免在对话框的OnInitDialog里启动却忘了在OnDestroy里关闭。

// GdiPlusHelper.h:全局对象的构造/析构自动管理 GDI+ 生命周期 class CGdiPlusHelper { public: CGdiPlusHelper() { Gdiplus::GdiplusStartupInput input; Gdiplus::GdiplusStartup(&m_nToken, &input, NULL); } ~CGdiPlusHelper() { Gdiplus::GdiplusShutdown(m_nToken); } private: ULONG_PTR m_nToken = 0; };

有了这个,DrawContent里就可以放心用Graphics了:

void CCustomButton::DrawContent(CDC* pDC, const CRect& rc) { using namespace Gdiplus; Graphics g(pDC->GetSafeHdc()); g.SetSmoothingMode(SmoothingModeAntiAlias); // 抗锯齿,圆角平滑的关键 g.SetPixelOffsetMode(PixelOffsetModeHalf); // 让边缘落在半像素上,更清晰 BtnState st = GetCurrentState(); // 圆角矩形路径 GraphicsPath path; int d = m_nRadius * 2; RectF r(0, 0, (REAL)rc.Width(), (REAL)rc.Height()); path.AddArc(r.X, r.Y, d, d, 180, 90); path.AddArc(r.GetRight() - d, r.Y, d, d, 270, 90); path.AddArc(r.GetRight() - d, r.GetBottom() - d, d, d, 0, 90); path.AddArc(r.X, r.GetBottom() - d, d, d, 90, 90); path.CloseFigure(); // 按状态取色,Pressed 态做一点垂直缩进的视觉效果 Color fill = (st == BtnState::Disabled) ? Color(255, 190, 190, 190) : (st == BtnState::Pressed) ? Color(255, GetRValue(m_crPressed), GetGValue(m_crPressed), GetBValue(m_crPressed)) : (st == BtnState::Hover) ? Color(255, GetRValue(m_crHover), GetGValue(m_crHover), GetBValue(m_crHover)) : Color(255, GetRValue(m_crNormal), GetGValue(m_crNormal), GetBValue(m_crNormal)); SolidBrush brush(fill); g.FillPath(&brush, &path); // 文字居中,禁用的文字用灰色 CString strText; GetWindowText(strText); if (!strText.IsEmpty()) { FontFamily ff(L"Microsoft YaHei"); Font font(&ff, 10.0f, FontStyleRegular, UnitPoint); StringFormat fmt; fmt.SetAlignment(StringAlignmentCenter); fmt.SetLineAlignment(StringAlignmentCenter); Color textColor = (st == BtnState::Disabled) ? Color(255, 160, 160, 160) : Color(255, GetRValue(m_crText), GetGValue(m_crText), GetBValue(m_crText)); SolidBrush textBrush(textColor); g.DrawString(strText, -1, &font, r, &fmt, &textBrush); } // 焦点虚线框,只在有焦点时画,且只画在按钮内侧 if (GetFocus() == this) { Pen pen(Color(120, 0, 0, 0), 1.0f); pen.SetDashStyle(DashStyleDot); RectF inner(4, 4, r.Width - 8, r.Height - 8); g.DrawRectangle(&pen, inner); } }

GraphicsPath的四个AddArc分别对应四个圆角,起始角度和扫掠角度都是顺时针方向,最后一个参数 90 表示扫过 90 度。这四个圆弧的矩形坐标要稍微算一下:左上角是(0,0),右上角是(宽度-直径, 0),右下角是(宽度-直径, 高度-直径),左下角是(0, 高度-直径)。算错一个画的就不是圆角,而是缺一块的方形。

SetPixelOffsetMode(PixelOffsetModeHalf)这一句很容易被漏。GDI+ 默认的坐标对齐方式和 GDI 有半像素偏差,抗锯齿一开,线条会显得糊。加上这行之后边缘就清爽了,细节但影响观感。

DrawString的最后一个参数是矩形r,配合StringAlignmentCenter把文字拉到正中间。CString直接转const WCHAR*是隐式的,-1表示长度由系统自己数到\0。

3.3 鼠标悬停跟踪:为什么按钮会卡在高亮态

悬停效果最直观的实现就是处理WM_MOUSEMOVE,判断鼠标坐标是否在按钮矩形内,是就设m_bHover = true并重绘。但只有这一半是不够的:鼠标移出按钮之后,按钮收不到任何消息,m_bHover永远停在true,界面就卡在悬停高亮上。这是自绘按钮最经典的一个 bug,看起来像"按钮坏掉了"。

正确的做法是第一次进WM_MOUSEMOVE时调TrackMouseEvent注册TME_LEAVE,系统会在鼠标离开客户区时主动投一条WM_MOUSELEAVE过来。

void CCustomButton::OnMouseMove(UINT nFlags, CPoint point) { // 第一次移动进来时注册离开通知,只注册一次 if (!m_bTracking) { TRACKMOUSEEVENT tme = { sizeof(TRACKMOUSEEVENT) }; tme.dwFlags = TME_LEAVE; tme.hwndTrack = m_hWnd; if (::TrackMouseEvent(&tme)) m_bTracking = true; } if (!m_bHover) { m_bHover = true; Invalidate(FALSE); // FALSE:不擦背景,配合双缓冲减少闪烁 } CButton::OnMouseMove(nFlags, point); } LRESULT CCustomButton::OnMouseLeave(WPARAM /*wParam*/, LPARAM /*lParam*/) { m_bTracking = false; // 离开后要复位,下次进来重新注册 if (m_bHover) { m_bHover = false; Invalidate(FALSE); } return 0; }

m_bTracking这个标志位是关键。TrackMouseEvent每调一次只发一次WM_MOUSELEAVE,发完就失效了,所以下次鼠标再进来必须重新注册。如果不做标志位,每次WM_MOUSEMOVE都注册一次,鼠标快速划过时会收到一串重复的离开通知,按钮状态在悬停和普通之间反复横跳,界面闪得厉害。

WM_MOUSELEAVE的回调里把m_bTracking复位成false,这样下次移进来就能重新注册。这个复位如果漏了,第一次悬停正常,再进去就再也不亮了,而且是那种"重启程序又好了"的间歇性故障,很难查。

最后一行CButton::OnMouseMove是留给父类处理的,不调的话基类的一些内部状态可能不同步,虽然大多数情况下看不出问题,但保持调用是个好习惯,尤其是以后要把这个类当成别的类的基类的时候。

4. 交互与体验细节:点击、焦点、禁用与键盘响应

4.1 点击流程:从 WM_LBUTTONDOWN 到 BN_CLICKED

自绘按钮用BS_OWNERDRAW之后,点击行为仍然由 CButton 处理,BN_CLICKED通知照常发往父窗口,前提是你没有把消息链截断。很多人写自绘按钮时会顺手重写OnLButtonDown只改状态、不调基类,结果按钮点了半天没反应,父窗口的ON_BN_CLICKED一次都没进。

正确的写法是:按下时设状态并重绘,然后调基类让 CButton 走它原有的捕获和通知流程。

void CCustomButton::OnLButtonDown(UINT nFlags, CPoint point) { if (IsWindowEnabled()) { m_bPressed = true; Invalidate(FALSE); // 立刻切到按下态 } CButton::OnLButtonDown(nFlags, point); // 必须调,否则 BN_CLICKED 不触发 } void CCustomButton::OnLButtonUp(UINT nFlags, CPoint point) { m_bPressed = false; Invalidate(FALSE); CButton::OnLButtonUp(nFlags, point); }

CButton::OnLButtonDown内部会调SetCapture把鼠标捕获住,这样即使鼠标按下后移出按钮,抬起事件仍然回到这个按钮。同时父窗口会在抬起时收到BN_CLICKED。自己写捕获逻辑当然也行,但没必要,容易把ReleaseCapture漏掉导致整个程序卡住鼠标。

还有一个细节:按下之后拖到按钮外面再松开,CButton的默认行为是不触发点击。自绘按钮要保持一致,所以OnLButtonUp里不需要自己判断坐标,交给基类处理,把m_bPressed复位就行。

4.2 焦点框与键盘响应

对话框上的按钮默认参与 Tab 键遍历,自绘之后焦点虚线框被我们自己画的内容盖掉了,用户按 Tab 走一遍完全看不出焦点在哪个按钮上,这在需要键盘操作的工控场景里是要被投诉的。

前面DrawContent里已经用 GDI+ 的虚线Pen画了一圈内缩 4px 的矩形。这里补一个WM_SETFOCUS/WM_KILLFOCUS的重绘,否则焦点变化时那个框不会立刻出现或消失:

afx_msg void CCustomButton::OnSetFocus(CWnd* pOldWnd) { CButton::OnSetFocus(pOldWnd); Invalidate(FALSE); // 焦点进来,补画虚线框 } afx_msg void CCustomButton::OnKillFocus(CWnd* pNewWnd) { CButton::OnKillFocus(pNewWnd); m_bPressed = false; // 焦点切走时清掉按下态,防止卡在按下视觉 Invalidate(FALSE); }

OnKillFocus里那个m_bPressed = false是踩过坑才加的。切焦点的时候(比如 Alt+Tab 或者点别的窗口),有些情况下WM_LBUTTONUP会因为捕获丢失而收不到,按钮就永远停在"按下"的凹陷状态,看起来像卡死了。加上这一行之后至少视觉上能自愈。

键盘响应本身不用额外写,CButton已经处理了空格和回车的触发,BS_OWNERDRAW不影响这块逻辑。这也是我不建议自己拦截键盘消息的原因,基类那套处理考虑过WS_TABSTOP、助记符、默认按钮这些边界情况,重写一遍很难覆盖全。

4.3 禁用态与手型光标

禁用态除了颜色要变灰,还有一个容易漏的点:鼠标经过时应该显示箭头而不是手型。WM_SETCURSOR是唯一能控制这个的地方:

BOOL CCustomButton::OnSetCursor(CWnd* pWnd, UINT nHitTest, UINT message) { if (nHitTest == HTCLIENT && IsWindowEnabled()) { ::SetCursor(::LoadCursor(NULL, IDC_HAND)); // 手型,暗示可点击 return TRUE; // 返回 TRUE 表示已处理,系统不再设光标 } return CButton::OnSetCursor(pWnd, nHitTest, message); }

返回TRUE的含义是"我处理了,你别管了",返回FALSE或者调基类则交给默认处理。nHitTest == HTCLIENT这个判断不能少,否则连按钮边框上的调整区都会变手型。

禁用态本身不用单独处理绘制,GetCurrentState()已经把它排在最高优先级。真正要注意的是EnableWindow(FALSE)之后要主动Invalidate,CWnd 的EnableWindow会触发重绘,但如果之前按下状态没复位,画出来还是个按下去的灰块。稳妥做法是在SetColors或者专门的SetEnabled封装里,先复位m_bPressed和m_bHover再调基类的EnableWindow。

5. 避坑与排查:自绘按钮五个高频故障

5.1 界面疯狂闪烁,鼠标一动就闪

现象很典型:鼠标划过按钮区域,整个按钮白闪一下再变色,多移几次像是接触不良。原因基本跑不出两条,要么OnEraseBkgnd没返回 TRUE,系统每次都老老实实擦一遍背景,要么Invalidate里传了TRUE,重绘时又把背景擦了。两条同时中的时候闪得最凶。

解决办法就是第 3 章那套:OnEraseBkgnd返回 TRUE,所有绘制走内存 DC,Invalidate一律传FALSE。还有一种不那么常见的情况——按钮所在的父对话框自己有WS_CLIPCHILDREN没设,导致父窗口重绘时把子控件区域也刷了。检查对话框样式里有没有这个位,没有就加上,加上之后子控件区域会被裁掉,闪烁能少一大半。

5.2 鼠标移出后按钮卡在高亮态

现象是按钮悬停之后一直亮着,移开鼠标也不灭,点别的窗口才恢复。原因就是前面说的没处理WM_MOUSELEAVE,光标离开客户区之后按钮收不到任何通知,m_bHover永远是 true。

解决方式:在OnMouseMove里用TrackMouseEvent注册TME_LEAVE,并在ON_MESSAGE(WM_MOUSELEAVE, ...)里复位状态。已经注册过的用一个m_bTracking标志位挡住,重复注册会引发状态抖动,反而更难看。

还有一个变种:鼠标快速从按钮上划过去,WM_MOUSEMOVE和WM_MOUSELEAVE几乎同时到,万一m_bHover的判定顺序写反了,会出现一次闪烁。这种情况不容易复现,属于看运气的那类,稳妥做法是离开通知里先判m_bHover再Invalidate,不要无条件重绘。

5.3 高分屏下圆角和文字糊成一团

现象是开发机(100% 缩放)看着挺好,客户机器(150% 缩放)上按钮圆角有毛边,文字发虚。原因有两个:圆角半径和字号都写死成像素了,以及程序没声明 DPI 感知,系统直接把整个窗口拉伸,抗锯齿的形状被二次采样破坏了。

解决分两步。第一步在程序清单或者InitInstance里声明 DPI 感知(SetProcessDpiAwareness或清单里的dpiAware节点),让系统不要做位图拉伸。第二步把所有像素数值换成按当前 DPI 缩放的值,比如圆角半径m_nRadius * GetDpiForWindow(m_hWnd) / 96。GDI+ 的Font用UnitPoint而不是UnitPixel,字号就跟 DPI 走了,不用自己算。

5.4 圆角外面出现黑块或者白块

现象是按钮四个角不是父窗口的背景色,而是黑块,在某些主题下是白块。原因在于内存位图创建出来是未初始化的,圆角路径之外的像素没有画到,BitBlt贴上去就把这些垃圾像素一起贴上去了。

解决方式是在绘制圆角之前,先用父窗口的背景色把整个内存位图填满,也就是DrawItem里那句FillSolidRect。如果父窗口是纯色,这句就够了;如果父窗口是渐变或者图片,就得从父窗口 DC 上做一次BitBlt把背景拷过来,性能代价比纯色填充高一个数量级,按钮数量多的时候要慎重。

5.5 按钮点了没反应,父窗口收不到 BN_CLICKED

现象是按钮看着能按、有按下态,但父对话框的响应函数一次都没进。最常见的原因是OnLButtonDown或OnLButtonUp里忘了调基类,把 CButton 内部的消息处理链截断了。另一个原因是创建按钮的时候样式没带BS_OWNERDRAW,DrawItem压根不会被调,但按钮本身还是能点,属于另一种反向翻车。

排查顺序建议这样:先在DrawItem里打个断点,看有没有进来;进来说明样式没问题,去查OnLButtonDown里基类调用还在不在;没进说明样式没设,检查PreSubclassWindow里那句ModifyStyle有没有被执行到,动态创建和对话框模板两条路径都要验一遍。

提示:这五个问题里,第 1 条和第 2 条占了实际遇到故障的大半,先把这两块处理干净,剩下的基本都是细节调优。

6. 二次开发:把自绘按钮改成项目里能长期用的控件

自绘按钮写到能看只是第一步,要在一个项目里长期用下去,得让它具备三个能力:能配置、能扩展、能验证。

配置这块,前面已经用SetColors和SetCornerRadius打了底。往深做一层,我一般会加一个ApplyStyle(const ButtonStyle&)接口,ButtonStyle是个结构体,包含四种状态色、圆角、边框宽度、字体名和字号。这样换肤的时候只需要在对话框初始化里遍历所有按钮,套一个预定义的结构体,不用一个个调函数。

扩展这块,最常加的是图标。CButton本身支持BS_ICON或BS_BITMAP,但和BS_OWNERDRAW共存时行为不一致,稳妥做法是在DrawContent里自己画:把HICON或Gdiplus::Image作为成员,按"图标在左、文字在右"布局,用MeasureString算文字宽度再居中对齐整块内容。动画(比如按下时的颜色渐变)可以用SetTimer做,20ms 一帧插值,注意在OnDestroy里KillTimer,不然窗口销毁之后定时器回调会往一个野指针上写数据,这是崩溃日志里非常经典的一类。

验证这块,我给自己定的规矩是每次改完必须过三个场景:100% 和 150% 两种缩放下看圆角和文字;焦点用 Tab 走一圈看虚线框有没有跟着走;禁用态和按下态各点一次,确认颜色和鼠标形状都对。这三个场景覆盖了绝大部分翻车点,比在代码里反复读逻辑靠谱得多。另一个小技巧是把所有按钮放到一个测试对话框里,一次性铺 20 个,改一次样式看一眼,比在真实业务界面里零散地验高效。

有次改配色改到半夜,界面上就三个按钮,怎么测都没问题,第二天客户那边一打开就说某个弹窗上的按钮还是老的灰块。查了半天发现那个弹窗的按钮是动态Create出来的,PreSubclassWindow没被触发到,BS_OWNERDRAW压根没加上。从那以后我每次给控件类加新能力,都会强制把动态创建和对话框模板这两条路径各跑一遍,不再只验一条。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询