
简介这是一份基于Visual Studio 2022的MFC界面美化完整工程案例面向需要优化程序视觉体验的C桌面开发者尤其适合对控件自绘感兴趣的中级读者。项目针对菜单栏、标题栏、按钮和列表控件进行了深度重绘同时实现最大化、最小化及还原时的界面自适应弥补MFC原生界面简陋、定制性差的短板。工程内含121个文件压缩包约175.27MB文件类型以cpp/h源码、png效果图、exe可执行程序和工程配置文件为主其中按钮重绘、菜单重绘、列表重绘等模块划分清晰均可单独提取复用。内容覆盖去除原生菜单栏、自绘头部菜单区域、文件/选项/帮助子菜单弹出样式以及最大化/最小化/退出按钮与启动/停止按钮的贴图美化列表控件同样完成样式重绘并在窗口缩放时对菜单栏、按钮和列表进行自适应调整。随包附带运行后的真实效果截图可直观对照美化前后的界面差异目前已有514人学习下载适合想系统掌握MFC自绘控件与界面美化技巧的中级开发者参考。 MFC 的界面美化是个老生常谈、但永远有需求的话题。这两年我用 MFC 维护一个快十年的 Windows 桌面程序业务逻辑全在 C 里功能已经很稳定唯独界面一直停在系统默认控件的水平灰底、直角按钮、普通菜单栏客户每次验收都要提一嘴这界面能改好看点吗。这次终于集中处理了一轮把菜单栏、标题栏、按钮、列表控件全部做了自绘美化并且把最大化、最小化时的控件布局自适应一起解决掉。整个过程踩了不少坑整理成一篇完整案例给正在折腾 MFC 界面美化的朋友参考。先说清楚这篇适合什么人你的 MFC 工程代码量很大、暂时没有重构到 Qt 的计划但又想在不改业务逻辑的前提下把界面整体升级一遍。案例基于 VS2019 MFC 对话框程序用到的核心技术是控件子类化、Owner-Draw 自绘、NM_CUSTOMDRAW 定制绘制和 WM_SIZE 布局自适应。文章会给出关键代码和踩坑记录照着做基本能复现出一套统一风格的界面。1. 动手前的路线选择自绘、换肤还是重构1.1 MFC 控件外观为什么停留在十几年前MFC 本身不是界面库它是对 Win32 控件的一层 C 包装。按钮、列表、菜单这些控件的外观绘制由系统主题负责MFC 默认不干预也没有 CSS 这种统一的样式机制。这就导致了一个现实问题想改一个控件的外观就得自己接管它的绘制流程。在做任何美化之前先搞清楚 MFC 自绘常用的三把钥匙子类化SubclassDlgItem 之后重写消息处理、Owner-Draw通过 BS_OWNERDRAW、MF_OWNERDRAW 等样式让父窗口负责绘制、Custom Draw系统在绘制过程中发 NM_CUSTOMDRAW 通知让程序在特定时机改颜色、字体等。三者的控制力从低到高侵入性也从低到高后面每个控件选哪种方式都是基于这个坐标系来决策的。1.2 三条路线怎么选实际动手前我列了一个对比把可行性想清楚才没有走弯路路线改动量维护成本风险适用场景逐个控件自绘/子类化中等可控低控件种类不多、追求可控性换肤库SkinMagic 等小高中快速出效果、能接受第三方依赖迁移 Qt / Duilib大中高新项目或计划长期重构我最终选了第一条项目里高频控件就按钮、列表、菜单、标题栏这几样子类化之后封装成自定义控件类后续复用也方便。换肤库看着快但老换肤库对新系统兼容性参差不齐出了问题反而难排查。迁移 Qt 的成本对这个老工程来说属于伤筋动骨业务代码全要重写不现实。1.3 先统一色系再动笔正式写绘制代码前我先把颜色变量抽成了统一主题避免在绘制代码里散落一堆颜色字面量。这套色系后面按钮、列表、菜单共用保证视觉上是一个整体主色RGB(52, 152, 219)按钮默认背景、选中高亮悬停色RGB(41, 128, 185)比主色深一档按压缩暗RGB(35, 110, 160)列表斑马纹RGB(248, 249, 250) 与白色交替文字主色RGB(44, 62, 80)颜色定下来之后后面所有控件的绘制代码都从主题结构体里取色后期想换一套皮肤只需要改这一处。2. 标题栏与菜单栏先把窗口的天花板换掉2.1 标题栏去掉系统标题栏自己接管标题栏美化最直接的做法不是自绘系统非客户区而是干脆去掉系统标题栏在客户区顶部自绘一条标题栏。原因是 WM_NCPAINT 自绘非客户区要处理系统按钮、圆角、阴影、拖拽吸附一堆行为成本高而且非常容易出兼容问题。去掉系统标题栏的代码很简单在 OnInitDialog 里执行一次LONG style ::GetWindowLong(m_hWnd, GWL_STYLE); style ~(WS_CAPTION | WS_SYSMENU); ::SetWindowLong(m_hWnd, GWL_STYLE, style); SetWindowPos(NULL, 0, 0, 0, 0, SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER);注意别把 WS_THICKFRAME 一起去掉否则窗口会失去拖拽改变大小的能力。去掉 WS_CAPTION 之后系统不再处理标题栏区域的拖拽需要自己在 WM_NCHITTEST 里返回 HTCAPTION让窗口管理器接管移动UINT CMyDialog::OnNcHitTest(CPoint point) { ScreenToClient(point); if (point.y m_nTitleHeight) { // 给最小化/最大化/关闭按钮留出点击区域 if (m_rcCloseBtn.PtInRect(point) || m_rcMaxBtn.PtInRect(point) || m_rcMinBtn.PtInRect(point)) return HTCLIENT; return HTCAPTION; } return CDialogEx::OnNcHitTest(point); }标题栏右侧三个按钮自己画最小化是两个小矩形最大化是一个方框关闭是两条交叉线。注意最大化状态下最大化按钮要画成还原形态也就是两个重叠方框。按钮的悬停效果通过 TrackMouseEvent 跟踪鼠标离开事件来控制需要把 TRACKMOUSEEVENT 结构里的 TME_LEAVE 设上否则鼠标移出后状态不会复位。2.2 菜单栏Owner-Draw 菜单的完整链路MFC 菜单最省事的做法是保留系统绘制但那样和自绘风格始终有割裂感。要做成和整体一致就得走 Owner-Draw。核心分两步第一步插入菜单项时给每一项设置 MF_OWNERDRAW同时把 dwItemData 指向一个自定义结构里面存文字、图标、状态等信息第二步在菜单子类里重写 MeasureItem 和 DrawItem前者告诉系统每一项的尺寸后者负责画背景、文字、选中高光。void CMyMenu::MeasureItem(LPMEASUREITEMSTRUCT lpMIS) { // 根据文字长度计算菜单项宽度 CString strText GetItemText(lpMIS-itemID); lpMIS-itemWidth 计算文字宽度 左右留白; lpMIS-itemHeight 28; } void CMyMenu::DrawItem(LPDRAWITEMSTRUCT lpDIS) { // 选中项画主题色背景普通项画白色背景 // 文字用 DrawText 或 GDI DrawString 绘制 // 注意 ODS_SELECTED 时背景需要反色 }一个常见坑MF_OWNERDRAW 的菜单项在鼠标悬停或键盘选中切换时系统不会自动重绘需要自己在菜单的 OnMouseMove 里判断当前高亮项是否变化再触发 Invalidate。实测下来菜单项数量少时体验还行如果菜单很深、子菜单很多建议直接放弃标准菜单改成点击图标弹出一个自绘 Popup 窗口控制力强得多也更容易做出动画效果。我这次的项目菜单层级不深Owner-Draw 够用所以没有走 Popup 方案。3. 按钮与列表两个最容易出效果的高频控件3.1 按钮子类化 CButton 后封装成 CMyButton按钮美化优先级最高因为它是界面上数量最多的交互控件。做法是给 CButton 派生子类在 PreSubclassWindow 里设置 BS_OWNERDRAW然后重写 DrawItem。绘制前先定义状态机Normal、Hover、Pressed、Disabled。Hover 状态靠 WM_MOUSEMOVE 进入、TrackMouseEvent(TME_LEAVE) 退出Pressed 在 WM_LBUTTONDOWN/UP 时切换。DrawItem 的核心绘制逻辑用 GDI 实现void CMyButton::DrawItem(LPDRAWITEMSTRUCT lpDIS) { CDC* pDC CDC::FromHandle(lpDIS-hDC); CRect rc lpDIS-rcItem; BOOL bPressed (lpDIS-itemState ODS_SELECTED) ! 0; BOOL bDisabled (lpDIS-itemState ODS_DISABLED) ! 0; BOOL bFocus (lpDIS-itemState ODS_FOCUS) ! 0; Gdiplus::Graphics g(pDC-GetSafeHdc()); g.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); g.SetTextRenderingHint(Gdiplus::TextRenderingHintClearTypeGridFit); Gdiplus::Color clrBg 根据状态选择背景色; // 先画圆角矩形背景再画文字 // 圆角半径取 6按钮高度较小时适当缩小 Gdiplus::GraphicsPath path; // 通过 AddArc 拼出圆角路径 // ... g.FillPath(new Gdiplus::SolidBrush(clrBg), path); // 绘制文字 CString str; GetWindowText(str); Gdiplus::Font font(pDC-GetSafeHdc()); Gdiplus::StringFormat sf; sf.SetAlignment(Gdiplus::StringAlignmentCenter); sf.SetLineAlignment(Gdiplus::StringAlignmentCenter); // 按下时文字用白色普通状态用深色 g.DrawString(str, str.GetLength(), font, rectF, sf, textBrush); // 有焦点时画虚线框保证键盘可用性 if (bFocus) { CRect rcFocus rc; rcFocus.DeflateRect(3, 3); pDC-DrawFocusRect(rcFocus); } }两个提醒第一GDI 在程序初始化时要调用 GdiplusStartup退出时 GdiplusShutdown否则绘制直接异常第二所有 new 出来的 Brush、Pen、GraphicsPath 用完必须 delete或者直接在栈上构造利用析构释放否则 GDI 对象泄漏到一定程度整个程序绘制都会变慢。如果发现按钮边缘出现锯齿多半是没开 SmoothingModeAntiAlias。3.2 列表NM_CUSTOMDRAW 实现斑马纹和悬停CListCtrl 的美化不需要用 Owner-Draw 全量接管那会让滚动、编辑、排序都变得很麻烦。用 NM_CUSTOMDRAW 通知在绘制关键节点介入即可。封装一个 CMyListCtrl拦截 NM_CUSTOMDRAWvoid CMyListCtrl::OnCustomDraw(NMHDR* pNMHDR, LRESULT* pResult) { NMLVCUSTOMDRAW* pLVCD (NMLVCUSTOMDRAW*)pNMHDR; *pResult CDRF_DODEFAULT; if (pLVCD-nmcd.dwDrawStage CDDS_PREPAINT) { *pResult CDRF_NOTIFYITEMDRAW; // 需要逐项通知 return; } if (pLVCD-nmcd.dwDrawStage CDDS_ITEMPREPAINT) { *pResult CDRF_NOTIFYSUBITEMDRAW; // 需要逐子项通知 return; } if (pLVCD-nmcd.dwDrawStage (CDDS_ITEMPREPAINT | CDDS_SUBITEM)) { int nRow (int)pLVCD-nmcd.dwItemSpec; // 斑马纹奇数行和偶数行交替背景 pLVCD-clrTextBk (nRow % 2) ? m_clrOddRow : m_clrEvenRow; // 鼠标所在行高亮 if (nRow m_nHoverRow) pLVCD-clrTextBk m_clrHoverRow; pLVCD-clrText m_clrText; *pResult CDRF_NEWFONT; } }同时在 OnMouseMove 里记录 m_nHoverRow 并 Invalidate鼠标离开时重置为 -1就能实现悬停行高亮。表头是独立的 CHeaderCtrl可以调用 GetHeaderCtrl 拿到指针后设置背景色或者给表头也设置 Owner-Draw 自己画渐变背景和分割线。我图省事给表头设了浅灰背景配合列表整体配色已经够干净。列表还有一个必须开的扩展样式m_list.SetExtendedStyle(m_list.GetExtendedStyle() | LVS_EX_DOUBLEBUFFER | LVS_EX_FULLROWSELECT);LVS_EX_DOUBLEBUFFER 能直接消灭列表滚动时的闪烁强烈建议打开。LVS_EX_FULLROWSELECT 让整行选中而不是只有第一列高亮视觉上更整齐。4. 最大化最小化自适应布局缩放不是简单按比例4.1 记录设计尺寸WM_SIZE 里计算缩放自适应最基础的做法在 OnInitDialog 里记录当前窗口的设计尺寸和每个控件的初始矩形在 OnSize 里按新窗口尺寸与设计尺寸的比值重新 MoveWindowvoid CMyDialog::OnSize(UINT nType, int cx, int cy) { CDialogEx::OnSize(nType, cx, cy); if (!m_bInited || cx 0 || cy 0) return; double sx (double)cx / m_szDesign.cx; double sy (double)cy / m_szDesign.cy; for (const auto item : m_layoutItems) { CRect rcNew; rcNew.left (int)(item.rcInit.left * sx); rcNew.top (int)(item.rcInit.top * sy); rcNew.right (int)(item.rcInit.right * sx); rcNew.bottom (int)(item.rcInit.bottom * sy); item.pWnd-MoveWindow(rcNew); } // 列表列宽按整宽比例分配 int nListW 列表区域的新宽度; m_list.SetColumnWidth(0, (int)(nListW * 0.20)); m_list.SetColumnWidth(1, (int)(nListW * 0.50)); m_list.SetColumnWidth(2, (int)(nListW * 0.30)); // 自绘标题栏上的按钮重新定位 LayoutTitleButtons(cx); }关键细节最大化、还原、手动拖拽都会触发 WM_SIZE所以这套逻辑能统一处理三种情况。但 OnSize 在窗口初始化过程中会触发多次必须用 m_bInited 标志位挡住窗口还没完全创建时的调用否则 MoveWindow 时控件句柄可能无效或者把初始尺寸覆盖掉。这个标志位在 OnInitDialog 末尾再置为 true。4.2 自绘元素在最大化时怎么处理窗口变大后除了控件要缩放自绘的界面元素也要跟着变。我这里的处理原则是标题栏高度固定不变标题栏上的按钮宽度固定44 像素左右它们只做右对齐偏移不做缩放内容区和列表做等比缩放。这样最大化时标题栏始终稳定不会出现按钮被拉宽变形的怪相。再补一个最小尺寸限制避免窗口拖得太小控件挤成一团void CMyDialog::OnGetMinMaxInfo(MINMAXINFO* lpMMI) { lpMMI-ptMinTrackSize.x 800; lpMMI-ptMinTrackSize.y 600; CDialogEx::OnGetMinMaxInfo(lpMMI); }还有一个容易被忽略的点如果窗口自定义了圆角最大化时圆角会出现黑边。我在这项目里用 IsZoomed() 判断最大化时去掉圆角、还原时恢复圆角这个逻辑放在窗口样式更新和重绘的地方执行。5. 实测中的那些坑闪烁、GDI 泄漏与 DPI5.1 闪烁基本都出在背景擦除MFC 窗口默认在 WM_ERASEBKGND 里用刷子清背景自绘内容一多清背景和绘制不同步就出现闪烁。通用解法重写 OnEraseBkgnd 直接 return TRUE背景统一在 OnPaint 里画按钮自绘内部也尽量一次性把背景和前景画完不要在绘制过程中触发级联 Invalidate。对话框可以考虑加 WS_CLIPCHILDREN 样式减少子控件重绘时对父窗口背景的反复擦除。5.2 GDI 对象泄漏是排查起来最痛苦的自绘代码里最容易犯的错是 CreatePen、CreateBrush 之后忘了 DeleteObject。GDI 对象是进程级资源泄漏到一定程度整个程序绘制都会变慢而且很难定位是哪个控件泄漏。我的经验是绘制函数里尽量用栈对象不要 new如果用了 GDI所有 new 出来的对象用完立刻 delete或者封装成局部对象利用析构释放。SelectObject 换入新对象后一定要把旧对象 Select 回去再释放新对象顺序反了会导致对象句柄无效这个细节在自绘代码里出现的频率非常高。5.3 DPI 感知不声明最大化就是错的老 MFC 程序如果不声明 DPI 感知在 150% 缩放的屏幕上窗口尺寸会被系统虚拟化最大化时尺寸是错的自绘的文字也会模糊。我在 InitInstance 最前面加了进程级声明SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);同时布局缩放计算时要考虑 DPI拿到设计尺寸后乘上 DPI 缩放系数。如果程序要兼容 Win7PerMonitorV2 不可用可以用 SetProcessDPIAware() 退而求其次保证系统级缩放正确。5.4 别把键盘用户丢了自绘界面做得再好看键盘走查时焦点全丢也是非常掉链子的事。自绘按钮要处理 ODS_FOCUS 状态并画焦点框自绘菜单要兼容键盘方向键高亮。另外关闭按钮、最小化按钮这类自定义点击区域最好也响应 VK_SPACE 和 VK_RETURN让键盘用户能触发。这个细节不做合规检查或者无障碍测试时一定会被打回。这套方案在老项目上完整跑了一个迭代整体观感明显提升实际业务功能一行都没动。我个人最大的体会是MFC 美化不是做不到而是要有节制地做。自绘的重点应该放在用户每天都会点、会看的控件上比如按钮、列表、标题栏菜单这类低频控件能统一风格就行不用追求过度精致。另外一定把主题色和布局缩放做成统一工具类否则后期每加一个按钮就要到处复制粘贴绘制代码维护成本会迅速失控。如果你正在折腾 MFC 界面建议先从按钮和列表下手这两处的投入产出比最高。本文还有配套的精品资源点击获取