
简介面向需要定制Windows窗口外观的MFC开发者资源演示如何在单文档SDI应用中抛开系统默认样式自己绘制边框、标题栏与关闭按钮适用于希望摆脱千篇一律系统界面、搭建个性化客户端框架的入门到中级读者。内容聚焦CFrameWnd派生类的重写思路涵盖OnEraseBkgnd、OnPaint消息处理、GDI绘图对象及非客户区鼠标事件判断并考虑了按钮高亮、按下状态与DPI适配等问题。压缩包共32个文件核心为8个h头文件与7个cpp源文件另有rc/rc2资源脚本、ico图标与bmp位图等界面素材整体约95KB代码结构紧凑便于对照学习。目前已有391人学习/下载。借助其中的说明.txt与SkinTest示例工程可直观看到边框自绘、按钮状态切换和缩放适配的完整实现路径压缩包还保留Debug与Release编译输出及htm生成日志方便对比自绘窗口在不同构建版本下的表现对理解MFC消息机制与窗口绘制流程很有帮助。1. 为什么非要自己画边框和标题栏MFC单文档程序默认的标题栏和边框由系统绘制标题栏上只有系统图标、标题文字、最小化/最大化/关闭三个按钮样式完全由Windows主题决定。做工具类软件、行业软件或者想给程序换一套视觉风格的人迟早会遇到一个现实问题默认的灰底白字标题栏跟自己的界面风格搭不上想放个自定义按钮或者Logo图标进去又无从下手。自绘非客户区就是把这部分“外包给系统”的活儿收回来自己干。标题里的“自己画MFC单文档边框标题栏”本质上是处理WM_NCPAINT消息在非客户区用GDI/GDI自己绘制边框、标题栏背景、标题文字和窗口按钮。这个方案的直接收益有三个界面视觉完全可控不再被系统主题绑架可以把公司Logo、产品名称、自定义按钮放进标题栏边框粗细、颜色、交互样式都能按自己预期实现。需要说明的是这里说的“自己画”不是用无边框窗口WS_POPUP然后完全模拟而是在保留系统窗口管理能力的基础上只接管绘制和命中测试。两条路线我都试过无边框方案在窗口缩放、贴边、分屏、任务栏预览这些细节上要处理的东西太多成熟度不如直接接管非客户区。这个方案适合有一定MFC基础、熟悉消息映射和DC绘制的人参考新手也能照着步骤实现但建议先弄懂WM_NCPAINT和客户区的区别再动手。2. 自绘非客户区的核心原理和整体思路2.1 WM_NCPAINT到底是干什么的Windows窗口的绘制区域分成两部分客户区Client Area和非客户区Non-Client Area。标题栏、菜单栏、边框、滚动条这些由系统管理的部分都属于非客户区系统发送WM_NCPAINT消息让窗口自己绘制这部分区域。默认情况下DefWindowProc处理WM_NCPAINT会调用系统主题绘制函数画出用户熟悉的蓝色或深色标题栏。一旦你想改变这部分外观就要拦截WM_NCPAINT消息自己画。这里有个关键点非客户区并不是只有窗口边缘那一圈它包含标题栏、四边边框、四角以及标题栏上的系统按钮区域。绘制顺序也有讲究。系统在调整窗口大小、状态变化、被遮挡后重新暴露时都会发WM_NCPAINT。如果你在这条消息里画了东西但没有调用默认处理系统会认为你已经接管了绘制这没问题但如果你自己画完又调用了DefWindowProc那么系统会把你画的内容再覆盖掉一次导致闪烁或者干脆看不到你的绘制结果。另一个容易忽略的是WM_NCACTIVATE消息。窗口在激活/失活时系统会发送这条消息通知非客户区变化。默认实现会重绘标题栏颜色激活时一种颜色失活时另一种。如果你接管了自绘必须拦截WM_NCACTIVATE并返回TRUE否则系统会拿默认标题栏覆盖你的绘制。// 拦截激活状态变化 BOOL CMyFrame::OnNcActivate(BOOL bActive) { // 这里可以先记录状态触发重绘 m_bActive bActive; // 返回TRUE表示非客户区由程序自己管理 return TRUE; }2.2 为什么要在WM_NCPAINT里用GetWindowDC绘制非客户区不能用GetDC因为GetDC返回的是客户区DC剪裁区域也仅限于客户区。要画标题栏和边框必须用GetWindowDC获取整个窗口的DC包括非客户区。这个DC允许你在窗口的任意位置绘图包括标题栏上的系统按钮区域、四个边框。获取DC之后通常要先清底再画。清底有多种做法我习惯用FillRect填充整个非客户区的背景色再用DrawEdge或自定义代码画边框和标题栏。填充范围不需要自己精确计算窗口的非客户区矩形直接用整窗口矩形即可这样代码简单也不容易出边界问题。绘制完成后ReleaseDC要释放否则会造成DC泄漏。MFC的CWindowDC类封装了这个过程构造时自动GetWindowDC析构时自动ReleaseDC配合CClientDC的使用习惯代码更简洁不易出错。void CMyFrame::OnNcPaint() { CWindowDC dc(this); // 自动获取窗口DC CRect rcWin; GetWindowRect(rcWin); // 全窗口矩形屏幕坐标 rcWin.OffsetRect(-rcWin.left, -rcWin.top); // 转为窗口坐标 // 1. 画整个非客户区背景 dc.FillSolidRect(rcWin, RGB(45, 45, 48)); // 2. 画客户区边界边框线 CRect rcClient; GetClientRect(rcClient); ClientToScreen(rcClient); ScreenToClient(rcClient); // 上一步坐标转换 dc.Draw3dRect(rcClient, RGB(80, 80, 85), RGB(80, 80, 85)); // 3. 画标题栏文字略见后文 // 4. 画按钮略见后文 }这样处理后窗口的非客户区就完全是程序画的系统默认的蓝色标题栏不会出现。2.3 尺寸计算标题栏高度和边框宽度不能拍脑袋自绘标题栏第一个坑就是尺寸。标题栏高度和边框宽度不能拍脑袋写死不同的Windows版本、不同的DPI设置下系统标题栏高度差异很大。写死数值的后果是在Win10的150%缩放下按钮错位在Win11下标题栏要么太宽要么太窄。正确的做法是读取系统指标。有两个关键API可以拿到系统非客户区的标准尺寸SystemParametersInfo配合SPI_GETNONCLIENTMETRICS可以拿到标题栏高度、滚动条宽度、边框宽度等。参考代码如下NONCLIENTMETRICS ncm { sizeof(NONCLIENTMETRICS) }; SystemParametersInfo(SPI_GETNONCLIENTMETRICS, sizeof(ncm), ncm, 0); int nCaptionHeight ncm.iCaptionHeight; // 标题栏高度不含边框 int nBorderWidth ncm.iBorderWidth; // 边框宽度窗口未被拖拽时的状态 int nScrollWidth ncm.iScrollWidth; // 滚动条宽度可参考这里要说明一个细节iBorderWidth得到的是未调整大小时的边框宽度窗口可调整大小时实际拖拽边框会宽一些。如果你不需要区分这两种状态直接用这个值做边框绘制宽度即可视觉厚度由自己代码控制不影响窗口命中测试。另外标题栏按钮区域一般有个默认宽度GetSystemMetrics(SM_CXSIZE)可以拿到标准标题栏按钮的宽度和高度。这个值在DPI缩放时也会随之变化。int nButtonWidth GetSystemMetrics(SM_CXSIZE); int nButtonHeight GetSystemMetrics(SM_CYSIZE);2.4 双缓冲不闪的秘诀非客户区绘制最容易翻车的就是闪烁。因为WM_NCPAINT在窗口大小调整、拖动、覆盖恢复时会频繁触发如果直接往窗口DC上图元逐条绘制可以看到明显的闪动和撕裂。解决思路跟客户区双缓冲一样先在内存DC里画好再一次BitBlt到窗口DC。步骤// 1. 创建内存DC和位图 CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rcWin.Width(), rcWin.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 2. 在内存DC里完成所有绘制 ... // 3. 一次性贴回窗口 dc.BitBlt(0, 0, rcWin.Width(), rcWin.Height(), memDC, 0, 0, SRCCOPY); // 4. 恢复对象 memDC.SelectObject(pOldBmp);如果不做双缓冲边框线条一多闪烁肉眼可见。做了之后哪怕你画几十个图形也不会有任何闪动。2.5 基本消息清单自绘非客户区涉及的消息不止WM_NCPAINT。完整的一项自绘工程至少要处理消息作用关键动作WM_NCPAINT绘制非客户区自定义绘制背景、边框、按钮WM_NCACTIVATE激活状态变化返回TRUE并触发重绘WM_NCHITTEST命中测试返回HTCLIENT让窗口可拖动返回HTLEFT/HTRIGHT等让窗口可缩放WM_NCLBUTTONDOWN / UP非客户区鼠标按下/抬起处理标题栏拖拽、按钮点击WM_NCMOUSEMOVE非客户区鼠标移动实现按钮悬停效果WM_NCLBUTTONDBLCLK非客户区双击实现双击标题栏最大化/还原WM_GETMINMAXINFO最小最大尺寸限制窗口最小尺寸字段WM_NCCALCSIZE非客户区大小计算自绘时通常让系统默认处理系统发送WM_NCHITTEST时返回值决定鼠标行为。比如标题栏区域返回HTCAPTIONWindows就能让窗口随意拖拽边框区域返回HTLEFT、HTRIGHT、HTTOP等鼠标悬停时出现调整大小的光标拖拽时调整窗口尺寸。这比无边框窗口方案省事得多。3. 实操落地手把手画一个标题栏3.1 环境与工程准备我这边用的VS2019 MFC工程基于“MFC应用”选择“单文档”项目类型选“MFC标准”完成创建。为了让演示更简单建议关闭“功能区”和“状态栏”在CMainFrame类里操作即可。有一点需要先说明MFC的框架窗口CMainFrame默认是“有标题栏的普通窗口”自绘非客户区之后标题栏仍然由系统负责但视觉上不再显示系统默认内容。OpenGL和控件的客户区绘制不受影响。3.2 重写OnNcPaint画出一个完整的标题栏和边框下面是一个可以直接跑的完整OnNcPaint实现。它完成三件事画边框背景、画标题栏文字、画三个窗口按钮。void CMainFrame::OnNcPaint() { CWindowDC dc(this); CRect rcWin; GetWindowRect(rcWin); rcWin.OffsetRect(-rcWin.left, -rcWin.top); // 转窗口坐标 // 内存双缓冲 CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rcWin.Width(), rcWin.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 1. 背景填充 memDC.FillSolidRect(rcWin, m_bActive ? RGB(50, 50, 55) : RGB(80, 80, 85)); // 2. 获取客户区矩形计算边框厚度 CRect rcClient; GetClientRect(rcClient); ClientToScreen(rcClient); ScreenToClient(rcClient); // 边框颜色默认深灰色 memDC.Draw3dRect(rcClient, RGB(20, 20, 25), RGB(20, 20, 25)); // 3. 标题栏高度使用系统标准 int nCapHeight GetSystemMetrics(SM_CYCAPTION); CRect rcCaption(0, 0, rcWin.Width(), nCapHeight); // 标题文字 CString strTitle; GetWindowText(strTitle); memDC.SetBkMode(TRANSPARENT); memDC.SetTextColor(RGB(255, 255, 255)); CFont* pOldFont memDC.SelectObject(m_fontTitle); // m_fontTitle 在OnCreate里创建 CRect rcText rcCaption; rcText.left 8; // 文字左边距给系统图标留位置 rcText.right - 10; memDC.DrawText(strTitle, rcText, DT_LEFT | DT_VCENTER | DT_SINGLELINE); memDC.SelectObject(pOldFont); // 4. 画按钮区域这里先画三个按钮的背景占位 int nBtnW GetSystemMetrics(SM_CXSIZE); int nBtnH GetSystemMetrics(SM_CYSIZE); int nBtnGap 2; // 最小化按钮 memDC.FillSolidRect(rcWin.right - nBtnW * 3 - nBtnGap * 2, nCapHeight - nBtnH - 2, nBtnW, nBtnH, RGB(70, 70, 75)); // 最大化/还原按钮 memDC.FillSolidRect(rcWin.right - nBtnW * 2 - nBtnGap * 1, nCapHeight - nBtnH - 2, nBtnW, nBtnH, RGB(70, 70, 75)); // 关闭按钮 memDC.FillSolidRect(rcWin.right - nBtnW, nCapHeight - nBtnH - 2, nBtnW, nBtnH, RGB(200, 60, 60)); // 5. 一次性贴回窗口 dc.BitBlt(0, 0, rcWin.Width(), rcWin.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }这段代码已经能显示一个深色标题栏和边框。运行后窗口标题栏不再是系统默认样式文字和按钮都是程序画的。虽然按钮点击还没有反应但外观已经是自己的了。3.3 设置标题字体与初始化m_fontTitle字体在OnCreate里创建。系统默认标题栏字体可以从SystemParametersInfo(SPI_GETNONCLIENTMETRICS)里拿到这样可以保证字体大小随DPI缩放而不是固定字号导致在高DPI下偏小。int CMainFrame::OnCreate(LPCREATESTRUCT lpCreateStruct) { if (CFrameWnd::OnCreate(lpCreateStruct) -1) return -1; NONCLIENTMETRICS ncm { sizeof(NONCLIENTMETRICS) }; SystemParametersInfo(SPI_GETNONCLIENTMETRICS, sizeof(ncm), ncm, 0); m_fontTitle.CreateFontIndirect(ncm.lfCaptionFont); return 0; }3.4 处理命中测试让窗口可以被拖动和缩放自绘了标题栏之后标题栏区域默认还是会被系统当作普通非客户区处理鼠标在标题栏上拖动时窗口能移动这没问题。但如果你打算在标题栏上放自定义控件或者按钮就会发现整个标题栏都变成了“可拖动区域”鼠标落上去全是HTCAPTION点击事件根本进不来。解决方法是在WM_NCHITTEST里精细控制。先让系统默认处理然后根据命中坐标覆盖结果UINT CMainFrame::OnNcHitTest(CPoint point) { CRect rcWin; GetWindowRect(rcWin); // 按钮区域判断 int nBtnW GetSystemMetrics(SM_CXSIZE); int nCapHeight GetSystemMetrics(SM_CYCAPTION); // 关闭按钮 CRect rcClose(rcWin.right - nBtnW, rcWin.top, rcWin.right, rcWin.top nCapHeight); if (rcClose.PtInRect(point)) return HTCLOSE; // 最大化/还原按钮 CRect rcMax(rcWin.right - nBtnW * 2, rcWin.top, rcWin.right - nBtnW, rcWin.top nCapHeight); if (rcMax.PtInRect(point)) return HTMAXBUTTON; // 最小化按钮 CRect rcMin(rcWin.right - nBtnW * 3, rcWin.top, rcWin.right - nBtnW * 2, rcWin.top nCapHeight); if (rcMin.PtInRect(point)) return HTMINBUTTON; // 默认让系统处理拖拽和缩放 return CFrameWnd::OnNcHitTest(point); }这里注意按钮区域必须覆盖标题栏按钮的整个矩形否则鼠标落在按钮周边会触发拖拽而不是点击。命中测试返回HTCLOSE/HTMAXBUTTON/HTMINBUTTON后系统会发送WM_NCLBUTTONDOWN等消息我们在下面处理响应。3.5 响应按钮点击最小化、最大化、关闭按钮有了命中测试接下来就是消息响应。在MFC里可以直接添加消息处理函数。void CMainFrame::OnNcLButtonDown(UINT nHitTest, CPoint point) { switch (nHitTest) { case HTCLOSE: SendMessage(WM_CLOSE); break; case HTMAXBUTTON: if (IsZoomed()) ShowWindow(SW_RESTORE); else ShowWindow(SW_MAXIMIZE); break; case HTMINBUTTON: ShowWindow(SW_MINIMIZE); break; default: CFrameWnd::OnNcLButtonDown(nHitTest, point); break; } }这里有个细节最大化按钮需要区分当前状态。窗口已经最大化时按钮应该显示“还原”图标未最大化时显示“最大化”图标。你可以在OnNcPaint里通过IsZoomed()判断状态来绘制不同图标。3.6 按钮的悬停和按下效果系统按钮有悬停高亮、按下变色的交互反馈。自绘按钮也必须模拟这些。做法是在WM_NCMOUSEMOVE里记录鼠标所在位置触发重绘在WM_NCLBUTTONDOWN时记录按下状态然后在WM_NCLBUTTONUP时清除状态并触发动作。实现思路用两个成员变量保存当前悬停按钮索引和按下按钮索引每次状态变化就InvalidateRect一个很小的区域来重绘不需要整个窗口重绘节省开销。void CMainFrame::OnNcMouseMove(UINT nHitTest, CPoint point) { // 判断当前鼠标落在哪个按钮上与存储在m_bHoverIndex做对比 int nHover HitTestButton(point); if (nHover ! m_nHoverIndex) { // 重绘旧按钮区域和新按钮区域 InvalidateButton(m_nHoverIndex); m_nHoverIndex nHover; InvalidateButton(m_nHoverIndex); } CFrameWnd::OnNcMouseMove(nHitTest, point); }在实际项目中我遇到一个坑鼠标悬停刷新按钮区域时如果直接用InvalidateRect刷新非客户区部分Windows版本会触发整窗重绘造成轻微闪烁。建议在刷新按钮时就直接在按钮矩形范围内调用RedrawWindow(NULL, NULL, RDW_INVALIDATE | RDW_NOERASE | RDW_UPDATENOW)并且配合双缓冲基本无感。4. 最大化、DPI与细节问题处理4.1 最大化时边框变宽导致客户区溢出这是自绘窗口最经典的坑。窗口最大化后系统会把窗口边缘扩大防止任务栏遮挡这时候边框会比正常状态下粗。如果你在OnNcPaint里还拿固定值画边框会出现客户区内容被裁剪一截、或者边框粗细不一致的问题。处理办法最大化时把边框绘制宽度置0同时调整客户区大小。需要在WM_NCCALCSIZE里做处理比较彻底。使用WM_NCCALCSIZE返回WVR_REDRAW标记可以让系统重绘整个窗口的非客户区。我用的做法是在OnNcPaint里判断IsZoomed()如果是最大化就跳过画边框的步骤只画标题栏。这样标题栏的视觉高度和按钮位置都不会受边框宽度干扰。4.2 高DPI下字体和按钮错位的处理Win10、Win11下缩放比例可能是100%、125%、150%甚至更多。如果代码里写死像素值100%下正常150%下按钮重叠、文字偏出标题栏。解决办法是尽量用GetSystemMetrics和SystemParametersInfo获取系统指标这些API内部已经做了DPI缩放。如果你在标题栏上绘制自己的Logo或图片建议在初始化时获取当前DPI缩放值手动换算尺寸UINT dpi GetDpiForWindow(m_hWnd); // Windows 10 1607 double scale dpi / 96.0; int nLogoW (int)(32 * scale);对老旧系统兼容的写法是GetDeviceCaps(dc, LOGPIXELSX)获取当前DC的DPI。4.3 最大化双击恢复、任务栏缩略图这些系统行为跟自绘不冲突前提是你遵循了“命中测试返回标准值”的设计。标题栏空白区域返回HTCAPTION双击行为由系统处理自动在最大化和还原之间切换。任务栏缩略图预览显示的是窗口内容非客户区自绘不影响。主题强调“自己画边框标题栏”实际上只要把WM_NCHITTEST处理好了窗口最大化的贴边、分屏功能都会正常用户习惯不会被打破。4.4 Aero阴影和Windows版本差异Win10以上系统自带窗口阴影和圆角。自绘非客户区后部分版本可能会丢失阴影效果因为系统认为非客户区已经是“定制状态”。这个问题目前没有完美的通用解法常见方案是保留窗口的WS_THICKFRAME样式让系统维持基本阴影或者用DwmExtendFrameIntoClientArea把部分边框扩展到客户区绘制。我的建议如果是内部工具软件不强求阴影去掉也没人注意如果是面向用户的商业软件最好在目标系统上提前测试视觉效果。5. 常见问题与排查技巧实录5.1 标题栏文字闪烁现象拖动窗口时标题文字闪动屏幕刷新时能看到文字多次绘制。排查方向是否在OnNcPaint里直接往窗口DC绘制没有双缓冲这是绝大多数闪烁的原因。是否在WM_NCACTIVATE里触发了重绘但不必要激活状态变化只改一个布尔值不需要立刻重绘可以在OnNcPaint里统一判断。5.2 最大化后按钮跑到屏幕外现象窗口最大化后标题栏按钮被任务栏遮住一部分或者按钮位置偏移出屏幕。排查方向按钮的x坐标计算是否用rcWin.right最大化后窗口矩形会包含工作区外的一部分正确做法是先用SystemParametersInfo(SPI_GETWORKAREA)获取工作区再做相对坐标。是否还在绘制边框导致标题栏区域变高最大化时跳过边框绘制即可解决。5.3 客户区被擦成白色现象窗口刚显示或者拖动边框时客户区闪白或者边缘出现白色残留。排查方向这通常是系统在WM_ERASEBKGND里擦除了背景MFC默认用白色背景刷来擦。解决重写OnEraseBkgnd返回TRUE或者设置合适的背景刷。如果闪白只出现在边框区域检查WM_NCCALCSIZE是否返回了无效值。5.4 标题栏上的按钮点击没反应现象命中测试返回了HTCLOSE点击后却没有任何反馈。排查方向WM_NCLBUTTONDOWN是否被重写但没有调用基类基类负责把HTCLOSE转换成WM_CLOSE如果你在重写里发WM_CLOSE那就要确保消息发送成功或者在基类返回之后再处理。按钮区域是否被其他控件遮挡如果客户区有子窗口盖住了标题栏区域鼠标事件会被子窗口吃掉不会进入非客户区。5.5 窗口不可拖动现象鼠标按住标题栏拖不动窗口。排查方向标题栏区域命中测试是否返回了HTCAPTION返回HTCLIENT的话系统会当作客户区鼠标操作窗口自然拖不动。是否在WM_NCHITTEST里把整个窗口都映射成HTCLIENT或者HTBORDERHTBORDER不会触发拖动只有HTCAPTION可以。6. 一点扩展往标题栏放自定义控件自绘到这一步基础功能已经齐全。更进一步的做法是在标题栏区域放置自定义控件——比如搜索框、下拉框、设置按钮这跟自绘非客户区一脉相承。要想在标题栏放控件我建议把标题栏整体看成一个“虚拟控件区”在WM_NCHITTEST里精确划定每个控件的区域返回对应的命中值。然后自绘时在对应位置画出控件外观。鼠标交互都在WM_NCMOUSEMOVE、WM_NCLBUTTONDOWN、WM_NCLBUTTONUP里处理。如果想放真正的子窗口控件那就需要在WM_NCCALCSIZE里调整客户区大小预留出标题栏控件的位置但这会让窗口边界计算复杂不少。对于大多数场景手绘加命中测试的做法更轻量也更容易维护。实际做项目时我还用自绘标题栏做过“未读消息角标”“用户头像”“状态指示灯”配合定时器在内存DC里直接绘制不依赖任何子窗口控件性能开销极小效果也稳定。我个人在实际操作中的一个体会是自绘非客户区的调试过程比功能开发更花时间尤其是DPI缩放和Windows版本差异带来的细微问题。但这套能力一旦掌握做出来的工具类软件在视觉统一性上会明显上一个台阶。如果你正在做的项目对界面风格有讲究值得花一个下午把这条链路跑通。本文还有配套的精品资源点击获取