MFC分组框Group Box:CButton本质、单选互斥与自绘高DPI

发布时间:2026/11/4 9:59:18
MFC分组框Group Box:CButton本质、单选互斥与自绘高DPI

MFC 的 Group Box 控件大概是工具箱里最没存在感的一个:拖上去、改个标题、把几个单选按钮框进去,就完事了。但它也是翻车率最高的控件之一——单选按钮莫名其妙全对话框互斥、改了颜色没反应、禁用分组框结果里面的选项还能点、自绘时边框画得歪歪扭扭、高 DPI 下标题跑到框外面去。这些问题单看每个都不大,凑在一起就是半天工时。

这篇东西想做的事情,是把分组框从"画个框的装饰品"重新拆一遍:它底层到底是什么控件、属性面板里那几项各自管什么、哪些外观改动是够得着的、够不着的部分怎么自己画、运行期怎么创建和布局。适合已经能拖控件写对话框、但被分组框坑过的开发者,也适合刚接触 MFC 界面开发、想把基础控件吃透的人。文中代码都是可以照着敲的片段,不是伪代码。

1. 先搞清楚 Group Box 到底是什么控件:它不是容器

1.1 一个典型误会:禁用分组框为什么挡不住里面的人

先说我见过最多的一个误会。界面上放了一个分组框,里面三个单选按钮,业务上需要"这个模块整体不可用",于是有人写了这么一行:

GetDlgItem(IDC_GROUP_ADV)->EnableWindow(FALSE);

跑起来一看,框和标题确实灰了,但里面三个单选按钮该点点、该选选,一点没受影响。

原因很简单也很反直觉:分组框不是容器,里面那些控件不是它的子窗口。在对话框模板里,分组框和它"框住"的控件是平级的兄弟关系,父窗口都是对话框本身。分组框只是在视觉上画了一个带缺口的矩形,并且这个矩形区域恰好覆盖了下面那些控件的位置。所谓"框进去",纯粹是坐标上的巧合,没有任何父子归属关系。

所以EnableWindow(FALSE)只作用于分组框这一个窗口。要真正禁用整组,得自己把范围内的控件挨个找出来。我常用的做法是按矩形包含关系收集:

