简介:面向 MFC 界面定制开发者的自绘 Edit 控件示例工程,适合已掌握 CEdit 基本用法、希望突破系统默认外观限制的 Windows 桌面开发人员参考。它围绕重写 CEdit 绘制流程展开,涉及 WM_PAINT 与 OnPaint、CDC/CRect 绘图、文本颜色与背景模式设置、WM_CTLCOLOREDIT 背景刷、边框线条绘制,以及鼠标键盘消息扩展和重绘性能控制等关键环节,可帮助读者理解自绘编辑框从消息响应到 GDI 输出的完整链路。压缩包共 20 个文件,约 139KB,包含 6 个 .h 头文件、4 个 .cpp 源文件,以及 .sln、.vcxproj、.rc、.ico 等工程与资源文件,另附 ReadMe.txt 说明,结构紧凑,便于直接打开编译并对照修改。已有 256 人学习下载,可作为课程设计、控件封装练习或现有 MFC 项目界面美化的参考样例,从中提取绘制思路与消息处理骨架,快速迁移到自己的编辑框定制需求中。
1. MFC 自绘 Edit:为什么原生控件总在换肤时露馅
做 MFC 界面的人大概都遇到过这种场面:对话框上的编辑框在默认灰底窗口里看着还行,一旦套上深色皮肤、圆角面板或者带渐变背景的主界面,白色方框加系统边框立刻显得格格不入,像贴上去的一块补丁。原生 Edit 控件的绘制逻辑沉在系统里,WM_CTLCOLOREDIT 只能改背景和文字色,边框、圆角、内边距、光标区域一律插不上手。自绘 Edit 要解决的就是这件事:把输入框的绘制权从系统手里拿回来,让它在任何背景下都长得像界面的一部分,同时保留输入、选中、剪贴板、滚动这些基本能力。适合正在做 MFC 换肤、需要统一视觉风格,又不愿意整体迁移到其他 UI 框架的开发者。
2. 自绘的两种路线:子类化拦截与非客户区重算
动手之前得先定路线,MFC 里让 Edit 变成「自绘」的路子不止一条,选错了后面全是返工。常见做法分两派:一派是子类化原生 Edit,拦截 WM_PAINT、WM_NCPAINT、WM_ERASEBKGND 这些消息,自己画边框和背景,文本仍然交给系统;另一派是彻底不用原生 Edit,从 CWnd 派生一个纯自绘控件,字符排版、光标闪烁、选区高亮、输入法候选框定位全部自己做。两条路线的成本差着一个数量级,效果上限也差着一截。
2.1 子类化路线:保留系统文本引擎,只接管外观
子类化的核心思路是「外观我管,文本你管」。用SetWindowLongPtr替换窗口过程,或者在 MFC 里用SubclassDlgItem/SubclassWindow把控件挂到自己的 C++ 类上,然后在消息处理里挑几个关键消息做文章。文本绘制依然走系统的DefWindowProc,所以中文输入、光标跟随、选中拖拽、Ctrl+A、右键菜单这些全都不需要重新实现,风险最低。
这条路线的边界也很清楚:你能改的是绘制结果,不是绘制流程。系统什么时候重绘文本、按什么顺序绘制光标,你只能通过Invalidate和裁剪区域去引导,没法精确控制。表现到界面上就是:给 Edit 加圆角边框时,四角容易残留系统的白色像素;深色背景下拖动选择文本,选区高亮色偶尔会闪一下系统默认色。这些不是 bug,是路线本身的天花板。
我一般会在项目时间紧、只需要统一配色和边框风格的场景下选这条路。下面是一段可以直接抄的子类化骨架,挂在对话框的OnInitDialog里调用:
// CSkinEdit.h —— 子类化原生 Edit,接管背景与边框 class CSkinEdit : public CEdit { public: COLORREF m_crBk = RGB(32, 34, 38); // 编辑区背景 COLORREF m_crText = RGB(220, 222, 226); // 正文颜色 COLORREF m_crBorder = RGB(70, 74, 82); // 边框颜色 int m_nRadius = 4; // 圆角半径,0 表示直角 protected: afx_msg HBRUSH CtlColor(CDC* pDC, UINT nCtlColor); afx_msg void OnNcPaint(); afx_msg BOOL OnEraseBkgnd(CDC* pDC); DECLARE_MESSAGE_MAP() }; // CSkinEdit.cpp BEGIN_MESSAGE_MAP(CSkinEdit, CEdit) ON_WM_CTLCOLOR_REFLECT() // 反射消息,比在父窗口里判断控件 ID 干净 ON_WM_NCPAINT() ON_WM_ERASEBKGND() END_MESSAGE_MAP() HBRUSH CSkinEdit::CtlColor(CDC* pDC, UINT /*nCtlColor*/) { pDC->SetBkColor(m_crBk); pDC->SetTextColor(m_crText); static CBrush s_brush(m_crBk); // 静态画刷,避免每帧创建 GDI 对象 return (HBRUSH)s_brush.GetSafeHandle(); } void CSkinEdit::OnNcPaint() { // 不走默认实现,非客户区(边框)完全自绘 CWindowDC dc(this); CRect rc; GetWindowRect(&rc); rc.OffsetRect(-rc.left, -rc.top); // 转成窗口坐标 dc.FillSolidRect(rc, m_crBk); // 先铺满,压掉系统底色 CPen pen(PS_SOLID, 1, m_crBorder); CPen* pOld = dc.SelectObject(&pen); CBrush* pOldBrush = (CBrush*)dc.SelectStockObject(NULL_BRUSH); if (m_nRadius > 0) dc.RoundRect(rc, CPoint(m_nRadius * 2, m_nRadius * 2)); else dc.Rectangle(rc); dc.SelectObject(pOld); dc.SelectObject(pOldBrush); } BOOL CSkinEdit::OnEraseBkgnd(CDC* /*pDC*/) { return TRUE; // 背景已由 CtlColor 与非客户区绘制覆盖,跳过默认擦除 }逻辑说明:ON_WM_CTLCOLOR_REFLECT是 MFC 的反射机制,父窗口收到WM_CTLCOLOREDIT时会先转给控件自己处理,省去在对话框里写一堆if (pWnd->GetDlgCtrlID() == IDC_XXX)的脏代码。OnNcPaint里先FillSolidRect再画框,是因为非客户区默认会被系统涂成COLOR_WINDOW,不先铺底就会在圆角外缘透出白边。OnEraseBkgnd返回 TRUE 是关键,不返回的话每次重绘都会有一次闪烁。
参数说明:m_nRadius控制圆角,RoundRect的第二个参数是椭圆宽高而非半径,所以要乘 2;m_crBk必须和父窗口背景接近,否则圆角外侧的填充色会露馅。静态画刷用static是血泪经验,早期我在CtlColor里new CBrush,输入框一多、一重绘,GDI 句柄几分钟就顶到上限。
注意:子类化后原生 Edit 的内边距仍是系统默认值,想调左右留白只能通过
EM_SETMARGINS消息,上下留白无解,需要靠调整控件高度或改用纯自绘路线。
2.2 纯自绘路线:从 CWnd 派生,全部自己来
纯自绘适合界面规范要求高、需要占位提示文字、右侧清除按钮、字数统计这类增强能力的项目。它从CWnd派生,自己维护文本缓冲、插入符位置、选区锚点,接收WM_CHAR、WM_KEYDOWN、WM_IME_*系列消息。成本在于输入法:候选框要跟着光标走,就得在处理WM_IME_STARTCOMPOSITION时算出插入符的屏幕坐标,再ImmSetCandidateWindow。
这条路线我通常只推荐给有明确交付周期、并且团队里有人啃过 IME 消息的人。否则光一个「输入到行尾要自动横向滚动」就能磨掉两天。下面的骨架演示绘制与插入符两件事,文本排版用DrawText配合DT_SINGLELINE | DT_VCENTER先跑通单行,多行再扩。
// CEditEx.h —— 从 CWnd 派生的纯自绘单行编辑框 class CEditEx : public CWnd { public: CString m_strText; // 文本缓冲 int m_nCaret = 0; // 插入符字符索引 int m_nScrollX = 0; // 横向滚动偏移(像素) COLORREF m_crBk = RGB(32,34,38), m_crText = RGB(220,222,226); BOOL CreateEx(CWnd* pParent, UINT nID, const CRect& rc); protected: afx_msg void OnPaint(); afx_msg void OnChar(UINT nChar, UINT nRepCnt, UINT nFlags); afx_msg void OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags); afx_msg void OnSetFocus(CWnd* pOld); afx_msg void OnKillFocus(CWnd* pNew); DECLARE_MESSAGE_MAP() }; void CEditEx::OnPaint() { CPaintDC dc(this); CRect rc; GetClientRect(&rc); dc.FillSolidRect(rc, m_crBk); // 用裁剪区把文本限制在客户区内,避免长文本画出边界 CRect rcText = rc; rcText.DeflateRect(6, 0); int nSaved = dc.SaveDC(); dc.IntersectClipRect(rcText); CFont* pOldFont = dc.SelectObject(GetFont()); dc.SetTextColor(m_crText); dc.SetBkMode(TRANSPARENT); dc.DrawText(m_strText, rcText, DT_SINGLELINE | DT_VCENTER | DT_LEFT | DT_NOPREFIX); // 插入符:把插入符索引换算成像素坐标 CSize sz = dc.GetTextExtent(m_strText.Left(m_nCaret)); if (GetFocus() == this) { CreateSolidCaret(1, rc.Height() - 8); // 只在有焦点时创建 SetCaretPos(sz.cx + 6 - m_nScrollX, 4); ShowCaret(); } dc.SelectObject(pOldFont); dc.RestoreDC(nSaved); }逻辑说明:SaveDC/RestoreDC包住裁剪区修改,防止影响后续绘制。插入符每次OnPaint都CreateSolidCaret是刻意为之——Windows 只允许一个插入符,重复创建会先销毁旧的,这样不需要额外维护生命周期。GetTextExtent取到插入符前的文本宽度,加上左侧 6 像素内边距就是插入符的 x 坐标。
参数说明:CreateSolidCaret的宽高单位是像素,宽度给 1 最自然,高度留出上下各 4 像素呼吸。DT_NOPREFIX必须加,否则文本里出现&会被当成快捷键前缀吞掉。m_nScrollX在OnChar里需要按光标位置动态修正,否则文本超出宽度后插入符会跑到控件外。
提示:走纯自绘路线时,
WM_GETDLGCODE要返回DLGC_WANTALLKEYS | DLGC_WANTCHARS,否则 Tab 键会被对话框拿去切换焦点,回车会直接触发默认按钮。
3. 三条必须处理的绘制细节:闪烁、坐标与 GDI 泄漏
控件画出来只是开始,真正决定观感的往往是几个不显眼的细节:重绘时闪不闪、坐标算得准不准、GDI 对象会不会越积越多。这三点在子类化和纯自绘两条路线上都同样成立,只是触发方式不同。下面按我实际调试的顺序展开,每条都给出可以直接验证的方法。
3.1 双缓冲压掉闪烁
闪屏的根因是「擦背景」和「画前景」分两步走,中间那一帧被用户看见了。子类化路线里,OnEraseBkgnd返回 TRUE 只是第一步,WM_PAINT默认的填充动作还在。彻底的做法是在OnPaint里先画到内存 DC,再一次性BitBlt到屏幕。纯自绘路线更简单,因为整个绘制过程本来就在我们手里。
void CEditEx::OnPaint() { CPaintDC dc(this); CRect rc; GetClientRect(&rc); if (rc.IsRectEmpty()) return; // 内存 DC + 兼容位图,尺寸严格等于客户区 CDC memDC; memDC.CreateCompatibleDC(&dc); CBitmap bmp; bmp.CreateCompatibleBitmap(&dc, rc.Width(), rc.Height()); CBitmap* pOldBmp = memDC.SelectObject(&bmp); memDC.FillSolidRect(rc, m_crBk); memDC.SetBkMode(TRANSPARENT); memDC.SetTextColor(m_crText); CFont* pOldFont = memDC.SelectObject(GetFont()); CRect rcText = rc; rcText.DeflateRect(6, 0); memDC.DrawText(m_strText, rcText, DT_SINGLELINE | DT_VCENTER | DT_LEFT | DT_NOPREFIX); memDC.SelectObject(pOldFont); dc.BitBlt(0, 0, rc.Width(), rc.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }逻辑说明:兼容位图必须用CreateCompatibleBitmap(&dc, ...)而不是CreateBitmap,后者画出来是单色的,这个坑坑过的人不在少数。SelectObject恢复旧位图的操作放在BitBlt之后,否则位图还被 DC 占用时析构会失败。
参数说明:BitBlt的SRCCOPY是唯一正确的标志位,换成SRCAND或SRCPAINT会得到黑块或白块。如果控件尺寸变化频繁(比如跟随窗口缩放),内存位图每次重绘都重建有开销,可以缓存成成员变量,在OnSize里销毁重建。
3.2 客户区坐标与非客户区坐标别混
子类化路线最容易翻车的地方是坐标。GetWindowRect拿到的是屏幕坐标,GetClientRect拿到的是客户区坐标,而CWindowDC的坐标原点在窗口左上角包含边框。混用之后表现是边框画到了标题栏位置,或者插入符偏出几十像素。
我一般的处理习惯是:进入绘制函数第一件事就是明确当前 DC 的坐标系,CWindowDC配GetWindowRect再OffsetRect归零,CClientDC或CPaintDC配GetClientRect。纯自绘控件没有非客户区,全部用客户区坐标,反而省心。如果确实需要跨坐标系换算,ClientToScreen/ScreenToClient成对使用,不要手算偏移量,因为 DPI 缩放和窗口边框厚度在不同主题下都不同。
还有一个容易忽略的点:高 DPI 下GetWindowRect返回的是物理像素,而 MFC 对话框资源坐标在设计时是逻辑像素,如果程序清单里没有声明 DPI 感知,1.5 倍缩放下圆角半径会显得比设计稿小一圈。常见做法是在OnInitDialog里按当前 DPI 重新换算一遍所有尺寸参数,而不是硬编码。
3.3 GDI 对象的生命周期
GDI 泄漏不会立刻报错,等到进程句柄数逼近一万时,整个界面会集体变黑或者绘制失败,排查成本极高。自绘控件的绘制函数调用频率高,任何一次在OnPaint里new出来的画笔、画刷、字体都会在几分钟内累积出问题。
规矩很简单:能复用的对象做成成员变量或静态变量,只在参数变化时重建;临时对象用栈上构造,函数返回前SelectObject恢复旧对象。字体这类创建成本高的对象,缓存起来比每次CreateFont强得多。另外,CreateCompatibleBitmap产生的位图一定要DeleteObject,虽然 MFC 的CBitmap析构会处理,但前提是它已经从 DC 里被 select 出来了。
注意:调试期可以用任务管理器的「GDI 对象」列盯着,自绘控件反复重绘时这个数字应该保持稳定,持续上涨就说明有对象没释放。
4. 避坑排查:五个让自绘 Edit 翻车的现场
4.1 圆角外侧残留白边
现象:深色界面上给 Edit 加圆角后,四个角外侧透出白色或浅灰的正方形小角。
原因:非客户区默认会被系统用COLOR_WINDOW填充,OnNcPaint里画的圆角只在圆角内部覆盖,外侧那部分没有先被父窗口背景色盖住。子类化路线里,父窗口不会主动重绘控件外接矩形。
解决:在OnNcPaint里先用父窗口背景色FillSolidRect整个外接矩形,再画圆角边框;或者把控件区域改成不规则窗口,SetWindowRgn按圆角矩形裁剪。后者效果好但会带来点击区域变化,需要权衡。
4.2 输入中文时光标不跟、候选框跑到屏幕角落
现象:纯自绘路线下输入中文,输入法候选框固定在窗口左上角或者屏幕某个固定位置,拼音串也显示不出来。
原因:只处理了WM_CHAR,没有处理WM_IME_STARTCOMPOSITION、WM_IME_COMPOSITION,候选框位置默认取 0,0。
解决:在WM_IME_STARTCOMPOSITION里调用ImmGetContext,算出插入符的屏幕坐标,填充CANDIDATEFORM结构后ImmSetCandidateWindow。同时WM_IME_COMPOSITION里带GCS_COMPSTR标志时要把组字串画出来,否则用户看不到自己正在输入什么。这块没有捷径,只能照着消息流程一步步接。
4.3 点击控件不获得焦点
现象:纯自绘控件鼠标点进去没有任何反应,光标不出现,键盘输入没响应。
原因:从CWnd派生的控件不会自动处理鼠标激活逻辑,WM_LBUTTONDOWN默认实现里没有SetFocus。
解决:在OnLButtonDown里主动SetFocus(),并根据点击位置用CharFromPos之类的换算逻辑把插入符移过去。如果控件支持拖拽选区,还要在WM_MOUSEMOVE里配合MK_LBUTTON更新选区终点。
4.4 快速输入时丢字符
现象:纯自绘控件在长按键盘或者输入法快速上屏时,偶尔少一个字符。
原因:WM_CHAR消息处理里直接m_strText += (TCHAR)nChar,但没有考虑 Unicode 代理对和输入法上屏时的批量提交;另外Invalidate之后立即UpdateWindow的时序也会造成消息堆积。
解决:WM_CHAR处理保持轻量,只做缓冲追加和插入符位移,重绘交给Invalidate(FALSE)让系统合并;输入法上屏的文本在WM_IME_COMPOSITION的GCS_RESULTSTR分支里追加,不要和WM_CHAR重复处理。
4.5 高 DPI 下边框粗细不均
现象:把系统缩放调到 150% 后,自绘边框左右两侧比上下两侧粗,或者圆角变形。
原因:边框宽度硬编码成 1 像素,绘制时没有按 DPI 缩放;RoundRect的椭圆参数在高 DPI 下被取整成不同值。
解决:在OnInitDialog或者WM_DPICHANGED里读取GetDpiForWindow,把边框宽度、圆角半径、内边距全部按dpi / 96.0换算。程序清单里声明 Per-Monitor DPI 感知,避免系统做位图拉伸。
5. 用状态位把重绘压到最少:一个自绘 Edit 的收尾技巧
自绘控件写完之后,性能问题往往不是出在绘制本身,而是出在不必要的重绘次数上。我见过一个项目,输入框每敲一个字符整个对话框的按钮都要重画一遍,原因是每次文本变化都调了父窗口的Invalidate。后来把重绘范围收窄、加了一层状态位判断,CPU 占用从 8% 降到 1% 以内,肉眼可见地顺了。
具体做法是给控件维护一个「脏区域」标记,只在实际变化的部分调用InvalidateRect,并且第二个参数传 FALSE 避免触发背景擦除。文本追加时只需重绘文本区,插入符移动时只需重绘新旧插入符两小块矩形。下面这个辅助函数可以直接放进控件类:
// 只重绘插入符新旧位置,避免整控件刷新 void CEditEx::UpdateCaretRegion(int nOldCaret, int nNewCaret) { CDC* pDC = GetDC(); if (!pDC) return; CFont* pOldFont = pDC->SelectObject(GetFont()); auto CaretRect = [&](int nIndex) -> CRect { CSize sz = pDC->GetTextExtent(m_strText.Left(nIndex)); CRect rc; GetClientRect(&rc); int x = sz.cx + 6 - m_nScrollX; return CRect(x, 4, x + 2, rc.Height() - 4); // 宽度给 2 像素容错 }; CRect rcOld = CaretRect(nOldCaret); CRect rcNew = CaretRect(nNewCaret); pDC->SelectObject(pOldFont); ReleaseDC(pDC); InvalidateRect(&rcOld, FALSE); InvalidateRect(&rcNew, FALSE); }逻辑说明:用GetDC只做文本测量不实际绘制,测完立刻ReleaseDC,避免占用 DC 影响后续绘制。插入符新旧区域各算一次,两次InvalidateRect交给系统合并成一次WM_PAINT。宽度给 2 像素是因为 1 像素插入符在抗锯齿字体旁容易看到残影,重绘区域略宽一点可以把边缘抹干净。
参数说明:InvalidateRect的第二个参数 FALSE 必须给,TRUE 会连带擦背景,等于把双缓冲省下的开销又还回去。如果控件设置了横向滚动,m_nScrollX的变化也要纳入脏区域计算,否则滚动时会出现文本残影。
这套状态位思路还能扩展到选区高亮:维护一个「当前选区起止」的缓存,鼠标拖动时对比新旧选区,只重绘差集区域。实测在 500 行多行文本的场景里,全量重绘一次约 6 毫秒,差集重绘平均 0.4 毫秒,滚动条拖动时的流畅度差别很明显。
从那以后我每次写自绘控件,都强制先在绘制函数里问自己一句:这次的Invalidate范围是不是已经缩到最小了。没缩到就先改重绘逻辑再谈画得好不好看,顺序反了会浪费很多时间。希望帮到你。
本文还有配套的精品资源,点击获取