MFC按钮自绘完全指南:从Owner Draw到GDI+圆角与贴图
简介面向MFC界面开发者的自定义图片按钮实现资源内容围绕继承CButton派生CMyImageButton类、重绘图形、加载位图、绑定对话框控件等关键环节展开解决MFC默认按钮不支持直接显示图片的问题适合有一定C基础并希望提升控件定制能力的读者。压缩包共47个文件涵盖bmp位图、h/cpp源码、rc资源脚本、工程文件及可执行程序等并保留了调试版本与中间构建文件便于直接对照工程结构进行运行验证整体大小约28.91MB。该资源已有333人学习下载。借助完整的对话框示例、位图素材与按钮重绘代码读者可以掌握在MFC项目中添加图像按钮、处理鼠标交互及触发界面刷新的核心方法并能够进一步扩展实现多状态按钮或动画效果。该实现不仅适用于普通图片按钮也为自绘控件的扩展提供了参考范式。1. 为什么MFC按钮非要自己画三个方案先想清楚1.1 默认按钮的「原罪」只要是维护过老MFC项目的朋友大概率都经历过这样一个场景程序跑起来了界面一出来满屏都是那种灰扑扑、带个立体边框的经典按钮——Windows 95时代的审美放在今天确实有点「复古过头」。尤其当客户指着界面说这个按钮能不能做得现代一点的时候你第一反应可能是引入一个第三方界面库比如Skin、BCGSoft之类。但重型界面库的接入成本、兼容性、控件覆盖范围对一个只需要按钮好看点的项目来说往往又有点杀鸡用牛刀。MFC自带的CButton之所以难看核心原因是它用的是系统标准绘制逻辑也就是Windows主题没有接管时的那套经典样式。它不会自己去判断鼠标悬停、按下、焦点的细微颜色变化视觉上就是一块灰底黑边然后一行文字。想在不引入重量级依赖的前提下把它画漂亮就得自己动手——这就是自定义按钮这个需求的真实来源。1.2 三种自定义路线的取舍做MFC自定义按钮市面上常见的路线无外乎三种我用一张表先把各自的定位说清楚方案原理适用场景难度Owner Draw自绘重写DrawItem给按钮设置BS_OWNERDRAW样式让系统把绘制权交还给你的代码想要扁平化、圆角、渐变、任意颜色和状态UI风格统一中等贴图按钮Bitmap按钮把设计好的PNG/BMP当成按钮背景根据状态切换图片有UI设计师出图、追求精品效果、图片资源充足低中换界面库/控件替换引入BCGSoft、Qt等或改用CButtonST等开源增强按钮类整个项目界面都要升级、时间充裕、可接受依赖引入高绝大多数只想让按钮变个颜色、加个悬停效果、看起来不是上世纪产物的项目Owner Draw自绘就是性价比最高的一条路。它不需要额外资源文件所有视觉逻辑全部落在代码里分发部署也简单。接下来的内容我会从机制讲起给出一份可以直接抄的完整实现再聊聊GDI圆角和贴图两种进阶玩法最后把我这些年踩过的坑一并倒出来。2. Owner Draw自绘的核心机制DrawItem是怎么被调用的2.1 BS_OWNERDRAW样式是总开关先明确一个基础概念Windows的按钮控件默认情况下绘制逻辑在系统内部你拿到的是一个「成品」——系统画好的灰色按钮。如果你希望自己控制它的每一根线条、每一个像素就必须在窗口创建或运行时给它加一个样式标记BS_OWNERDRAW自绘按钮。设置了BS_OWNERDRAW之后按钮收到WM_DRAWITEM消息系统会把一个DRAWITEMSTRUCT结构体传给你里面放着按钮的矩形区域rcItem、当前状态itemState比如是否按下、是否获得焦点、是否禁用、所属窗口句柄hwndItem、以及一个设备上下文句柄hDC。你用这个DC在这个矩形里画什么按钮最终就长什么样——画一个圆、画一张图片、画一段文字都行。有一个关键细节样式设置的方式决定了你的代码能不能写对。推荐的做法不是去资源编辑器里找属性而是在按钮子类化的瞬间PreSubclassWindow统一处理这样资源模板里怎么写都无所谓代码始终可控。2.2 在派生类里重写DrawItem而不是响应WM_DRAWITEM我在不少老代码里见过这种写法在对话框类里用ON_WM_DRAWITEM()响应WM_DRAWITEM然后switch控件的IDC去挨个画。这当然能跑但控件一多对话框类就变成一坨又臭又长的绘制代码可维护性很差。正确的思路是写一个CButton的派生类比如叫CMyButton把绘制逻辑全部封装进去。这样每个按钮就是一个独立的、可复用的对象UI风格也好统一。在派生类里你只需要重写一个虚函数class CMyButton : public CButton { public: CMyButton(); virtual ~CMyButton(); protected: afx_msg void PreSubclassWindow() override; virtual void DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct) override; // 记录鼠标状态 BOOL m_bHover; BOOL m_bTracking; };当按钮需要重绘时框架会调用这个DrawItem你在这个函数里做的事情就是按钮最终呈现出来的样子。为什么强调用重写虚函数而不是响应消息因为基类CButton的DrawItem本身是一个虚钩子框架把WM_DRAWITEM消息处理之后转调了它你重写这个虚函数等于在最干净的接口处介入不用关心消息路由、不用管理控件ID匹配逻辑清晰得多。另外提醒一句如果你在局部变量里看到了CButtonST这个老牌开源类的用法它本质上也是走了这条路只是封装更完整。自己写一遍才能真正理解里面每个环节的原理。3. 一个能直接用的CMyButton完整实现与逐段拆解3.1 类和状态定义下面给出一份我实际项目里一直在用的CMyButton精简版去掉了和主题无关的业务代码保留最核心的绘制、状态、交互逻辑。先看头文件我习惯把可配置项都做成成员变量这样不用改绘制代码就能调出多种配色// CMyButton.h #pragma once class CMyButton : public CButton { public: CMyButton(); virtual ~CMyButton(); // 设置按钮文字颜色和背景色 void SetTextColor(COLORREF clrText) { m_clrText clrText; } void SetBgColor(COLORREF clrBg) { m_clrBg clrBg; } void SetHoverBgColor(COLORREF clrHoverBg) { m_clrHoverBg clrHoverBg; } void SetPressedBgColor(COLORREF clrPressedBg) { m_clrPressedBg clrPressedBg; } // 是否禁用时也自绘 void SetDrawDisabled(BOOL bDrawDisabled) { m_bDrawDisabled bDrawDisabled; } protected: virtual void PreSubclassWindow() override; virtual void DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct) override; afx_msg BOOL OnEraseBkgnd(CDC* pDC); afx_msg void OnMouseMove(UINT nFlags, CPoint point); afx_msg void OnMouseLeave(); DECLARE_MESSAGE_MAP() private: COLORREF m_clrText; COLORREF m_clrBg; COLORREF m_clrHoverBg; COLORREF m_clrPressedBg; BOOL m_bHover; // 鼠标是否悬停 BOOL m_bTracking; // 是否已跟踪鼠标离开事件 BOOL m_bDrawDisabled; // 是否自绘禁用态 }; // CMyButton.cpp BEGIN_MESSAGE_MAP(CMyButton, CButton) ON_WM_ERASEBKGND() ON_WM_MOUSEMOVE() ON_WM_MOUSELEAVE() END_MESSAGE_MAP() CMyButton::CMyButton() : m_clrText(RGB(255, 255, 255)) , m_clrBg(RGB(60, 63, 65)) , m_clrHoverBg(RGB(80, 84, 86)) , m_clrPressedBg(RGB(45, 46, 48)) , m_bHover(FALSE) , m_bTracking(FALSE) , m_bDrawDisabled(TRUE) { } CMyButton::~CMyButton() { }这个颜色方案是一套深灰低饱和配色作为默认值比较耐看。实际用的时候SetHoverBgColor这种接口会让你在几十个按钮的界面上也能很快统一风格。3.2 PreSubclassWindow里的关键一步头文件里PreSubclassWindow这个函数是整个自绘能否生效的开关所在。一定要在按钮被子类化之后立刻修改样式加上BS_OWNERDRAWvoid CMyButton::PreSubclassWindow() { CButton::PreSubclassWindow(); // 关键加上自绘样式 ModifyStyle(0, BS_OWNERDRAW); }这个细节经常有人漏。你在资源编辑器里放了一个CButton然后用DDX_Control把它绑定到CMyButton对象上如果不在PreSubclassWindow里设置BS_OWNERDRAWDrawItem永远不会被调用按钮看起来还是老样子。ModifyStyle的用法就是把原来的样式基础上追加BS_OWNERDRAW对原有样式没有影响。3.3 DrawItem绘制流程拆解核心的绘制函数并不复杂但每一步都有讲究。我把它拆开看void CMyButton::DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct) { CDC* pDC CDC::FromHandle(lpDrawItemStruct-hDC); CRect rc lpDrawItemStruct-rcItem; UINT state lpDrawItemStruct-itemState; // 双缓冲避免闪烁 CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rc.Width(), rc.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 根据状态选择背景色 COLORREF clrBg m_clrBg; if (!IsWindowEnabled()) { if (m_bDrawDisabled) { clrBg RGB(50, 50, 50); } else { // 如果不禁用自绘直接交给系统画 CButton::DrawItem(lpDrawItemStruct); return; } } else if (state ODS_SELECTED) { clrBg m_clrPressedBg; } else if (m_bHover) { clrBg m_clrHoverBg; } // 填充背景 memDC.FillSolidRect(rc, clrBg); // 可选画一个细边框提升层次感 CPen pen(PS_SOLID, 1, RGB(80, 80, 80)); CPen* pOldPen memDC.SelectObject(pen); CBrush* pOldBrush (CBrush*)memDC.SelectStockObject(NULL_BRUSH); memDC.Rectangle(rc); memDC.SelectObject(pOldPen); memDC.SelectObject(pOldBrush); // 绘制文字 CString strText; GetWindowText(strText); if (!strText.IsEmpty()) { memDC.SetBkMode(TRANSPARENT); memDC.SetTextColor(m_clrText); memDC.DrawText(strText, rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } // 焦点态画一个虚线框保持无障碍访问习惯 if (state ODS_FOCUS) { CRect rcFocus rc; rcFocus.DeflateRect(3, 3); memDC.DrawFocusRect(rcFocus); } // 把内存DC内容一次性拷贝到屏幕上 pDC-BitBlt(0, 0, rc.Width(), rc.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }几个值得注意的细节状态判断顺序很重要。先判断禁用再判断按下最后判断悬停。如果把悬停放在按下前面用户按住鼠标不放时背景色会一直显示悬停色而不是按下色视觉上会很奇怪。DrawText用DT_SINGLELINE之后还要注意文字的垂直居中。如果按钮高度比较大DT_VCENTER会让文字垂直居中但老版本Windows上有时会偏上可以手动调整rc.top和rc.bottom来微调。文字颜色我用SetTextColor设置但背景模式一定要设置TRANSPARENT否则文字背后会出现一块不透明的矩形底色盖掉你刚画的背景色。焦点虚线框不是必须的但强烈建议保留。一方面是无障碍操作的基本礼仪另一方面键盘用户Tab键切换焦点、空格键触发操作时没有焦点反馈会很困惑。3.4 鼠标悬停和离开的细节MFC的CButton默认不会通知你鼠标何时进入、何时离开。如果不处理按钮就只有普通和按下两种状态Hover效果做不出来。正确做法是调用Windows的TrackMouseEvent来跟踪鼠标离开void CMyButton::OnMouseMove(UINT nFlags, CPoint point) { if (!m_bTracking) { TRACKMOUSEEVENT tme { 0 }; tme.cbSize sizeof(TRACKMOUSEEVENT); tme.dwFlags TME_LEAVE; tme.hwndTrack m_hWnd; TrackMouseEvent(tme); m_bTracking TRUE; } if (!m_bHover) { m_bHover TRUE; Invalidate(FALSE); } CButton::OnMouseMove(nFlags, point); } void CMyButton::OnMouseLeave() { m_bTracking FALSE; m_bHover FALSE; Invalidate(FALSE); CButton::OnMouseLeave(); }这段代码的思路是第一次鼠标移动时注册TME_LEAVE跟踪之后系统会在鼠标离开按钮窗口时发送WM_MOUSELEAVE消息你再把悬停状态复位并触发重绘。注意Invalidate参数用FALSE表示不擦除背景配合后面讲的双缓冲机制可以减少闪烁。4. 想让按钮更好看GDI圆角与贴图两条进阶路线4.1 GDI圆角按钮的实现思路扁平化设计流行之后圆角按钮几乎成了标准配置。MFC自带的GDI画圆角矩形其实也能做RoundRect嘛但GDI的方式只能填充纯色做不了渐变、半透明、阴影这些效果。想要真正精致我建议用GDI。使用GDI需要先初始化。在应用启动时比如CWinApp派生类的InitInstance里调用GdiplusStartup退出时GdiplusShutdown这个环节很多人会忘导致后面所有GDI调用都失败。在DrawItem里用GDI重画背景的骨架大概是这样#include gdiplus.h #pragma comment(lib, gdiplus.lib) using namespace Gdiplus; void CMyButton::DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct) { CDC* pDC CDC::FromHandle(lpDrawItemStruct-hDC); CRect rc lpDrawItemStruct-rcItem; Graphics graphics(pDC-GetSafeHdc()); graphics.SetSmoothingMode(SmoothingModeAntiAlias); // 圆角半径为高度的一半就是胶囊形按钮 int radius rc.Height() / 2; GraphicsPath path; path.AddArc(rc.left, rc.top, radius * 2, radius * 2, 180, 90); path.AddArc(rc.right - radius * 2, rc.top, radius * 2, radius * 2, 270, 90); path.AddArc(rc.right - radius * 2, rc.bottom - radius * 2, radius * 2, radius * 2, 0, 90); path.AddArc(rc.left, rc.bottom - radius * 2, radius * 2, radius * 2, 90, 90); path.CloseFigure(); // 根据状态选择颜色 Color clr; if (!IsWindowEnabled()) clr Color(255, 50, 50, 50); else if (lpDrawItemStruct-itemState ODS_SELECTED) clr Color(255, 45, 46, 48); else if (m_bHover) clr Color(255, 80, 84, 86); else clr Color(255, 60, 63, 65); SolidBrush brush(clr); graphics.FillPath(brush, path); }GDI的构建圆角路径这段代码是我常用的写法比直接把整个矩形传进FillRoundRect更灵活——你可以只让上面两个角圆、下面两个角直角做那种「圆角上半、平直下半」的对话框底部按钮效果。文字绘制部分可以用Graphics::DrawString配合StringFormat实现居中也可以直接在GDI路径填充完成后再用GDI的DrawText画文字混用没有问题只要注意选对字体对象。4.2 贴图按钮三态切换和九宫格问题如果项目里有设计师或者公司有现成的UI素材贴图按钮是见效最快的方案。思路是在DrawItem里根据状态选三张图中的一张直接绘制void CMyButton::DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct) { CDC* pDC CDC::FromHandle(lpDrawItemStruct-hDC); CRect rc lpDrawItemStruct-rcItem; UINT state lpDrawItemStruct-itemState; int imgIdx 0; if (!IsWindowEnabled()) imgIdx 3; // 禁用图 else if (state ODS_SELECTED) imgIdx 2; // 按下图 else if (m_bHover) imgIdx 1; // 悬停图 else imgIdx 0; // 正常图 CBitmap bmp; bmp.LoadBitmap(IDB_BTN_NORMAL imgIdx); // 假设三张位图的ID连续 BITMAP bm; bmp.GetBitmap(bm); CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap* pOldBmp memDC.SelectObject(bmp); pDC-StretchBlt(rc.left, rc.top, rc.Width(), rc.Height(), memDC, 0, 0, bm.bmWidth, bm.bmHeight, SRCCOPY); memDC.SelectObject(pOldBmp); // 接着画文字如果有 }这个方案有一个绕不开的坑如果按钮尺寸和图片尺寸不一致StretchBlt会把图片拉伸变形。最常见的是圆角按钮在水平方向拉伸后四个圆角变成椭圆。解决这个问题的标准做法是九宫格切图——把图片拆成九个区域只拉伸中间区域和边缘四个角保持原始尺寸。九宫格实现起来代码量不大核心是根据源图四个角的宽高分别对应目标矩形的四个角绘制由于篇幅原因这里不展开但如果你的按钮宽度是动态变化的九宫格是你绕不过去的一课。5. 实战中容易踩的坑透明背景、闪烁与资源管理5.1 背景不一致问题自绘按钮常见的第一个尴尬按钮自己画成深灰色了但按钮所在的对话框背景是浅灰色于是按钮周围出现一个明显的深色矩形色块——因为按钮的背景矩形覆盖掉了对话框的背景。解决这个问题有两条路。第一条路让按钮背景不可见也就是绘制时先取出父窗口的背景贴上去再叠加按钮自身的图形。核心是用WM_PRINTCLIENT让父窗口把自己画进按钮的DC里BOOL CMyButton::OnEraseBkgnd(CDC* pDC) { CRect rc; GetClientRect(rc); // 把父窗口的背景画到按钮DC中 CWnd* pParent GetParent(); if (pParent) { pParent-SendMessage(WM_PRINTCLIENT, (WPARAM)pDC-GetSafeHdc(), 0); } return TRUE; // 告诉系统背景已擦除 }这个做法的好处是无论父对话框背景是纯色、渐变还是位图按钮背景都能保持一致而且不会闪烁。缺点是需要父窗口支持WM_PRINTCLIENTMFC的CDialog默认是支持的实测问题不大。第二条路更省事统一界面配色让按钮背景色和对话框背景色刻意保持一致。如果你的UI风格就是整体深色背景那么按钮背景直接填同色即可根本不存在「色块」的违和感。很多现代深色主题的程序就是这么做的。5.2 用双缓冲消灭闪烁自绘按钮的闪烁问题几乎是新手必踩。原因很简单每次重绘按钮先被擦成白色或背景色然后再重新填充和绘制这个「擦除→重绘」的间隙肉眼可见鼠标移动时就会闪个不停。解决办法是双缓冲先在内存里画好整幅图像再一次BitBlt到屏幕上。上面DrawItem里的memDC/CBitmap就是标准的双缓冲写法。注意两点第一BitBlt一定要放在所有绘制完成之后、函数返回之前绝不能画一点往屏幕拷一点第二Invalidate时用Invalidate(FALSE)避免触发背景擦除这样双缓冲才能发挥最佳效果。把这两点配合起来按钮即使在鼠标快速滑动、窗口缩放时也是稳的。5.3 字体和画刷的资源释放绘制按钮时大家很容易图省事直接在DrawItem里创建字体、画刷、画笔对象用完就扔。如果按钮只有一两个问题不大但界面上如果有几十个按钮每个按钮每帧都创建和销毁GDI对象很快GDI句柄就会耗尽程序要么绘制异常要么直接崩溃。正确做法是把常用的GDI对象做成成员变量在类的构造函数或OnCreate里统一创建在析构函数里统一释放。比如按钮文字通常全界面统一使用一种字体你可以在CMyButton里持有一个CFont成员在PreSubclassWindow里create在析构里delete// 头文件中 CFont m_font; // 实现中 void CMyButton::PreSubclassWindow() { CButton::PreSubclassWindow(); ModifyStyle(0, BS_OWNERDRAW); if (m_font.GetSafeHandle() NULL) { m_font.CreatePointFont(90, LMicrosoft YaHei); } } CMyButton::~CMyButton() { if (m_font.GetSafeHandle()) m_font.DeleteObject(); }DrawItem里使用的时候用SelectObject把字体选入DC绘制完恢复原字体即可。另外强调一下画笔、画刷这类小对象也尽量避免在绘制函数里临时new能复用的成员变量就复用。Windows的GDI对象是系统级资源这个资源泄漏不像内存泄漏那么好抓往往是程序运行一段时间后才慢慢卡死排查起来相当费劲。最后分享一个我自己的习惯。每写完一个自绘控件我不光测正常点击、悬停、按下、禁用这几个状态还会用键盘Tab键把焦点移到按钮上按空格触发确认焦点框、选中态都正常。再放大DPI缩放到150%确认文字和图形没有错位。这几个测试都过了按钮才算真正写完。MFC虽然老但做好了一样能支撑起像样的产品界面而这些细节正是决定界面质感的关键。本文还有配套的精品资源点击获取