MFC控件外观定制:WM_CTLCOLOR颜色、CFont字体与自绘实战
发布时间:2026/10/1 16:39:55来源:尧图网络
MFC这套框架到今天还在大量产线设备、工控上位机、医疗仪器、检测仪表里跑着很多项目一维护就是十几年。接手这类老项目最常被提的需求不是重构而是这个界面太丑了能不能把标题改红一点、字大一点、背景别是那种灰蒙蒙的默认色。听起来简单做起来却经常卡住代码写完了编译也过了运行起来颜色死活不变或者调试的时候生效了Release 版本又失效再或者改完字体之后对话框关不掉句柄数一路飙到几千。问题的根子在于MFC 里控件的文本颜色、字体、背景这三件事走的是完全不同的三条通道——颜色靠 WM_CTLCOLOR 消息族字体靠 CFont 对象生命周期背景则要看控件是自绘还是系统绘制。任何一条通道搞错表现就是没反应。这篇内容就是把我这些年改 MFC 界面时踩过的坑和总结出来的套路整理一遍从消息机制讲到代码落地再到排查手段。适合两类人看一类是刚接手 MFC 老项目、只会拖控件不会改外观的开发者另一类是写过不少 MFC 代码但每次设置颜色都要靠试错、靠搜索、靠玄学的老手。看完之后你至少能建立一套可复用的判断逻辑什么控件用什么方法为什么这么用出问题先查哪里。1. MFC控件外观定制的整体思路与方案选型1.1 为什么设置控件外观在MFC里这么绕很多人第一次接触 MFC 界面定制时会有一种错觉觉得这跟 WinForms、WPF 一样选中控件、在属性面板里改一改 ForeColor、BackColor、Font 就完事了。实际动手才发现MFC 的 CWnd 及其派生类压根就没提供 SetTextColor、SetBackColor 这种成员函数属性面板里也没有颜色项。这不是微软偷懒而是 MFC 本身就是对 Win32 API 的一层薄封装控件的绘制权根本不在 MFC 手里而在各个控件的窗口过程Window Procedure里。理解这一点非常关键。控件本质上是一个独立的小窗口它画自己文字的时候会先问父窗口一句我这段文字用什么颜色画这句话在 Win32 里就是 WM_CTLCOLOR 系列消息。父窗口如果不回应控件就用系统默认颜色画父窗口回应了控件就按回应内容画。所以你在 MFC 里设置控件颜色本质上是父窗口拦截子控件的咨询请求并给出答复而不是直接命令控件变色。这解释了一个特别常见的疑惑为什么同样的代码对静态文本有效对按钮就无效因为按钮不完全通过 WM_CTLCOLOR 询问颜色它有自己的一套主题绘制逻辑。也解释了另一个疑惑为什么改了颜色之后背景变成一块丑陋的方块因为你只告诉它文字颜色没告诉它背景怎么处理它就用了系统默认的填充色。1.2 三条技术路线的取舍在动手之前先明确三条路线选错路线会白干半天。第一条路线是WM_CTLCOLOR 消息族用于处理标准控件的文字颜色和背景色。它简单、可靠、代码量小适用面覆盖静态文本、编辑框、列表框、对话框背景、组框。缺点是它管不了字体也管不了按钮的完整外观。第二条路线是CFont 对象 SetFont用于设置控件文字的大小、字重、字形。这是唯一正确的方式Font 是独立的 GDI 对象通过 WM_SETFONT 消息传给控件。它跟颜色是两条完全平行的通道互不影响。第三条路线是自绘Custom Draw / Owner Draw用于处理列表控件、树控件、按钮的深度定制。当你需要列表奇数行不同底色、需要按钮有渐变、需要树节点有图标文字混排时就必须走这条路。代价是代码复杂度陡增而且容易跟系统主题冲突。我的建议是分级处理能用一个 OnCtlColor 解决的绝不写自绘颜色和字体分开处理不要试图在 OnCtlColor 里顺手改字体能用标准控件实现的视觉效果就不要为了好看去写 Owner Draw——后期维护的人会感谢你。2. WM_CTLCOLOR消息族颜色控制的主战场2.1 消息族与控件的对应关系WM_CTLCOLOR 不是一个消息而是一族消息。Win32 把不同类别的控件拆成了不同的消息号映射到 MFC 里就是 OnCtlColor 的第三个参数 nCtlColor。搞不清对应关系是改了没用的头号原因。nCtlColor 常量对应控件常见坑点CTLCOLOR_STATIC静态文本、组框、只读编辑框、禁用状态的控件只读/禁用控件会走这里不走 EDITCTLCOLOR_EDIT可编辑的编辑框只读时会变成 STATICCTLCOLOR_LISTBOX列表框组合框的下拉列表也走这里CTLCOLOR_BTN按钮开启视觉样式后基本无效CTLCOLOR_DLG对话框本身影响客户区背景CTLCOLOR_SCROLLBAR滚动条极少用CTLCOLOR_MSGBOX消息框基本不用这里必须重点强调两个坑。第一个是只读编辑框你把一个 CEdit 的 Read Only 属性设为 True它就不再发送 CTLCOLOR_EDIT 了改发 CTLCOLOR_STATIC。如果你的颜色设置只写在 CTLCOLOR_EDIT 分支里运行起来就会看到可编辑时是蓝字一旦变只读就变回黑字很多人会误以为是属性丢失。第二个是禁用状态的控件。任何控件被 EnableWindow(FALSE) 之后颜色消息同样会切到 CTLCOLOR_STATIC。所以如果你的界面里有输入框根据权限禁用的逻辑颜色设置必须同时覆盖 STATIC 分支才能保持一致。正确的写法是先用控件 ID 做精确判断再用 nCtlColor 做兜底分类。用一个 switch 处理类别用 if 处理具体控件这样既清晰又不会漏。HBRUSH CDevicePanelDlg::OnCtlColor(CDC* pDC, CWnd* pWnd, UINT nCtlColor) { HBRUSH hbr CDialogEx::OnCtlColor(pDC, pWnd, nCtlColor); // 先按控件 ID 精确命中 UINT nID pWnd-GetDlgCtrlID(); if (nID IDC_STATIC_TITLE) { pDC-SetTextColor(RGB(198, 40, 40)); pDC-SetBkMode(TRANSPARENT); return (HBRUSH)::GetStockObject(NULL_BRUSH); } if (nID IDC_EDIT_VALUE) { pDC-SetTextColor(RGB(20, 90, 160)); pDC-SetBkColor(RGB(245, 250, 255)); return (HBRUSH)m_brEditBk.GetSafeHandle(); } // 再按类别兜底 switch (nCtlColor) { case CTLCOLOR_STATIC: case CTLCOLOR_EDIT: pDC-SetTextColor(RGB(60, 60, 60)); pDC-SetBkMode(TRANSPARENT); return (HBRUSH)::GetStockObject(NULL_BRUSH); case CTLCOLOR_DLG: return (HBRUSH)m_brDlgBk.GetSafeHandle(); default: break; } return hbr; }2.2 OnCtlColor返回值的真实含义几乎每个 MFC 新手都会在返回值的类型上卡一次。OnCtlColor 的签名里返回值类型是 HBRUSH但真正被系统使用的、决定绘制效果的东西是你在 pDC 上设置的属性和你返回的那个刷子。两者分工明确pDC 上设置的东西文字颜色SetTextColor、文字背景填充色SetBkColor、背景填充模式SetBkMode。这些决定了文字怎么画。返回值一个画刷句柄决定文字背后的那块底色用什么刷子刷。系统会用它去 FillRect 控件的背景区域。很多人只设置了 SetTextColor然后 return hbr 把默认刷子原样返回结果就是文字变蓝了但背景还是系统灰看着像一块补丁。要让文字背景色也跟着变必须返回一个颜色匹配的刷子或者干脆返回 NULL_BRUSH 让控件完全不刷背景此时配合 SetBkMode(TRANSPARENT)背景会露出父窗口的底色。这里有个经典陷阱绝对不能返回临时对象的句柄。下面这段代码是错的而且错得很隐蔽// 错误示范返回局部刷子的句柄 HBRUSH CMyDlg::OnCtlColor(CDC* pDC, CWnd* pWnd, UINT nCtlColor) { CBrush brush(RGB(240, 240, 240)); // 局部对象 pDC-SetBkColor(RGB(240, 240, 240)); return (HBRUSH)brush.GetSafeHandle(); // brush析构后句柄失效 }函数返回时 brush 被析构底层 HBRUSH 被 DeleteObject 释放。系统拿着一个已经失效的句柄去填充背景运气好是画不出来运气不好就是花屏、闪烁、甚至崩溃。这个 bug 在 Debug 下可能看不出来因为堆内存没被立刻复用Release 下必现非常难查。正确做法是把刷子做成类的成员变量在 OnInitDialog 里创建在析构时自动释放CBrush 的析构函数会调 DeleteObject// 头文件中声明 CBrush m_brEditBk; CBrush m_brDlgBk; // OnInitDialog 中创建 BOOL CDevicePanelDlg::OnInitDialog() { CDialogEx::OnInitDialog(); m_brEditBk.CreateSolidBrush(RGB(245, 250, 255)); m_brDlgBk.CreateSolidBrush(RGB(250, 250, 252)); // ... return TRUE; }注意成员刷子必须在 OnCtlColor 被调用之前就创建好。如果你在 WM_INITDIALOG 之后才初始化第一次绘制就会拿到空句柄表现为首次显示颜色不对、拖动窗口后才正常。2.3 SetBkMode透明与不透明之间的那道开关SetBkMode 是这一族里最容易被忽略、却最影响观感的函数。它只有两个取值OPAQUE默认和 TRANSPARENT。在 OPAQUE 模式下系统画文字之前会先用当前的背景色把文字那个矩形区域填满然后才画字。所以哪怕你返回了 NULL_BRUSH文字周围那一小块仍然是实心背景色。在 TRANSPARENT 模式下系统只画文字本身不碰背景背景由父窗口的绘制决定。对于静态文本几乎总是应该设成 TRANSPARENT。原因很直接静态文本控件的矩形区域通常比文字本身大一圈如果是不透明模式你会看到文字周围有一块跟对话框背景色略微不同的色块尤其是对话框有渐变背景或者贴图背景时这块色块会非常刺眼。对于编辑框情况反过来。编辑框通常希望有清晰的输入区域边界所以一般是 OPAQUE用 SetBkColor 设置一个浅色底再返回一个同色刷子这样输入框看起来是一个完整的浅色方框视觉上更规整。还有一个细节如果你用 TRANSPARENT 且返回 NULL_BRUSH但父窗口对话框自身没有正确处理背景会出现文字区域残留上一次绘制的内容快速拖动窗口时尤其明显。这通常是因为对话框没有响应 WM_ERASEBKGND背景没被清干净。这时候要么给对话框加背景刷子要么在 OnEraseBkgnd 里手工填充BOOL CDevicePanelDlg::OnEraseBkgnd(CDC* pDC) { CRect rcClient; GetClientRect(rcClient); pDC-FillSolidRect(rcClient, RGB(250, 250, 252)); return TRUE; }返回 TRUE 表示我已经擦过背景了系统不用再擦这样能有效减少闪烁。3. 字体设置CFont对象的创建、换算与生命周期3.1 三种Create方式的区别CFont 提供了好几个创建函数用错会导致字体大小完全不对或者字形缺失。CreatePointFont(nPointSize, lpszFaceName)按磅值创建函数内部自动做 DPI 换算。参数 nPointSize 是磅数的十倍比如要 12 磅就传 120。这是最省事的写法也是我日常最推荐的。它内部会调用 CreatePointFontIndirect把磅值转成逻辑高度。CreateFont(...)最底层参数有十四个包括高度、宽度、倾斜角、字重、斜体、下划线、删除线、字符集、精度、裁剪精度、输出质量、字距族、字体名。参数多但控制力最强需要精细控制时用它。CreateFontIndirect(const LOGFONT)*通过 LOGFONT 结构体创建适合从一个已有的 LOGFONT 复制并微调的场景比如在系统字体基础上加粗、加大两磅。三种方式的关系是CreatePointFont 内部算出高度后走 CreateFontIndirect 的逻辑CreateFont 内部把参数装进 LOGFONT 再走 Indirect。所以如果你已经有一个 LOGFONT直接用 CreateFontIndirect 最直接。// 方式一按磅值最省心 m_fontTitle.CreatePointFont(140, _T(微软雅黑)); // 14磅 // 方式二按逻辑高度控制精确但需自己换算 m_fontValue.CreateFont( -18, 0, 0, 0, FW_NORMAL, FALSE, FALSE, 0, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_SWISS, _T(微软雅黑)); // 方式三从系统字体派生 LOGFONT lf { 0 }; ::GetObject(::GetStockObject(DEFAULT_GUI_FONT), sizeof(lf), lf); lf.lfWeight FW_BOLD; lf.lfHeight -20; m_fontBold.CreateFontIndirect(lf);字符集参数要特别注意。老项目里经常看到 ANSI_CHARSET在纯英文环境下没问题一旦界面里要显示中文、日文、韩文就可能出现方块字或者字体回退。现代做法是统一用 DEFAULT_CHARSET让系统自己按当前区域设置挑合适的字形。输出质量用 CLEARTYPE_QUALITY在液晶屏上文字边缘更平滑如果发现某些老设备上文字发虚改成 ANTIALIASED_QUALITY 兼容性更好。3.2 磅值到底怎么换算成LOGFONT高度这是很多人搞不明白的一个点为什么 CreateFont 的 lfHeight 用的是负数而且数值跟字号对不上原因在于 LOGFONT 的 lfHeight 有两套语义。正值表示字符单元格高度包含字体内部的行距和上下空白负值表示字符本身的高度也就是从字符最高点到最低点的实际高度。日常我们说的12 磅字指的是字符本身的视觉高度所以要用负值。换算公式是lfHeight -MulDiv(nPointSize, GetDeviceCaps(hdc, LOGPIXELSY), 72)逻辑很直白一磅等于 1/72 英寸屏幕上一个逻辑英寸包含 LOGPIXELSY 个像素所以磅值乘以 LOGPIXELSY 再除以 72就得到像素高度。MulDiv 是 Win32 提供的函数做乘法再除法时内部用 64 位中间结果避免 32 位溢出。举个实际例子。一台常见的 96 DPI 显示器LOGPIXELSY 是 96。要设 12 磅字12 × 96 ÷ 72 16所以 lfHeight -16。要设 14 磅字14 × 96 ÷ 72 ≈ 18.67取整 19所以 lfHeight -19。而在一台 144 DPI 的高分屏上同样的 12 磅字12 × 144 ÷ 72 24lfHeight -24。这就是为什么硬件写死 -16 的代码在高分屏上字特别小——它没做换算。int PointToLogHeight(int nPointSize, CDC* pDC) { int nPixelsPerInch pDC-GetDeviceCaps(LOGPIXELSY); return -MulDiv(nPointSize, nPixelsPerInch, 72); }CreatePointFont 之所以省事就是因为它在内部替你做了这个换算并且是每次创建时按当前 DC 算的。如果你的程序可能运行在不同 DPI 的机器上优先用 CreatePointFont。3.3 字体对象存哪儿一个决定成败的细节CFont 是 GDI 对象遵循谁创建谁释放的原则。CFont 的析构函数会自动调用 DeleteObject所以只要保证对象被正常析构就不会泄漏。问题出在对象在哪里。最常见的错误是把 CFont 定义成局部变量// 错误示范 void CMyDlg::SetupFont() { CFont font; font.CreatePointFont(120, _T(微软雅黑)); m_staticTitle.SetFont(font); // 控件拿到了字体句柄 } // font析构底层HFONT被删除SetFont 做的事情是把 HFONT 句柄通过 WM_SETFONT 消息发给控件控件保存这个句柄每次重绘时用它来选字体。函数返回后 font 析构句柄被删除控件手里拿着一个野句柄。表现就是字体在设置后的一瞬间是对的一旦窗口重绘比如被遮挡后恢复、拖动改变大小字体就变回默认或者直接乱码。这个 bug 有个很坑的特点——在小程序里可能永远不出现因为被删除的句柄值可能还没被复用系统查表还能查到旧数据。一旦程序里 GDI 对象多了句柄值被复用就会突然变成花屏或者崩溃而且出现时机完全不固定。正确做法只有两个把 CFont 做成类的成员变量或者动态 new 出来并妥善管理。前者更简单// 头文件 class CDevicePanelDlg : public CDialogEx { // ... private: CFont m_fontTitle; CFont m_fontValue; CFont m_fontList; };然后在 OnInitDialog 里创建、设置BOOL CDevicePanelDlg::OnInitDialog() { CDialogEx::OnInitDialog(); m_fontTitle.CreatePointFont(140, _T(微软雅黑)); m_fontValue.CreatePointFont(110, _T(微软雅黑)); m_fontList.CreatePointFont(100, _T(微软雅黑)); GetDlgItem(IDC_STATIC_TITLE)-SetFont(m_fontTitle); GetDlgItem(IDC_EDIT_VALUE)-SetFont(m_fontValue); m_listRecord.SetFont(m_fontList); // 列表控件的表头是独立窗口字体要单独设 if (m_listRecord.GetHeaderCtrl()) m_listRecord.GetHeaderCtrl()-SetFont(m_fontList); return TRUE; }这里有个特别容易漏的点CListCtrl 的表头HeaderCtrl是一个独立的子窗口给列表设字体不会影响表头必须单独取出来设。不设的话会出现内容行字体变了表头还是老样子的割裂感很多人在这一步卡很久还以为是 SetFont 失败。提示如果需要在运行时改字体大小比如做一个字体切换按钮不能简单地对已创建的 CFont 再次调用 CreatePointFont。必须先 DeleteObject 再重建或者换一个成员对象。重复创建同一个 CFont 而不释放会累积 GDI 泄漏。4. 逐个控件实战从静态文本到列表控件4.1 静态文本与编辑框静态文本是最容易出效果的也是最容易翻车的。它的颜色走 CTLCOLOR_STATIC字体走 SetFont两条路都要走通。一个完整可用的静态文本定制流程是这样的// OnInitDialog m_fontTitle.CreatePointFont(160, _T(微软雅黑)); GetDlgItem(IDC_STATIC_TITLE)-SetFont(m_fontTitle); // OnCtlColor if (nID IDC_STATIC_TITLE) { pDC-SetTextColor(RGB(198, 40, 40)); pDC-SetBkMode(TRANSPARENT); return (HBRUSH)::GetStockObject(NULL_BRUSH); }三行搞定。注意最后返回的是 NULL_BRUSH配合 TRANSPARENT文字完全融入对话框背景。编辑框要复杂一点因为它有交互状态。我一般按三种状态分别处理状态触发条件建议配色正常可编辑有焦点/无焦点深灰文字 浅蓝底只读Read Only True中灰文字 极浅灰底禁用EnableWindow(FALSE)浅灰文字 浅灰底由于只读和禁用都会走 CTLCOLOR_STATIC判断逻辑要写成如果控件是编辑框且处于异常状态用 STATIC 配色实现上可以在 OnCtlColor 里根据控件 ID 反查状态if (nID IDC_EDIT_VALUE) { CEdit* pEdit (CEdit*)pWnd; BOOL bReadOnly (pEdit-GetStyle() ES_READONLY) ! 0; BOOL bEnabled pWnd-IsWindowEnabled(); if (bReadOnly || !bEnabled) { pDC-SetTextColor(RGB(150, 150, 150)); pDC-SetBkColor(RGB(240, 240, 240)); return (HBRUSH)m_brDisabledBk.GetSafeHandle(); } pDC-SetTextColor(RGB(20, 90, 160)); pDC-SetBkColor(RGB(245, 250, 255)); return (HBRUSH)m_brEditBk.GetSafeHandle(); }这里判断 ES_READONLY 而不是直接依赖 nCtlColor是为了兼容同一控件在不同状态下切换的场景。只靠 nCtlColor 分类的话一旦状态变了你需要重新走一遍绘制流程容易出现颜色不同步。4.2 按钮与组框按钮是 MFC 界面定制里最让人头疼的控件原因是它从 Windows XP 开始就使用了视觉样式Visual Styles按钮的绘制完全交给了主题引擎WM_CTLCOLOR 基本被忽略。如果你确实需要改按钮文字颜色有几条路第一条是关闭视觉样式。在 stdafx.h 里注释掉引入 comctl32 v6 的那段 pragma comment或者在工程属性里去掉启用视觉样式。代价是整个程序的所有控件都退回老式外观界面会显得很复古也可以说很丑一般不建议。第二条是自绘按钮Owner Draw / BS_OWNERDRAW。完全接管绘制过程颜色、圆角、渐变、图标、悬停效果都能做但代码量上去而且需要处理焦点框、按下状态、禁用状态。第三条是用 CButton 的子类化。继承 CButton重写 DrawItem把绘制逻辑封装起来。这是最工程化的做法写一次到处用。组框Group Box走的是 CTLCOLOR_STATIC但它有个特别之处组框的标题文字和边框是同一个绘制过程设文字颜色会同时影响标题边框颜色则由系统主题决定基本改不了。常见需求是组框标题改深色那就if (nCtlColor CTLCOLOR_STATIC nID IDC_GROUP_COMM) { pDC-SetTextColor(RGB(40, 40, 40)); pDC-SetBkMode(TRANSPARENT); return (HBRUSH)::GetStockObject(NULL_BRUSH); }组框的字体也是走 SetFont但要注意组框的标题区域和内容区域是同一个矩形字体太大时会跟边框重叠视觉上很难看。我的经验是组框标题字号不要超过 12 磅。4.3 CListCtrl与CTreeCtrl的颜色控制列表和树这两个控件用 WM_CTLCOLOR 是改不了行颜色的因为它们的每一行都是控件自己在 OnPaint 里画的根本不向父窗口咨询。要改行颜色必须走 NM_CUSTOMDRAW 通知。CListCtrl 的自绘比较特殊它分多个阶段CDDS_PREPAINT 是绘制前CDDS_ITEMPREPAINT 是每一行绘制前CDDS_SUBITEM 是每个单元格绘制前。要设置单元格级别的前景背景色必须一路放行到 SUBITEM 阶段void CDevicePanelDlg::OnNMCustomdrawListRecord(NMHDR* pNMHDR, LRESULT* pResult) { LPNMLVCUSTOMDRAW pLVCD reinterpret_castLPNMLVCUSTOMDRAW(pNMHDR); *pResult CDRF_DODEFAULT; switch (pLVCD-nmcd.dwDrawStage) { case CDDS_PREPAINT: *pResult CDRF_NOTIFYITEMDRAW; break; case CDDS_ITEMPREPAINT: *pResult CDRF_NOTIFYSUBITEMDRAW; break; case CDDS_ITEMPREPAINT | CDDS_SUBITEM: { int nRow (int)pLVCD-nmcd.dwItemSpec; int nCol pLVCD-iSubItem; // 斑马纹 pLVCD-clrTextBk (nRow % 2 0) ? RGB(248, 251, 255) : RGB(255, 255, 255); pLVCD-clrText RGB(50, 50, 50); // 报警列用红色强调 if (nCol COL_STATUS m_listRecord.GetItemText(nRow, COL_STATUS) _T(超限)) { pLVCD-clrText RGB(198, 40, 40); pLVCD-clrTextBk RGB(255, 240, 240); } *pResult CDRF_NEWFONT; break; } default: break; } }有几个要点必须记牢。第一返回值 CDRF_NEWFONT 表示我改了字体和颜色请用我说的CDRF_DODEFAULT 表示不改用默认的。如果你改完颜色却返回 CDRF_DODEFAULT颜色不会生效。第二dwItemSpec 在 ITEMPREPAINT 阶段是行索引转换时用强转即可。第三如果要在自绘里获取单元格文字不要用 GetItemText 反复查性能差更稳的做法是维护一份和列表同步的数据缓存。还要注意一点开启视觉样式后CListCtrl 的某些绘制细节会被主题接管可能出现 clrTextBk 生效但选中行的颜色改不掉的情况。要完全控制选中行颜色需要在 CDDS_ITEMPREPAINT 里处理 CDIS_SELECTED 状态if (pLVCD-nmcd.uItemState CDIS_SELECTED) { pLVCD-clrTextBk RGB(0, 120, 215); pLVCD-clrText RGB(255, 255, 255); } else if (pLVCD-nmcd.uItemState CDIS_HOT) { pLVCD-clrTextBk RGB(229, 241, 251); pLVCD-clrText RGB(50, 50, 50); }CTreeCtrl 的定制思路类似也是 NM_CUSTOMDRAW但阶段名不同CDDS_ITEMPREPAINT 对应节点。树控件额外支持 SetTextColor 和 SetBkColor但这两个函数只在关闭视觉样式时有效开了主题就完全被忽略。所以别指望用那两个函数偷懒老老实实写自绘。4.4 对话框整体背景与圆角面板对话框的背景色有三种改法按侵入性从低到高排列。第一种是在 OnCtlColor 里处理 CTLCOLOR_DLG返回一个刷子。这是最标准的做法代码量最少if (nCtlColor CTLCOLOR_DLG) { return (HBRUSH)m_brDlgBk.GetSafeHandle(); }注意刷子必须是成员变量理由前面说过了。第二种是重写 OnEraseBkgnd手工填充矩形。这种方式更灵活可以做渐变、贴图、绘制分隔线BOOL CDevicePanelDlg::OnEraseBkgnd(CDC* pDC) { CRect rcClient; GetClientRect(rcClient); // 垂直渐变顶部浅蓝到白色 TRIVERTEX vert[2]; vert[0].x rcClient.left; vert[0].y rcClient.top; vert[0].Red 0xE8 8; vert[0].Green 0xF3 8; vert[0].Blue 0xFC 8; vert[0].Alpha 0x0000; vert[1].x rcClient.right; vert[1].y rcClient.bottom; vert[1].Red 0xFF 8; vert[1].Green 0xFF 8; vert[1].Blue 0xFF 8; vert[1].Alpha 0x0000; GRADIENT_RECT gRect { 0, 1 }; ::GradientFill(pDC-GetSafeHdc(), vert, 2, gRect, 1, GRADIENT_FILL_RECT_V); return TRUE; }GradientFill 来自 msimg32.dll需要在工程里链接 msimg32.lib头文件是 wingdi.h。这个函数在工控界面里很实用一个淡淡的渐变就能让整体观感提升一个档次而且几乎没有性能开销。第三种是圆角面板效果本质上是在对话框背景上画一个圆角矩形。用 CreateRoundRectRgn 配合 FillRgn或者用 GDI 的 GraphicsPath 画抗锯齿圆角。GDI 的效果更好圆角边缘平滑但需要初始化 Gdiplus。工控项目里如果对体积敏感用 CreateRoundRectRgn 也够用void CDevicePanelDlg::DrawRoundPanel(CDC* pDC, CRect rc, int nRadius, COLORREF clrFill) { CRgn rgn; rgn.CreateRoundRectRgn(rc.left, rc.top, rc.right 1, rc.bottom 1, nRadius, nRadius); CBrush brush(clrFill); pDC-FillRgn(rgn, brush); }注意 CreateRoundRectRgn 的右下角坐标是开区间所以要 1否则右边和下边的圆角会被切掉一个像素肉眼能看出来不圆。5. 常见问题排查实录5.1 颜色不生效的排查清单颜色问题排查有很强的规律性我整理成一张表遇到问题按顺序过一遍基本都能定位。现象最可能的原因处理方式静态文本颜色完全没变控件 ID 判断写错或没写 CTLCOLOR_STATIC 分支加断点确认 nID 和 nCtlColor文字变色但周围有色块只设了 SetTextColor没设 SetBkMode(TRANSPARENT)补上透明模式并返回 NULL_BRUSH编辑框可编辑时正常只读时失效只读态走 CTLCOLOR_STATIC两个分支都处理按钮文字颜色改不了视觉样式接管绘制改用自绘或用 CMFCButton列表行颜色没变化返回了 CDRF_DODEFAULT改成 CDRF_NEWFONT首次显示正常拖动后花屏返回了局部刷子的句柄改成成员刷子列表表头字体没变表头是独立窗口单独对 GetHeaderCtrl 调 SetFont设置颜色后整个对话框闪烁严重背景被重复擦除重写 OnEraseBkgnd 返回 TRUE这里我要单独聊聊首次正常、拖动后失效这一类问题因为它最迷惑人。除了局部刷子之外还有一个常见原因是在 OnCtlColor 里动态创建 GDI 对象// 错误示范每次绘制都创建一个新刷子 if (nCtlColor CTLCOLOR_DLG) { CBrush* pBrush new CBrush(RGB(240, 240, 240)); return (HBRUSH)pBrush-GetSafeHandle(); // 内存和GDI对象双泄漏 }这段代码每绘制一次就 new 一个 CBrush永远不 delete。画面看着是对的因为句柄一直有效但任务管理器里的 GDI 对象数会持续上涨涨到 10000 左右程序就再也画不出东西了表现为整个窗口变成白板。这类 bug 在开发阶段往往发现不了因为开发时没人会连续开着窗口拖两个小时。排查 GDI 泄漏有一个非常直接的办法在任务管理器里把GDI 对象和用户对象两列调出来开着程序做各种操作拖动、切换标签、反复打开关闭子窗口观察这两个数字。如果只涨不跌肯定有泄漏。正常程序在稳定操作后这两个数应该在一个小范围内波动。5.2 GDI对象泄漏与句柄耗尽Windows 对每个进程的 GDI 对象数量有默认配额历史上是 10000 个。现在的版本虽然放宽了但仍然有上限。一个 MFC 程序如果 GDI 对象泄漏通常在运行几小时到几天后崩掉这在工控场景7×24 运行里是致命的。几个高发区第一在 OnCtlColor 里创建对象。这是最典型的因为它在每次重绘时都被调用频率极高。第二字体或刷子只在某个分支里创建但不在对应分支里释放。比如做了个主题切换功能每次切换都 CreateFont 一次旧的没删。第三DC 的 CreateCompatibleDC 不配对 DeleteDC。这在做双缓冲绘制时很常见一定要用 CDC 对象析构自动释放而不是裸 HDC。第四GetDC 不配对 ReleaseDC。尤其是在 OnPaint 里用 GetDC 画东西的话必须 ReleaseDC或者直接用 CPaintDC。我个人的工程习惯是在 OnInitDialog 里统一创建所有 GDI 对象在 OnDestroy 或析构里什么都不做因为 CFont/CBrush 的析构函数会自动 DeleteObject。运行时绝对不创建 GDI 对象。这样泄漏的可能性就降到了几乎为零。如果需要运行时改颜色怎么办答案是创建一个新的 CBrush 覆盖成员变量void CDevicePanelDlg::SetDlgBackColor(COLORREF clr) { if (m_brDlgBk.GetSafeHandle()) m_brDlgBk.DeleteObject(); // 先删旧的 m_brDlgBk.CreateSolidBrush(clr); // 再建新的 Invalidate(); // 触发重绘 }这里的 DeleteObject 是关键CBrush 对象本身可以复用但底层的 GDI 句柄必须先释放否则每次调用都会占用一个新句柄。5.3 高DPI下的字体模糊与错位越来越多设备用高分屏老 MFC 程序在高 DPI 下的表现经常很糟糕字变小、界面挤成一团、按钮文字被裁掉。症状的根源是程序没有声明 DPI 感知。Win7 时代系统会做 DPI 虚拟化程序以为自己跑在 96 DPI 下实际被系统拉伸结果是整体模糊。Win10 之后如果声明了 per-monitor DPI aware就必须自己处理缩放字体和布局都得按 DPI 换算。处理方式分两步。第一步是在工程里加一个 manifest声明 DPI 感知级别application xmlnsurn:schemas-microsoft-com:asm.v3 windowsSettings dpiAware xmlnshttp://schemas.microsoft.com/SMI/2005/WindowsSettingstrue/pm/dpiAware /windowsSettings /application第二步是把所有硬编码的像素尺寸改成按 DPI 换算。字体用 CreatePointFont 就自动搞定了因为它是按磅值算的。但控件的宽度、高度、位置这些还是硬编码的需要自己按比例缩放。对于老项目一个务实的做法是不声明 DPI 感知让系统做虚拟化至少能保证布局不乱、文字不裁代价是轻微模糊。对于新项目或者愿意深改的项目就老老实实做 DPI 适配。字体模糊还有一个独立的原因输出质量设成了 DEFAULT_QUALITY 或者 PROOF_QUALITY。在小字号下这两个值的渲染效果明显差于 CLEARTYPE_QUALITY。如果你的界面文字看着脏先检查 lfQuality。6. 几个可以抄作业的实操技巧到这里核心内容讲完了最后分享几个我平时用得很顺手、但文档里基本不会写的小技巧。第一做一个统一的字体和配色管理类。不要在 OnInitDialog 里散落一堆 CreatePointFont而是集中到一个 UiTheme 类里struct UiTheme { CFont fontTitle; CFont fontNormal; CFont fontMono; CBrush brDlg; CBrush brEdit; CBrush brDisabled; COLORREF clrTitleText RGB(198, 40, 40); COLORREF clrNormalText RGB(60, 60, 60); COLORREF clrEditBk RGB(245, 250, 255); void Init() { fontTitle.CreatePointFont(160, _T(微软雅黑)); fontNormal.CreatePointFont(100, _T(微软雅黑)); fontMono.CreatePointFont(100, _T(Consolas)); brDlg.CreateSolidBrush(RGB(250, 250, 252)); brEdit.CreateSolidBrush(clrEditBk); brDisabled.CreateSolidBrush(RGB(240, 240, 240)); } };然后每个对话框持有一个 UiTheme 成员或者用单例共享。好处是改配色只需要改一个地方多个对话框的视觉风格天然一致。这个做法在有多窗口的系统里价值特别大——我见过太多项目主窗口是浅蓝色子窗口是灰色一看就是不同人写的。第二用控件 ID 名称做颜色分组的判断依据而不是维护一个枚举映射。MFC 的资源 ID 通常会按功能分组命名比如 IDC_EDIT_XXX 都是输入框IDC_STATIC_XXX 都是标签。与其维护一张庞大的 ID 到颜色的映射表不如写几个判断函数static bool IsInputCtrl(UINT nID) { return nID IDC_EDIT_FIRST nID IDC_EDIT_LAST; }前提是你在资源文件里按区间连续编号。这个习惯从项目一开始就要建立后期改起来成本很高。第三改完颜色之后立刻调用 Invalidate(FALSE)。很多人改完配色发现没生效其实是因为没有触发重绘窗口还是上一次绘制的结果。FALSE 参数表示不擦除背景可以减少闪烁。如果你确实需要连背景一起重画才用 TRUE。第四排查颜色问题时先用一个极端颜色出证据。比如怀疑某个静态文本没走进颜色分支直接把它设成 RGB(255, 0, 255) 亮洋红一运行就看出来。比起加断点单步调试这种方式在复杂对话框里定位得更快。第五列表控件的自绘里尽量不要调用 GetItemText。每次重绘都要读一遍行文本行数上千时会有明显的拖动卡顿。正确做法是在添加、修改数据时同步维护一个std::vectorCStringArray或结构体数组自绘时直接查内存速度提升一个数量级。最后一个体会MFC 的界面定制看起来零散但底层逻辑其实很统一——系统控件负责画父窗口负责提供素材颜色、刷子、字体两边的接口就是那几条消息和 SetFont。把这个模型记住遇到任何改了没用的情况先问自己这条通道到底通没通是消息没发到还是素材给错了还是句柄已经失效了按这个顺序排查基本上十分钟内都能定位。
网站建设高端定制企业官网