简介:面向具备一定 C++ 基础的 Windows 桌面开发者,这份资料聚焦 MFC 单文档(SDI)界面中边框、标题栏与关闭按钮的自行绘制,帮助读者摆脱系统默认窗口样式,实现个性化皮肤效果。内容围绕框架窗口类的继承与重写展开,涉及背景擦除、OnPaint 绘图、GDI 线条与填充、位图图标资源加载、非客户区鼠标事件判断以及按钮高亮与按下状态切换,并提醒兼顾不同分辨率与 DPI 下的缩放兼容。资源包共 32 个文件、约 95KB,以 h 头文件和 cpp 源文件为主体,另含 ico 图标、bmp 位图、rc 资源脚本、工程与解决方案文件及说明文本,构成一个可直接编译调试的示例工程。已有 394 人学习下载。读者可据此获得一套可运行的 SDI 自绘窗口样例、消息处理与绘图代码骨架,并借助说明与注释理解非客户区绘制的组织方式,为后续换肤与界面定制提供参考。
1. MFC单文档自绘边框标题栏:先别急着贴图,先看窗口谁在画
MFC单文档默认的边框标题栏是系统画的,灰白一条,和现在产品的深色界面放一起很突兀。很多需求嘴上说“把标题栏换个色”,落到代码里其实是两件事:边框要不要自己画,标题栏按钮要不要自己接管。前者碰的是非客户区,后者碰的是命中测试和窗口行为。MFC单文档边框标题栏这套东西,难的不是画一个矩形,而是画完之后窗口还能拖、能最大化、能双击还原、高DPI下按钮不糊。适合已经能建SDI工程、想在不换UI框架的前提下把系统标题栏收掉的读者。下面按“选路线、落笔、补行为、避坑、封装”走一遍,代码按CMainFrame拆,能直接往模拟项目X里套。
2. 路线选型:三种MFC边框标题栏改法,为什么我选去标题栏加NC收边
2.1 标准边框标题栏由谁画:NC区、客户区与DWM的分工
先分清窗口上两块地。客户区是OnPaint管的地方,非客户区是系统管的地方,标题栏、可调边框、系统菜单都算非客户区。MFC单文档主框架默认样式里带WS_CAPTION,系统就在WM_NCPAINT里画标题栏和边框,DWM再在外层补阴影、圆角、动画。你在OnPaint里贴一张深色图,只能盖住客户区,盖不住标题栏,所以会看到“客户区黑了,标题栏还是白的”。这不是绘制顺序问题,是地没踩对。
自绘边框标题栏有三条路。第一条保留WS_CAPTION,在WM_NCPAINT里重画整个非客户区,系统行为基本都在,但Win10/11的DWM会插一脚,边框阴影、最大化边框、圆角都要单独处理。第二条用DWM扩展框架,把客户区扩展到标题栏位置,配合WM_NCCALCSIZE,视觉上最接近现代窗口,但DWM属性在不同系统版本上表现不完全一致,调试成本高。第三条去掉WS_CAPTION,保留WS_THICKFRAME做可调边框,标题栏画在客户区顶部,边框用WM_NCPAINT收边。这条路的边界最清楚:系统只负责拉大小,标题栏和按钮全归你,命中测试也归你。
我一般选第三条。原因是MFC单文档的框架窗口本来就有CFrameWndEx的一堆消息映射,去标题栏比改DWM属性可控;标题栏画在客户区,OnPaint、OnMouseMove、OnLButtonDown都能直接用,按钮四态和hover不用跟非客户区消息打架;保留WS_THICKFRAME,窗口边缘拉大小仍然走系统,不用自己算八个方向的鼠标形状。代价是最大化时边框会多出一圈,WM_NCCALCSIZE要补;拖动和双击要在WM_NCHITTEST里返回HTCAPTION,按钮区域要返回HTCLIENT。这些都是确定性的活,比追DWM的玄学稳。
2.2 三种实现路线对比:WM_NCPAINT、DWM扩展框架、全客户区自绘
把三条路摆在一张表里看,选型时就不容易来回改。重点看“系统行为保留多少”和“调试入口在哪”。下表里的工作量按一个SDI主框架、三个标题栏按钮、高DPI适配估算,不含换肤和动画。
| 路线 | 保留的系统行为 | 主要调试入口 | 工作量 | 适用场景 |
|---|---|---|---|---|
| 保留WS_CAPTION,WM_NCPAINT重画 | 拖动、双击、系统菜单、拉大小都在 | WM_NCPAINT、WM_NCACTIVATE | 中 | 只想换标题栏颜色,不想接管按钮 |
| DWM扩展框架+WM_NCCALCSIZE | 需要自己补命中测试和最大化 | WM_NCCALCSIZE、DwmSetWindowAttribute | 中高 | 追求Win10/11原生阴影和圆角 |
| 去WS_CAPTION,客户区画标题栏,NC收边 | 拉大小保留,拖动和按钮自己接 | OnPaint、WM_NCHITTEST、WM_NCCALCSIZE | 中 | 标题栏要完全自定义,按钮要四态 |
选第三条不是因为它最短,而是因为它出错时能看到现场。按钮没反应,查OnNcHitTest返回的是HTCLIENT还是HTCAPTION;标题栏闪烁,查InvalidateRect有没有带RDW_FRAME;最大化多一条边,查WM_NCCALCSIZE里工作区坐标。每条都有明确断点。DWM那条路出问题时,很多时候只能靠对比不同系统版本的表现,排错像开黑匣子。
提示:去WS_CAPTION时不要顺手把WS_SYSMENU、WS_MINIMIZEBOX、WS_MAXIMIZEBOX也去掉。这三个样式不影响你画标题栏,但会影响系统菜单和任务栏右键菜单里的项,去掉之后行为会缺一块。
2.3 建立可调试的SDI骨架:PreCreateWindow与样式修改
第一步不是画,是把窗口样式改成“没有系统标题栏、但还能拉大小”。MFC单文档的CMainFrame在PreCreateWindow里改CREATESTRUCT最直接。下面这段是骨架,重点是去掉WS_CAPTION和FWS_ADDTOTITLE,保留WS_THICKFRAME、WS_SYSMENU、WS_MINIMIZEBOX、WS_MAXIMIZEBOX,同时去掉WS_EX_CLIENTEDGE,避免客户区外面多一圈系统凹边。
BOOL CMainFrame::PreCreateWindow(CREATESTRUCT& cs) { if (!CFrameWndEx::PreCreateWindow(cs)) return FALSE; // 去掉系统标题栏和MFC自动追加文档名的行为 cs.style &= ~(WS_CAPTION | FWS_ADDTOTITLE); // 保留可调边框、系统菜单、最小化/最大化能力 cs.style |= WS_THICKFRAME | WS_SYSMENU | WS_MINIMIZEBOX | WS_MAXIMIZEBOX; // 去掉客户区凹边,边框交给WM_NCPAINT收 cs.dwExStyle &= ~WS_EX_CLIENTEDGE; // 注册带双击支持的窗口类,后面标题栏双击最大化要用 cs.lpszClass = AfxRegisterWndClass( CS_DBLCLKS, ::LoadCursor(nullptr, IDC_ARROW), (HBRUSH)::GetStockObject(NULL_BRUSH), nullptr); return TRUE; }这段代码的逻辑是先把系统标题栏拿掉,再把“窗口还能被用户操作”的样式补回来。WS_THICKFRAME是拉大小的根,没有它窗口边缘就没有可调区域;WS_SYSMENU是系统菜单的根,标题栏右键和Alt+空格都靠它;WS_MINIMIZEBOX和WS_MAXIMIZEBOX影响任务栏和系统菜单里的项,不是画不画按钮的问题。FWS_ADDTOTITLE是MFC内部样式,不去掉的话框架还会尝试往标题里追加文档名,虽然标题栏已经没了,但窗口文本会变,后面DrawText取标题时容易拿到不想要的值。
AfxRegisterWndClass这一句不是必须,但建议加。CS_DBLCLKS让窗口能收到双击消息,标题栏空白处双击最大化时不会因为窗口类不带双击而丢消息。第二个参数是箭头光标,第三个参数用NULL_BRUSH是为了不让系统在擦背景时画一笔,第四个参数传nullptr表示用默认图标。改完样式后编译运行,窗口应该没有系统标题栏,只剩一圈可拉大小的边框,客户区从边框内开始。这个状态就是后面所有绘制的底座。
3. 非客户区落笔:WM_NCPAINT画外框、客户区画标题栏的具体坐标
3.1 计算标题栏矩形:客户区顶部、边框内缩与DPI
标题栏画在客户区顶部,但它的坐标不能写死36像素。高DPI下36像素在150%缩放里只有24个逻辑单位,按钮会挤在一起;4K屏上又显得太薄。常见做法是按96 DPI为基准,用GetDpiForWindow取当前窗口DPI,再MulDiv换算。GetDpiForWindow在Win10以后可用,老系统可以用GetDeviceCaps(LOGPIXELSY)兜底。标题栏高度我一般设36个逻辑像素,按钮宽46个逻辑像素,左右各留8个逻辑像素的内边距。
int CMainFrame::DpiScale(int value) const { UINT dpi = 96; // GetDpiForWindow需要Win10,工程里可以做版本判断后兜底 HMODULE user32 = ::GetModuleHandle(_T("user32.dll")); if (user32 != nullptr) { typedef UINT(WINAPI* GetDpiForWindowFn)(HWND); auto getDpi = (GetDpiForWindowFn)::GetProcAddress(user32, "GetDpiForWindow"); if (getDpi != nullptr) dpi = getDpi(m_hWnd); } return MulDiv(value, dpi, 96); } CRect CMainFrame::GetTitleBarRect() const { CRect client; GetClientRect(&client); CRect title = client; title.bottom = title.top + DpiScale(36); return title; }DpiScale里用GetProcAddress动态取函数,是为了不让工程在旧SDK下编译不过。取不到就按96算,至少不会崩。GetTitleBarRect只算客户区顶部一条,左边从0开始,右边到客户区宽度。因为窗口还留着WS_THICKFRAME,客户区本身已经从边框内缩了,所以标题栏不需要再减边框厚度。窗口最大化时客户区会贴到工作区,标题栏也跟着贴边,这个后面在WM_NCCALCSIZE里再修。
按钮矩形从标题栏右边往左排,关闭在最右,最大化/还原在它左边,最小化再左边。每个按钮宽46个逻辑像素,高等于标题栏高度。按钮之间不留缝,hover时整块变色,视觉上更像一条连续的窗口控制区。标题文字区域是标题栏左边到最小化按钮左边,再减去左右内边距。文字用DrawText单行、垂直居中、尾部省略,避免文档名太长把按钮盖住。
3.2 WM_NCPAINT画外框:CWindowDC、裁剪与最大化状态
边框用WM_NCPAINT画,因为客户区DC画不到窗口最外圈。WM_NCPAINT里拿CWindowDC,坐标是窗口坐标,左上角是(0,0),宽高用GetWindowRect。不要在这里用CPaintDC,CPaintDC只针对客户区,画到非客户区会被裁掉。下面这段先取窗口矩形和客户区矩形,算出边框厚度,再用1像素笔沿窗口外圈画一圈。颜色按激活状态分两种,失焦时用暗一点的灰,避免标题栏和边框脱节。
void CMainFrame::OnNcPaint() { // 先让系统处理默认非客户区,保留拉大小的命中区域 CFrameWndEx::OnNcPaint(); CWindowDC dc(this); CRect wr; GetWindowRect(&wr); // 窗口坐标从0开始 CRect rc(0, 0, wr.Width(), wr.Height()); // 最大化时外圈边框在屏幕外,画了也看不见,直接跳过 if (IsZoomed()) return; COLORREF borderColor = m_bActive ? RGB(45, 45, 48) : RGB(80, 80, 84); CPen pen(PS_SOLID, 1, borderColor); CPen* oldPen = dc.SelectObject(&pen); CBrush* oldBrush = (CBrush*)dc.SelectStockObject(NULL_BRUSH); dc.Rectangle(&rc); dc.SelectObject(oldBrush); dc.SelectObject(oldPen); }这段的关键在CFrameWndEx::OnNcPaint()要先调。有人一上来就自己画,结果窗口边缘拉大小的热区没了,鼠标放到边上不变形。先让系统画默认非客户区,再用自己的笔覆盖外圈,命中区域还在,视觉颜色也换了。CWindowDC的坐标是窗口坐标,rc从(0,0)到窗口宽高,画出来的矩形正好压在最外圈。IsZoomed()时直接返回,是因为最大化窗口的边框被系统推到屏幕外,画了也看不见,反而可能在某些显卡驱动上留下残影。
激活状态m_bActive在WM_NCACTIVATE里更新。窗口失去焦点时,DWM会自己处理阴影,但自绘边框不会自动变灰,所以要在WM_NCACTIVATE里改m_bActive,然后RedrawWindow带RDW_FRAME。不要只Invalidate客户区,边框在非客户区,不带RDW_FRAME刷不到。
3.3 标题栏按钮布局与四态绘制
标题栏绘制放在OnPaint里。先 FillSolidRect 铺底色,再画标题文字,最后画三个按钮。按钮四态用两个成员变量记:m_nHotButton记鼠标悬停的按钮,m_nPressedButton记按下的按钮。枚举用BTN_NONE、BTN_MIN、BTN_MAX、BTN_CLOSE。绘制时根据状态选背景色:普通态透明,hover态浅灰,按下态深灰,关闭按钮hover态用红。最大化按钮要根据IsZoomed()在“最大化”和“还原”两个图形之间切。
void CMainFrame::OnPaint() { CPaintDC dc(this); CRect title = GetTitleBarRect(); // 标题栏底色 dc.FillSolidRect(title, m_bActive ? RGB(32, 32, 36) : RGB(48, 48, 52)); // 标题文字 CRect textRect = title; textRect.left += DpiScale(12); textRect.right = GetButtonRect(BTN_MIN).left - DpiScale(8); dc.SetBkMode(TRANSPARENT); dc.SetTextColor(m_bActive ? RGB(235, 235, 235) : RGB(160, 160, 160)); CString titleText; GetWindowText(titleText); dc.DrawText(titleText, &textRect, DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS); // 三个按钮 DrawCaptionButton(&dc, BTN_MIN); DrawCaptionButton(&dc, BTN_MAX); DrawCaptionButton(&dc, BTN_CLOSE); // 客户区剩余部分交给视图,主框架不要在这里画业务 }textRect.right取最小化按钮左边再减8个逻辑像素,保证标题不会压到按钮。DT_VCENTER要配合DT_SINGLELINE才生效,DT_END_ELLIPSIS在文档名太长时出省略号。主框架的OnPaint只画标题栏,客户区下面的视图区域不要在这里Fill,否则会把视图盖掉。如果SDI里主框架客户区包含工具栏和视图,标题栏高度以下的区域应该由子窗口自己画,主框架只处理顶部一条。
DrawCaptionButton里根据GetButtonRect拿矩形,再按状态画背景和图形。最小化画一条横线,最大化画一个方框,还原画两个错位方框,关闭画两条斜线。图形用1到2像素笔,坐标按DPI缩放。关闭按钮hover时背景用RGB(196,43,28),这是常见窗口关闭红,不要用太亮的纯红,深色标题栏上会刺眼。
3.4 WM_NCHITTEST把按钮和拖动区分开
标题栏画在客户区,但拖动不能靠OnLButtonDown里自己算鼠标偏移,那样会丢掉系统双击最大化、拖到屏幕边缘分屏这些行为。正确做法是在WM_NCHITTEST里把标题栏空白处返回HTCAPTION,把按钮区域返回HTCLIENT。系统收到HTCAPTION后自己处理拖动和双击,按钮区域收到HTCLIENT后鼠标消息进客户区,OnLButtonDown才能接到。
LRESULT CMainFrame::OnNcHitTest(CPoint point) { // point是屏幕坐标,先转客户区 CPoint pt = point; ScreenToClient(&pt); CRect title = GetTitleBarRect(); if (title.PtInRect(pt)) { // 按钮区域必须返回HTCLIENT,否则收不到鼠标消息 if (HitCaptionButton(pt) != BTN_NONE) return HTCLIENT; // 标题栏空白处交给系统拖动和双击 return HTCAPTION; } // 其他区域走默认,保留WS_THICKFRAME的拉大小命中 return CFrameWndEx::OnNcHitTest(point); }这里最容易翻车的是坐标没转。WM_NCHITTEST给的point是屏幕坐标,GetTitleBarRect是客户区坐标,不转直接PtInRect会永远不命中。另一个点是按钮判断要在HTCAPTION之前,顺序反了按钮区域也会被当成标题栏,点了没反应。CFrameWndEx::OnNcHitTest(point)要传原始屏幕坐标,不要传转换后的pt,否则边框拉大小的命中会错。
按钮区域返回HTCLIENT后,鼠标在按钮上移动会进OnMouseMove,按下会进OnLButtonDown。标题栏空白处返回HTCAPTION后,系统接管拖动,鼠标消息不一定进客户区,所以不要把hover逻辑依赖在空白处。按钮的hover只发生在按钮矩形内,正好是HTCLIENT区域,逻辑是顺的。
4. 行为补齐:拖动、最大化、关闭按钮与DPI缩放的消息闭环
4.1 拖动与双击最大化:HTCAPTION和WM_NCLBUTTONDOWN的配合
标题栏空白处返回HTCAPTION后,系统自己处理拖动和双击最大化,不需要在OnLButtonDown里写SendMessage(WM_NCLBUTTONDOWN, HTCAPTION, ...)。但按钮区域返回的是HTCLIENT,如果用户在按钮上按下又拖到标题栏空白处,系统不会自动切成拖动,因为按下时命中的是客户区。这种情况我一般不管,窗口按钮的常规交互不要求从按钮上起拖。如果产品要求“按在标题栏任何地方都能拖”,那就要在OnLButtonDown里判断点是否在标题栏且不在按钮上,再手动发WM_NCLBUTTONDOWN。
手动拖动的写法是:
void CMainFrame::OnLButtonDown(UINT nFlags, CPoint point) { CRect title = GetTitleBarRect(); if (title.PtInRect(point) && HitCaptionButton(point) == BTN_NONE) { // 手动进入系统拖动循环 SendMessage(WM_NCLBUTTONDOWN, HTCAPTION, MAKELPARAM(point.x, point.y)); return; } CFrameWndEx::OnLButtonDown(nFlags, point); }MAKELPARAM里放的是客户区坐标,WM_NCLBUTTONDOWN的lParam按屏幕坐标解释,所以严格来说这里应该传屏幕坐标。很多示例传客户区也能动,是因为系统只取低16位做偏移参考,窗口位置会跳一下。稳妥写法是先ClientToScreen再MAKELPARAM。这段代码只在你想接管拖动时用;如果WM_NCHITTEST已经返回HTCAPTION,系统在按下前就命中了非客户区,OnLButtonDown根本不会进,别把两套逻辑叠在一起,否则会出现拖一下跳一下。
双击最大化同理。标题栏空白返回HTCAPTION后,系统收到双击会发WM_SYSCOMMAND的SC_MAXIMIZE或SC_RESTORE,不需要自己处理。如果你在OnLButtonDown里手动发了WM_NCLBUTTONDOWN,双击消息会被拖动循环吃掉,双击最大化就失效。所以能交给WM_NCHITTEST就交,手动拖动只作为兜底。
4.2 最小化、最大化、关闭:按钮消息走WM_SYSCOMMAND还是直接ShowWindow
按钮点击在OnLButtonUp里判断。最小化可以直接ShowWindow(SW_MINIMIZE),也可以发WM_SYSCOMMAND, SC_MINIMIZE。区别在系统菜单和任务栏动画:走WM_SYSCOMMAND更接近系统行为,尤其是最小化动画和任务栏缩略图状态。最大化/还原走WM_SYSCOMMAND, IsZoomed() ? SC_RESTORE : SC_MAXIMIZE,不要直接ShowWindow(SW_MAXIMIZE),因为SC_MAXIMIZE会走系统的最大化布局和WM_GETMINMAXINFO,直接ShowWindow在某些多显示器配置下工作区算得不一样。关闭走PostMessage(WM_CLOSE),不要DestroyWindow,WM_CLOSE会给文档和视图保存提示的机会。
void CMainFrame::OnLButtonUp(UINT nFlags, CPoint point) { CaptionButton btn = HitCaptionButton(point); if (btn != BTN_NONE && btn == m_nPressedButton) { switch (btn) { case BTN_MIN: SendMessage(WM_SYSCOMMAND, SC_MINIMIZE, 0); break; case BTN_MAX: SendMessage(WM_SYSCOMMAND, IsZoomed() ? SC_RESTORE : SC_MAXIMIZE, 0); break; case BTN_CLOSE: PostMessage(WM_CLOSE); break; default: break; } } m_nPressedButton = BTN_NONE; InvalidateRect(GetTitleBarRect(), FALSE); CFrameWndEx::OnLButtonUp(nFlags, point); }btn == m_nPressedButton是防止按下最小化、松开在关闭上时误触关闭。按下时在OnLButtonDown里把m_nPressedButton设成命中按钮,松开时比对。InvalidateRect只刷标题栏,FALSE表示不擦背景,避免闪烁。按钮按下态的重绘要在OnLButtonDown里也做一次,否则按下去没有视觉反馈。最大化按钮还要在窗口大小变化后重绘,因为图形要在“最大化”和“还原”之间切,WM_SIZE里刷一下标题栏。
4.3 系统菜单与右键标题栏:TrackPopupMenu和WM_SYSCOMMAND
标题栏空白处返回HTCAPTION后,右键系统菜单系统会自己弹,但样式是系统默认的,和自绘标题栏不搭。要自定义就在WM_NCRBUTTONUP里处理,或者在客户区标题栏区域拦截OnRButtonUp。因为标题栏空白是HTCAPTION,右键可能走非客户区消息,所以两个地方都要考虑。简单做法是保留系统默认,复杂做法是自己TrackPopupMenu。
void CMainFrame::OnRButtonUp(UINT nFlags, CPoint point) { CRect title = GetTitleBarRect(); if (title.PtInRect(point)) { CMenu* sysMenu = GetSystemMenu(FALSE); if (sysMenu != nullptr) { // 系统菜单项由MFC维护,不要自己重建 CPoint screen = point; ClientToScreen(&screen); UINT cmd = sysMenu->TrackPopupMenu( TPM_LEFTALIGN | TPM_RETURNCMD | TPM_RIGHTBUTTON, screen.x, screen.y, this); if (cmd != 0) SendMessage(WM_SYSCOMMAND, cmd, 0); } return; } CFrameWndEx::OnRButtonUp(nFlags, point); }GetSystemMenu(FALSE)拿的是系统菜单副本,不要用TRUE去重置。TPM_RETURNCMD让TrackPopupMenu直接返回选中项ID,拿到后自己发WM_SYSCOMMAND,这样菜单项和系统行为走同一条路。TPM_RIGHTBUTTON让右键也能选。坐标要转屏幕,TrackPopupMenu要屏幕坐标。这段只在标题栏区域拦截,其他区域照常走父类,避免影响视图右键菜单。
注意:系统菜单里的“关闭”ID是SC_CLOSE,不要自己定义成IDC_CLOSE再SendMessage,系统不认。所有菜单项都原样转发WM_SYSCOMMAND。
4.4 高DPI与多显示器:GetDpiForWindow、WM_DPICHANGED、最大化工作区
高DPI下最常见的两个问题是标题栏高度不跟缩放走、按钮图形模糊。高度用DpiScale算,按钮图形用1到2像素笔,不要用固定像素的位图。窗口从一个显示器拖到另一个不同缩放比例的显示器时,系统发WM_DPICHANGED,里面带新DPI和建议窗口矩形。处理这个消消息时不要直接SetWindowPos到建议矩形就完事,还要重新算标题栏高度、按钮宽度、字体,再刷标题栏。
LRESULT CMainFrame::OnDpiChanged(WPARAM wParam, LPARAM lParam) { // wParam低字是新DPI,lParam是建议窗口矩形 UINT newDpi = LOWORD(wParam); RECT* suggested = (RECT*)lParam; SetWindowPos(nullptr, suggested->left, suggested->top, suggested->right - suggested->left, suggested->bottom - suggested->top, SWP_NOZORDER | SWP_NOACTIVATE); // 标题栏字体和布局都按新DPI重算 RecreateTitleFont(newDpi); InvalidateRect(GetTitleBarRect(), FALSE); return 0; }LOWORD(wParam)取新DPI,lParam是建议矩形,必须用,否则窗口会突然变大或变小。RecreateTitleFont里按新DPI创建CFont,字号用MulDiv(9, newDpi, 96)这类换算。不要忘记InvalidateRect刷标题栏,否则字体变了但画面还是旧的。
最大化时还有工作区问题。窗口最大化后,WS_THICKFRAME的边框会被系统推到屏幕外,客户区顶部可能多出一条或左边少一像素。处理WM_NCCALCSIZE时,如果IsZoomed()为真,把客户区矩形按边框厚度外扩,让客户区贴住显示器工作区。边框厚度用GetSystemMetrics(SM_CXSIZEFRAME) + GetSystemMetrics(SM_CXPADDEDBORDER)算,不要写死8。多显示器下工作区用MonitorFromWindow取当前显示器,再GetMonitorInfo拿rcWork,不要用GetSystemMetrics(SM_CXSCREEN),那是主屏尺寸。
5. 避坑与排查:自绘MFC标题栏最容易翻车的五个现场
5.1 拖动时边框残影:先查WM_NCPAINT裁剪,不是加双缓冲
现象是拖动窗口时外框拖出一条灰线,松手才消失。原因通常有两个:WM_NCPAINT里画完没有让系统擦除旧区域,或者OnPaint里把标题栏画到了客户区外面。WM_NCPAINT的DC是窗口DC,系统在移动窗口时会发WM_NCPAINT重画非客户区,如果你在OnNcPaint里先调了父类又画了自己的矩形,但没有限制裁剪区,可能画到窗口外。解决是把绘制限制在窗口矩形内,CWindowDC的Rectangle只画一圈,不要Fill整个窗口。另一个原因是OnPaint里用了CPaintDC却画了标题栏以外的区域,客户区DC会被裁剪,画出去的部分变成残影。把标题栏绘制严格限制在GetTitleBarRect()里,边框绘制严格限制在WM_NCPAINT里,两边不要互相越界。
5.2 最大化多一条边:WM_NCCALCSIZE和工作区差一像素
现象是窗口最大化后,标题栏上面多出一条灰边,或者左边少一像素,客户区内容整体偏移。原因是WS_THICKFRAME在最大化时仍然占边框厚度,系统把窗口矩形扩展到了屏幕外,但客户区计算没跟上。解决是在WM_NCCALCSIZE里判断IsZoomed(),把rgrc[0]按边框厚度外扩,让客户区覆盖整个工作区。边框厚度不要用固定值,用GetSystemMetrics(SM_CXSIZEFRAME) + GetSystemMetrics(SM_CXPADDEDBORDER)。多显示器下还要用MonitorFromWindow取当前显示器的工作区,不要用主屏尺寸。改完之后用Spy++看WM_NCCALCSIZE的rgrc[0],最大化时应该等于显示器工作区。
5.3 点击按钮无响应:命中测试返回了HTCAPTION
现象是标题栏按钮画出来了,鼠标放上去有hover,但点击没反应,或者点了之后窗口开始拖动。原因是WM_NCHITTEST在按钮区域返回了HTCAPTION,系统把按下当成标题栏拖动,OnLButtonDown根本没进。解决是OnNcHitTest里先判断按钮矩形,命中按钮返回HTCLIENT,再判断标题栏空白返回HTCAPTION。顺序不能反。另一个常见原因是坐标没从屏幕转客户区,PtInRect永远不命中,按钮区域也走了HTCAPTION。调试时在OnNcHitTest里打日志,打印point和转换后的pt,看按钮矩形对不对。
5.4 高DPI下按钮模糊:字体和图形用了固定像素
现象是150%缩放时标题栏文字发虚,按钮图标边缘糊。原因是字体按96 DPI创建,图形用固定像素位图。解决是标题栏高度、按钮宽度、字体高度全部走DpiScale,图形用矢量画,不要贴PNG。窗口收到WM_DPICHANGED时重建字体、重算布局、刷标题栏。如果工程还要支持旧系统,GetDpiForWindow用GetProcAddress动态取,取不到按96算。模糊的另一个来源是SetWindowPos之后没有重新创建字体,字体还是旧DPI的,系统拉伸导致发虚。
5.5 失焦后标题栏不重绘:WM_NCACTIVATE只改了标志没刷帧
现象是窗口切到后台,标题栏还是激活时的亮色,边框也没变灰。原因是WM_NCACTIVATE里改了m_bActive,但只Invalidate了客户区,没有刷非客户区边框。解决是WM_NCACTIVATE里更新标志后,用RedrawWindow(nullptr, nullptr, RDW_INVALIDATE | RDW_FRAME | RDW_UPDATENOW),RDW_FRAME会把WM_NCPAINT带出来。不要只调Invalidate,非客户区不会重画。另一个原因是OnNcPaint里没有根据m_bActive选颜色,改了标志但绘制代码没读。检查OnNcPaint和OnPaint里是不是都用了m_bActive分支。
6. 封装与验证:把边框标题栏抽成可复用类的落地清单
把散在CMainFrame里的代码抽成CFrameTitleBar类,主框架只留消息转发。类里管标题栏矩形、按钮矩形、hover/pressed状态、DPI、字体、绘制。主框架的OnPaint调m_titleBar.Draw(&dc, this),OnNcHitTest调m_titleBar.HitTest(point),OnLButtonDown/Up调m_titleBar.OnLButtonDown/Up。这样下一个SDI工程直接拷类,改PreCreateWindow的样式位就能用。
class CFrameTitleBar { public: void Init(CWnd* host); void Draw(CDC* dc, CWnd* host); LRESULT HitTest(CPoint screenPoint, CWnd* host); bool OnLButtonDown(CPoint point, CWnd* host); bool OnLButtonUp(CPoint point, CWnd* host); void OnMouseMove(CPoint point, CWnd* host); void RecalcLayout(CWnd* host); void SetActive(bool active) { m_bActive = active; } private: CRect GetButtonRect(CaptionButton btn) const; CaptionButton HitButton(CPoint pt) const; void DrawButton(CDC* dc, CaptionButton btn); bool m_bActive = true; CaptionButton m_nHotButton = BTN_NONE; CaptionButton m_nPressedButton = BTN_NONE; int m_titleHeight = 36; CFont m_titleFont; };封装时注意HitTest返回的是WM_NCHITTEST的LRESULT,不是bool。主框架的OnNcHitTest直接返回它,不要二次转换。OnLButtonDown返回true表示标题栏处理了,主框架就不再调父类。RecalcLayout在WM_SIZE和WM_DPICHANGED里调,重算按钮矩形和字体。SetActive在WM_NCACTIVATE里调。
验证按下面这张表走一遍,比肉眼盯靠谱。每项都用Spy++或日志看消息和矩形,不要只看截图。
| 验证项 | 操作 | 预期 |
|---|---|---|
| 样式 | PreCreateWindow后看窗口样式 | 无WS_CAPTION,有WS_THICKFRAME |
| 拖动 | 按住标题栏空白拖动 | 窗口跟随,无跳变 |
| 双击 | 双击标题栏空白 | 最大化/还原切换 |
| 按钮 | 依次点最小化、最大化、关闭 | 行为正确,按下态有反馈 |
| 边框 | 鼠标移到窗口边缘 | 光标变双向箭头,可拉大小 |
| 高DPI | 150%缩放运行 | 标题栏高度和按钮同比放大,文字不糊 |
| 失焦 | 点击其他窗口 | 标题栏和边框变灰,重绘无残影 |
| 最大化 | 最大化后看顶部 | 无多出一条边,客户区贴工作区 |
我最早做这套时,在WM_NCHITTEST里把按钮区域也返回了HTCAPTION,结果按钮点了没反应,查了半天以为是OnLButtonUp没收到,后来用Spy++看消息才发现按下根本没进客户区。从那以后我每次改标题栏命中测试,都先拿Spy++把WM_NCHITTEST的返回值打出来,按钮区域必须是HTCLIENT,标题栏空白必须是HTCAPTION,边框区域是HTLEFT/HTRIGHT这些,三个区域对上了再往下写绘制。希望帮到你。
本文还有配套的精品资源,点击获取