ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MFC界面美化实战:从标题栏到按钮列表的现代化改造

MFC界面美化实战:从标题栏到按钮列表的现代化改造 简介面向有一定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消息把自绘标题栏区域返回HTCAPTIONLRESULT 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_castNMLVCUSTOMDRAW*(pNMHDR); *pResult CDRF_DODEFAULT; switch (pLVCD-nmcd.dwDrawStage) { case CDDS_PREPAINT: // 请求子项绘制通知这样才能精确控制每一列 *pResult CDRF_NOTIFYITEMDRAW | CDRF_NOTIFYSUBITEMDRAW; break; case CDDS_ITEMPREPAINT: { int nItem static_castint(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, LMicrosoft 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 SnapDWM属性未设置调用DwmExtendFrameIntoClientArea或DwmSetWindowAttribute最大化后窗口有白边窗口区域圆角未还原最大化时清除窗口圆角Region5.2 双缓冲为什么能解决九成闪烁自绘控件闪烁的本质是系统先擦除背景再绘制新内容两次操作之间存在时间差人眼就看到了闪烁。双缓冲的思路是先在一块内存DC里画好所有内容再一次性地复制到窗口DC上。MFC中我喜欢用CMemDC因为它在析构时自动把内容BitBlt到目标DC代码写起来干净也不容易遗漏DeleteDC。具体到CButton自绘在DrawItem开头构造CMemDCCDC* pDC CDC::FromHandle(lpDIS-hDC); CMemDC memDC(*pDC, lpDIS-rcItem); CDC dc memDC.GetDC(); // 后续所有dc.xxx绘制都画在内存中这样改动后按钮和列表的闪烁基本消失。如果列表里的行还是闪检查一下是否同时启用了LVS_EX_DOUBLEBUFFER扩展样式。5.3 无边框窗口圆角自绘的取舍无边框窗口如果想做圆角需要用到SetWindowRgnCRgn 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, LMicrosoft YaHei); return font; }菜单项高度可以通过OnDrawMenuItem或全局的CMFCToolBarInfo调整。经验值是菜单项高度比字号大8~10像素留足内边距鼠标操作起来不憋屈。写在最后的一点工程体会这套界面改造做完后我最大的感触是MFC美化的技术难点其实不多真正费时间的是排查那些“风格不统一”的细节——比如按钮圆角大小、列表行高、标题栏高度、菜单间距这些数值散落在各个自绘类里如果不集中管理后期调整就是一场灾难。我建议把所有颜色、圆角、边距、字号都抽成全局常量或配置结构放在一个独立的头文件里。这样以后换主题或加控件只需要改一处。再分享一个小技巧自绘控件在调试阶段可以把背景色故意调成高对比度颜色比如荧光绿这样能快速定位哪些区域没画到、哪些矩形被其他窗口覆盖了。等布局和绘制逻辑都稳定了再恢复正式配色。这个办法帮我少走了很多弯路。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进