void CMyDlg::EnableGroup(CWnd* pGroup, BOOL bEnable) { if (!pGroup || !pGroup->GetSafeHwnd()) return; CRect rcGrp; pGroup->GetWindowRect(&rcGrp); ScreenToClient(&rcGrp); for (CWnd* p = GetWindow(GW_CHILD); p; p = p->GetNextWindow()) { if (p->GetSafeHwnd() == pGroup->GetSafeHwnd()) continue; CRect rc; p->GetWindowRect(&rc); ScreenToClient(&rc); // 以控件中心点是否落在分组框内作为归属判断 if (rcGrp.PtInRect(rc.CenterPoint())) p->EnableWindow(bEnable); } // 分组框本身最后处理,保证它在视觉上也同步 pGroup->EnableWindow(bEnable); }

这段代码有几个地方值得多说两句。判断用的是中心点而不是矩形相交或完全包含,因为实际布局里经常出现控件边缘压到框线上的情况,用包含判断会漏掉;中心点判断虽然不是百分之百严谨,但对绝大多数的对话框布局都够用。另外遍历用的是GetWindow(GW_CHILD)加GetNextWindow(),遍历的是 z 序上的兄弟窗口,能覆盖到分组框之后创建的所有控件。如果对话框里有嵌套的分组框(一个框里套一个框),这个函数会做两层包含判断,内层控件会被外层也扫一遍,逻辑上没问题,但要注意别重复改变状态。

至于这种做法是不是"优雅",我的看法是:只要对话框不太复杂,这套矩形判断是最省事的,不需要维护任何 ID 列表,改布局也不会失效。真想做得干净,就在对话框里维护一个std::vector<UINT>记录每组的控件 ID,这属于工程习惯问题。

1.2 BS_GROUPBOX 背后:它就是一个按钮

在 MFC 里,分组框的封装类是CButton,不是CStatic,也不是某个专门的CGroupBox。这一点从资源编辑器里给分组框绑变量时的下拉框就能看出来——类型列表里给的是CButton。

底层原因是 Win32 的按钮控件族里,分组框的样式值就是BS_GROUPBOX(0x00000007),和BS_PUSHBUTTON、BS_CHECKBOX、BS_RADIOBUTTON共用同一个窗口类。系统在绘制这个"按钮"的时候,走的是专门的画法:先画一个矩形边框,再把窗口文本画在左上角,并且把文字下方那条边框线挖空,形成常见的缺口效果。

理解这一点的价值在后面的自绘会特别明显:因为它是按钮,所以父窗口能收到WM_DRAWITEM,因为它的样式值是一个低四位字段,所以可以用ModifyStyle把BS_GROUPBOX换成BS_OWNERDRAW。这两条通道,是后面所有高级定制的地基。

顺带说清楚几个相关的样式含义,免得看文档时被绕晕:

样式值说明
BS_GROUPBOX0x00000007分组框,文字画在左上,边框线留缺口
BS_OWNERDRAW0x0000000B自绘按钮,父窗口负责全部绘制
WS_GROUP0x00020000一组控件的起始标记,和上面那个不是一回事

注意BS_GROUPBOX和WS_GROUP完全不同:前者决定"长什么样",后者决定"归哪一组"。这两个概念在中文语境下都叫"分组",混淆的人不在少数。

1.3 什么时候别用它:三种更合适的替代方案

分组框不是万能的,下面几种场景我会绕开它。

第一种是只需要一条分隔线的场合。有些界面用分组框纯粹为了视觉分块,结果多出来一个标题、多出来一圈边框,反而显得啰嗦。这种情况用一条CStatic的蚀刻横线(SS_ETCHEDHORZ),或者干脆留白,视觉上更干净。

第二种是需要精确控制标题样式的场合。分组框的标题字体、颜色、位置受系统绘制逻辑约束,能改的空间很窄。如果设计要求标题带图标、带说明文字、颜色特殊、或者在框的右上角,直接用CStatic画标题、再自己画框,比死磕分组框高效得多。这个方案后面第 3 节会详细讲。

第三种是需要整体控制可用性、需要整体隐藏的场合。因为分组框不是容器,隐藏它不会隐藏内容,禁用它不会禁用内容。这种场景我更倾向用一个无边框的CPanel(自己派生的CWnd)作为真正的容器,把子控件真的创建在它里面,这样EnableWindow、ShowWindow天然向下传递。代价是多一层窗口,布局代码要重写。

2. 拖进对话框的第一步:属性面板里那几项到底管什么

2.1 Group 属性与单选按钮互斥的真实规则

资源编辑器里分组框的属性页很短,真正容易出事的是Group这一项。

Group对应窗口样式位WS_GROUP。系统在解析对话框模板时,会按 Tab 顺序从头扫一遍,把每个带WS_GROUP的控件视为"一组的开始",直到遇到下一个带WS_GROUP的控件为止,中间这段属于同一组。同一组内的BS_AUTORADIOBUTTON之间互斥,点一个,组里其他自动清空。注意关键词——自动。用BS_RADIOBUTTON(也就是属性里把 Auto 取消掉的单选按钮)不会自动互斥,得自己写代码管状态。

所以最基本的规则是:每个分组的第一个控件要把Group勾上。分组框本身就是最常见的那个"第一个控件",勾上它,组内所有单选按钮就自然成为一个互斥组,同时和别的分组框隔离开。

我经常见到一个让人抓狂的现象:界面上有三个分组框、三组单选按钮,结果点第一组的选项,第二组的选中状态被清掉了。九成情况下就是其中一个分组框忘记勾Group,导致两组 radio 混进了同一个组——从系统视角看,它们属于同一段 Tab 顺序区间,互斥范围自然合并了。

还有一个流传很广的"双保险"做法:不仅分组框勾Group,组内第一个单选按钮也勾Group。这个做法站得住脚,因为组内第一个 radio 再开一个新组,这个新组的结束边界仍然是下一个带WS_GROUP的控件(也就是下一个分组框),所以互斥范围并没有被切碎,只是多了一个无意义的组起点。它不会有坏处,遇到某些资源模板手工编辑过、样式位错乱的工程时反而更保险。我个人的习惯是:分组框勾,组内第一个 radio 也勾。

至于组内出现非单选的控件会不会打乱顺序,答案是不会。静态文本、复选框、输入框夹在中间,都只是组成员,不影响互斥判定——互斥只在 radio 之间发生。

2.2 Tab 顺序与方向键导航的隐形依赖

Group勾不勾,直接影响的是两件事:单选互斥和方向键导航。

在对话框里按 Tab 键,焦点按 Tab 顺序在控件之间跳;在同一个组内按上下左右方向键,焦点会在组成员之间移动,并且对于 radio,还会顺带改变选中项。这是对话框管理器内建的行为,不需要写代码。

问题在于Tab 顺序是模板里的实际顺序,不是视觉位置顺序。资源编辑器里调整视觉排版的时候,如果只是拖动控件改变位置,Tab 顺序不会跟着变。结果就是:界面上看着是一组的控件,实际 Tab 顺序里被别的控件隔开了,于是方向键怎么按都不对,单选互斥也出问题。

我的做法是,任何一次布局调整之后,都打开一次 Tab 顺序视图,从头到尾过一遍。快捷键是 Ctrl+D,进去之后每个控件上会显示一个序号,按你期望的顺序依次点一遍就重新编号了。这个动作花不了两分钟,但能省掉后面一堆"为什么方向键跳错组"的排查。

顺带说一句:分组框本身通常不应该带Tab Stop。它没有实际的交互行为,能获得焦点纯属干扰,用户 Tab 到它身上会看到一个焦点框,按空格也没反应。资源编辑器里把Tab Stop取消掉,Tab 顺序视图里它的序号是 0 就对了。

2.3 绑变量:为什么多数时候应该选 CButton

给分组框添加成员变量,向导里 Class Wizard 给出的类型是CButton。这个是对的,因为分组框的窗口类就是按钮类。

但很多人的第一反应是绑成CStatic,理由是"它看起来像个静态文本"。用CStatic做DDX_Control绑定在运行时是会出问题的:MFC 的DDX_Control在DoDataExchange里会调用SubclassWindow,把 HWND 绑定到指定类型的 C++ 对象上。绑成CStatic之后,调用CStatic独有的方法(比如SetIcon)行为是未定义的,因为底层窗口根本不支持那些消息。窗口类不匹配导致的崩溃往往在现场很隐蔽,调试起来费劲。

绑成CButton之后,SetWindowText/GetWindowText改标题、EnableWindow改可用性、ShowWindow改可见性,全部正常。常用的就这三个。

如果只需要改标题、不需要保持成员变量,SetDlgItemText(IDC_GROUP_ADV, _T("高级选项"))更省事,不用绑变量。我在实际项目里的取舍标准是:这个分组框需要在运行期被查询状态(是否可见、矩形位置)就绑变量,只是静态展示就不绑。

3. 改字体、改文字颜色、改背景:可行与不可行的边界

3.1 字号与标题排版:字体、边距与控件高度的关系

分组框的标题字体由WM_SETFONT决定。要改字号,创建一个CFont,CreateFont之后SetFont给控件即可:

// 成员变量,别用局部变量 CFont m_fontGroupTitle; BOOL CMyDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 12 号字,加粗 m_fontGroupTitle.CreateFont( -16, 0, 0, 0, FW_BOLD, FALSE, FALSE, 0, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_DONTCARE, _T("SimSun")); GetDlgItem(IDC_GROUP_ADV)->SetFont(&m_fontGroupTitle); return TRUE; }

这里有个必须强调的点:CFont对象必须是成员变量,不能是局部变量。控件只是保存了字体句柄,并不拥有这个 GDI 对象。局部CFont在函数结束时析构,字体被销毁,控件下次重绘时拿着一个已经失效的句柄去绘制,结果就是标题消失、乱码,或者偶发的绘制异常。这个坑我在早期项目里踩过不止一次,症状很随机,因为它依赖重绘时机。

字号改大之后马上会遇到第二个问题:标题被裁掉一半。原因是分组框的标题画在窗口矩形的最上沿,垂直居中对齐到那条边框线。字号变大,字高超过系统预留的那点空间,上半截就被窗口矩形裁掉了。

解决办法有两个。一是把控件高度整体加大,同时把内部控件往下挪,给顶部留出足够空间。二是改字号的时候顺便用SetWindowPos把控件顶边往上提——注意是往上提,让标题有空间展开,然后内部的空间靠增大高度补回来。第二种做法在已经排好版的对话框里更实用,因为不用动内部控件的坐标:

CRect rc; CWnd* pGrp = GetDlgItem(IDC_GROUP_ADV); pGrp->GetWindowRect(&rc); ScreenToClient(&rc); const int nExtra = 10; // 顶部额外让出的像素 pGrp->SetWindowPos(NULL, rc.left, rc.top - nExtra, rc.Width(), rc.Height() + nExtra, SWP_NOZORDER | SWP_NOACTIVATE);

具体留多少,得按字体高度算。经验值是:字号每增大 4 个磅值,顶部多留 4 到 6 个像素(96 DPI 下)。这个数字别背,写完之后看一眼效果,微调两次就对了。

3.2 WM_CTLCOLOR 系列消息里哪些改得动

接下来是改颜色,这块的边界最模糊,也是最多人问的地方。

对话框在绘制子控件之前,会先给父窗口发WM_CTLCOLORxxx消息,让父窗口有机会提供文字颜色和背景画刷。MFC 把它封装成了OnCtlColor。分组框发出的消息类型,实测下来是CTLCOLOR_STATIC,不是CTLCOLOR_BTN。这一点很关键,因为很多资料会说"按钮走 CTLCOLORBTN",于是有人写if (nCtlColor == CTLCOLOR_BTN)然后发现分支根本不进。

能改的和不能改的,实测结论如下:

目标是否可行说明
标题文字颜色可行在CTLCOLOR_STATIC分支里SetTextColor
标题文字背景部分可行返回的画刷会被用于填充,但容易出残影
边框颜色不可行边框由系统绘制,不受画刷影响
圆角/线宽不可行只能自绘
标题位置对齐不可行只能自绘或用静态文本替代

改文字颜色的写法:

HBRUSH CMyDlg::OnCtlColor(CDC* pDC, CWnd* pWnd, UINT nCtlColor) { HBRUSH hbr = CDialogEx::OnCtlColor(pDC, pWnd, nCtlColor); if (pWnd->GetDlgCtrlID() == IDC_GROUP_ADV && nCtlColor == CTLCOLOR_STATIC) { pDC->SetTextColor(RGB(0, 92, 158)); pDC->SetBkMode(TRANSPARENT); // 返回 NULL_BRUSH 避免系统用默认画刷填充 return (HBRUSH)::GetStockObject(NULL_BRUSH); } return hbr; }

SetBkMode(TRANSPARENT)配合返回空画刷,能让文字背景透出对话框底色,这是最稳的组合。但要注意:返回NULL_BRUSH之后,如果该区域此前被别的内容画过,可能留下残留像素,视觉上表现为标题后面有一块脏东西。这种情况常见于窗口尺寸变化、控件移动之后没有触发完整重绘。

我的处理办法是在需要的时候主动触发一次重绘:

InvalidateRect(NULL, TRUE); UpdateWindow();

或者更精细一点,只失效分组框所在的矩形。别嫌麻烦,比留着残影强。

至于边框颜色,我试过CTLCOLOR_STATIC返回各种画刷、试过WM_ERASEBKGND里填色,在开启视觉样式的情况下基本全部无效,因为系统用的是主题引擎绘制的边框,绕过了普通填充路径。要让这些方法生效,得先把主题关掉。

3.3 关闭视觉样式这条退路,以及它的代价

关主题的 API 是SetWindowTheme:

#include <uxtheme.h> #pragma comment(lib, "uxtheme.lib") SetWindowTheme(GetDlgItem(IDC_GROUP_ADV)->GetSafeHwnd(), L"", L"");

第一个参数是窗口句柄,后两个传空串表示"不使用任何主题"。调用之后,分组框会退回经典样式的绘制:一条细的蚀刻线边框,标题在缺口处。这时候WM_CTLCOLORSTATIC返回的画刷开始生效,边框颜色也可能跟着变化。

但代价很实在:

  1. 视觉风格和系统其他对话框不一致。开启视觉样式之后,分组框的边框是一根细的浅灰线,圆润、克制;关掉之后变成深灰色的两道蚀刻线,观感差异明显。如果只有个别分组框关掉主题,整个界面会显得拼接感很重。
  2. 高 DPI 下经典样式的线宽不跟随缩放。主题绘制会按 DPI 调整线宽,经典绘制不一定会,可能在 150% 缩放下显得过细。
  3. 系统主题切换时可能不重绘。用户切换深色/浅色主题,关掉主题的控件是感知不到的。

所以我的建议是:这条退路只在特定场景用,比如整个对话框本来就是自绘风格、所有控件都关主题统一为经典外观。如果只是想让某一条边框换个颜色,老老实实走自绘路线,别用关主题这种半吊子手段。

4. 自绘分组框:两条路线与逐行实现

4.1 路线选择:ModifyStyle 改成 Owner Draw 还是子类化拦 WM_PAINT

想要圆角、渐变、渐变标题、任意位置标题,只能自己画。目前可用的两条路线,实现方式差别不小。

路线 A:切成自绘按钮。利用前面说的"分组框就是个按钮",把BS_GROUPBOX换成BS_OWNERDRAW,父窗口就会收到WM_DRAWITEM,在OnDrawItem里全权负责绘制:

m_grpAdv.ModifyStyle(BS_GROUPBOX, BS_OWNERDRAW); m_grpAdv.SetWindowPos(NULL, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);

SWP_FRAMECHANGED不能省,样式位改了之后需要让系统重新计算非客户区和绘制状态,不加这一句可能第一次显示还是旧样子。

路线 B:子类化,拦WM_PAINT。派生一个CButton子类,SubclassDlgItem之后在自己的OnPaint里画。样式保持不变,仍然是BS_GROUPBOX,只是绘制时机被自己接管:

class CGroupBoxEx : public CButton { DECLARE_DYNAMIC(CGroupBoxEx) public: afx_msg void OnPaint(); afx_msg BOOL OnEraseBkgnd(CDC* pDC); DECLARE_MESSAGE_MAP() };

两条路线我都用过,对比如下:

维度路线 A(Owner Draw)路线 B(子类化 OnPaint)
改动量小,父窗口加一个OnDrawItem中,多一个类
绘制上下文父窗口收到DRAWITEMSTRUCT控件自己拿到CPaintDC
复用性差,绘制逻辑挤在对话框里好,可以直接拖到别的对话框
焦点/状态处理需要自己判断ODS_状态位不涉及
调试难度稍高,断点在父窗口低,断点在控件自己

结论比较清楚:一次性改造、只在一个对话框里用,走路线 A;要复用、或者这个对话框本来就有多个自绘控件,走路线 B。下面我按路线 B 来写完整实现,因为它更贴近工程实际,而且路线 A 只是把同样的绘制代码挪个地方。

4.2 手绘一个圆角带渐变标题的框

先明确绘制步骤。一个好看的分组框需要画三样东西:一个圆角矩形边框、一段嵌在边框上的标题文字、以及标题下方被"挖空"的那段边框。挖空的实现方式是:画完边框之后,用背景色在标题文字所在的位置填一块矩形,把线盖掉,再画文字。这个顺序不能乱。

void CGroupBoxEx::OnPaint() { CPaintDC dc(this); CRect rcClient; GetClientRect(&rcClient); // 双缓冲,避免闪烁 CDC memDC; memDC.CreateCompatibleDC(&dc); CBitmap bmp; bmp.CreateCompatibleBitmap(&dc, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp = memDC.SelectObject(&bmp); // 1. 用父窗口背景铺底 CRect rcWnd(rcClient); ClientToScreen(&rcWnd); GetParent()->ScreenToClient(&rcWnd); memDC.SetWindowOrg(rcWnd.left, rcWnd.top); GetParent()->SendMessage(WM_ERASEBKGND, (WPARAM)memDC.GetSafeHdc()); memDC.SetWindowOrg(0, 0); // 2. 计算边框矩形,顶部留出标题高度 CRect rcFrame(rcClient); rcFrame.top += m_nTitleHeight / 2; // 3. 画圆角边框 CPen pen(PS_SOLID, 1, RGB(196, 202, 210)); CPen* pOldPen = memDC.SelectObject(&pen); CBrush* pOldBrush = (CBrush*)memDC.SelectStockObject(NULL_BRUSH); memDC.RoundRect(rcFrame, CPoint(8, 8)); // 4. 挖掉标题区域 CString strTitle; GetWindowText(strTitle); if (!strTitle.IsEmpty()) { CSize sz = memDC.GetTextExtent(strTitle); CRect rcBlank(rcFrame.left + 10, rcClient.top, rcFrame.left + 10 + sz.cx + 4, rcClient.top + m_nTitleHeight); memDC.FillSolidRect(rcBlank, m_clrBack); // 5. 画标题 CFont* pOldFont = memDC.SelectObject(&m_fontTitle); memDC.SetBkMode(TRANSPARENT); memDC.SetTextColor(m_clrTitle); memDC.DrawText(strTitle, rcBlank, DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX); memDC.SelectObject(pOldFont); } memDC.SelectObject(pOldBrush); memDC.SelectObject(pOldPen); // 6. 一次性贴回 dc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }

这段代码里几个关键点值得单拎出来讲。

背景铺底为什么要找父窗口要?因为分组框的背景应该是透明的,露出对话框的底色。让父窗口处理WM_ERASEBKGND是最省事的做法,比自己猜背景色靠谱。但注意SetWindowOrg那两句:父窗口的WM_ERASEBKGND是按它自己的坐标系绘制的,如果不把原点偏移到分组框在父窗口中的位置,铺底图案会错位,表现出来就是背景有明显的接缝或者偏移。这个细节是我在一段渐变背景对话框上发现的,纯色背景看不出问题,一旦有纹理就露馅。

m_clrBack是什么?是挖空标题时填的颜色,必须和对话框背景色一致。如果对话框本身是渐变或图片背景,这个填色方案就露馅了,得换成从父窗口截图取像素,或者干脆不做挖空,改成在标题后面画一个圆角小标签块。后者在视觉上也很好看,而且完全规避了背景色匹配问题——我个人现在更倾向这种。

标题高度m_nTitleHeight怎么定?用字体的实际高度加内边距算出来,别写死。GetTextExtent拿到字高之后加上下各 2 到 4 像素,就是标题区域的高度。写死数字的后果是换了字体或者换了 DPI 就错位。

DT_NOPREFIX别忘。分组框标题里如果有&,默认会被解释成快捷键下划线,标题就变成了"高级选项"少个字符加一条下划线。加DT_NOPREFIX就按原样显示。

4.3 双缓冲、擦除背景与闪烁治理

自绘控件最烦的是闪烁。闪烁的根源是"先擦背景再画内容"这两步被看到了。治理手段有三层,逐层加:

第一层是重写OnEraseBkgnd直接返回TRUE,告诉系统"背景我自己管":

BOOL CGroupBoxEx::OnEraseBkgnd(CDC* pDC) { return TRUE; }

这一层解决的是WM_ERASEBKGND导致的整块白闪。

第二层是双缓冲,也就是上面代码里的CDC+CBitmap。所有绘制在内存位图上完成,最后BitBlt一次贴回。这一层解决的是逐元素绘制过程中的闪烁。

第三层是样式位。给对话框加WS_CLIPCHILDREN,给子控件加WS_CLIPSIBLINGS,避免父窗口重绘时把子控件区域也刷一遍,减少不必要的重绘范围。

三层叠完之后,正常情况下已经看不到闪烁了。如果还有,检查是不是有Invalidate()调用没带FALSE参数——Invalidate(TRUE)会连带背景一起失效,等于把前面三层白做了。

还有一个隐藏的闪烁来源:窗口尺寸变化时调用MoveWindow或SetWindowPos,如果频繁调用(比如OnSize里跟在鼠标后面实时计算),每一帧都触发一次重绘。这种情况加一个节流,或者在OnSize里先SetRedraw(FALSE)、算完再SetRedraw(TRUE)加Invalidate(FALSE)。

4.4 高 DPI 下的坐标与线宽换算

自绘最容易在别人的高分屏上翻车。写死像素值在 100% 缩放下完美,到 150% 就全乱。

基本思路是把所有设计尺寸定义成"96 DPI 下的值",绘制时按当前 DPI 缩放:

int CGroupBoxEx::DpiScale(int n96Value) const { HDC hdc = ::GetDC(NULL); int nDpi = ::GetDeviceCaps(hdc, LOGPIXELSX); ::ReleaseDC(NULL, hdc); return MulDiv(n96Value, nDpi, 96); }

MulDiv是系统提供的安全乘除函数,内部用 64 位中间值,能避免nValue * nDpi溢出。自己写n96Value * nDpi / 96在小数值下没问题,但坐标值大了之后有溢出风险。

对于 Windows 10 1607 及以上的系统,也可以直接用GetDpiForWindow(m_hWnd),比GetDeviceCaps更准,因为它返回的是这个窗口实际生效的 DPI,在多显示器、不同缩放混用的环境下更可靠。我给自己的封装是:优先GetDpiForWindow,动态加载失败时回落到GetDeviceCaps。

除了坐标,线宽和圆角半径也要缩。1 像素的线在 200% 缩放下看起来只有一半粗,观感会显得很轻飘。我的做法是线宽固定max(1, DpiScale(1)),圆角半径DpiScale(8)。

最后一个坑:DPI 变化时控件要重新计算。用户把窗口从 100% 缩放的显示器拖到 150% 的显示器上,会收到WM_DPICHANGED。如果控件自己缓存了尺寸,必须在这条消息里清缓存、重新算。MFC 的对话框默认对 DPI 变化的响应有限,这块需要自己补。至少保证绘制代码里不缓存绝对像素值,每次OnPaint现算,虽然多一点计算量,但省掉一堆状态同步的麻烦。

5. 运行期创建与随窗口伸缩的布局处理

5.1 Create 的样式组合与 ID 分配

有些对话框的分组数量是运行期决定的,比如按配置动态生成几组参数。这时候得用Create手动创建。

// 成员变量 CGroupBoxEx m_grpDynamic; BOOL CMyDlg::OnInitDialog() { CDialogEx::OnInitDialog(); CRect rc(20, 20, 320, 200); if (!m_grpDynamic.Create(_T("动态分组"), WS_CHILD | WS_VISIBLE | WS_GROUP | BS_GROUPBOX, rc, this, IDC_GROUP_DYN)) { TRACE(_T("分组框创建失败\n")); } m_grpDynamic.SetFont(GetFont()); return TRUE; }

几个要点:

样式里WS_GROUP要不要加,取决于这个分组是不是需要和别的单选按钮隔离。加了之后它就是一个新组的起点,框内的单选按钮自成一组;不加就会和上一个组混在一起。手动创建时特别容易漏掉这个,因为资源编辑器里它是属性面板的一个勾选项,写代码时它变成了一个样式位,很容易忘。

ID 的处理。资源模板里的控件 ID 是连续的(IDC_GROUP_ADV之类),动态创建的控件 ID 要么在资源文件里预先定义好一组,要么用/256那样的规则,要么干脆从一个大数开始自己分配。我的习惯是在Resource.h里预留一段 ID 段给动态控件,注释写清楚范围,避免和静态控件撞车。撞车的后果是GetDlgItem拿到错误的窗口,症状很隐蔽。

字体必须手动设。资源编辑器里创建的控件会自动继承对话框字体,Create出来的不会,得手动SetFont(GetFont())。忘了这行,动态分组框的字体会是系统默认的宋体小五,和界面其他部分明显不搭。

Create失败要检查。返回FALSE通常意味着 ID 重复或者父窗口无效。加一行TRACE在调试版能看到原因,比事后猜强。

5.2 OnSize 里重排子控件的顺序陷阱

对话框可调整大小时,OnSize里要重新摆放分组框以及它"框住"的那些控件。这里有一个反复踩的坑:分组框和内部控件必须成组移动,而且分组框要先移。

听起来是废话,但实际写代码时很容易被拆散。比如内部控件用了一个通用的布局函数批量处理,分组框单独处理,两边的计算基准不一致,就会出现分组框移动了、内部控件没跟着走,或者反过来。用户拉动一次窗口,框就跑到别的地方去了。

我的做法是给每个分组写一个独立的布局函数,把框和它的成员放在一起算:

void CMyDlg::LayoutAdvGroup(int nLeft, int nTop, int nWidth, int nHeight) { // 先移动分组框 m_grpAdv.SetWindowPos(NULL, nLeft, nTop, nWidth, nHeight, SWP_NOZORDER | SWP_NOACTIVATE); // 再按对话框客户区坐标摆放内部控件 const int nPadX = DpiScale(16); const int nPadTop = DpiScale(28); const int nRowH = DpiScale(24); m_chkAdvOption1.SetWindowPos(NULL, nLeft + nPadX, nTop + nPadTop, nWidth - nPadX * 2, nRowH, SWP_NOZORDER | SWP_NOACTIVATE); m_chkAdvOption2.SetWindowPos(NULL, nLeft + nPadX, nTop + nPadTop + nRowH + DpiScale(6), nWidth - nPadX * 2, nRowH, SWP_NOZORDER | SWP_NOACTIVATE); }

这样写的好处是,调整内边距只需要改一个地方,而且分组框和成员用的是同一套坐标基准,不会错位。

还有一个细节:SWP_NOZORDER一定要带。不带的话SetWindowPos会改变 z 序,而分组框如果被提到了 z 序最上层,它会盖住内部的控件,视觉上控件消失,点击也点不到。这个现象非常唬人,看起来像控件被删了,实际上只是被分组框的窗口矩形挡住了。第 6 节还会展开讲。

另外,OnSize在对话框初始化阶段可能被调用多次,而且第一次调用时子控件可能还没创建完成。防护写法是先判断句柄有效性:

void CMyDlg::OnSize(UINT nType, int cx, int cy) { CDialogEx::OnSize(nType, cx, cy); if (!m_grpAdv.GetSafeHwnd()) return; // 控件尚未创建,直接返回 // ... 布局代码 }

不加这个判断,调试版会在一堆断言里停下来,发布版可能直接崩。

6. 几个只有真做过项目才会遇到的坑

6.1 控件被"框住"了点不动:z 序问题

前面提过,这里说完整。现象是:对话框打开,某个分组框里的复选框看得见,但点不动,Tab 也跳不进去,用鼠标框选也选不上。有时候文字还显得比别的地方淡一点。

根因是分组框窗口的 z 序跑到了内部控件上面。因为分组框的窗口矩形覆盖了整块区域(不只是边框),它一旦在上面,鼠标点击就被它截获了——虽然它不处理点击,但也不会往下传。

造成 z 序颠倒的常见操作有三种:SetWindowPos忘了SWP_NOZORDER;运行期用Create创建分组框时,创建得比内部控件晚(后创建的窗口在 z 序上层);调用过BringWindowToTop或SetForegroundWindow。

排查方法很简单:在OnInitDialog末尾加一行遍历,把 z 序打出来。

for (CWnd* p = GetWindow(GW_CHILD); p; p = p->GetNextWindow()) { CString str; p->GetWindowText(str); TRACE(_T("z-order: id=%d, text=%s\n"), p->GetDlgCtrlID(), (LPCTSTR)str); }

遍历顺序就是 z 序,先出来的在上面。对照一下心里就有数了。

修复也简单,把分组框压到底:

m_grpAdv.SetWindowPos(&CWnd::wndBottom, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);

用wndBottom而不是wndTop。如果分组框需要保持在某个背景图之上、内部控件之下,那就把背景图设为wndBottom,分组框放在它上面,内部控件保持在分组框之上——顺序是:背景图 < 分组框 < 内部控件。

6.2 卸载字体对象导致的绘制异常

前面提过CFont必须是成员变量。这里补充一个更隐蔽的变种:成员变量是成员变量,但在OnInitDialog里被反复CreateFont了。

比如对话框支持"切换字号"功能,用户点一下按钮,代码重新创建字体。如果没有先DeleteObject旧的,GDI 句柄会泄漏;如果DeleteObject了旧的但控件还引用着它,下一次重绘就会用到一个无效句柄。

正确顺序是:先创建新字体、SetFont到所有相关控件、触发重绘、最后删除旧字体。或者更省事的做法,用两个字体对象交替:

CFont m_fontA, m_fontB; CFont* m_pCurFont = nullptr; void CMyDlg::SwitchFont(int nSize) { CFont* pNew = (m_pCurFont == &m_fontA) ? &m_fontB : &m_fontA; pNew->DeleteObject(); pNew->CreateFont(-nSize, 0, 0, 0, FW_NORMAL, FALSE, FALSE, 0, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_DONTCARE, _T("SimSun")); m_grpAdv.SetFont(pNew); m_chkAdvOption1.SetFont(pNew); m_pCurFont = pNew; Invalidate(FALSE); }

这样旧字体在切换后仍然存活到下一次切换才被删除,中间这段时间控件已经全部换到新字体上了,安全。

顺便说一个检测泄漏的小技巧:调试版里在OnInitDialog前后各调一次GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS),打印差值。正常情况下应该是个位数。如果每次打开对话框都涨十几二十个,那就是有 GDI 对象没释放,八成是字体或者画刷。

6.3 主题切换与系统设置变化时的重绘

最后说一个在系统设置页折腾过的用户才会遇到的问题。

用户把系统主题从浅色切到深色(或者反过来),或者调整了"缩放与布局",已经打开的对话框可能表现异常:分组框的边框变透明、标题文字和背景颜色撞色看不清、自绘的分组框还保持着旧的配色。

对于系统主题变化,会收到WM_SETTINGCHANGE,参数里带着变化类型。在消息处理里触发重绘是基本操作:

LRESULT CMyDlg::OnSettingChange(WPARAM wParam, LPARAM lParam) { // 重新读取系统颜色,刷新自绘控件 ApplySystemTheme(); Invalidate(TRUE); return CDialogEx::OnSettingChange(wParam, lParam); }

ApplySystemTheme里做的是重新取系统颜色。比如浅色主题下背景取::GetSysColor(COLOR_BTNFACE),深色主题下取::GetSysColor(COLOR_WINDOW),具体取哪个要看你希望它跟哪块走。别硬编码 RGB 值,硬编码之后在任何主题下都是那个颜色,一定和周围不搭。

对于 DPI 变化,前面提过WM_DPICHANGED。MFC 对话框在较新的 SDK 里默认会处理这个,但自绘控件需要自己响应,重新计算缓存的尺寸、重新加载字体。做法是给自绘控件也加一条ON_MESSAGE(WM_DPICHANGED, ...),在里面对内部缓存做失效标记,下一次OnPaint自然重新计算。

这里我给一个自己的经验判断标准:自绘控件里如果有任何成员变量缓存了像素值,就必须处理 DPI 变化;如果一律现算,就可以不处理。前者的性能好一点但要多写不少同步代码,后者简单粗暴但几乎不会出错。对于分组框这种绘制成本极低的控件,我毫不犹豫选后者——现算,不缓存。

真要说最后一点体会:分组框这个控件,用得好它就是个安静的框,用不好它就是一堆说不清的怪现象。绝大多数问题的根源都在同一个认知上——它是个按钮,不是容器。把这一条记住,禁用不生效、z 序遮挡、自绘走哪条路、绑什么类型,这些问题就都有了解释的落点。