MFC按钮自绘完全指南:从Owner Draw到GDI+圆角与贴图
2026/9/8 13:09:42 网站建设 项目流程

简介:面向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_OWNERDRAW:

void CMyButton::PreSubclassWindow() { CButton::PreSubclassWindow(); // 关键:加上自绘样式 ModifyStyle(0, BS_OWNERDRAW); }

这个细节经常有人漏。你在资源编辑器里放了一个CButton,然后用DDX_Control把它绑定到CMyButton对象上,如果不在PreSubclassWindow里设置BS_OWNERDRAW,DrawItem永远不会被调用,按钮看起来还是老样子。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_PRINTCLIENT,MFC的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, L"Microsoft YaHei"); } } CMyButton::~CMyButton() { if (m_font.GetSafeHandle()) m_font.DeleteObject(); }

DrawItem里使用的时候,用SelectObject把字体选入DC,绘制完恢复原字体即可。另外强调一下:画笔、画刷这类小对象也尽量避免在绘制函数里临时new,能复用的成员变量就复用。Windows的GDI对象是系统级资源,这个资源泄漏不像内存泄漏那么好抓,往往是程序运行一段时间后才慢慢卡死,排查起来相当费劲。

最后分享一个我自己的习惯。每写完一个自绘控件,我不光测正常点击、悬停、按下、禁用这几个状态,还会用键盘Tab键把焦点移到按钮上,按空格触发,确认焦点框、选中态都正常。再放大DPI缩放到150%,确认文字和图形没有错位。这几个测试都过了,按钮才算真正写完。MFC虽然老,但做好了一样能支撑起像样的产品界面,而这些细节,正是决定界面质感的关键。

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

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

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

立即咨询