ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MFC对话框创建及使用:模态非模态、DDX与控件交互避坑

MFC对话框创建及使用:模态非模态、DDX与控件交互避坑 对话框这东西在MFC里算是入门的第一道坎也是被误解最深的一块。很多人第一次接触MFC都是从VS里新建一个基于对话框的应用开始的跟着向导点几下一个能跑起来的窗口就出来了。但真正上手做项目之后才发现资源编辑器里摆得好好的控件运行时位置全乱DDX绑定了变量改完值界面不刷新非模态对话框一关就崩。问题不在你手笨而在于MFC对Win32的封装是半包的——它把窗口过程藏起来了却又把一堆细节留给了你。这篇就围绕MFC对话框的创建及使用把从资源设计、类派生、模态与非模态的选择、数据交换到控件交互这一整条链路掰开讲清楚顺手把List Control列对齐、对话框拉伸、显示不全这些高频坑一起填了。不管你是在做工业采集软件、仪表上位机还是单纯想搞懂MFC对话框到底怎么跑下面这些内容都能直接用上。1. 对话框的本质它其实是Win32里一个被特殊对待的窗口1.1 对话框和普通窗口差在哪要说清楚对话框得先回到Win32。普通窗口靠CreateWindowEx创建消息进你写的WndProc你自己switch。对话框不太一样它由系统提供的对话框管理器Dialog Manager负责创建和消息分发你在资源脚本.rc里描述界面长相系统按模板把子控件一个个建出来再通过DialogProc回调给你处理消息。换句话说对话框是声明式的——你描述长什么样系统帮你创建普通窗口是命令式的——每一步都得你自己来。MFC的CDialog以及它的父类CWnd做的封装工作就是把这个DialogProc包装成C的虚函数和消息映射表。向导生成的OnInitDialog、OnOK、OnCancel这些虚函数本质上都是对话框管理器在特定时机回调进来的。理解了这一层你就能明白为什么对话框有些行为看起来不受你控制——因为分发逻辑在系统那边你只是被通知的一方。1.2 一个对话框从生到死的完整轨迹拿模态对话框举例从DoModal()被调用开始大致走这么几步先加载对话框模板资源就是资源编辑器里那版二进制形式存在.rc编译产物里然后系统按模板创建窗口和所有子控件接着依次触发WM_INITDIALOG对应OnInitDialog、如果你在里面返回FALSE焦点就不会被系统默认抢走。之后进入消息循环按钮点击、编辑框输入都通过消息映射分给你。用户点确定或取消触发IDOK/IDCANCELEndDialog被调用DoModal返回对应的返回值。非模态对话框走的是另一条路没有DoModal而是Create它不等你创建完立刻返回对话框和主窗口并列存在。这条不等你的特性恰恰是后面一堆坑的根源。我在实际项目里见过太多人照着模态的写法去写非模态结果变量作用域一退出控件就没了——原因就在这。提示判断一个对话框该用模态还是非模态看一个标准即可——在它开着的时候用户是否还需要操作主界面。需要就非模态不需要就模态。2. 资源编辑器里的对话框IDD模板与那些看不出来的设计细节2.1 新建对话框资源的实际顺序在VS里右键项目 - 添加 - 资源 - Dialog会生成一个带确定取消两个按钮的默认模板。这个模板有个默认ID比如IDD_DIALOG1你可以改成有意义的名字比如IDD_SERIAL_CONFIG。改ID这件事别偷懒一个项目里十几个IDD_DIALOGx过俩月你自己都不知道哪个是哪个。紧接着是给它派类。双击对话框资源或者右键 - 添加类MFC会帮你生成一个CDialogEx或CDialog的派生类。这里有个选择基类选CDialog还是CDialogEx。CDialogEx是VS2010之后MFC引入的多了背景色设置、SetBackgroundColor这类便利功能。如果你只做最基础的对话框CDialog够了想做主题化外观用CDialogEx省事。我个人的习惯是功能型对话框用CDialog界面型比如登录、关于用CDialogEx。2.2 控件ID命名和Tab Order这两件小事控件ID的命名我强烈建议加统一前缀按钮IDC_BTN_编辑框IDC_EDT_静态文本IDC_STC_列表IDC_LST_。这不是洁癖是因为后面写消息映射、DDX绑定的时候一个清晰的ID能让你少翻好几次资源视图。Tab Order更容易被忽略。资源编辑器里按CtrlD可以看控件的Tab顺序那个顺序决定了用户按Tab键时焦点的跳转路径。默认顺序是按你摆放控件的先后定的往往和视觉顺序对不上。我见过一个配置对话框用户填完第一个框按Tab焦点直接跳到第五个框去了排查半天以为是代码问题其实只是Tab Order乱。花两分钟按CtrlD点一遍比事后debug省事得多。2.3 对话框单位DLU和高DPI的坑对话框里控件的坐标用的是对话框单位DLU不是像素。DLU基于对话框所用字体的平均字符宽度和高度换算1个水平DLU约等于字体平均宽度的1/41个垂直DLU约等于字体高度的1/8。这就意味着同样的模板在不同系统字体设置下实际像素尺寸是不一样的。这个设计在当年是为了跨显示器一致性但到了高DPI时代就成了麻烦。如果你在125%缩放的屏幕上设计对话框换到100%的机器上可能就挤成一团或者出现滚动条。解决办法有几个一是在OnInitDialog里根据实际DPI动态调整控件位置和大小二是直接在清单文件里声明DPI感知让系统帮你缩放但会糊三是干脆用固定像素坐标、代码里动态布局。老项目一般用第一种新项目可以考虑整体重画布局逻辑。这个我们在第6节还会细说。3. 模态与非模态DoModal和Create到底怎么选、怎么用3.1 DoModal为什么能卡住整个程序DoModal()是个阻塞调用。它内部会跑一个属于自己的消息循环直到对话框关闭才返回。这个卡住不是bug是设计——模态对话框的语义就是你不处理完我别的都别动。返回值就是EndDialog传进来的值常见的是IDOK和IDCANCEL。写模态对话框最典型的模式是这样的把一个结构体或几个变量作为对话框类的公有成员DoModal返回IDOK后再去读它们。CSerialConfigDlg dlg; dlg.m_nBaudRate 9600; // 打开前把初值喂进去 if (dlg.DoModal() IDOK) { // 用户点了确定读取用户填的值 m_nBaudRate dlg.m_nBaudRate; ApplyConfig(); }这里有个细节容易漏初值要在DoModal之前赋给成员变量然后在对话框的OnInitDialog里通过UpdateData(FALSE)刷到控件上。如果你在构造函数里赋值很多时候也能work但不如在DoModal前赋值来得清晰——因为构造函数调用时机早有些初始化逻辑可能还没准备好。3.2 非模态对话框的Create生命周期是第一大坑非模态对话框用Create它接受一个资源ID和父窗口指针m_pFindDlg new CFindDlg(this); m_pFindDlg-Create(IDD_FIND, this); m_pFindDlg-ShowWindow(SW_SHOW);第一个坑是对象必须存活。如果你写成局部变量CFindDlg dlg; dlg.Create(...)函数一返回栈上的对象析构CWnd的析构会把窗口一起销毁界面上窗口瞬间消失。所以非模态对话框的对象要么是主窗口的成员变量要么用new出来并记住在合适的时候delete。第二个坑更隐蔽。CDialog::OnOK和OnCancel对模态是合理的它们内部调用EndDialog但对非模态就是灾难——EndDialog用在非模态对话框上会导致未定义行为甚至崩溃。所以派生类里如果重写了OnOK/OnCancel非模态场景下必须改成DestroyWindow。void CFindDlg::OnCancel() { DestroyWindow(); // 非模态必须用这个 } void CFindDlg::PostNcDestroy() { CDialog::PostNcDestroy(); delete this; // 窗口销毁后释放自己 }PostNcDestroy里delete this这个模式是非模态对话框的标准处理方式配合new一起用从创建到销毁形成闭环。第一次看到delete this会觉得很怪但它在这里是安全且惯用的——因为函数返回后this就不再被访问了。3.3 CDialogBar这类对话框形式的工具栏有人问过CDialogBar能不能拉伸大小。CDialogBar本质是把一个对话框模板嵌进控制条区域它继承自CControlBar而控制条的大小通常由框架的停靠算法管理默认是按模板尺寸固定的。想让它随窗口拉伸得在父框架的OnSize里手动计算并调用SetWindowPos调整它的尺寸同时还要考虑停靠方向——横向停靠拉伸宽度纵向停靠拉伸高度。这不是一个开关能解决的得自己算。实际用起来如果你只是想要一个带控件的可停靠面板CDockablePane往往比CDialogBar更合适它的尺寸管理逻辑更完善。4. DDX和DDV控件和变量之间的那座桥4.1 DoDataExchange到底什么时候被调用MFC的数据交换靠的是DoDataExchange(CDataExchange* pDX)这个虚函数。向导生成的代码长这样void CSerialConfigDlg::DoDataExchange(CDataExchange* pDX) { CDialog::DoDataExchange(pDX); DDX_Text(pDX, IDC_EDT_BAUD, m_nBaudRate); DDX_Control(pDX, IDC_LST_PORTS, m_lstPorts); DDV_MinMaxInt(pDX, m_nBaudRate, 300, 921600); }DDX_Text把编辑框和m_nBaudRate绑在一起DDX_Control把控件本身和控件类的成员变量绑在一起注意区别前者绑的是值后者绑的是控件对象。DDV_开头的是范围校验会在数据交换时顺带检查。关键在于DoDataExchange不是你主动调用的而是UpdateData触发的。UpdateData(TRUE)表示从控件往变量搬SaveUpdateData(FALSE)表示从变量往控件搬Load。理解这个方向性能解决一大半我改了变量为什么界面不变的问题。调用方式数据流向典型场景UpdateData(TRUE)控件 - 变量点确定前读取用户输入UpdateData(FALSE)变量 - 控件OnInitDialog里初始化界面4.2 校验失败是怎么拦下来的DDV_MinMaxInt这类宏在数据交换时如果发现越界会弹一个提示框并调用pDX-Fail()从而中断当前操作。如果你在OnOK里调UpdateData(TRUE)校验失败时UpdateData会返回FALSE但很多人直接写void CSerialConfigDlg::OnBnClickedOk() { UpdateData(TRUE); // 校验失败也往下走 CDialog::OnOK(); }正确写法应该判断返回值void CSerialConfigDlg::OnBnClickedOk() { if (!UpdateData(TRUE)) return; // 校验没过留在对话框 CDialog::OnOK(); }这个小判断能省掉不少用户输入非法值之后的诡异行为。4.3 DDX不是万能的该手写还得手写DDX只覆盖了最常见的几种类型映射int、double、CString、BOOL以及若干控件类。像自定义数据类型、格式化显示的数值比如带单位的9600 bpsDDX就不管了得你自己在OnInitDialog里SetWindowText在OnOK前GetWindowText再解析。我做过一个流量计上位机读取的频率值日志显示格式是12.34 Hz编辑框里用户要填纯数字那就是DDX管不了的场景老老实实手动转换。5. 消息映射与控件交互按钮、组合框和List Control5.1 从按钮点击到消息处理函数对话框类里的消息处理靠的是消息映射宏。双击一个按钮向导会生成BEGIN_MESSAGE_MAP(CSerialConfigDlg, CDialogEx) ON_BN_CLICKED(IDC_BTN_OPEN, CSerialConfigDlg::OnBnClickedBtnOpen) END_MESSAGE_MAP()ON_BN_CLICKED对应按钮点击ON_CBN_SELCHANGE对应组合框选项改变ON_EN_CHANGE对应编辑框内容变化。这里有个高频坑如果你手动在资源编辑器里复制了一个控件结果忘了改ID新控件的ID和老控件一样那么消息映射里就出现两个同ID的处理项编译能过但运行时行为诡异。资源编辑器和代码不同步的时候CtrlShiftX打开类向导重新绑定一下比手改宏靠谱。组合框Combo Box的初始化也是高频需求。常见的做法是在OnInitDialog里AddString后调SetCurSel(0)选中第一项。注意AddString返回的是索引如果你添加的是排序过的下拉框用InsertString按位置插更可控。5.2 List Control第一列对齐为什么设了没反应这是被问得最多的一个问题之一vs2010 mfc list控件 第一行设置lvcfmt_left无效果。拆开看它其实包含两层误解。第一层LVCFMT_LEFT本身就是左对齐也是默认值你设它等于没设。如果你真正想要的是居中或右对齐应该用LVCFMT_CENTER或LVCFMT_RIGHT。所以设置LVCFMT_LEFT无效果这句描述很多时候是描述者没意识到自己设的恰好是默认值。第二层也是真正要命的Report视图下第一列列索引0的对齐在老旧版本的comctl32里是被忽略的。列索引0的文本绘制逻辑早期由List Control内部接管LVCFMT_RIGHT和LVCFMT_CENTER对第一列不生效——注意这说的是非左对齐不生效影响的是你想让第一列居中/右对齐的场景。要让它生效需要保证程序加载的是comctl32的6.0版本也就是在工程里加一个清单manifest依赖启用视觉样式。// 插入列第二列开始想怎么对齐都行 m_lstPorts.InsertColumn(0, _T(端口), LVCFMT_LEFT, 120); m_lstPorts.InsertColumn(1, _T(状态), LVCFMT_CENTER, 80);另外还有一种情况第一行如果指的是**第一个列表项Item**而不是第一列Column那问题就更简单了LVCFMT_*是列级别的属性行Item本身没有对齐概念。你没法让某一行单独左对齐而另一行右对齐除非自绘。这是概念层面的误解理清了就不会再纠结。5.3 自绘从彩色方块理解控件绘制想在对话框里画个彩色方块或者给控件做自定义外观就得碰自绘。最简单的做法是给对话框加OnPaint拿到CPaintDC直接画void CMyDlg::OnPaint() { CPaintDC dc(this); CRect rc(20, 20, 120, 120); CBrush brush(RGB(255, 128, 0)); dc.FillRect(rc, brush); dc.Rectangle(rc); // 描个边 }CPaintDC在构造时自动调用BeginPaint析构时调用EndPaint对应WM_PAINT的标准处理流程。注意别在里面做耗时计算WM_PAINT可能被频繁触发。如果方块要随数据变化把绘制逻辑抽成函数数值变了就InvalidateRect对应的矩形区域别整个窗口重画。我在一个仪表项目里因为整窗重绘数据刷新快了之后CPU占用飙升改成局部重绘才压下来。6. 实战避坑显示不全、拉伸、类型转换与工程配置6.1 对话框显示不全的排查思路惠普扫描保存完对话框显示不全这类问题本质是对话框尺寸和控件布局在特定环境下的失配。排查按这个顺序走先看是不是DLU和DPI的锅。把对话框在100%和125%缩放下各截一张图对比如果只是缩放出的问题就是DPI适配。再看是不是控件实际超出了对话框客户区——资源编辑器里放得下不代表代码运行时放得下尤其是动态添加控件或者文本被本地化拉长之后。还有一种对话框本身没设可调整大小但内容需要滚动那就得加WS_VSCROLL或者用可滚动容器。排查的时候可以用一个笨办法在OnInitDialog里把所有控件的GetWindowRect打印出来和对话框客户区大小一对比谁超出了一眼就能看出来。CRect rcDlg; GetClientRect(rcDlg); for (CWnd* p GetWindow(GW_CHILD); p; p p-GetNextWindow()) { CRect rc; p-GetWindowRect(rc); ScreenToClient(rc); TRACE(_T(ctrl %d: %d,%d-%d,%d\n), p-GetDlgCtrlID(), rc.left, rc.top, rc.right, rc.bottom); }6.2 让对话框支持拉伸的正确姿势默认对话框是固定大小的。想拉伸Diaolg属性里把Border设成Resizing或者代码里加WS_THICKFRAME。但光能拉伸没用拉伸之后控件不动空空一片。要做得体面得在OnSize里重新布局void CSerialConfigDlg::OnSize(UINT nType, int cx, int cy) { CDialogEx::OnSize(nType, cx, cy); if (!::IsWindow(m_lstPorts.GetSafeHwnd())) return; // OnSize可能在控件创建前被调一次 // 让列表控件跟着右下角走 m_lstPorts.MoveWindow(cx - 220, 40, 200, cy - 80); }这里那个IsWindow判断非常关键。对话框创建过程中OnSize可能在控件还没建好时就被调用一次此时去操作控件会崩。加上这个判断是最省心的防崩手段。6.3 CString与类型转换那些让人头大的细节MFC项目里字符串类型转换的坑基本都能归到Unicode和ANSI的差异上。_T(...)宏在Unicode编译下展开成L...ANSI下就是普通字符串。如果你把一个CString直接传给需要const char*的接口Unicode工程下会编译报错。现代写法是用CStringA/CStringW显式区分或者用CW2A/CA2W转换宏CString str _T(9600); CStringA strA(str); // 明确转成ANSI const char* p strA.GetString();反过来很多纯Win32的API比如网络相关的getaddrinfo它的pNodeName参数就是PCSTR需要ANSI字符串这时候就从CStringW转过去。转换本身不难难的是别在热路径里反复转——CStringA每次构造都可能涉及内存分配和数据拷贝循环里转就慢了。6.4 控制台程序想用MFC怎么办有时候你有个现成的控制台工程想复用一段MFC代码。做法是项目属性 - 配置属性 - 常规 - 使用MFC改成在共享DLL中使用MFC或在静态库中使用MFC然后在源文件开头#include afxwin.h。加了之后入口点会从普通main变成需要AfxWinInit或者改写成_tWinMain因为MFC需要初始化它自己的运行时。这里最容易出的错是链接错误通常是使用MFC的选项和运行库/MT、/MD不匹配导致的把两边调一致就行。顺带提一句如果你的对话框应用里要调getaddrinfo做网络通信千万别放在UI线程直接调——它可能阻塞界面直接卡死。丢到工作线程里跑结果通过PostMessage回传给对话框更新这是保证界面流畅的基本纪律。这一点无论是工业采集还是普通工具软件都一样。6.5 一个被低估的小技巧善用类向导而不是手写映射最后分享一个我踩过坑之后的习惯。早期我喜欢手写消息映射和DDX代码觉得快。后来发现手写最容易在资源ID改了但代码没同步这种事上翻车——编译通过运行时不响应排查半天。现在我的做法是资源有任何改动都用CtrlShiftX打开类向导重新走一遍绑定让工具帮我把ID和函数名对齐。多花的那点时间远少于事后debug的时间。尤其是团队协作的时候别人改了资源你不知道类向导至少能保证绑定关系是当前有效的。对话框这块东西说到底不复杂但细节多。它不像某些框架那样能傻瓜化到底MFC把控制权留给你同时也把责任留给你。把资源、生命周期、数据交换、消息映射这四条线理清楚剩下的基本都是查文档能解决的活儿。真正拉开差距的往往就是那些文档里不写、只有踩过才知道的小地方——比如OnSize里的那个IsWindow判断比如非模态的delete this模式。这些用一次就记住以后写对话框会顺手很多。
返回列表