
简介针对 MFC 单文档SDI窗口的自绘需求这份示例工程展示了如何覆盖系统默认边框、标题栏和关闭按钮实现完全自定义的窗口效果适合有 MFC 基础、希望打造个性化界面的开发者。压缩包共 32 个文件由 C 头文件与源文件、RC 资源脚本、ICO/BMP 图标位图、两个可执行示例及说明文档组成整体仅 95KB便于阅读运行。已有 393 人学习。内容围绕 CFrameWnd 派生类的 OnEraseBkgnd 与 OnPaint 重写展开覆盖 GDI 绘图、非客户区布局、关闭按钮的鼠标命中与状态切换并兼顾 DPI 缩放兼容。从背景擦除到边框、标题栏与按钮的设计示例提供完整可运行实现对照工程和 Exe 能掌握自绘窗口的完整流程快速迁移到自己的项目。1. 自己画MFC单文档边框标题栏从默认皮肤到全自绘的实战记录接手一个交互工具的开发任务时甲方提出要一个完全自定义的界面风格默认的MFC标题栏和边框风格与整体设计格格不入市面上的换肤库又太重。经过查找发现可以自己动手绘制MFC单文档的边框和标题栏通过接管非客户区的绘制和消息处理实现完全自定义的外观。这个过程涉及MFC框架对WM_NC系列消息的处理机制、设备上下文绘制技巧、以及Windows的命中测试流程最终做出来的效果跟原生应用基本无差别而且不像换肤库那样拖着几十兆资源。整个改造过程大约只动了几个函数却彻底去掉了“MFC原生疙瘩”的感觉。适合手上有MFC老项目需要做界面定制、或者想深入理解Windows非客户区机制的人。2. 自绘原理为什么改几个消息就能换掉整个窗口皮肤2.1 非客户区与客户区的边界在哪里Windows窗口从布局上分成两个区域客户区是程序真正绘制业务内容的地方非客户区则是系统负责的标题栏、边框、系统菜单按钮和最小化/最大化/关闭按钮。MFC默认情况下把非客户区的绘制权交给系统所以标题栏和边框都是系统主题风格。要自己画边框标题栏核心思路是接管这个区域的绘制权和控制权。我这里用的方案是重写CWnd或CFrameWnd的OnNcPaint、OnNcCalcSize、OnNcHitTest、OnNcActivate等成员函数。OnNcCalcSize决定非客户区占多大面积OnNcPaint负责实际绘制OnNcHitTest决定鼠标点在哪个位置时返回什么命中类型OnNcActivate处理窗口激活时标题栏颜色变化。// 头文件中的声明 afx_msg void OnNcCalcSize(BOOL bCalcValidRects, NCCALCSIZE_PARAMS* lpncsp); afx_msg void OnNcPaint(); afx_msg UINT OnNcHitTest(CPoint point); afx_msg BOOL OnNcActivate(BOOL bActive);这四个函数是自绘标题栏所需的最少集合。OnNcCalcSize里通过修改NCCALCSIZE_PARAMS结构体中的rgrc数组告诉系统客户区要往下缩多少预留出自己绘制标题栏的空间。如果这里不改OnNcPaint绘制的区域会被系统默认的非客户区遮盖或者空间不够画出来的标题栏要么被截断要么被覆盖。2.2 MFC的消息映射如何把系统消息转到自定义处理MFC对WM_NC*系列消息有默认处理逻辑在CWnd的基类中已经有了成套响应。我们需要在BEGIN_MESSAGE_MAP中手动添加映射项把消息路由到重写后的函数。BEGIN_MESSAGE_MAP(CMainFrame, CFrameWnd) ON_WM_NCCALCSIZE() ON_WM_NCPAINT() ON_WM_NCHITTEST() ON_WM_NCACTIVATE() ON_WM_GETMINMAXINFO() END_MESSAGE_MAP()这几个宏展开后分别对应WM_NCCALCSIZE、WM_NCPAINT、WM_NCHITTEST、WM_NCACTIVATE和WM_GETMINMAXINFO。ON_WM_GETMINMAXINFO用于限制窗口最小尺寸防止标题栏按钮被压缩到看不见。这里有一个容易忽略的点一旦你接管了WM_NCCALCSIZE且返回TRUE系统会认为你已经修改了客户区大小后续的OnNcPaint收到的区域范围就是新的非客户区绘制坐标要基于这个新区域计算而不是系统原始的边框范围。2.3 两种自绘路线的取舍自绘标题栏通常有两种路线一种是在OnNcPaint里用CWindowDC直接绘制非客户区这种方式改动最小、不需要破坏系统原有的WM_NCCALCSIZE逻辑但绘制区域受限于系统给定的裁剪区有些效果比如做圆角、异形边框会受到限制。另一种是先改WM_NCCALCSIZE把整个非客户区干掉让客户区延伸到窗口顶部然后自己在客户区顶部画一个假的标题栏。// 方案A保留系统非客户区仅重绘 void CMainFrame::OnNcPaint() { CWindowDC dc(this); CRect rcWnd; GetWindowRect(rcWnd); // 绘制的坐标要相对于窗口左上角 dc.FillSolidRect(0, 0, rcWnd.Width(), 18, RGB(45, 45, 48)); // 继续绘制标题文字等... CWnd::OnNcPaint(); // 让系统绘制剩余部分 } // 方案B完全干掉非客户区 void CMainFrame::OnNcCalcSize(BOOL bCalcValidRects, NCCALCSIZE_PARAMS* lpncsp) { if (bCalcValidRects) { // 把非客户区大小改为0标题栏由客户区自绘 lpncsp-rgrc[0].top 0; // 不再让出顶部空间 // 其他方向也不让 } CWnd::OnNcCalcSize(bCalcValidRects, lpncsp); }我最终采用的是方案B。理由很简单方案A虽然代码少但系统会在OnNcPaint结束后用主题色补齐剩余非客户区如果你绘制的区域有遗漏或者半透明效果会出现明显的系统底色透出。方案B直接把非客户区清零整个窗口顶部都是客户区绘制自由度大很多但代价是你需要自己处理窗口的缩放边框——因为非客户区边框也被清掉了。3. 核心模块拆解从WM_NCCALCSIZE到窗口标题栏的完整绘制链路3.1 自定义标题栏的高度和按钮布局当WM_NCCALCSIZE把系统非客户区清零后程序里的客户区从窗口物理顶部开始。这时标题栏的“物理空间”是客户区顶部的一块矩形。我需要在这里绘制背景、图标、标题文字以及最小化、最大化、关闭三个系统按钮。// 定义标题栏布局参数 #define TITLEBAR_HEIGHT 32 // 标题栏高度 #define BUTTON_WIDTH 46 // 每个系统按钮宽度 #define BUTTON_SPACING 1 // 按钮间距 #define TITLE_ICON_SIZE 16 // 图标大小 #define TITLE_LEFT_MARGIN 8 // 标题左侧边距 // 计算三个按钮的矩形区域 void CMainFrame::GetSystemButtonRects(CRect rcMin, CRect rcMax, CRect rcClose) { CRect rcClient; GetClientRect(rcClient); // 按钮在客户区顶部右侧排布 rcClose.SetRect(rcClient.right - BUTTON_WIDTH, 0, rcClient.right, TITLEBAR_HEIGHT); rcMax.SetRect(rcClose.left - BUTTON_WIDTH - BUTTON_SPACING, 0, rcClose.left - BUTTON_SPACING, TITLEBAR_HEIGHT); rcMin.SetRect(rcMax.left - BUTTON_WIDTH - BUTTON_SPACING, 0, rcMax.left - BUTTON_SPACING, TITLEBAR_HEIGHT); }这里有一个细节按钮区域计算使用的是客户区坐标因为此时客户区已经从窗口顶部开始GetClientRect返回的矩形左上角就是窗口的物理左上角。三个按钮的宽度设为46像素是经验值与微软官方推荐的触控目标尺寸接近在实际工程中如果标题文字较长可以用GetTextExtent动态调整。3.2 在OnNcPaint中绘制背景和标题文字窗口的每一次重绘都会触发OnNcPaint包括窗口移动、尺寸变化、遮挡解除等。在这个函数中先绘制整块背景再画文字和按钮。为了性能我预先加载了CImageList用于存储系统按钮的图标资源避免每次重绘都从磁盘读取位图。void CMainFrame::OnNcPaint() { CWindowDC dc(this); CRect rcClient; GetClientRect(rcClient); // 步骤1绘制标题栏背景渐变效果 CRect rcTitle(0, 0, rcClient.Width(), TITLEBAR_HEIGHT); dc.FillSolidRect(rcTitle, RGB(28, 28, 30)); // 步骤2绘制左侧的图标和标题文字 HICON hIcon AfxGetApp()-LoadIcon(IDR_MAINFRAME); if (hIcon) { DrawIconEx(dc.GetSafeHdc(), TITLE_LEFT_MARGIN, (TITLEBAR_HEIGHT - TITLE_ICON_SIZE) / 2, hIcon, TITLE_ICON_SIZE, TITLE_ICON_SIZE, 0, NULL, DI_NORMAL); } CString strTitle; GetWindowText(strTitle); dc.SetBkMode(TRANSPARENT); dc.SetTextColor(RGB(235, 235, 235)); CFont* pOldFont dc.SelectObject(m_fontTitle); dc.TextOutW(TITLE_LEFT_MARGIN TITLE_ICON_SIZE 6, (TITLEBAR_HEIGHT - 16) / 2, strTitle); dc.SelectObject(pOldFont); // 步骤3绘制系统按钮 DrawTitleButton(dc, m_rcMinBtn, TITLEBAR_MIN); DrawTitleButton(dc, m_rcMaxBtn, TITLEBAR_MAX); DrawTitleButton(dc, m_rcCloseBtn, TITLEBAR_CLOSE); }注意CWindowDC与CPaintDC的区别。CPaintDC只能在WM_PAINT处理函数中使用绘制区域被限制在无效区域内而CWindowDC可以在任意消息处理函数中创建且能绘制到窗口的非客户区是整个窗口的设备上下文。因为OnNcPaint本身不在WM_PAINT流程中所以必须用CWindowDC。DrawTitleButton函数负责根据按钮状态正常、悬停、按下、禁用绘制不同的背景和图标。这里用了一个小技巧用CDC::GetCurrentBrush判断当前是否存在有效画刷避免对空状态误判。void CMainFrame::DrawTitleButton(CWindowDC* pDC, const CRect rcBtn, int nType) { // 判断当前按钮状态并绘制对应背景色 CBrush brBackground; if (nType TITLEBAR_CLOSE m_bCloseHover) { brBackground.CreateSolidBrush(RGB(232, 17, 35)); // 关闭按钮悬停用红色 } else if (m_bMaxHover nType TITLEBAR_MAX) { brBackground.CreateSolidBrush(RGB(55, 55, 58)); } else { brBackground.CreateSolidBrush(RGB(45, 45, 48)); } pDC-FillRect(rcBtn, brBackground); // 绘制图标这里使用矢量线条方式避免位图缩放模糊 CPen pen(PS_SOLID, 1, RGB(200, 200, 200)); CPen* pOldPen pDC-SelectObject(pen); CPoint ptCenter(rcBtn.CenterPoint()); // 最小化一条横线 if (nType TITLEBAR_MIN) { pDC-MoveTo(ptCenter.x - 8, ptCenter.y); pDC-LineTo(ptCenter.x 8, ptCenter.y); } // 最大化/还原一个方框 else if (nType TITLEBAR_MAX) { pDC-Rectangle(ptCenter.x - 8, ptCenter.y - 6, ptCenter.x 8, ptCenter.y 6); } // 关闭叉号 else if (nType TITLEBAR_CLOSE) { pDC-MoveTo(ptCenter.x - 7, ptCenter.y - 7); pDC-LineTo(ptCenter.x 7, ptCenter.y 7); pDC-MoveTo(ptCenter.x 7, ptCenter.y - 7); pDC-LineTo(ptCenter.x - 7, ptCenter.y 7); } pDC-SelectObject(pOldPen); }矢量线条绘制图标的好处是无需依赖图标资源任意缩放不失真而且颜色可以跟随按钮状态变化。缺点是需要自己控制线条粗细和对齐像素不居中时看起来会很别扭因此这里的坐标全部基于CenterPoint()计算。3.3 命中测试与鼠标消息的配合窗口的非客户区被自己接管后WM_NCHITTEST的返回值变得至关重要。如果这里不处理鼠标在标题栏区域点击时系统会认为你点在客户区窗口将无法拖动双击标题栏也不会自动最大化。UINT CMainFrame::OnNcHitTest(CPoint point) { // 先转换到客户区坐标 CRect rcClient; GetClientRect(rcClient); CPoint ptLocal point; ScreenToClient(ptLocal); // 检查是否命中自定义按钮区域 if (m_rcCloseBtn.PtInRect(ptLocal)) return HTCLOSE; if (m_rcMaxBtn.PtInRect(ptLocal)) return HTMAXBUTTON; if (m_rcMinBtn.PtInRect(ptLocal)) return HTMINBUTTON; // 标题栏空白区域允许拖动窗口 if (ptLocal.y TITLEBAR_HEIGHT) return HTCAPTION; // 窗口边缘缩放区域处理 int nBorderSize 4; if (ptLocal.x nBorderSize ptLocal.y nBorderSize) return HTTOPLEFT; if (ptLocal.x nBorderSize ptLocal.y rcClient.Height() - nBorderSize) return HTBOTTOMLEFT; if (ptLocal.x rcClient.Width() - nBorderSize ptLocal.y nBorderSize) return HTTOPRIGHT; if (ptLocal.x rcClient.Width() - nBorderSize ptLocal.y rcClient.Height() - nBorderSize) return HTBOTTOMRIGHT; if (ptLocal.x nBorderSize) return HTLEFT; if (ptLocal.x rcClient.Width() - nBorderSize) return HTRIGHT; if (ptLocal.y nBorderSize) return HTTOP; if (ptLocal.y rcClient.Height() - nBorderSize) return HTBOTTOM; return HTCLIENT; }这里要特别留意坐标转换问题。OnNcHitTest的参数CPoint point是屏幕坐标而按钮矩形m_rcCloseBtn是在OnSize时按客户区坐标计算的直接比较肯定出错必须先ScreenToClient。另外由于系统非客户区不存在了窗口的缩放边框也需要靠命中测试返回HTLEFT这类值来告诉系统“这里允许调整大小”。3.4 拦截按钮点击并执行对应动作光有命中测试返回HTCLOSE还不够系统会针对这些返回值触发默认动作但因为我们自己独占非客户区有些默认行为不会发生。实际项目中我对按钮区域的鼠标点击做了一层拦截直接发送窗口命令。void CMainFrame::OnMouseMove(UINT nFlags, CPoint point) { if (m_rcCloseBtn.PtInRect(point)) { m_bCloseHover true; } else { m_bCloseHover false; } // 只在状态变化时触发重绘避免频繁刷新 if (m_bCloseHover ! m_bLastCloseHover) { m_bLastCloseHover m_bCloseHover; RedrawTitleButton(m_rcCloseBtn); } CWnd::OnMouseMove(nFlags, point); } void CMainFrame::OnLButtonUp(UINT nFlags, CPoint point) { if (m_rcCloseBtn.PtInRect(point)) { SendMessage(WM_SYSCOMMAND, SC_CLOSE, 0); return; } if (m_rcMaxBtn.PtInRect(point)) { SendMessage(WM_SYSCOMMAND, SC_MAXIMIZE, 0); return; } if (m_rcMinBtn.PtInRect(point)) { SendMessage(WM_SYSCOMMAND, SC_MINIMIZE, 0); return; } CWnd::OnLButtonUp(nFlags, point); }这一步其实是绕开了系统的非客户区按钮处理改为自己在客户区模拟。注意SendMessage(WM_SYSCOMMAND, ...)发送的是系统命令和直接调用ShowWindow(SW_MAXIMIZE)效果一样但能触发系统对应的动画和状态切换。悬停状态的局部重绘用InvalidateRect(m_rcCloseBtn)再UpdateWindow比全窗口Invalidate性能好很多。4. 避坑指南自绘边框标题栏的5个高频事故4.1 窗口最大化后标题栏跑到屏幕外面现象点击自定义最大化按钮后窗口铺满整个屏幕但标题栏却超出屏幕顶部按钮整个看不见了。原因是没有处理WM_GETMINMAXINFO窗口最大化时系统把工作区设置为整个屏幕但自绘标题栏从客户区顶部开始而客户区顶部在最大化状态下对应屏幕外。解决方法是重写OnGetMinMaxInfo把ptMaxPosition和ptMaxSize调整为当前工作区的大小和位置。void CMainFrame::OnGetMinMaxInfo(MINMAXINFO* lpMMI) { lpMMI-ptMaxPosition.x 0; lpMMI-ptMaxPosition.y 0; lpMMI-ptMaxSize.x GetSystemMetrics(SM_CXSCREEN); lpMMI-ptMaxSize.y GetSystemMetrics(SM_CYSCREEN); lpMMI-ptMaxTrackSize.x GetSystemMetrics(SM_CXSCREEN); lpMMI-ptMaxTrackSize.y GetSystemMetrics(SM_CYSCREEN); CWnd::OnGetMinMaxInfo(lpMMI); }4.2 自绘按钮出现残影和重影现象鼠标快速划过标题栏按钮按钮图标出现拖影或者上一下悬停状态的背景色残留。原因是RedrawTitleButton只调用了局部InvalidateRect但该区域内的窗口更新可能和WM_NCPAINT的完整重绘交叉触发导致绘制顺序错乱。解决方法是所有按钮状态变化统一走Invalidate(FALSE)让标题栏整体重绘。void CMainFrame::RedrawTitleButton(const CRect* pRect) { // 不要单独只刷按钮区域容易和背景产生拼接缝隙 CRect rcTitle(0, 0, m_rcClientWidth, TITLEBAR_HEIGHT); InvalidateRect(rcTitle, FALSE); UpdateWindow(); }4.3 窗口激活和失活时标题栏不刷新现象两个窗口互相切换当前窗口标题栏永远保持灰色失活窗口反而显示激活状态。原因是在OnNcActivate中没有返回值或没有触发重绘。系统在窗口激活状态变化时发送WM_NCACTIVATEMFC默认会擦除并重画系统标题栏但现在自绘后系统不知道要让自己重画。解决方法是在OnNcActivate中强制触发一次OnNcPaint。BOOL CMainFrame::OnNcActivate(BOOL bActive) { m_bActive bActive; // 重绘标题栏背景和文字根据激活状态调整颜色 Invalidate(TRUE); UpdateWindow(); return TRUE; // 不调用基类因为系统已无标题栏可重绘 }4.4 双击标题栏无法正常最大化现象双击自绘标题栏空白区域窗口没有任何反应。原因是OnNcHitTest返回了HTCAPTION系统双击HTCAPTION区域的默认行为是发送WM_SYSCOMMAND的SC_MAXIMIZE但自绘窗口的消息路由在MFC层被吃掉了一部分或者鼠标消息先被客户区的OnLButtonDblClk拦截。解决方法是重写OnLButtonDblClk如果双击点在标题栏区域手动发送最大化命令。void CMainFrame::OnLButtonDblClk(UINT nFlags, CPoint point) { if (point.y TITLEBAR_HEIGHT) { // 排除按钮区域 if (!m_rcCloseBtn.PtInRect(point) !m_rcMaxBtn.PtInRect(point) !m_rcMinBtn.PtInRect(point)) { SendMessage(WM_SYSCOMMAND, SC_MAXIMIZE, 0); return; } } CWnd::OnLButtonDblClk(nFlags, point); }4.5 系统按钮悬停状态丢失现象鼠标在标题栏按钮上快速移动按钮的悬停反馈忽有忽无甚至出现“明明鼠标在上面按钮却显示普通状态”的情况。原因是OnMouseMove只在客户区有鼠标移动时触发如果鼠标从按钮移出时没有经过客户区例如从按钮下方移动到窗口外WM_MOUSELEAVE没有被处理导致悬停标志位残留。更彻底的做法是调用TrackMouseEvent订阅WM_MOUSELEAVE消息。void CMainFrame::OnMouseMove(UINT nFlags, CPoint point) { // 跟踪鼠标离开事件 TRACKMOUSEEVENT tme; tme.cbSize sizeof(tme); tme.dwFlags TME_LEAVE; tme.hwndTrack m_hWnd; TrackMouseEvent(tme); // ... 省略状态更新 CWnd::OnMouseMove(nFlags, point); } void CMainFrame::OnMouseLeave() { m_bCloseHover false; m_bMaxHover false; m_bMinHover false; InvalidateTitlebar(); CWnd::OnMouseLeave(); }OnMouseLeave需要手动映射WM_MOUSELEAVE消息在类中添加afx_msg void OnMouseLeave();并在消息映射里加上ON_WM_MOUSELEAVE()。5. 二次开发技巧如何把自绘标题栏扩展成可复用的框架一旦跑通了基础的自绘流程后续可以做的定制内容就有了很好的空间。我用过的扩展场景有在标题栏上增加消息徽标、加载动画、多标签切换按钮甚至把标题栏整个替换成自定义的工具栏——都在这套框架上完成。而要做到这点核心是把跟业务无关的基础绘制抽取成基类方便新窗口直接继承复用。创建一个基类CBaseFrameWnd把从WM_NCCALCSIZE到鼠标消息的整套逻辑都封装进去子类只需要实现OnDrawTitleBar和OnTitleBarButton两个虚函数填入自己的UI元素即可。class CBaseFrameWnd : public CFrameWnd { protected: // 子类可覆盖的自绘接口 virtual void OnDrawTitleBar(CDC* pDC, const CRect rcTitle) { } virtual void OnDrawTitleButton(CDC* pDC, const CRect rcBtn, int nIndex) { } // 核心机制保留 afx_msg void OnNcCalcSize(BOOL, NCCALCSIZE_PARAMS*); afx_msg void OnNcPaint(); afx_msg UINT OnNcHitTest(CPoint); }; // 子类示例带搜索框的标题栏 class CSearchFrameWnd : public CBaseFrameWnd { virtual void OnDrawTitleBar(CDC* pDC, const CRect rcTitle) override; };这种做法的价值在于你换一个项目只需要修改OnDrawTitleBar和OnDrawTitleButton的实现而不需要重新调试命中测试、双击最大化这些基础机制。我后来在另一个工具上做了一套深色标题栏整个改造只花了约两小时其中一半时间在调像素级对齐和按钮光效的参数。高DPI适配也是一个常见改造点。Windows的缩放比例会让绘制文字和按钮出现模糊常见的做法是在OnNcCalcSize中根据GetDpiForWindow动态调整标题栏高度和按钮宽度。另外如果你用的是Windows 10以上系统打开系统设置中的“标题栏颜色随主题变化”开关后原生自绘窗口的边框颜色会跟系统主题联动而自定义绘制的标题栏不会这时可以监听WM_DWMCOLORIZATIONCOLORCHANGED来同步颜色。关键改造时的验证思路我习惯写一个小工具脚本循环切换激活/失活、最小化/还原、最大化/还原用自动化截图对比每次界面是否符合预期。血泪经验是千万别只手动点两下就认为完成了窗口失活再激活、从任务栏点击缩略图恢复这些路径特别容易触发隐藏问题。从那以后我每次改完自绘窗口都强制走一遍全状态轮回的自动化测试再加人工抽验保证所有消息路径都被实际执行过这套自绘代码才算真的落地。希望这篇拆解能帮到你少走我趟过的这些坑。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。