首页 / 资讯中心 / 文章详情

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

MFC分组框Group Box:CButton本质、单选互斥与自绘高DPI ★ FEATURED ARTICLE
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::vectorUINT记录每组的控件 ID这属于工程习惯问题。1.2 BS_GROUPBOX 背后它就是一个按钮在 MFC 里分组框的封装类是CButton不是CStatic也不是某个专门的CGroupBox。这一点从资源编辑器里给分组框绑变量时的下拉框就能看出来——类型列表里给的是CButton。底层原因是 Win32 的按钮控件族里分组框的样式值就是BS_GROUPBOX0x00000007和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 顺序视图从头到尾过一遍。快捷键是 CtrlD进去之后每个控件上会显示一个序号按你期望的顺序依次点一遍就重新编号了。这个动作花不了两分钟但能省掉后面一堆为什么方向键跳错组的排查。顺带说一句分组框本身通常不应该带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决定。要改字号创建一个CFontCreateFont之后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返回的画刷开始生效边框颜色也可能跟着变化。但代价很实在视觉风格和系统其他对话框不一致。开启视觉样式之后分组框的边框是一根细的浅灰线圆润、克制关掉之后变成深灰色的两道蚀刻线观感差异明显。如果只有个别分组框关掉主题整个界面会显得拼接感很重。高 DPI 下经典样式的线宽不跟随缩放。主题绘制会按 DPI 调整线宽经典绘制不一定会可能在 150% 缩放下显得过细。系统主题切换时可能不重绘。用户切换深色/浅色主题关掉主题的控件是感知不到的。所以我的建议是这条退路只在特定场景用比如整个对话框本来就是自绘风格、所有控件都关主题统一为经典外观。如果只是想让某一条边框换个颜色老老实实走自绘路线别用关主题这种半吊子手段。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() };两条路线我都用过对比如下维度路线 AOwner 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导致的整块白闪。第二层是双缓冲也就是上面代码里的CDCCBitmap。所有绘制在内存位图上完成最后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 序遮挡、自绘走哪条路、绑什么类型这些问题就都有了解释的落点。
阅读完成 · 觉得有帮助?
咨询建站