简介:面向Windows桌面开发者的MFC自定义界面实践包,聚焦单文档框架下自行绘制非客户区,重点解决默认标题栏、边框与关闭按钮样式难以个性化的问题。内容以C++源码为主体,配合头文件、资源脚本、图标位图与可运行示例,覆盖窗口背景擦除、绘制消息、非客户区鼠标响应及按钮状态切换等知识点,并说明资源加载与缩放兼容的处理方式,便于对照理解自定义皮肤的实现脉络。压缩包共32个文件,包含8个头文件、7个源文件,以及图标、位图、资源脚本、工程与解决方案、说明文本和可执行程序,整体约95KB,体量轻便。已有394人学习下载,适合具备MFC消息机制与GDI基础、希望深入窗口绘制和事件处理的开发者参考,也可作为高DPI适配与界面美化的入门样例。
1. 客户区顶部那条自己画的标题栏,比换一套皮肤更划算
去年帮一个做设备采集的团队改主界面,截图发过来时最扎眼的不是图表配色,是顶部那条系统标题栏:灰色渐变、宋体字、右边三个按钮还是老版本 Windows 的圆角风格,和下面那套深色 UI 像两个年代的东西拼在一起。他们的诉求很朴素——标题栏换成自己的颜色,按钮换成自己的图标,能拖能缩别坏。这类需求在 MFC 单文档工程里出现频率很高,因为 SDI 框架默认给你的那层外壳,恰恰是全窗口最显眼、又最难改的一块。
可行的做法就是把系统标题栏整层剥掉:WS_CAPTION从窗口样式里删干净,用WM_NCCALCSIZE把客户区撑满整个窗口,标题栏变成你自己在客户区顶部画的一条矩形。颜色、字体、按钮顺序、悬停高亮、暗色主题全部归你,代价是拖动和缩放要自己补回来。适合手上已经有 MFC 单文档工程、又不想为了换个外观把整套框架迁走的人。
2. 先把系统那层壳剥掉:样式位与 WM_NCCALCSIZE 的配合
这一步只动窗口样式和一个消息处理函数,但顺序不能乱。样式位决定系统给你留多少非客户区,WM_NCCALCSIZE决定客户区最终落在哪,两者对不上,后面画得再漂亮也会整体偏移几个像素。
2.1 PreCreateWindow 里减哪几位,减完谁会消失
CMainFrame::PreCreateWindow是唯一能改创建参数的地方。这里有一个容易踩的细节:WS_CAPTION不是独立的一位,它等于WS_BORDER | WS_DLGFRAME,所以清它的时候不能用等号直接赋值,必须是位清除。
BOOL CMainFrame::PreCreateWindow(CREATESTRUCT& cs) { if (!CFrameWnd::PreCreateWindow(cs)) return FALSE; // 1) 去掉标题栏本身。WS_CAPTION = WS_BORDER | WS_DLGFRAME cs.style &= ~WS_CAPTION; // 2) 这四个样式位保留:系统菜单、最小化、最大化、可调整边框 // 任务栏右键菜单里的"还原/最小化/最大化"依赖它们,删了会变灰 cs.style |= WS_SYSMENU | WS_MINIMIZEBOX | WS_MAXIMIZEBOX | WS_THICKFRAME; // 3) 去掉客户区四周的 3D 凹陷边,否则画到边缘会看到一圈浅灰 cs.dwExStyle &= ~WS_EX_CLIENTEDGE; // 4) 保证任务栏有独立图标,不被 owner 窗口吞掉 cs.dwExStyle |= WS_EX_APPWINDOW; cs.dwExStyle &= ~WS_EX_TOOLWINDOW; // 5) 类风格必须带 CS_DBLCLKS,否则客户区收不到 WM_LBUTTONDBLCLK cs.lpszClass = AfxRegisterWndClass( CS_DBLCLKS | CS_HREDRAW | CS_VREDRAW, ::LoadCursor(NULL, IDC_ARROW), NULL, NULL); return TRUE; }逐条看参数。WS_THICKFRAME是这一版方案的地基,保留它意味着系统仍然认为这是个可调整大小的窗口,Windows 10/11 会继续给它 DWM 阴影、贴靠动画和圆角边框,这些特性如果改成WS_POPUP就得自己拿DwmExtendFrameIntoClientArea去补,补出来还不一定有原生的手感。WS_SYSMENU单独保留没有副作用,因为标题栏没了,系统按钮根本不会绘制,但它能让Alt+Space系统菜单和任务栏右键菜单保持可用。
AfxRegisterWndClass这一行要小心。MFC 默认给框架窗口注册的类带CS_DBLCLKS,一旦你在PreCreateWindow里指定lpszClass,就等于把默认类换掉了,hbrBackground传NULL表示窗口不擦背景——这是好事,省掉一次无意义的填充;但如果传了画刷,每次尺寸变化都会先刷一遍背景色,反而增加闪烁。
WS_EX_CLIENTEDGE一定要清。它是CEdit、CListCtrl这类控件常用的凹陷边框,框架窗口上也带着,宽度随 DPI 变化,2 到 3 像素不等,肉眼看着像一条脏边。
2.2 WM_NCCALCSIZE 把客户区撑满整窗,以及最大化时的那 8 个像素
样式改完之后,窗口状态变成"有可调整边框、但没有标题栏"。系统仍然按WS_THICKFRAME给你留出四周边框,客户区比窗口小一圈。要让它撑满,得接管WM_NCCALCSIZE。
这个函数的语义是:系统问你,客户区应该占多大。lpncsp->rgrc[0]进来时是窗口矩形的建议值,你改成什么,客户区就是什么。直接改rgrc[0]而不调用父类,就等于告诉系统"客户区等于整个窗口"。
void CMainFrame::OnNcCalcSize(BOOL bCalcValidRects, NCCALCSIZE_PARAMS* lpncsp) { if (bCalcValidRects) { if (IsZoomed()) { // 最大化时窗口会被撑到工作区之外,四周各溢出边框宽度 int bx = GetSystemMetrics(SM_CXSIZEFRAME) + GetSystemMetrics(SM_CXPADDEDBORDER); int by = GetSystemMetrics(SM_CYSIZEFRAME) + GetSystemMetrics(SM_CXPADDEDBORDER); lpncsp->rgrc[0].left += bx; lpncsp->rgrc[0].top += by; lpncsp->rgrc[0].right -= bx; lpncsp->rgrc[0].bottom -= by; } // 非最大化:rgrc[0] 保持窗口矩形不动,客户区即整窗 return; } CFrameWnd::OnNcCalcSize(bCalcValidRects, lpncsp); }SM_CXPADDEDBORDER这个值容易被漏掉。从 Windows 10 开始,可调整边框分成两层:SM_CXSIZEFRAME是命中区宽度,SM_CXPADDEDBORDER是绘制阴影用的额外内边距,两者相加才是窗口实际溢出屏幕的像素。只减前者,最大化后左右各会露出 1 到 4 个像素的白边,看起来像窗体没对齐,实际是算法少算了一项。
bCalcValidRects为FALSE的情况出现在窗口创建早期,那时客户区还没有有效矩形,交给父类处理更稳妥。
2.3 标题栏矩形、DPI 因子与重绘触发点
样式和客户区都对了,接下来要给"标题栏"这个概念划定物理范围。它是一个CRect,位置固定在客户区顶部,高度按逻辑像素 32(或 36)乘以 DPI 因子换算。
void CMainFrame::RecalcLayout() { // 取当前窗口 DPI。GetDpiForWindow 需要 Win10 1607 以上,老工程要有 fallback m_nDpi = 96; HMODULE hUser32 = ::GetModuleHandle(_T("user32.dll")); if (hUser32) { typedef UINT (WINAPI *PFN_GETDPIFORWINDOW)(HWND); PFN_GETDPIFORWINDOW pfn = (PFN_GETDPIFORWINDOW)::GetProcAddress(hUser32, "GetDpiForWindow"); if (pfn && m_hWnd) m_nDpi = pfn(m_hWnd); } CRect rcClient; GetClientRect(&rcClient); int nTitleH = MulDiv(34, m_nDpi, 96); // 逻辑 34px 转物理像素 m_nBtnW = MulDiv(46, m_nDpi, 96); // 单个按钮宽 m_rcTitle.SetRect(rcClient.left, rcClient.top, rcClient.right, rcClient.top + nTitleH); }MulDiv比直接乘除更可靠,它在四舍五入时不会因为整数截断让 125% 缩放下的高度差出 1 像素。重绘的触发点有三个:OnSize里调用RecalcLayout后InvalidateRect(&m_rcTitle, FALSE);OnActivate和OnNcActivate里切换激活态并重画;WM_SETTEXT之后重画文字。InvalidateRect的第二个参数传FALSE表示不擦背景,配合后面的双缓冲才不会闪。
如果在 150% 缩放的机器上发现标题栏高度是 51 而不是 50,别急着改除数,先确认进程有没有声明 DPI 感知。没有声明的情况下,系统会对整个窗口做位图拉伸,MulDiv算出来的值再准也没用,画面照样糊。
3. 双缓冲画一条标题栏:从内存 DC 到四种按钮状态
绘制这一步决定了最终观感。直接往窗口 DC 上画,改一个字都会触发整片重绘,鼠标划过按钮就闪。正确的做法是先把整条标题栏画进一张与目标尺寸相同的位图,再一次性BitBlt贴回去。
3.1 为什么不用 GetWindowDC:两条绘制路线的取舍
标题栏有两种画法。一种是留在非客户区,用GetWindowDC配合WM_NCPAINT绘制;另一种是把标题栏搬进客户区,用普通的CPaintDC或CClientDC。
前一种更"正统",因为标题栏本来就在非客户区。但它有三个绕不开的麻烦:GetWindowDC拿到的坐标原点在窗口左上角,包含边框,每次都要先把WM_NCCALCSIZE算出的偏移量加上;系统在任何一次WM_NCACTIVATE、WM_SETWINDOWPOS之后都可能自己重画一遍非客户区,把你画的完美覆盖成默认样式;CPaintDC里拿不到非客户区,两套绘制逻辑要维护两份。
后一种写起来简单得多:标题栏就是客户区顶部一条普通矩形,OnPaint里画,InvalidateRect局部失效,坐标全是客户区坐标,不用做任何换算。代价是要自己处理"标题栏区域该响应什么鼠标消息",这部分在第 4 章补。
我一般选第二种。原因很实际——WM_NCPAINT那条路上踩坑的成本远高于省下来的那点坐标换算。系统重绘的时机分散在主题切换、DPI 变化、窗口激活、SetWindowPos等多个入口,漏一个就出现"拖动一下标题栏变回系统样式"的玄学现象。
3.2 双缓冲的完整代码与参数含义
void CMainFrame::DrawTitleBar(CDC* pDC) { if (m_rcTitle.IsRectEmpty()) return; const int w = m_rcTitle.Width(); const int h = m_rcTitle.Height(); // 1) 后备缓冲:与标题栏等尺寸,不用整窗大小 CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, w, h); CBitmap* pOldBmp = memDC.SelectObject(&bmp); // 2) 背景:激活/非激活两套色,别指望系统给你 BOOL bActive = (GetActiveWindow() == this); COLORREF crBack = bActive ? RGB(30, 32, 38) : RGB(58, 60, 66); memDC.FillSolidRect(CRect(0, 0, w, h), crBack); // 3) 标题文字:左侧留 12 逻辑像素,右侧让开三个按钮 CFont font; font.CreateFont(-MulDiv(13, m_nDpi, 96), 0, 0, 0, FW_NORMAL, 0, 0, 0, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_SWISS, _T("Microsoft YaHei")); CFont* pOldFont = memDC.SelectObject(&font); memDC.SetBkMode(TRANSPARENT); memDC.SetTextColor(bActive ? RGB(232, 234, 238) : RGB(160, 162, 168)); CString strTitle; GetWindowText(strTitle); CRect rcText(MulDiv(12, m_nDpi, 96), 0, w - m_nBtnW * 3 - MulDiv(8, m_nDpi, 96), h); memDC.DrawText(strTitle, rcText, DT_SINGLELINE | DT_VCENTER | DT_LEFT | DT_END_ELLIPSIS); // 4) 三个按钮,状态由 m_nHot / m_nPressed 决定 for (int i = 0; i < 3; ++i) { CRect rcBtn(w - m_nBtnW * (3 - i), 0, w - m_nBtnW * (2 - i), h); DrawCaptionButton(&memDC, rcBtn, i, (i == m_nHot), (i == m_nPressed), bActive); } // 5) 一次性贴回屏幕,杜绝逐元素闪烁 pDC->BitBlt(m_rcTitle.left, m_rcTitle.top, w, h, &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldFont); memDC.SelectObject(pOldBmp); }几个参数值得单独说。CreateCompatibleBitmap的宽高必须用位图尺寸而不是窗口尺寸,标题栏多高就建多高,一张 1920 宽的整窗位图在 4K 屏上能吃掉 8MB 显存,每次重绘还慢。CreateFont的字符集用DEFAULT_CHARSET,不要硬写GB2312_CHARSET,后者在非中文系统上会退化成方块字。DT_END_ELLIPSIS保证长文件名不撑破标题区,比手动截断字符串再拼省略号稳。
DrawCaptionButton里建议画矢量路径而不是贴位图,因为按钮宽度随 DPI 变化,位图缩放会有毛边。最小化是一条 10×1 的横线,最大化是个 10×10 的空心方框,关闭是两条对角线,用MoveTo和LineTo画,线宽取MulDiv(1, m_nDpi, 96)即可。
3.3 按钮状态机与 WM_MOUSELEAVE 的必要性
按钮要区分四种状态:非激活态(整体压暗)、普通、悬停、按下。前三种靠m_nHot一个索引就能表达,按下需要单独一个m_nPressed,因为按下之后鼠标移出按钮再移回来,视觉上要能区分"还按着"和"已经松手了"。
悬停状态的坑在于WM_MOUSEMOVE不会无限发。鼠标停在标题栏不动,系统就不再投递移动消息,此时如果鼠标快速移出窗口,WM_MOUSEMOVE一次都不会触发,按钮会永远保持高亮。解决办法是进入标题栏时调一次TrackMouseEvent,让系统在鼠标离开时补一个WM_MOUSELEAVE。
void CMainFrame::OnMouseMove(UINT nFlags, CPoint point) { if (!m_bTracking) { TRACKMOUSEEVENT tme = { sizeof(TRACKMOUSEEVENT) }; tme.dwFlags = TME_LEAVE; tme.hwndTrack = m_hWnd; tme.dwHoverTime = HOVER_DEFAULT; ::TrackMouseEvent(&tme); m_bTracking = TRUE; } int nHot = HitButton(point); // -1 表示不在任何按钮上 if (nHot != m_nHot) { m_nHot = nHot; InvalidateRect(&m_rcTitle, FALSE); // 只废标题栏,别整窗重画 } CFrameWnd::OnMouseMove(nFlags, point); } void CMainFrame::OnMouseLeave() { m_bTracking = FALSE; if (m_nHot != -1 || m_nPressed != -1) { m_nHot = m_nPressed = -1; InvalidateRect(&m_rcTitle, FALSE); } CFrameWnd::OnMouseLeave(); }InvalidateRect传&m_rcTitle而不是NULL,这一点在拖动窗口时体感差异明显。传NULL会把整个客户区标脏,如果客户区里挂着一个刷新频率很高的图表控件,标题栏每次悬停变化都会带着它重画一遍,CPU 占用肉眼可见地上去。
还有一处细节:OnPaint里必须把标题栏画在客户区绘制的最后。MFC 的视图窗口是框架窗口的子窗口,正常流程下视图会盖住标题栏那块区域。常见的处理是在OnSize里把视图窗口的矩形显式压到标题栏下方:
void CMainFrame::OnSize(UINT nType, int cx, int cy) { CFrameWnd::OnSize(nType, cx, cy); RecalcLayout(); if (CWnd* pView = GetActiveView()) { CRect rc; GetClientRect(&rc); rc.top = m_rcTitle.bottom; // 视图从标题栏下沿开始 pView->SetWindowPos(NULL, rc.left, rc.top, rc.Width(), rc.Height(), SWP_NOZORDER | SWP_NOACTIVATE); } InvalidateRect(&m_rcTitle, FALSE); }这样标题栏和视图各占一块,互不遮挡,重绘范围也干净。
4. 把拖动和缩放补回来:WM_NCHITTEST 的判定顺序
样式改完之后,窗口是"画得出来但拖不动"的状态。原因在于鼠标消息的分发路径:系统先发WM_NCHITTEST问"这个点属于窗口的哪个部位",拿到HTCAPTION才启动拖动,拿到HTLEFT之类才启动缩放,拿到HTCLIENT才往窗口投递WM_MOUSEMOVE这些客户区消息。返回HTNOWHERE或者让默认处理走完,窗口就是一块不动的板子。
4.1 命中测试的返回值表与边角优先原则
判定顺序必须是"四角 → 四边 → 标题栏 → 客户区",因为角落同时满足两条边的条件,先判边会把角落吃掉,用户拖角时只能单向缩放。
| 返回值 | 触发区域 | 系统行为 |
|---|---|---|
HTTOPLEFT/HTTOPRIGHT | 左上/右上角 | 双向缩放 |
HTBOTTOMLEFT/HTBOTTOMRIGHT | 左下/右下角 | 双向缩放 |
HTLEFT/HTRIGHT | 左右边框 | 横向缩放 |
HTTOP/HTBOTTOM | 上下边框 | 纵向缩放 |
HTCAPTION | 标题栏空白区 | 拖动、双击最大化、右键系统菜单 |
HTCLIENT | 标题栏按钮、视图区 | 投递客户区鼠标消息 |
UINT CMainFrame::OnNcHitTest(CPoint point) { ScreenToClient(&point); // 入参是屏幕坐标 CRect rcClient; GetClientRect(&rcClient); int bx = GetSystemMetrics(SM_CXSIZEFRAME) + GetSystemMetrics(SM_CXPADDEDBORDER); int by = GetSystemMetrics(SM_CYSIZEFRAME) + GetSystemMetrics(SM_CXPADDEDBORDER); if (IsZoomed()) bx = by = 0; // 最大化时不留缩放边 BOOL bLeft = point.x < rcClient.left + bx; BOOL bRight = point.x >= rcClient.right - bx; BOOL bTop = point.y < rcClient.top + by; BOOL bBottom = point.y >= rcClient.bottom - by; // 角优先,否则 1 像素的角永远判成边 if (bTop && bLeft) return HTTOPLEFT; if (bTop && bRight) return HTTOPRIGHT; if (bBottom && bLeft) return HTBOTTOMLEFT; if (bBottom && bRight) return HTBOTTOMRIGHT; if (bLeft) return HTLEFT; if (bRight) return HTRIGHT; if (bTop) return HTTOP; if (bBottom) return HTBOTTOM; // 标题栏区域:按钮之外返回 HTCAPTION,拖动和双击全部白拿 if (m_rcTitle.PtInRect(point)) { if (HitButton(point) >= 0) return HTCLIENT; // 按钮必须自己处理点击 return HTCAPTION; } return HTCLIENT; }这里最省事的一招是标题栏空白区直接返回HTCAPTION。返回之后,拖动、双击最大化、右键弹出系统菜单、Alt+Space、窗口贴靠,全部由系统接管,一行额外代码都不用写。这也是为什么前面坚持保留WS_THICKFRAME和WS_SYSMENU——系统那套交互逻辑全靠这些样式位识别窗口能力。
按钮区必须返回HTCLIENT,否则点击会被当成拖动起手,WM_LBUTTONDOWN根本进不来。
4.2 拖动、双击最大化、右键系统菜单
按上面的写法,拖动和双击是免费的。但如果产品要求"双击标题栏不做最大化",或者"拖动到屏幕顶部不触发贴靠",就得把HTCAPTION换成HTCLIENT并自己接管。
// 方案 B:标题栏返回 HTCLIENT,一切自己来 void CMainFrame::OnLButtonDown(UINT nFlags, CPoint point) { int nBtn = HitButton(point); if (nBtn >= 0) { m_nPressed = nBtn; SetCapture(); // 保证鼠标移出窗口也能收到抬起消息 InvalidateRect(&m_rcTitle, FALSE); return; } if (m_rcTitle.PtInRect(point)) { // 发一个"点在标题栏上"的伪消息,让系统接管拖动 ReleaseCapture(); SendMessage(WM_NCLBUTTONDOWN, HTCAPTION, MAKELPARAM(point.x, point.y)); return; } CFrameWnd::OnLButtonDown(nFlags, point); } void CMainFrame::OnLButtonUp(UINT nFlags, CPoint point) { if (m_nPressed >= 0) { int nBtn = HitButton(point); if (nBtn == m_nPressed) ExecuteCaptionButton(nBtn); // 按下和抬起在同一按钮才生效 m_nPressed = -1; ReleaseCapture(); InvalidateRect(&m_rcTitle, FALSE); } CFrameWnd::OnLButtonUp(nFlags, point); }SendMessage(WM_NCLBUTTONDOWN, HTCAPTION, ...)这一句是手动拖动的标准写法,前面必须配ReleaseCapture()。不释放捕获的话,鼠标消息还锁在窗口里,系统的拖动循环收不到,表现就是"按住没反应"。MAKELPARAM里的坐标要用客户区坐标,虽然这个值在拖动场景下系统并不真正使用,但传错在某些远程桌面环境下会导致拖动起点跳一下。
右键菜单同理,在WM_NCRBUTTONUP或者客户区WM_RBUTTONUP里判断点是否落在标题栏,是的话执行:
void CMainFrame::ShowSystemMenu(CPoint ptScreen) { HMENU hSys = ::GetSystemMenu(m_hWnd, FALSE); if (!hSys) return; ::SetMenuDefaultItem(hSys, SC_CLOSE, FALSE); ::TrackPopupMenu(hSys, TPM_RETURNCMD | TPM_RIGHTBUTTON, ptScreen.x, ptScreen.y, 0, m_hWnd, NULL); }用TPM_RETURNCMD拿到返回值后自己SendMessage(WM_SYSCOMMAND, nCmd, 0),比让TrackPopupMenu自己发消息更好控制——有些命令(比如SC_RESTORE)在自绘窗口里需要额外的重排动作。
4.3 最小尺寸、贴靠与多显示器缩放
最小尺寸靠WM_GETMINMAXINFO,不设的话用户能把窗口拖成一条 30 像素宽的竖线,标题栏三个按钮直接重叠。
void CMainFrame::OnGetMinMaxInfo(MINMAXINFO* lpMMI) { lpMMI->ptMinTrackSize.x = MulDiv(720, m_nDpi, 96); lpMMI->ptMinTrackSize.y = MulDiv(480, m_nDpi, 96); CFrameWnd::OnGetMinMaxInfo(lpMMI); }窗口贴靠(拖到屏幕边缘自动半屏)依赖WS_THICKFRAME和WS_MAXIMIZEBOX,前面都保留了,所以这一块不用额外做。但要注意:贴靠之后窗口会收到WM_SIZE,RecalcLayout必须在那里重算m_rcTitle的宽度,否则按钮还停在旧位置,右半边是空的。
多显示器且 DPI 不同时,窗口从 100% 屏拖到 150% 屏会收到WM_DPICHANGED,此时要按lParam给的新矩形SetWindowPos,然后重算m_nDpi并重建字体。老工程如果没声明 per-monitor v2,系统会做位图拉伸,标题栏文字会糊成一片——这个只能靠清单文件解决,代码里补不回来。
5. 避坑:这五类问题基本每个项目都会撞一次
以下都是我在实际工程里反复见到的现象,按触发频率排序。每条都给出定位方向,不用从头猜。
5.1 拖动窗口时标题栏拖影、闪烁
现象:按住标题栏拖动,窗口内容跟着走,但标题栏区域会拖出一条条重影,松手后恢复正常。
原因:OnPaint里没有做双缓冲,或者做了但OnEraseBkgnd仍然返回FALSE之外的值,系统先用背景色刷一遍,再画内容,两帧之间被用户看到。另一种情况是OnSize里对整窗调了Invalidate(TRUE),每次尺寸变化都触发全量重绘。
解决:OnEraseBkgnd直接return TRUE,声明背景由自己负责;OnPaint里只允许通过内存 DC 一次性BitBlt;所有局部刷新统一用InvalidateRect(&m_rcTitle, FALSE),第二个参数固定FALSE。如果还闪,检查有没有在OnPaint里调UpdateWindow,那会造成递归重绘。
5.2 最大化后顶部多出 8 像素或盖住任务栏
现象:点最大化,标题栏和屏幕顶边之间露出一条背景色,或者窗口底部把任务栏压住了一截。
原因:WM_NCCALCSIZE里只减了SM_CXSIZEFRAME,漏了SM_CXPADDEDBORDER;或者干脆没做最大化补偿,客户区跟着窗口一起溢出工作区。底部压住任务栏则是rgrc[0].bottom没减去边框高度。
解决:bx和by都按SM_CXSIZEFRAME + SM_CXPADDEDBORDER计算,四个方向对称处理。验证方法是在最大化状态下用GetClientRect和GetWindowRect各打一次日志,客户区宽高应该等于工作区宽高,误差为 0。如果任务栏设成自动隐藏,SystemParametersInfo(SPI_GETWORKAREA)拿到的值会包含任务栏区域,这时候少减一点反而更自然。
5.3 高 DPI 下按钮点不中、文字发糊
现象:在 150% 缩放的笔记本上,标题栏看着正常,但鼠标要点偏左十几像素才能命中关闭按钮;文字边缘有彩色毛边。
原因:绘制时用了 DPI 缩放后的坐标,命中测试里却用了硬编码的逻辑像素;或者反过来。字体创建时没乘 DPI 因子,系统做了位图放大。
解决:所有尺寸只保留一套来源——m_nDpi。RecalcLayout里统一算出m_nBtnW、m_rcTitle、字体高度,绘制和命中测试都从这些成员读,不再出现任何字面量像素。字体质量用CLEARTYPE_QUALITY,配合SetBkMode(TRANSPARENT),能消掉大部分毛边。如果进程本身没声明 DPI 感知,先改清单文件,代码层面做什么都是徒劳。
5.4 从最大化还原后窗口跑到屏幕外
现象:最大化状态下点还原,窗口恢复到最大化之前的尺寸,但位置偏到了屏幕左上角外面,标题栏看不见,只能靠Alt+Space救回来。
原因:最大化时WM_NCCALCSIZE改了rgrc[0],系统记录的"还原位置"被这个改动污染了。另一种情况是WM_GETMINMAXINFO里改了ptMaxPosition但没改回来。
解决:在OnNcCalcSize里只对IsZoomed()为真的分支做偏移,非最大化分支完全不碰rgrc[0]。还原位置的兜底是在WM_SYSCOMMAND处理SC_RESTORE之后手动SetWindowPos到MonitorFromWindow的工作区居中位置。血泪经验是这类问题在单屏开发机上测不出来,必须双屏、不同分辨率各试一遍。
5.5 任务栏图标、Alt+Tab 缩略图和右键菜单异常
现象:任务栏上没有程序图标,或者 Alt+Tab 切过来只看到一个空白框;任务栏右键菜单里"最小化"是灰的。
原因:WS_EX_TOOLWINDOW没清掉,窗口被当成工具窗;或者WS_MINIMIZEBOX、WS_SYSMENU被一起删了,系统认为这个窗口不支持这些操作。
解决:PreCreateWindow里显式cs.dwExStyle &= ~WS_EX_TOOLWINDOW并加上WS_EX_APPWINDOW;样式位只清WS_CAPTION,其余四个保留。Alt+Tab 缩略图是 DWM 自动截的,如果显示空白,通常是窗口在创建早期被ShowWindow(SW_HIDE)又快速显示,DWM 没来得及抓帧——加一个DwmSetWindowAttribute的DWMWA_FORCE_ICONIC_REPRESENTATION也不一定有用,更稳的做法是确保窗口首次显示时客户区已经有内容。
6. 阴影、圆角和一键自检:让自绘标题栏看起来不像假的
做到这一步,功能已经齐了,但和真正的商业软件比还差一口气:窗口四周没有柔和阴影,边角是直角,最大化时缺少那点细微的内边距。这几项补上,观感会拉开明显差距。
阴影不用自己画。保留WS_THICKFRAME的窗口在 Windows 10/11 上默认带 DWM 阴影,如果某台机器上没出来,多半是WS_CAPTION清得太狠把WS_BORDER也带走了。检查方式是打印GetWindowLong(GWL_STYLE),确认WS_THICKFRAME还在。实在没有就补一像素透明边:
// 需要在工程里链接 dwmapi.lib #include <dwmapi.h> #pragma comment(lib, "dwmapi.lib") void CMainFrame::EnableDwmShadow() { MARGINS m = { 1, 1, 1, 1 }; // 四边各扩 1 像素,触发 DWM 合成 ::DwmExtendFrameIntoClientArea(m_hWnd, &m); }Win11 的圆角是一条属性,值 33 目前没有公开常量名,自己定义:
#ifndef DWMWA_WINDOW_CORNER_PREFERENCE #define DWMWA_WINDOW_CORNER_PREFERENCE 33 #endif // DWMWCP_ROUND = 2 void CMainFrame::EnableRoundCorner() { DWORD dwPref = 2; ::DwmSetWindowAttribute(m_hWnd, DWMWA_WINDOW_CORNER_PREFERENCE, &dwPref, sizeof(dwPref)); }注意圆角只在窗口有非客户区边框时才生效。如果客户区已经撑满整窗、标题栏画到了最顶上,圆角会被你自己的绘制盖住——这种情况下要么在客户区顶部留出 1 像素不画,要么干脆关掉圆角保持直角,视觉上反而更统一。
调试阶段我用一张固定清单过一遍,比逐项点界面快得多:
| 检查项 | 判定标准 | 常见失败点 |
|---|---|---|
| 拖动 | 标题栏按住能移动,松手不偏移 | 忘了ReleaseCapture |
| 双击 | 最大化/还原切换正常 | 类风格缺CS_DBLCLKS |
| 边角缩放 | 八个方向都能拖,角上双向 | 角判定写在边判定之后 |
| 最大化 | 客户区等于工作区,无白边 | 漏SM_CXPADDEDBORDER |
| 还原 | 回到原位置,不出屏 | rgrc[0]被污染 |
| 悬停 | 按钮高亮,移出后复位 | 没调TrackMouseEvent |
| 失焦 | 标题栏变暗,文字仍可读 | 只在OnPaint里判一次激活态 |
| 150% DPI | 按钮命中 |
本文还有配套的精品资源,点击获取