简介:面向Windows桌面开发初学者及需要巩固VS2010控件基础的开发者,这份EDIT控件使用方法资料围绕文本输入框的创建、属性配置与交互处理展开,帮助解决编辑框在对话框程序中的常见用法与调试问题。内容覆盖基本创建与属性设置、EN_CHANGE等事件响应、GetDlgItemText与SetDlgItemText文本读写、ES_NUMBER及EM_SETLIMITTEXT输入限制、多行编辑与光标滚动控制、只读和回车换行格式控制、错误提示,以及EM_FORMATRANGE等GDI高级绘制场景,并配有HelpEdit示例工程供对照。压缩包共52个文件,约40.82MB,包含h/cpp源码、vcxproj/sln/filters工程配置、rc/res/ico资源文件,以及obj/pdb/exe/ilk等编译调试产物和日志缓存,便于按工程结构排查问题。已有612人学习,适合按知识点查阅并结合作品实践。
1. 一个 EDIT 控件写不对,整个对话框都像半成品
接手一个配置对话框,界面上十几个输入框,看着都能敲字,交付前一天才发现:数字框里能输字母、粘贴一段超长文本把界面撑爆、多行日志每刷新一次就闪一下、程序读到的值永远比用户看到的慢半拍。这不是 MFC 有多难,而是 VS2010 里 EDIT 控件的默认属性和消息时机跟直觉不一致,拖上去就能用,但离能用还差好几步。下面这份 VS2010 EDIT控件 使用方法 的整理,把单行、多行、只读三种形态的属性配置、取值赋值、输入约束和消息触发顺序串成一条能照着复现的路径。适合刚接手 MFC 对话框程序、需要把输入区做扎实的人;也适合已经会拖控件、但取值赋值全靠试错的人。
2. 先把控件选对:单行、多行、只读的形态差异与属性表
很多「控件不听话」的问题,根源在资源编辑器里那几个勾选项。EDIT 控件的属性并不是界面装饰,每一个都直接映射到窗口风格位,风格位决定控件怎么处理键盘、怎么处理回车、怎么滚动。改错一个,后面写十行代码都补不回来。
2.1 属性页里真正影响行为的十个勾选项
打开对话框资源,选中输入框,属性页里值得逐个确认的是这些:
| 属性 | 对应风格位 | 实际影响 | 什么时候必须改 |
|---|---|---|---|
| Multiline | ES_MULTILINE | 允许换行、支持多段落文本 | 日志、备注、SQL 预览 |
| Want Return | ES_WANTRETURN | 多行框里回车插入换行,而不是触发默认按钮 | 勾了 Multiline 基本都要勾 |
| Vertical Scroll | ES_AUTOVSCROLL + WS_VSCROLL | 内容超出行数时出现竖向滚动条 | 多行框必勾 |
| Horizontal Scroll | ES_AUTOHSCROLL + WS_HSCROLL | 单行框横向滚动,多行框不自动折行 | 长路径、长 URL 的单行框 |
| Read Only | ES_READONLY | 用户不可改,但仍可选中复制 | 展示结果、只读配置项 |
| Password | ES_PASSWORD | 显示为圆点,取值仍是明文 | 密码、密钥串 |
| Number | ES_NUMBER | 只接受数字字符 | 端口、数量(局限见第 4 章) |
| Uppercase | ES_UPPERCASE | 输入自动转大写 | 十六进制、编码类字段 |
| Limit text | EM_SETLIMITTEXT | 用户输入上限,默认 32767 | 所有要落库的字段 |
| Tab Stop | WS_TABSTOP | 是否参与 Tab 键切换 | 只读框常忘了勾,Tab 直接跳过去 |
有两个容易忽略的细节。第一个是Limit text为 0 时并不是「不限」,而是默认的 32767,日志框写满就不动了,表现像是程序卡死。第二个是Tab Stop,只读框经常是从可编辑框复制过来的,复制时把Tab Stop一起带上了还好,手工改只读的时候漏掉,用户按 Tab 会莫名其妙跳过一个格子,这种问题在验收阶段极难被发现。
注意:
Multiline必须在控件创建时就是多行。运行时用ModifyStyle补ES_MULTILINE,得到的会是个半残控件——能显示多行但回车、滚动、选中行为都不对。这条在后面排查章节还会再提一次。
2.2 三种形态怎么选:对照场景与最小配置步骤
单行、多行、只读不是三种外观,是三条不同的使用路径。选错的代价是后面所有代码都要绕路。
单行框用于一行为单位的短数据:名称、端口、路径、账号。最小配置是勾Tab Stop、设一个合理的Limit text、需要防呆的再加Number或Uppercase。单行框不要勾Multiline,也不要勾Horizontal Scroll之外的滚动项,否则横向滚动条会一直挂在那儿很难看。
多行框用于不定长文本:运行日志、备注说明、配置片段。最小配置是Multiline+Want Return+Vertical Scroll,Limit text按用途放宽到 64KB 级别。多行框有个细节:不勾Want Return时,用户在框里按回车,消息会冒泡到对话框,直接触发默认按钮把窗口关掉,用户以为程序崩了。
只读框用于展示不该被修改的结果:解析后的配置、校验输出、文件摘要。最小配置是Read Only+Tab Stop,同时确认外层没有禁用控件——EnableWindow(FALSE)会让控件变灰且无法选中,那是「禁用」不是「只读」,用户连复制都做不了。
资源里配好之后,初始化时还可以补两件事,但只补能补的:
BOOL CConfigDlg::OnInitDialog() { CDialogEx::OnInitDialog(); CEdit* pLog = (CEdit*)GetDlgItem(IDC_EDIT_LOG); if (pLog != NULL && pLog->GetSafeHwnd() != NULL) { // 只读状态可以运行时切换,等价于 SendMessage(EM_SETREADONLY, TRUE, 0) pLog->SetReadOnly(TRUE); // 上限只约束用户输入,不约束 SetWindowText,这点第 5 章会展开 pLog->SetLimitText(64 * 1024); } CWnd* pName = GetDlgItem(IDC_EDIT_NAME); if (pName != NULL) { pName->SetWindowText(_T("默认名称")); // 初始值统一在这里给,别散落在各处 } return TRUE; }这段的关键不是代码本身,而是那个不对称:SetReadOnly和SetLimitText属于运行时能改的风格,ES_MULTILINE、ES_WANTRETURN属于创建期风格。分不清这条界线,就会写出「初始化时把单行框改成多行」这种看起来能跑、实际全是玄学问题的代码。
3. 取值与赋值:GetWindowText 与 DDX 两条路的取舍
控件摆对了,接下来是数据进出。MFC 给了两条路:手写GetWindowText/SetWindowText,或者用 DDX/DDV 把控件和成员变量绑起来。两条路都能走通,混着用就是灾难。
3.1 手写 GetWindowText 的完整闭环
手写路线的优点是过程完全可见,适合需要精确控制的场景:值要做格式归一化、要根据输入联动别的控件、要在失焦时立刻校验。一个完整的闭环不只包含取值,还包含去空白、范围检查、出错定位、写回格式化四步。
void CConfigDlg::OnBnClickedApply() { CString strPort; // GetDlgItemText 内部就是 GetDlgItem + GetWindowText,少一次指针判空 GetDlgItemText(IDC_EDIT_PORT, strPort); strPort.Trim(); // 用户极容易在末尾多敲一个空格,不 Trim 直接比较必翻车 if (strPort.IsEmpty()) { MessageBox(_T("端口不能为空"), _T("提示"), MB_ICONWARNING); GetDlgItem(IDC_EDIT_PORT)->SetFocus(); // 焦点回到出错的框,用户不用自己找 return; } int nPort = _ttoi(strPort); if (nPort < 1 || nPort > 65535) { MessageBox(_T("端口范围 1-65535"), _T("提示"), MB_ICONWARNING); CWnd* pEdit = GetDlgItem(IDC_EDIT_PORT); pEdit->SetFocus(); ((CEdit*)pEdit)->SetSel(0, -1); // 全选,用户直接重输即可 return; } // 写回统一格式,避免用户看到 "0080" 而程序里存的是 80 strPort.Format(_T("%d"), nPort); SetDlgItemText(IDC_EDIT_PORT, strPort); }参数层面值得说明的是SetSel(0, -1):起始 0、结束 -1 表示全选,这是 EDIT 控件里少数几个用 -1 当「末尾」语义的地方。SetFocus()调用前要确保控件可见且可用,如果这个框在某个隐藏的分页里,SetFocus会失败,用户看到的是弹了框但光标没跳过去,体验很怪。
写回那一步不是多此一举。用户在端口框里敲0080,如果不写回,界面上一直是0080,程序内部是 80,下次打开配置又从别处读回80,用户会觉得「我改的东西没保存」。这种前后不一致是最常见的信任崩塌点。
3.2 DDX/DDV 绑定:适合什么、不适合什么
DDX 的价值在于把所有取值赋值收敛到DoDataExchange一个函数里,配合UpdateData(TRUE/FALSE)一次性同步,代码量能砍掉一大半。适合字段多、校验规则标准、不需要复杂联动的配置对话框。
void CConfigDlg::DoDataExchange(CDataExchange* pDX) { CDialogEx::DoDataExchange(pDX); // 数值型:框架自动做 CString <-> int 的转换 DDX_Text(pDX, IDC_EDIT_PORT, m_nPort); // 紧接着做范围校验,失败时框架自己弹框并把焦点定位到该控件 DDV_MinMaxInt(pDX, m_nPort, 1, 65535); DDX_Text(pDX, IDC_EDIT_NAME, m_strName); DDX_Text(pDX, IDC_EDIT_REMARK, m_strRemark); // 需要调用 CEdit 成员函数(SetSel、SetReadOnly 之类)时才用 DDX_Control DDX_Control(pDX, IDC_EDIT_LOG, m_wndLog); }这里的顺序有讲究:DDX_Text必须在对应的DDV_*之前,因为校验函数拿的是已经转换好的成员变量。DDX_Control和DDX_Text不能指向同一个控件 ID,否则运行时断言直接挂。
DDX 不适合的情况也很明确:需要根据一个框的内容实时改另一个框的可用状态、需要自定义格式(比如把1,024这种带千分位的文本解析成数字)、需要保留用户输入原样只在提交时转换。这几种硬套 DDX,最后都会在UpdateData前后打补丁,比手写还乱。我的习惯是:字段超过八个、校验规则是纯范围判断,就走 DDX;否则老老实实手写,别为了少写几行把逻辑搅在一起。
还有一条血泪经验:UpdateData(TRUE)的返回值一定要看。用户输入abc到一个数值框,转换失败,UpdateData返回 FALSE,此时成员变量还是旧值。不看返回值继续往下跑,等于拿上一轮的数据去做业务,这种 bug 复现起来极其痛苦,因为界面上明明显示着新内容。
3.3 CString 与数字互转:几个必须知道的边界
EDIT 控件的所有内容本质上都是字符串,转换环节是最容易埋雷的地方。
| 需求 | 推荐写法 | 失败时的表现 |
|---|---|---|
| CString 转 int | _ttoi+ 前置字符检查 | "12abc"得 12,"abc"得 0,无法区分「输入了 0」和「输入了垃圾」 |
| CString 转 int(严格) | _stscanf_s(str, _T("%d"), &n) == 1 | 返回 0 表示没匹配上,可以准确报错 |
| int 转 CString | str.Format(_T("%d"), n) | 格式符和类型不匹配会输出乱码 |
| CString 转 16 进制 | _tcstoul(str, NULL, 16) | 前缀0x可带可不带,但0x后面跟非十六进制字符会截断 |
_ttoi那个「区分不了 0 和垃圾输入」的问题,在端口、数量这类字段上是致命的。常见做法是先用_stscanf_s严格匹配,匹配成功再用_ttoi取值,两步走。十六进制那个截断问题同样烦人,_tcstoul会把"12GG"解析成 18,程序照单全收,界面上却还显示着12GG,用户以为存进去了。
4. 输入约束:ES_NUMBER 之外还得补什么
属性页里的Number勾选框看起来能解决一切数字输入问题,实际用起来会发现问题只解决了一半,而且解决得很难看。
4.1 ES_NUMBER 能挡什么、挡不住什么
ES_NUMBER的过滤发生在字符进入控件之前,键盘输入和小部分粘贴路径都能拦住,非法字符根本不会显示出来。好处是干净,坏处是安静——用户敲了字母,屏幕上什么都没发生,他只会觉得键盘坏了或者程序卡了,没有任何提示。
它还有三个明确的边界。第一,不支持负号和小数点,-1和0.5都输不进去,要处理温度、比例、偏移量这类字段就不能用它。第二,它不约束程序侧写入,SetWindowText照样能把abc塞进去。第三,它拦不住输入法的组字过程,中文输入法激活状态下,按键序列是先进入输入法再上屏,中间态有时会绕过去。
需要小数或负数的场合,就得自己拦字符。最快的做法是在对话框的PreTranslateMessage里按控件 ID 分流:
BOOL CConfigDlg::PreTranslateMessage(MSG* pMsg) { // 只处理字符消息,且焦点必须落在目标输入框上 if (pMsg->message == WM_CHAR && GetFocus() != NULL && GetFocus()->GetDlgCtrlID() == IDC_EDIT_RATIO) { TCHAR ch = (TCHAR)pMsg->wParam; // 退格、删除必须放行,否则用户改不了错 if (ch == VK_BACK || ch == VK_DELETE) return CDialogEx::PreTranslateMessage(pMsg); if ((ch < _T('0') || ch > _T('9')) && ch != _T('.') && ch != _T('-')) return TRUE; // 吞掉,不再往下传 CString strCur; GetFocus()->GetWindowText(strCur); if (ch == _T('.') && strCur.Find(_T('.')) >= 0) return TRUE; // 已经有了小数点,第二个吞掉 } return CDialogEx::PreTranslateMessage(pMsg); }return TRUE表示消息已处理完毕、不再分发,这就是「吞掉」的实现方式。返回CDialogEx::PreTranslateMessage(pMsg)则让消息继续走正常流程。注意VK_BACK和VK_DELETE必须显式放行,很多人第一次写这段只判断数字范围,结果退格键失效,用户只能全选重输。
一个负号的特殊处理:-只允许出现在首位。上面这段没做这个判断,因为GetWindowText拿的是当前文本,而字符还没插入,判断首位要结合选区位置算,代码会迅速变长。字段不多的时候,用EN_KILLFOCUS里统一校验更省事。
4.2 子类化 CEdit 做统一过滤
对话框里数字框一多,PreTranslateMessage里堆一排if就难看了。干净的做法是派生一个CEdit子类,把过滤逻辑封在控件内部,资源里的控件用SubclassDlgItem挂上去。
// 头文件里声明 class CNumEdit : public CEdit { DECLARE_DYNAMIC(CNumEdit) public: afx_msg void OnChar(UINT nChar, UINT nRepCnt, UINT nFlags); DECLARE_MESSAGE_MAP() }; // 实现文件里 BEGIN_MESSAGE_MAP(CNumEdit, CEdit) ON_WM_CHAR() END_MESSAGE_MAP() void CNumEdit::OnChar(UINT nChar, UINT nRepCnt, UINT nFlags) { if (nChar == VK_BACK || nChar == VK_DELETE) { CEdit::OnChar(nChar, nRepCnt, nFlags); return; } if (nChar >= _T('0') && nChar <= _T('9')) { CEdit::OnChar(nChar, nRepCnt, nFlags); // 合法字符交回基类插入 return; } // 中文输入法上屏时会产生 WM_CHAR,双字节字符按需放行或拦截 MessageBeep(MB_ICONASTERISK); // 不合法的字符直接 return,不调基类,等于丢弃 }挂载只需要一行:
m_wndPort.SubclassDlgItem(IDC_EDIT_PORT, this); // 必须在 OnInitDialog 里,且控件已创建SubclassDlgItem的第二个参数是父窗口指针,传this表示对话框。子类化之后,控件自己的消息映射先处理,处理不了再交给CEdit基类,基类是原生 EDIT 的封装。这个结构的好处是同一个类可以复用到任意对话框,过滤规则改一处、全部生效。
需要注意子类化对象m_wndPort的生命周期要跟对话框一致,一般声明成成员变量。声明成局部变量,函数返回时对象析构,控件就成野的了,下一次输入直接崩。
4.3 EN_CHANGE、EN_UPDATE、EN_KILLFOCUS 的触发时机
这三个通知消息搞混,就会写出「取值永远慢一步」或者「改文本触发递归」的代码。
| 通知消息 | 触发时机 | 适合做什么 | 不适合做什么 |
|---|---|---|---|
| EN_UPDATE | 文本已变、重绘之前 | 同步另一处显示 | 在里面改本控件文本,容易自激 |
| EN_CHANGE | 文本已变、已重绘 | 联动可用状态、统计字数 | 弹模态对话框、做耗时操作 |
| EN_KILLFOCUS | 焦点离开控件 | 格式归一化、范围校验 | 判断「用户是否点了确定」 |
| EN_MAXTEXT | 输入超过 Limit text 上限 | 提示已达上限 | 用来做长度校验(程序写入不触发) |
用ON_EN_CHANGE映射的处理函数里,如果又对这个控件调用SetWindowText,会再次触发EN_CHANGE,运气好是界面闪一下,运气差是栈溢出。稳妥写法是加一个成员标志位:
void CConfigDlg::OnEnChangeEditName() { if (m_bUpdatingName) return; // 防重入 m_bUpdatingName = TRUE; CString str; GetDlgItemText(IDC_EDIT_NAME, str); GetDlgItem(IDC_STATIC_COUNT)->SetWindowText(FormatCount(str)); // 只改别的控件 m_bUpdatingName = FALSE; }至于程序侧SetWindowText会不会触发EN_CHANGE,不同控件的实现细节并不一致,别把这个行为写进业务逻辑。要区分「用户改的」和「代码改的」,就老老实实用标志位隔开。
5. 避坑与排查:五个翻车现场的现象、原因与解法
下面这五条都是从实际对话框里扒出来的,每条按现象、原因、解决三段走。遇到类似的先对号入座,能省掉大半天。
5.1 多行框里按回车,对话框直接关了
现象:输入框明明是几行高,用户想换个行,一按回车整个对话框消失,像是点了确定。
原因:Multiline勾了但Want Return没勾。回车产生的WM_CHAR没被控件吃掉,冒泡到对话框,落到默认按钮 IDOK 上,OnOK直接执行。
解决:资源里补勾Want Return。如果这个框是运行时创建的,创建时就要带ES_WANTRETURN,事后用ModifyStyle补并不可靠。
5.2 程序里塞进去的超长文本,界面上照样显示
现象:Limit text设了 10,用户手输到 10 个字符就顶住,但代码里SetWindowText塞 200 个字符照样全显示,还能滚动看到。
原因:EM_SETLIMITTEXT约束的是用户输入路径,WM_SETTEXT走的是另一条路,不受这个限制。
解决:赋值前自己截断,或者统一走一个封装函数:
void SetEditTextLimited(CWnd* pWnd, const CString& strText, int nMax) { if (pWnd == NULL || pWnd->GetSafeHwnd() == NULL) return; CString strOut = strText; if (nMax > 0 && strOut.GetLength() > nMax) strOut = strOut.Left(nMax); // 注意按字符截断,别把多字节字符截一半 pWnd->SetWindowText(strOut); }截断中文时有个坑:Left(nMax)按CString的字符数算,在 Unicode 编译下就是字符个数,不会出现半个汉字。工程如果还是多字节字符集,就得用_tcsncpy_s之类按字节处理,容易截出乱码。
5.3 日志框刷新时整屏闪,用户选中的文本还丢了
现象:往多行框里追加一行日志,整个框闪一下,滚动条跳回顶部,用户正在框里选中的一段内容消失。
原因:用SetWindowText(全量文本)刷新。WM_SETTEXT会重建整个文本缓冲,清掉撤销记录和选区,再触发一次完整重绘。
解决:追加而不是重写,光标先移到末尾:
void CConfigDlg::AppendLog(LPCTSTR pszLine) { if (m_wndLog.GetSafeHwnd() == NULL) return; int nLen = m_wndLog.GetWindowTextLength(); m_wndLog.SetSel(nLen, nLen); // 光标定位到末尾 m_wndLog.ReplaceSel(pszLine); // 只替换选区,等于追加 m_wndLog.ReplaceSel(_T("\r\n")); // 多行框必须 \r\n,只给 \n 会显示成方块 }批量追加几十行时,先SetRedraw(FALSE)、追加完再SetRedraw(TRUE)加一次Invalidate,闪烁基本看不出来。
5.4 只读框里能选中,但 Ctrl+C 没反应
现象:Read Only勾上后,用户能用鼠标选中一段文字,按 Ctrl+C 剪贴板没变化,右键也没菜单。
原因:ES_READONLY只禁止修改,不禁止复制,WM_COPY本身是有效的。问题出在对话框没有把 Ctrl+C 映射到WM_COPY,加速键表里没这一项,按键被对话框吞掉了。
解决:在PreTranslateMessage里补一段映射,或者直接在对话框的加速键资源里加Ctrl+C到ID_EDIT_COPY。补映射的写法更省事:
BOOL CConfigDlg::PreTranslateMessage(MSG* pMsg) { if (pMsg->message == WM_KEYDOWN && pMsg->wParam == 'C' && ::GetKeyState(VK_CONTROL) < 0) { CWnd* pFocus = GetFocus(); if (pFocus != NULL && pFocus->GetDlgCtrlID() == IDC_EDIT_RESULT) { pFocus->SendMessage(WM_COPY); // 只读框也能响应 return TRUE; } } return CDialogEx::PreTranslateMessage(pMsg); }顺手检查一下只读框的Tab Stop,漏勾的话键盘用户根本进不去这个控件,鼠标能点、Tab 跳不过去,这种不一致最容易被投诉。
5.5 中文输入法下取值慢半拍,还重复触发
现象:用拼音往输入框里打字,绑定的EN_CHANGE疯狂触发,GetWindowText拿到的是拼音串不是汉字;有时候还出现处理函数反复执行。
原因:输入法组字过程中窗口文本就在变化,每次变化都会发通知;如果处理函数里又调用了会改文本的接口,还会形成自激。
解决:把「实时响应」和「取值校验」拆开。实时响应用EN_CHANGE但只做轻量的事(比如更新字数、改按钮可用状态);真正的取值放到EN_KILLFOCUS或者点确定时做。必须实时取值的话,加防重入标志位,并且判断输入法组字状态:
void CConfigDlg::OnEnChangeEditRemark() { if (m_bInComposition) return; // 输入法组字中,先不动 CString str; GetDlgItemText(IDC_EDIT_REMARK, str); GetDlgItem(IDC_STATIC_TIP)->SetWindowText(str.IsEmpty() ? _T("未填写") : _T("已填写")); }m_bInComposition由WM_IME_STARTCOMPOSITION和WM_IME_ENDCOMPOSITION两个消息维护,收到前者置 TRUE、后者置 FALSE,中间的过程全部跳过。
6. 把 EDIT 用顺手的两件事:日志追加的封装与校验的收口
多行日志框的追加函数值得单独做一层封装,原因不只是省代码。上面那段AppendLog里,SetSel(nLen, nLen)加ReplaceSel的组合看起来只是「移到末尾再写」,实际解决了三个问题:不重建文本缓冲、不丢用户选区、不触发全量重绘。换行符那一步更关键,Windows 的 EDIT 控件只认\r\n,从别处拿到的字符串经常只有\n,直接塞进去会显示成一个个方块,排查时很容易怀疑到编码上,其实只是换行符不匹配。
校验收口是另一件事。新人常把校验散在各处:EN_CHANGE里检查一次长度、EN_KILLFOCUS里检查一次范围、OnOK里再检查一次是否为空。三处逻辑不完全一致,用户就会遇到「输入的时候提示超长,点确定又能过」这种自相矛盾。我现在的做法是:交互期的通知消息只负责视觉反馈,不拦人;所有会导致提交失败的条件,全部收进OnOK一个函数,按控件顺序逐个检查,第一个不通过的就SetFocus加SetSel(0, -1),让用户直接重输。
void CConfigDlg::OnOK() { if (!ValidateAll()) return; // 校验全在一个函数里,失败就停在对话框 CDialogEx::OnOK(); }ValidateAll内部按控件 Tab 顺序排,保证用户被拦下的位置是从上往下的,不会出现「先提示下面那个框、再提示上面那个框」的跳跃感。这套顺序看起来是小事,实际用起来顺畅度差别很大。
从那以后我每次接对话框程序,都会先把资源里所有 EDIT 的属性逐个过一遍,再动手写代码——Multiline和Want Return是否成对、只读框的Tab Stop有没有勾、Limit text有没有漏设,这三项过完,后面至少一半的玄学问题不会发生。希望帮到你。
本文还有配套的精品资源,点击获取