简介:面向有一定MFC基础、希望提升桌面应用界面质感的开发人员,这是一份完整的MFC界面自绘与美化案例。工程基于VS2022可直接运行,演示如何去除MFC原生菜单栏及标题栏上的系统按钮,自行重绘顶部菜单区、文件/选项/帮助等菜单,并重绘启动/停止按钮与列表控件,同时为主界面背景上色,使整体风格统一。针对最大化、最小化与还原场景,工程对菜单栏、按钮、列表等控件做了自适应布局处理,避免界面错位。压缩包共121个文件,以C++源码文件(.h/.cpp)、图片资源(.png/.ico)、VS工程配置与编译中间文件为主,总大小约175.27MB,目录结构保留了可直接打开的完整工程,便于对照调试。目前已有514人学习过该资源。借助本项目可掌握MFC自绘按钮、菜单、列表及标题栏的实现路径,其中多处封装类可抽取复用到自己的项目,适合需要做界面美化改造或自绘控件实践的学习者参考。 做了这么多年MFC项目,被产品和用户吐槽最多的就是界面:“功能没问题,但看起来像上世纪的东西。”这话听着扎心,但确实没冤枉MFC——默认的灰色对话框、黑色凸起按钮、白底黑字的列表,搁在今天确实像出土文物。但MFC不是不能好看,而是网上关于界面美化的资料太碎片化:要么只讲一个CButton自绘,要么只讲CListCtrl隔行变色,很少有人把菜单栏、标题栏、按钮、列表这一整套从丑到美的改造流程串起来讲,更别说还要处理最大化、最小化时的自适应布局。这篇文章就是我最近一个实际项目的完整改造记录,把每个控件的实现思路、关键代码、踩坑点都写清楚。适合正在用VS开发MFC项目、想让老程序换新颜但又不想引入Qt或DuiLib等重量级方案的开发者。
1. 整体思路与方案选型:MFC美化不是零散自绘,是统一工程
1.1 立项前先把美化目标定清楚
这个项目不是单独美化某一个控件,而是要把整个对话框窗口做成一套统一的现代风格。所以在动手之前,我先定了几条硬性要求:
- 风格统一:菜单栏、标题栏、按钮、列表用同一套主色、圆角尺寸和字体。
- 保留原生交互:键盘快捷键、Tab切换、Alt菜单热键不能破坏。
- 自适应不塌方:窗口最大化、最小化再还原时,布局不能错位,控件不能变形。
- 不引入第三方界面库:只基于MFC本身加GDI/GDI+绘制,避免给项目增加依赖和迁移成本。
这条看起来简单,实际第2条最容易被忽略。很多自绘方案为了图方便,直接把窗口改成无边框、菜单栏也废弃了,结果快捷键全部失效,产品验收时直接被否。真正成熟的美化方案,一定是建立在稳住原有交互逻辑之上的。
1.2 四条技术路线,我为什么这么选
MFC界面美化的常见路线有四种,我做了个对比:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 纯自绘(Owner Draw) | 定制度高、无依赖、效果可控 | 每个控件都要写绘制逻辑,工作量大 | 中等规模项目,要求风格统一 |
| CustomDraw自定义绘制 | 代码量小,复用系统消息机制 | 只能改颜色字体等有限属性,细节定制难 | 列表、树形等复杂控件 |
| 换视觉管理器(CMFCVisualManager) | 集成度高,改动少 | 风格固化,深层次定制困难 | 使用了VS Feature Pack的项目 |
| 引入第三方界面库 | 效果好,组件全 | 依赖重、学习成本高、可能影响现有架构 | 新项目或允许大改的项目 |
最终我选择的是Owner Draw + CustomDraw + 自绘非客户区的组合方案:按钮用Owner Draw,列表用CustomDraw,标题栏用无边框自绘,菜单栏用CMFCMenuBar配合自定义Visual Manager。这套组合能覆盖绝大部分交互需求,又不至于把工程量抬得过高。
1.3 工程结构先说清楚,免得后面返工
自绘代码一定要和业务逻辑解耦。我最开始在对话框类里直接写绘制函数,结果改配色时差点把整个类翻个底朝天。这次我把每个自绘控件封装成独立类,对话框里只负责创建控件、注册锚点、处理消息。换肤就变成了改几个颜色常量和圆角尺寸的值,这才是做美化的正确姿势。
2. 菜单栏与标题栏美化:先把窗口的门面立起来
2.1 自绘标题栏:去掉系统标题栏是无边框方案的关键
默认的系统标题栏是没法改样式的,所以第一步是去掉它,改成自绘。在对话框的OnCreate或OnInitDialog里动态调整窗口样式:
// 在OnInitDialog中移除系统标题栏和粗边框 LONG style = GetWindowLong(m_hWnd, GWL_STYLE); style &= ~(WS_CAPTION | WS_THICKFRAME); SetWindowLong(m_hWnd, GWL_STYLE, style); // 保留圆角阴影需要引入DWM,同时支持Aero Snap DWMNCRENDERINGPOLICY policy = DWMNCRP_ENABLED; DwmSetWindowAttribute(m_hWnd, DWMWA_NCRENDERING_POLICY, &policy, sizeof(policy));去掉标题栏后,必须在客户区自己画一个标题栏。常见做法是:在窗口顶部预留一块固定高度的矩形区域,用GDI+画渐变背景、程序图标、标题文字,右上角放三个自定义按钮(最小化、最大化/还原、关闭)。这里有个关键点:既然是自绘,那窗口拖动逻辑也要自己接管。处理WM_NCHITTEST消息,把自绘标题栏区域返回HTCAPTION:
LRESULT CMyDlg::OnNcHitTest(CPoint point) { // point是屏幕坐标,转成客户区坐标判断 CRect rcClient; GetClientRect(rcClient); ClientToScreen(rcClient); CRect rcTitle(rcClient.left, rcClient.top, rcClient.right, rcClient.top + m_nTitleHeight); if (rcTitle.PtInRect(point)) { // 排除右上角三个按钮区域,避免拖拽冲突 for (auto& rcBtn : m_btnTitleButtons) { CRect rcBtnScreen = rcBtn; ClientToScreen(rcBtnScreen); if (rcBtnScreen.PtInRect(point)) return HTCLIENT; } return HTCAPTION; } return CDialog::OnNcHitTest(point); }这里踩过一个坑:如果不排除按钮区域,点击最小化按钮时系统会当成拖拽处理,按钮根本收不到鼠标消息。我后来是通过遍历自绘按钮的矩形来判断的。
双击最大化/还原的逻辑也要自己处理。可以在WM_LBUTTONDBLCLK里判断鼠标是否落在标题栏区域,是的话执行ShowWindow(SW_MAXIMIZE)或恢复。
2.2 菜单栏:CMFCMenuBar 比老式 CMenu 自绘省力太多
老式CMenu的菜单栏自绘非常痛苦,要处理WM_MEASUREITEM、WM_DRAWITEM,还得自己测量文本宽度、绘制高亮状态和图标,一个不小心菜单项高度就不对齐。如果项目是用VS2010以后版本创建的,强烈建议用CMFCMenuBar替代传统菜单。
CMFCMenuBar的使用很简单,在OnCreate里创建并关联菜单:
// 在OnCreate中 if (!m_wndMenuBar.Create(this)) { return -1; } m_wndMenuBar.SetMenu(GetMenu()); SetMenu(NULL); // 把系统菜单去掉,交给CMFCMenuBar接管CMFCMenuBar好看的关键在于自定义CMFCVisualManager。继承一个类,重写菜单栏相关的绘制函数,可以控制背景色、菜单项高亮色、文字颜色。比如:
class CMyVisualManager : public CMFCVisualManager { public: virtual void OnDrawMenuBarBackground(CDC* pDC, CMFCMenuBar* pMenuBar, CRect rect, const COLORREF& clrText) override { // 填充渐变色或纯色 pDC->FillSolidRect(rect, RGB(45, 45, 48)); } virtual void OnFillBarBackground(CDC* pDC, CBasePane* pBar, CRect rectClient, CRect rectClip, BOOL bShade) override { // 处理停靠栏背景 pDC->FillSolidRect(rectClient, RGB(45, 45, 48)); } };使用前调用CMFCVisualManager::SetDefaultManager(RUNTIME_CLASS(CMyVisualManager))。这套方案保留了菜单的原生交互逻辑(热键、快捷键、弹出逻辑),只是换皮而已,安全指数高很多。
还有个经验:CMFCMenuBar的图标如果没显示,先检查菜单项是否设置了位图资源、资源ID是否正确,再检查EnableMenuItemImage是否开启。图标建议统一16x16,过大时菜单项会整体变高,和标题栏不协调。
3. 按钮与列表控件美化:Owner Draw 和 CustomDraw 的实战组合
3.1 按钮自绘:把普通CButton变成圆角高亮三态按钮
普通按钮的美化我选择Owner Draw方式,重写CButton派生类的DrawItem。在资源编辑器里把按钮的Owner Draw属性设为TRUE,或者在创建时加上BS_OWNERDRAW样式。
绘制时需要处理四种状态:普通、悬停、按下、禁用。我的实现思路是用不同颜色和边框粗细来区分状态,核心代码如下:
void CMyButton::DrawItem(LPDRAWITEMSTRUCT lpDIS) { CDC* pDC = CDC::FromHandle(lpDIS->hDC); CRect rc = lpDIS->rcItem; UINT state = lpDIS->itemState; // 双缓冲,避免绘制时闪烁 CMemDC memDC(*pDC, rc); CDC& dc = memDC.GetDC(); // 根据状态取背景色和文字色 COLORREF clrBg = m_clrNormalBg; COLORREF clrBorder = m_clrNormalBorder; if (state & ODS_SELECTED) { clrBg = m_clrPressedBg; clrBorder = m_clrPressedBorder; } else if (state & ODS_DISABLED) { clrBg = m_clrDisabledBg; clrBorder = m_clrDisabledBorder; } else if (m_bHover) { clrBg = m_clrHoverBg; clrBorder = m_clrHoverBorder; } // 绘制圆角矩形背景 dc.FillSolidRect(rc, clrBg); dc.Draw3dRect(rc, clrBorder, clrBorder); CBrush brush(RGB(255, 255, 255)); dc.FrameRgn(...); // 或用RoundRect自行填充圆角 // 绘制文字 CString strText; GetWindowText(strText); dc.SetBkMode(TRANSPARENT); dc.SetTextColor(state & ODS_DISABLED ? RGB(160, 160, 160) : m_clrText); dc.DrawText(strText, rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE); }要响应悬停状态,需要给按钮加上CS_HREDRAW | CS_VREDRAW或手动处理鼠标进入/离开消息。比较简单的方式是子类化后处理WM_MOUSEMOVE和WM_MOUSELEAVE,用TrackMouseEvent监听鼠标离开,然后Invalidate(FALSE)触发重绘。实测下来,这个方案比判断光标位置再定时器轮询可靠得多,且不产生额外CPU负载。
注意:自绘按钮的文字别使用默认的System字体,最好在OnSetFont或创建时指定微软雅黑,否则文字描边和背景不协调,精细感会大打折扣。
3.2 列表控件:用NM_CUSTOMDRAW实现隔行变色和自定义单元格
列表控件CListCtrl是美化重灾区,它同时包含表头、行、单元格、滚动条。好在我通常不需要完全自绘所有区域,用CustomDraw就能满足90%的需求。在父窗口中响应NM_CUSTOMDRAW通知,处理方式如下:
void CMyDlg::OnNMCustomdrawList(NMHDR* pNMHDR, LRESULT* pResult) { NMLVCUSTOMDRAW* pLVCD = reinterpret_cast<NMLVCUSTOMDRAW*>(pNMHDR); *pResult = CDRF_DODEFAULT; switch (pLVCD->nmcd.dwDrawStage) { case CDDS_PREPAINT: // 请求子项绘制通知,这样才能精确控制每一列 *pResult = CDRF_NOTIFYITEMDRAW | CDRF_NOTIFYSUBITEMDRAW; break; case CDDS_ITEMPREPAINT: { int nItem = static_cast<int>(pLVCD->nmcd.dwItemSpec); // 隔行变色,同时让选中行保持高亮 if (pLVCD->nmcd.uItemState & CDIS_SELECTED) { pLVCD->clrTextBk = RGB(210, 228, 255); pLVCD->clrText = RGB(0, 0, 0); } else { pLVCD->clrTextBk = (nItem % 2 == 0) ? RGB(248, 248, 248) : RGB(255, 255, 255); pLVCD->clrText = RGB(64, 64, 64); } *pResult = CDRF_NEWFONT; break; } case CDDS_SUBITEMPREPAINT: // 这里可以对指定列单独设置颜色或字体 break; } }如果需要给列表添加图标或图片,记得给CListCtrl设置LVS_EX_FULLROWSELECT和LVS_EX_GRIDLINES等扩展样式,配合ImageList使用。我还遇到过一个问题:自绘隔行变色后,某些行的文字变成半透明、不清晰。后来发现是因为没有给CListCtrl设置LVS_OWNERDRAWFIXED却用了透明背景绘制,导致文字和背景叠色。解决方法是设置LVS_EX_DOUBLEBUFFER扩展样式,并确保在CDDS_PREPAINT里返回CDRF_NOTIFYITEMDRAW,让系统知道我们要接管绘制。
表头(Header Control)的美化也很影响整体观感。可以给列表的Header设置HDS_BUTTONS样式,然后在HDN_ITEMDRAWING消息里绘制。如果不做复杂表头,至少把表头背景改成和标题栏协调的深色:用CHeaderCtrl::SetBkColor不太稳定,更可靠的是派生子类重绘。表头高度也可以通过SetWindowPos调整,让列表整体更紧凑。
4. 最大化最小化自适应布局:锚点方案与DPI适配实战
4.1 OnSize自适应的核心原理
对话框默认布局是固定的,窗口拉大后控件不会自动跟着变。做自适应布局的核心就是响应WM_SIZE,根据窗口尺寸变化重新计算每个控件的位置和大小。
比例法最容易理解:记录初始窗口宽高和每个控件的初始矩形,在OnSize里按当前尺寸对初始尺寸的比例,重新算出控件的新矩形,然后MoveWindow。但这有个问题:某些控件希望固定大小(比如右侧的按钮),有些希望跟随拉伸(比如列表)。如果统一用比例,按钮也会被拉伸变形,视觉上非常难受。
锚点方案更实用。我给每个控件注册一个锚点配置,类似这样的结构:
struct AnchorItem { HWND hWnd; // 控件句柄 CRect rcInit; // 初始位置的客户区矩形 UINT uAnchors; // 锚点标志,LEFT|TOP|RIGHT|BOTTOM };锚点标志的含义是:控件相对父窗口哪几条边保持距离不变。比如列表控件一般注册LEFT|TOP|RIGHT|BOTTOM,表示四条边都跟随窗口变化,即列表整体拉伸;而“确定”“取消”按钮一般注册RIGHT|BOTTOM,表示固定在右下角,不随窗口缩放。
在OnSize里遍历所有锚点项:
void CMyDlg::OnSize(UINT nType, int cx, int cy) { CDialog::OnSize(nType, cx, cy); if (!m_bLayoutInit || cx == 0) return; // 初始化完成前跳过 for (auto& item : m_vecAnchors) { int nLeft = item.rcInit.left; int nTop = item.rcInit.top; int nRight = item.rcInit.right; int nBottom = item.rcInit.bottom; if (item.uAnchors & ANCHOR_LEFT) { // 左边距不变,宽度不变 } else { // 相对右边固定 nLeft = cx - (m_rcInit.right - item.rcInit.left); nRight = cx - (m_rcInit.right - item.rcInit.right); } // TOP/BOTTOM同理... CWnd* pWnd = CWnd::FromHandle(item.hWnd); pWnd->MoveWindow(nLeft, nTop, nRight - nLeft, nBottom - nTop); } }这段代码看着简单,真正坑人的点在:OnSize会被频繁触发,如果MoveWindow内部又触发WM_SIZE,就可能递归。我的做法是用m_bLayoutInit标志控制,在OnInitDialog里完成初始布局记录后置为TRUE,后续OnSize才真正执行。另外窗口最小化时cx、cy可能为0,要提前返回。
4.2 WM_GETMINMAXINFO 限制最小尺寸
自适应布局做得再完美,窗口缩得过小也会导致布局失真。我在WM_GETMINMAXINFO里限制了最小窗口尺寸:
void CMyDlg::OnGetMinMaxInfo(MINMAXINFO* lpMMI) { lpMMI->ptMinTrackSize.x = 800; lpMMI->ptMinTrackSize.y = 600; CDialog::OnGetMinMaxInfo(lpMMI); }这个值要和自绘标题栏高度、菜单栏宽度匹配,保证即使拖到最小,右侧按钮和下侧按钮也还在可视区域内。
4.3 DPI和高分辨率屏幕适配
MFC老程序在高分屏上最常见的毛病有两个:字体模糊、布局偏移。字体问题根源在于用了默认的System字体或按像素创建的字体。解决办法是改用CreatePointFont,按点创建字体,这样在不同DPI下字体的物理大小保持不变。
// 按10.5磅创建微软雅黑 m_font.CreatePointFont(105, L"Microsoft YaHei", m_hWnd);布局偏移则是另一个故事。自绘标题栏的宽度、列表的列宽、按钮的内边距,在高DPI下如果都用像素写死,会显得很局促。比较稳妥的方案是:在程序启动时获取系统DPI缩放比,把所有界面常量乘以缩放系数。
UINT uDpi = GetDpiForWindow(m_hWnd); double scale = uDpi / 96.0; m_nTitleHeight = (int)(32 * scale);如果项目用的是VS2015以后版本,还可以在app的InitInstance里调用SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2),这样窗口在不同DPI显示器间移动时会自动触发WM_DPICHANGED,需要自己处理坐标缩放。不过这种做法在兼容老系统上有风险,稳妥起见我还是统一用一个scale系数做手动缩放。
5. 常见问题与排查技巧实录:闪烁、错位、字体模糊都在这儿了
5.1 常见问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 按钮自绘时闪烁严重 | 没有双缓冲 | 使用CMemDC在内存DC绘制后再统一BitBlt |
| 最大化后控件错位 | OnSize里没判断初始尺寸 | 加m_bLayoutInit标志位;检查锚点注册顺序 |
| 标题栏拖动无响应 | WM_NCHITTEST未返回HTCAPTION | 检查消息处理函数返回值和坐标转换 |
| 菜单栏图标不显示 | 位图资源ID不对或未加载 | 检查资源ID、菜单项Image索引 |
| 列表隔行变色后文字变灰 | 未设置clrText或透明背景冲突 | 在CDDS_ITEMPREPAINT中显式设置clrText |
| 高DPI下字体发虚 | 创建字体时用了像素大小 | 改为CreatePointFont,或按DPI缩放字体高度 |
| 无边框窗口不能Aero Snap | DWM属性未设置 | 调用DwmExtendFrameIntoClientArea或DwmSetWindowAttribute |
| 最大化后窗口有白边 | 窗口区域圆角未还原 | 最大化时清除窗口圆角Region |
5.2 双缓冲为什么能解决九成闪烁
自绘控件闪烁的本质是:系统先擦除背景,再绘制新内容,两次操作之间存在时间差,人眼就看到了闪烁。双缓冲的思路是先在一块内存DC里画好所有内容,再一次性地复制到窗口DC上。MFC中我喜欢用CMemDC,因为它在析构时自动把内容BitBlt到目标DC,代码写起来干净,也不容易遗漏DeleteDC。
具体到CButton自绘,在DrawItem开头构造CMemDC:
CDC* pDC = CDC::FromHandle(lpDIS->hDC); CMemDC memDC(*pDC, lpDIS->rcItem); CDC& dc = memDC.GetDC(); // 后续所有dc.xxx绘制都画在内存中这样改动后,按钮和列表的闪烁基本消失。如果列表里的行还是闪,检查一下是否同时启用了LVS_EX_DOUBLEBUFFER扩展样式。
5.3 无边框窗口圆角自绘的取舍
无边框窗口如果想做圆角,需要用到SetWindowRgn:
CRgn rgn; rgn.CreateRoundRectRgn(0, 0, width + 1, height + 1, 12, 12); SetWindowRgn(rgn, TRUE);但这有个坑:窗口最大化时如果还保留圆角Region,四个角会被系统裁剪掉,看起来像窗口缺了角。所以要在最大化时重新创建矩形Region,还原时再恢复圆角Region。我踩过这个坑之后,直接在OnSize里判断当前是否SW_MAXIMIZE,动态切换Region,花了几分钟就解决了。
5.4 列表列宽在最大化后也要自适应
列表控件的列宽如果写死,窗口拉大后会出现很长的空白列,很丑。我是在OnSize里遍历列,按初始列宽比例重新设置:
for (int i = 0; i < m_listCtrl.GetHeaderCtrl()->GetItemCount(); i++) { int newWidth = (int)(m_initColWidths[i] * (double)cx / m_initClientWidth); m_listCtrl.SetColumnWidth(i, newWidth); }注意列宽整数精度问题:每次缩放后都从初始宽度按比例重新计算,不要用上一次的宽度累乘,否则多次缩放会累积误差,列宽会越拉越宽或越来越窄。
5.5 自绘菜单的字号和间距
CMFCMenuBar虽然不用自己绘制弹出菜单项,但默认字号往往偏小。通过CMyVisualManager里重写GetMenuFont,可以统一设置菜单字体:
CFont& CMyVisualManager::GetMenuFont() { static CFont font; if (font.GetSafeHandle() == NULL) font.CreatePointFont(95, L"Microsoft YaHei"); return font; }菜单项高度可以通过OnDrawMenuItem或全局的CMFCToolBarInfo调整。经验值是菜单项高度比字号大8~10像素,留足内边距,鼠标操作起来不憋屈。
写在最后的一点工程体会
这套界面改造做完后,我最大的感触是:MFC美化的技术难点其实不多,真正费时间的是排查那些“风格不统一”的细节——比如按钮圆角大小、列表行高、标题栏高度、菜单间距,这些数值散落在各个自绘类里,如果不集中管理,后期调整就是一场灾难。我建议把所有颜色、圆角、边距、字号都抽成全局常量或配置结构,放在一个独立的头文件里。这样以后换主题或加控件,只需要改一处。
再分享一个小技巧:自绘控件在调试阶段可以把背景色故意调成高对比度颜色,比如荧光绿,这样能快速定位哪些区域没画到、哪些矩形被其他窗口覆盖了。等布局和绘制逻辑都稳定了,再恢复正式配色。这个办法帮我少走了很多弯路。
本文还有配套的精品资源,点击获取