简介:面向 Windows C++ 桌面开发者的 MFC 单文档窗口自绘实践示例,重点在于覆盖系统默认的非客户区样式:通过继承 CFrameWnd、重写 OnPaint 与 OnEraseBkgnd 等消息处理函数来绘制窗口边框、标题栏与关闭按钮,并兼顾按钮高亮、按下状态切换,以及不同分辨率和 DPI 下的兼容处理。压缩包共 32 个文件、约 95KB,以 h、cpp 源码为主体,配合 ico、bmp 图标位图资源以及 rc、vcproj、sln 等工程配置,并附可运行的 exe 示例,便于直接对照编译调试。已有 394 人学习下载。读者可从中获得非客户区事件响应、GDI 绘图与皮肤资源加载的完整实现思路,理解鼠标点击区域判定与重绘时机,适合希望突破 MFC 默认界面外观、提升应用视觉体验的中级开发者参考。
1. 标题栏不是画出来的:MFC 单文档窗口真正难处理的地方在 NC 区
很多人在 MFC 单文档程序里第一次尝试修改边框标题栏时,都会掉进同一个误区:在OnDraw或者OnPaint里画了一条漂亮的横条,运行起来却发现它老老实实待在客户区顶部,系统标题栏该在还在、该丑还丑。原因不复杂——标题栏在 Win32 的坐标体系里根本不属于你。它属于非客户区(Non-Client Area,简称 NC 区),由系统的窗口管理器在WM_NCPAINT消息里绘制,客户区的任何绘制指令都覆盖不到它。
所以"自己画标题栏"这件事的真实含义,是完成两件事:接管 NC 区的绘制权(WM_NCPAINT),并接管该区域的命中测试权(WM_NCHITTEST)。只接管绘制权,你得到的是一个外观漂亮但所有按钮都点不动的假标题栏;只接管命中测试权,你得到一个能拖动但外观仍然由系统控制的普通窗口。MFC 的CFrameWnd在这条链路上几乎没有封装可依赖的现成逻辑,只能回到 Win32 消息层自己处理。这也正是这个机制值得深入学习的原因:它是理解"窗口边界到底由谁定义"的最短路径。
2. WM_NCPAINT 的调用链:系统标题栏究竟在什么时候被画出来
要接管一件事,先要知道它什么时候发生。窗口边框和标题栏的绘制并不是"窗口创建时调用一次绘制函数"这种朴素模型,它由一组消息协作完成,其中任何一环被改变,最终呈现的结果就会不一样。
2.1 一条 SetWindowPos 引发的四连消息
改变窗口大小、改变激活状态、恢复最小化,最终都会走到同一个流程里。以一次SetWindowPos为例,消息顺序大致是这样的:先发WM_WINDOWPOSCHANGING让程序有机会修改目标位置;然后进入尺寸计算阶段,系统发WM_NCCALCSIZE,问"客户区该从哪开始、到哪结束";算完之后发WM_WINDOWPOSCHANGED,接着才是两块绘制相关的消息——WM_ERASEBKGND负责擦背景,WM_NCPAINT负责画非客户区。
激活状态变化是另一条独立路径:窗口被点中或失去焦点时发WM_NCACTIVATE,wParam为TRUE表示变为激活态。默认处理里,系统会重画一遍标题栏,把标题文字从灰变黑或者反过来。如果你在自绘逻辑中重写了OnNcActivate却直接return TRUE,就等于告诉系统"我已经处理好了",但实际上什么都没画,结果就是窗口切走再切回来,标题栏颜色毫无变化——这是自绘 NC 区最常见的"假死"现象之一。
有一个容易忽略的细节:WM_NCPAINT的wParam名义上是需要重绘的 NC 区域句柄,但实际运行时绝大多数情况下传的是1,表示"整块 NC 区都要重画"。指望靠这个wParam做局部刷新是不现实的,真想优化就得自己用GetUpdateRgn拿区域。
2.2 CFrameWnd 没有替你封装的部分
CWnd里确实存在OnNcPaint、OnNcActivate、OnNcHitTest这几个消息处理函数,但它们的默认实现几乎都是Default()——把控制权原样交还给系统的窗口过程。CFrameWnd也没有在它们之上加任何逻辑。这意味着自绘标题栏在 MFC 里是一个"零起点"任务:消息映射要自己挂,绘制要自己写,连"什么时候该重画"都得自己判断。
消息映射的挂法本身很朴素,但漏掉一个就会导致整套逻辑时不时失效:
// MainFrm.cpp BEGIN_MESSAGE_MAP(CMainFrame, CFrameWnd) ON_WM_NCPAINT() // WM_NCPAINT -> OnNcPaint ON_WM_NCACTIVATE() // WM_NCACTIVATE -> OnNcActivate ON_WM_NCHITTEST() // WM_NCHITTEST -> OnNcHitTest ON_WM_NCMOUSEMOVE() // 按钮悬停高亮要用 ON_WM_NCLBUTTONDOWN() // 自绘按钮的按下反馈要用 END_MESSAGE_MAP()这五条消息构成一个最小闭环:WM_NCPAINT负责画,WM_NCACTIVATE负责在焦点变化时触发重画,WM_NCHITTEST负责把鼠标位置翻译成区域语义,后两条负责按钮的视觉反馈。少任何一条,交互都会出现"看起来能用、用起来别扭"的状态。
2.3 GetWindowDC 与坐标系原点
NC 区的绘制必须用GetWindowDC拿设备上下文,不能用GetDC。区别在于:GetDC返回的 DC 原点在客户区左上角,而GetWindowDC返回的 DC 原点在整个窗口(含边框和标题栏)的左上角。用错了,你画出来的东西会整体偏移一个标题栏加边框的距离,而且随着边框宽度变化而漂移。
GetWindowDC拿到的 DC 还带着一个裁剪区,只允许在 NC 区域内绘制。这个裁剪区是好事,能防止你意外画到客户区;但如果你打算用双缓冲把整窗一次性贴上去,就得先SelectClipRgn(NULL)或者干脆用内存 DC 拼好再BitBlt。
void CMainFrame::OnNcPaint() { // 关键点:这里绝不能调用 CFrameWnd::OnNcPaint(), // 它的实现是 Default(),会把系统标题栏原样画回去,覆盖你的成果 CDC* pDC = CDC::FromHandle(::GetWindowDC(GetSafeHwnd())); if (pDC == nullptr) return; CRect rcWindow; GetWindowRect(&rcWindow); // GetWindowDC 的原点在窗口左上角,所以要把屏幕坐标平移回来 rcWindow.OffsetRect(-rcWindow.left, -rcWindow.top); CRect rcTitle(rcWindow); rcTitle.bottom = rcTitle.top + GetTitleBarHeight(); // 高度按 DPI 计算,见 3.3 DrawTitleBar(pDC, rcTitle, m_bActive); ::ReleaseDC(GetSafeHwnd(), pDC->GetSafeHdc()); // 必须成对释放,否则 DC 泄漏 }这段代码里三个参数值得说明。GetSafeHwnd()是 MFC 对窗口句柄的安全包装,窗口未创建时返回NULL,可以省掉一次判空。GetWindowRect返回的是屏幕坐标,而GetWindowDC的原点在窗口自身左上角,所以必须做一次OffsetRect;这个平移是新手最容易漏的一步,漏了之后标题栏会画到屏幕左上角去。最后的ReleaseDC必须用::ReleaseDC这个全局版本,配套::GetWindowDC,不能和CDC::ReleaseDC混用。
3. 样式裁剪与坐标计算:让系统把标题栏的位置让出来
绘制只是表面,真正决定"你能不能画出想要的效果"的是窗口样式和 NC 区尺寸的计算。这两块决定了系统留给你多少可用的空间。
3.1 WS_CAPTION 其实是两个样式的和
很多人以为WS_CAPTION是一个独立的窗口样式,实际上它在winuser.h里就是这么定义的:WS_CAPTION = WS_BORDER | WS_DLGFRAME。这个定义解释了一个常见困惑——为什么去掉标题栏之后边框也跟着没了。因为标题栏和细边框共享同一个样式位。
想保留可缩放的粗边框而只去掉标题栏,正确做法是清掉WS_CAPTION再补上WS_THICKFRAME:
BOOL CMainFrame::PreCreateWindow(CREATESTRUCT& cs) { if (!CFrameWnd::PreCreateWindow(cs)) return FALSE; // WS_CAPTION 等价于 WS_BORDER | WS_DLGFRAME, // 直接清掉会连边框一起清掉,所以要显式补回 WS_THICKFRAME cs.style &= ~WS_CAPTION; cs.style |= WS_THICKFRAME | WS_SYSMENU | WS_MINIMIZEBOX | WS_MAXIMIZEBOX; // MFC 的 FWS_ADDTOTITLE 会把文档名自动拼到窗口标题后面, // 自绘标题栏时文字由自己控制,这个行为必须关掉 cs.style &= ~FWS_ADDTOTITLE; return TRUE; }WS_SYSMENU、WS_MINIMIZEBOX、WS_MAXIMIZEBOX这三个要保留,因为自绘标题栏的关闭、最小化、最大化按钮需要HTCLOSE、HTMINBUTTON、HTMAXBUTTON这几个命中码,而这些命中码只有在对应样式存在时才有意义。FWS_ADDTOTITLE是 MFC 特有的扩展样式,默认开启,作用是让框架窗口标题自动跟随当前文档名变化——自绘标题栏时它会把你精心设置的文字覆盖掉,属于典型的"查了半天以为是绘制 bug,实际是框架在替你做事"。
3.2 WM_NCCALCSIZE:客户区边界由谁决定
WM_NCCALCSIZE是整条链路上最容易被低估的消息。它的wParam决定lParam的解释方式:wParam为TRUE时,lParam指向NCCALCSIZE_PARAMS,里面有三个矩形,分别是"建议的新窗口矩形""改之前的窗口矩形""改之前的客户区矩形";wParam为FALSE时,lParam就是一个普通的RECT,表示客户区矩形。
返回值决定后续行为:返回0表示"按我改过的矩形来,不要再重画";返回WVR_REDRAW表示"按我改过的矩形来,但请重画整个窗口"。后者会引发一次额外的擦除加绘制,视觉上就是一道白闪。
自绘标题栏常见两种做法。第一种是保留系统边框:在OnNcCalcSize里返回0,让系统照常计算标题栏高度,然后你在这块区域上重画。这种做法改动小,边框缩放、窗口阴影都由系统负责,缺点是标题栏高度受系统主题牵制,做不出很窄或者很高的异形标题栏。
第二种是客户区铺满整窗:把rgrc[0]直接改成整个窗口矩形,NC 区宽度归零,然后自己画完整的一圈边框和标题栏。这种做法自由度高,但代价很直接——NC 区没了,系统就再也收不到边缘的命中测试,窗口缩放会立刻失效,必须在OnNcHitTest里自己补回HTLEFT、HTTOPRIGHT这些返回值。自由度是靠工作量换来的。
3.3 三个矩形与 DPI 缩放
想在代码里正确算出"标题栏矩形",必须同时掌握三个坐标系:GetWindowRect给的是屏幕坐标的整窗矩形;GetClientRect给的是客户区坐标的宽高,原点永远是(0,0);ClientToScreen负责两者之间的换算。标题栏高度在理论上等于"窗口高度 − 客户区高度 − 上下边框",但更稳妥的做法是直接查系统度量。
在 DPI 感知开启的程序里,GetSystemMetrics(SM_CYCAPTION)返回的是 96 DPI 下的逻辑值,直接拿去用在 150% 缩放的屏幕上会偏小。应该改用按窗口 DPI 取值的版本:
int CMainFrame::GetTitleBarHeight() const { // 按窗口所在显示器的 DPI 取标题栏高度,避免高缩放下偏小 UINT dpi = ::GetDpiForWindow(GetSafeHwnd()); int h = ::GetSystemMetricsForDpi(SM_CYCAPTION, dpi); // 标题文字行高 h += ::GetSystemMetricsForDpi(SM_CYSIZEFRAME, dpi); // 上边框 h += ScaleForDpi(4, dpi); // 自定义留白,同样要缩放 return h; }这里的ScaleForDpi是自己写的辅助函数,把设计稿上的像素值按dpi / 96.0换算。所有手写的间距、圆角半径、图标尺寸都得走这个函数,否则在 125% 缩放下会出现"图标和文字垂直方向不居中"这种看起来像玄学的问题。
4. 命中测试:为什么你的自绘按钮点不动
绘制决定窗口长什么样,命中测试决定窗口怎么响应。这两件事在 NC 区是完全解耦的:系统只认WM_NCHITTEST的返回值,不看你画了什么。
4.1 返回值就是一张区域地图
WM_NCHITTEST的lParam打包了鼠标的屏幕坐标,低位是 x,高位是 y,可以用GET_X_LPARAM/GET_Y_LPARAM拆出来,也可以直接用一个CPoint接住。返回值才是关键,它告诉系统"这个点属于哪块区域":
| 返回值 | 语义 | 系统后续行为 |
|---|---|---|
HTCLIENT | 客户区 | 后续鼠标消息发给客户区 |
HTCAPTION | 标题栏 | 支持拖动、双击最大化、拖到屏幕边缘吸附 |
HTLEFT/HTTOPRIGHT等 | 边框八个方向 | 进入缩放光标和缩放流程 |
HTCLOSE | 关闭按钮 | 点击后转换为WM_SYSCOMMAND+SC_CLOSE |
HTMINBUTTON/HTMAXBUTTON | 最小化 / 最大化 | 同上,走系统命令 |
HTNOWHERE | 无归属 | 鼠标事件被丢弃 |
最典型的失败方式是:鼠标坐标是屏幕坐标,而自己记录的按钮矩形是窗口坐标,两套坐标直接比较,结果永远不匹配。这类问题的表现很有迷惑性——按钮画得好好的,就是点不动,换个分辨率或者把窗口拖到别的位置又偶尔能点中,很容易被误判成"消息没收到"。
4.2 拖动和边缘吸附该交给系统处理
一个常见的实现冲动是自己处理WM_LBUTTONDOWN然后SetCapture、跟着鼠标MoveWindow。这条路能走通,但代价很大:多显示器不同 DPI 下坐标换算会漂移,窗口最大化时拖动还原的逻辑要自己写,拖到屏幕边缘的吸附效果完全没有。
正确做法是让WM_NCHITTEST对标题栏区域返回HTCAPTION,剩下的全交给系统。系统内部处理拖动时会自动处理这些边界情况,包括拖动过程中的半透明预览和释放后的窗口落位。这是那种"自己实现三天,交给系统三行"的典型场景。
UINT CMainFrame::OnNcHitTest(CPoint point) { // point 是屏幕坐标,这点必须先确认,否则后面的比较全是错的 CRect rcWindow; GetWindowRect(&rcWindow); CRect rcTitle = GetTitleBarRect(); // 窗口坐标下的标题栏 rcTitle.OffsetRect(rcWindow.TopLeft()); // 换算到屏幕坐标再比较 if (!rcTitle.PtInRect(point)) return CFrameWnd::OnNcHitTest(point); // 边缘缩放等仍走系统默认 CPoint ptClient = point - rcWindow.TopLeft(); // 转成窗口坐标 for (const auto& btn : m_buttons) { if (btn.rect.PtInRect(ptClient)) return btn.hitCode; // HTCLOSE / HTMINBUTTON / HTMAXBUTTON } return HTCAPTION; // 其余部分作为标题栏,拖动与双击最大化由系统接管 }这段代码里OffsetRect和point - rcWindow.TopLeft()是两个方向相反的换算,前者把窗口矩形搬到屏幕坐标,后者把屏幕点搬回窗口坐标。写成同一种会立刻出错,而且错得很隐蔽——窗口在屏幕左上角附近时看着正常,一拖远就失灵。
4.3 按钮的悬停反馈与 WM_NCACTIVATE 的联动
返回HTCLOSE之后点击能关闭窗口,但鼠标移上去没有任何高亮,体验上仍然是"半成品"。NC 区的悬停反馈需要自己维护:在WM_NCMOUSEMOVE里根据wParam(就是上一步返回的命中码)判断当前鼠标落在哪个按钮上,和上一次的状态比较,变了就重画 NC 区。
重画动作要克制。直接调Invalidate()会把客户区一起标记为脏,整个视图跟着重绘一遍,视觉上就是明显的闪。应该用RedrawWindow只刷新框架部分:
void CMainFrame::OnNcMouseMove(UINT nHitTest, CPoint point) { if (nHitTest != m_nHotButton) // 只有悬停目标变了才重画 { m_nHotButton = nHitTest; // 只刷 NC 区,不碰客户区,避免整窗闪烁 RedrawWindow(NULL, NULL, RDW_FRAME | RDW_INVALIDATE | RDW_UPDATENOW); } CFrameWnd::OnNcMouseMove(nHitTest, point); }RDW_FRAME是这里的关键标志,它把刷新范围限制在非客户区;RDW_UPDATENOW让重画立即发生而不是排队等到空闲,否则快速划过按钮时高亮会明显滞后。WM_NCACTIVATE的默认处理也要改:不能简单return TRUE,应该先把激活状态存下来,再触发一次 NC 区重画,否则焦点切换后标题栏颜色不变。
5. 避坑与排查:自绘 NC 区最容易翻车的五个现场
5.1 绘制类问题:闪烁与残影
现象:拖动窗口边缘改变大小时,标题栏出现明显的白闪,或者上一次画的文字残留在原地没被擦掉。
原因:调用CFrameWnd::OnNcPaint()或者Default()让系统先用自己的背景色擦了一遍再画你的内容;另一种情况是WM_NCCALCSIZE返回了WVR_REDRAW,系统会额外插入一次擦除。残影则通常是因为绘制前没有填满整个目标矩形,FillSolidRect只盖住了文字区域。
解决:OnNcPaint里完全不调父类实现;OnNcCalcSize返回0而不是WVR_REDRAW;绘制函数第一行就用背景色填满整个标题栏矩形,再往上叠元素。
5.2 绘制类问题:失活后标题栏不刷新
现象:从别的窗口切回来,标题栏颜色和按钮高亮状态没有跟着变,看起来像卡住了。
原因:重写了OnNcActivate并直接return TRUE,等于告诉系统"这活我接了",但实际上没写任何重画逻辑。
解决:在OnNcActivate里先记录bActive到成员变量,然后调RedrawWindow刷 NC 区,最后再决定返回值。如果希望系统也参与(比如保留窗口阴影),可以调用基类实现后再补一次自己的绘制。
5.3 交互类问题:窗口拖不动,或者边缘不能缩放
现象:标题栏区域按住鼠标完全没反应;或者边框看着在,鼠标移上去不变缩放光标。
原因:OnNcHitTest对所有点都返回了HTCLIENT,或者只处理了标题栏内圈、把外圈漏给了默认处理;在"客户区铺满整窗"的方案里,NC 区宽度归零,系统根本收不到边缘的命中测试。
解决:确认标题栏判断用的是屏幕坐标;在铺满方案里显式补上八个方向的缩放判断,按距离窗口边缘的像素宽度返回HTLEFT、HTTOPLEFT等;用调试输出把所有命中码打一遍,直观看出地图对不对。
5.4 尺寸类问题:最大化后边框超出屏幕
现象:窗口最大化时,自绘的右边框和下边框跑到屏幕外,被任务栏遮住一截。
原因:最大化状态下GetWindowRect返回的矩形本身就比显示器工作区大一圈,多出来的是系统预留的不可见边框。直接拿它当绘制基准,边框自然越界。
解决:在最大化状态下改用MonitorFromWindow拿到当前显示器,再用GetMonitorInfo取rcWork工作区矩形作为绘制基准,而不是GetWindowRect。同时注意WM_NCCALCSIZE在最大化时也会被调用,里面的矩形同样需要校正。
5.5 DPI 类问题:一换缩放比例就错位
现象:在 100% 缩放下完美,换到 150% 之后文字偏小、图标和文字垂直方向不居中、按钮点击区域和视觉区域对不上。
原因:所有尺寸都写成了硬编码像素;或者用了GetSystemMetrics而不是按 DPI 取值的版本;再或者绘制时按 DPI 缩放了,命中测试时忘了同步缩放,导致视觉和热区错位。
解决:把"设计稿像素 → 实际像素"的换算收敛到一个函数里,绘制和命中测试都调它;窗口收到WM_DPICHANGED时重新计算标题栏高度并触发一次 NC 区重画。这类问题在真机上不测很难发现,属于典型的血泪经验。
6. 设计取舍:完全自绘 NC 区值不值得
把标题栏接管过来之后,收益很直观:配色能跟产品主题走,标题栏高度、按钮样式、图标间距完全可控,做深色主题时不用再忍受系统标题栏那一块突兀的浅色。但代价清单比想象中长——你在重新实现操作系统已经做了很多年的一件事。
系统默认标题栏背后挂着一整套行为:拖到屏幕边缘的吸附分屏、拖到顶部最大化、拖动时的半透明预览、窗口阴影、打开关闭动画、贴靠布局、触摸和手写笔的命中容差、屏幕阅读器对按钮名称的朗读、任务栏缩略图和切换器里的预览。自绘之后,这些要么由系统继续负责(当你返回HTCAPTION和HTCLOSE时),要么就得自己补上。上面这套方案之所以坚持让WM_NCHITTEST返回系统语义码而不是自己处理鼠标,就是在尽量把这些行为留在系统手里。
如果需求只是"标题栏颜色跟着主题变",其实存在一条工作量小得多的路径:保留系统标题栏,通过桌面窗口管理器提供的属性接口直接改标题栏和边框的颜色。这条路不需要处理WM_NCPAINT、WM_NCCALCSIZE、WM_NCHITTEST中的任何一个,也不需要重算 DPI,代价是可定制程度有限,做不出异形标题栏和自定义按钮布局。选择哪条路,取决于你是想要"换个颜色"还是"完全掌控"。
一个可以落地的判断方法是列出需求清单,逐条问"系统默认实现能不能满足"。只有颜色、只有暗色模式、只需要在标题栏加一行副标题——选轻量方案。需要自定义按钮大小和位置、需要标题栏内嵌搜索框、需要完全无边框的卡片式窗口——才值得走完全自绘。中间地带最危险:为了改一个背景色就接管整个 NC 区,最后被迫维护一套永远追不上系统版本的交互逻辑。
从那以后我每次评估这类需求,都会强制先写一版最小验证:只接管绘制、不接管命中测试,跑一遍拖动、双击最大化、边缘吸附、多显示器不同 DPI、任务栏遮挡这五个场景,看系统默认行为还剩多少。这五个场景全过,再往里加自定义按钮;只要有一个过不去,就说明这套方案的成本被我低估了。这份资源里的代码骨架,正好可以当作这个验证的起点,把OnNcPaint、OnNcCalcSize、OnNcHitTest三处改造成自己要的样子,先跑通再谈优化。希望帮到你。
本文还有配套的精品资源,点击获取