简介:这是一份面向Windows开发者的VC++2010源码示例工程,由两个相互关联的对话框程序构成,集中演示Tab Control控件的多页签搭建与切换。工程内同时包含DLL注入模式外挂框架的参考实现,展示如何把功能模块以动态库形式注入目标进程,适合掌握C++基础、对Win32界面控件和进程注入技术感兴趣的读者深入学习。压缩包共55个文件,核心为14个cpp源文件和20个h头文件,并配有rc资源脚本、ico图标、VS工程配置等辅助文件,整体仅174KB,代码规模适合快速通读。已有258人学习浏览。解压后用VS2010打开解决方案文件,可查看基于MFC对话框的多页签界面:Dlg1至Dlg4分别对应不同选项卡,完整包含控件初始化、页签切换、数据交互等逻辑;wulin模块则展示了DLL注入框架的工程组织方式,便于对照界面操作与后台处理流程,也可作为自研Tab式工具窗口或注入框架的起点。 不知道你有没有遇到过这种需求:主窗口左边要放一块实时曲线区,右边放一组参数配置页,用户点不同的选项卡在“基本参数”“警告设置”“串口配置”之间来回切。很多人第一反应是直接用CPropertySheet,结果弹出来一个独立的模态对话框,怎么也嵌不进主窗口。这种场景下,Tab Control才是正解。我最近用VC++2010整理一批老的MFC上位机工程,顺手把Tab Control控件的挂载逻辑、切换处理、焦点细节全部过了一遍,整理成一套完整可跑的演示源码。这篇就围绕这套源码来拆,适合正在学MFC、或者像我一样要长期维护老工程的朋友。
1. Tab Control到底适合干什么:先别急着写代码
1.1 Tab Control和CPropertySheet的使用边界
Tab Control在MFC里属于通用控件,本质就是一组标签按钮加一个容器区域。它本身不管理页面生命周期,所有“页面的创建、显示、隐藏、销毁”都要由程序自己控制。这一点和CPropertySheet完全不同。
CPropertySheet是高层封装,内部用属性页机制管理,调用DoModal弹出一个带标签栏的模态对话框。它省事,但代价是它是个独立的顶层窗口,没法把属性页嵌进主窗体的某个区域。比如你要做一个设备调试面板,左边是波形显示、右边是参数配置页,这种布局CPropertySheet根本做不了。Tab Control没有这个限制,它可以作为普通控件被放在任意位置,子对话框也只当成子窗口贴进去,能和旁边其他控件自由共存。
所以选型其实很简单:能接受弹出窗口,用CPropertySheet,开发速度最快;必须内嵌到主界面,就老老实实用Tab Control。我见过不少人非要用CPropertySheet做嵌入,结果在父子窗口关系、样式互斥上折腾两天,最后还是要推翻重来。不如一开始就选对。
1.2 演示工程里我用它做了什么
这套演示源码的规划很克制,没有堆花活。主对话框上只放一个Tab Control和几个占位说明文字,Tab里挂两个子页面:一个叫“基本参数”,放几个编辑框和下拉框,模拟设备参数录入;另一个叫“警告设置”,放若干复选框和静态文本,模拟告警阈值配置。
这样的规划是为了让读者把精力集中在“Tab Control怎么玩”这件事上,而不是被业务逻辑干扰。工程结构也简单清晰:主对话框类是CTabDemoDlg,两个子页面类分别是CBasicPage和CAlarmPage,全部继承CDialogEx。源码分包组织,头文件、源文件、资源文件分离,后续要加新页面,照葫芦画瓢就行。
2. 工程初始化里没做对这些,后面全是白忙活
2.1 新建工程和放置控件的几个细节
打开VC++2010,新建MFC应用程序,项目类型选“基于对话框”,语言选中文,完成。工程默认会生成一个CMainDlg类和一个主对话框资源。这些步骤都很常规,但有三个细节我建议在一开始就做掉,不然中途会难受。
第一个细节:确认工程字符集是Unicode。在项目属性->常规->字符集里设置,如果工程是“未设置”或多字节,后面InsertItem传_T字符串容易出乱码,我还是习惯一开始就统一到Unicode。第二个细节:给Tab Control单独起一个语义明确的ID,比如IDC_TAB_MAIN,不要留着默认IDC_TAB1到处飞,等代码写多了,一看ID就知道是哪个控件。第三个细节:在资源编辑器里把Tab Control拉到合适大小,别指望后面全靠代码重新框位置,先拉个大概能省不少事。
然后右键Tab Control,选择“添加变量”,变量名m_tabMain,类别Control,类型CTabCtrl,类向导会自动生成关联代码。CTabCtrl是MFC对Windows Tab Control的标准封装,几乎所有操作都通过这个对象完成。
2.2 OnInitDialog里必须先做的两件事
OnInitDialog是对话框初始化入口。Tab Control的初始化逻辑我建议全放在里面,按固定顺序做两件事:插入标签项和设置默认选中项。
插入标签用InsertItem,原型是int InsertItem(int nItem, LPCTSTR lpszItem),按页签顺序逐个插入。比如演示源码里这样写的:
BOOL CTabDemoDlg::OnInitDialog() { CDialogEx::OnInitDialog(); m_tabMain.InsertItem(0, _T("基本参数")); m_tabMain.InsertItem(1, _T("警告设置")); m_tabMain.SetCurSel(0); // 后面子对话框的创建挂载逻辑也在这里 return TRUE; }SetCurSel(0)这一行特别容易漏。不调用SetCurSel,Tab Control虽然显示了两个标签,但当前没有选中任何一项,GetCurSel()会返回-1,后面所有依赖当前选中索引的切换逻辑全会错乱。这几乎是Tab Control演示工程新手必踩的一个坑。
还有一个隐藏问题:如果标签很多,或者标签文本需要动态变化,建议把InsertItem集中放在OnInitDialog里,不要分散到各个子页面的初始化函数中,不然调试时很难追踪标签从哪里来的。
3. 子对话框挂载:把页面“安装”进Tab客户区
3.1 子对话框资源属性设置:这几项必须改
Tab Control本身不提供页面功能,所谓的内容页,实际是子对话框。你需要在资源编辑器里插入两个Dialog,然后生成对应的对话框类。但插进来之后,子对话框的默认属性是顶层可移动的弹窗,如果不改属性,它创建出来会飞在主窗口外面。这就是很多演示源码第一次跑起来最让人懵的问题。
选中子对话框资源,在属性栏里必须改三项:Style改成Child,Border改成None,Visible建议保持勾选或取消都行(反正创建后会用ShowWindow统一控制)。Style=Child意味着这个窗口被创建为子窗口,由父窗口决定位置和显示;Border=None去掉对话框的标题栏和边框,让它看起来是一个干净的页面。这两项不改,子窗口就不会“贴”在Tab Control里,而是变成一个带标题栏的独立窗口,肉眼可见地失败。
生成类也简单:在子对话框资源上右键,选择“添加类”,类名填CBasicPage,基类选CDialogEx,完成。VC++2010会自动生成头文件和源文件。这里要注意,不要勾选“创建可停靠窗格”之类的选项,我们需要的是一个最普通的对话框类。
3.2 Create、MoveWindow、ShowWindow的标准连贯操作
子对话框类生成好之后,要在主对话框类头文件里声明两个成员对象:CBasicPage m_basicPage; 和CAlarmPage m_alarmPage;。注意用对象成员而不是指针,这样生命周期跟着主对话框走,程序退出时自动析构,省去手动delete的麻烦。
然后回到OnInitDialog,在InsertItem之后,补充创建逻辑。核心代码如下:
CRect rcTab; m_tabMain.GetWindowRect(&rcTab); ScreenToClient(&rcTab); m_tabMain.AdjustRect(FALSE, &rcTab); m_basicPage.Create(IDD_DIALOG_BASIC, &m_tabMain); m_basicPage.MoveWindow(&rcTab); m_basicPage.ShowWindow(SW_SHOW); m_alarmPage.Create(IDD_DIALOG_ALARM, &m_tabMain); m_alarmPage.MoveWindow(&rcTab); m_alarmPage.ShowWindow(SW_HIDE);这段代码的关键是计算客户区矩形。AdjustRect(FALSE, &rcTab)这行要重点讲:它把rcTab从“Tab Control的窗口矩形”转换成“去掉标签栏后真正用来显示内容的客户区矩形”。如果这里直接拿GetClientRect的结果去MoveWindow,你会发现子页面区域会偏小,底部空出一截标签栏高度的空白。因为GetClientRect返回的是整个控件客户区,标签栏也占着这个区域,必须再通过AdjustRect做一次修正。
Create的第二个参数传&m_tabMain,这个父窗口指针是必须的。跳过MoveWindow直接ShowWindow也能显示,但子页面默认位置会在(0,0),正好被标签栏盖住,表现为“页面怎么也看不见”。所以MoveWindow一起做掉比较靠谱。
4. TCN_SELCHANGE切换逻辑与焦点细节
4.1 用类向导还是手写消息映射
Tab Control在用户点击标签的时候,会向父窗口发送WM_NOTIFY通知码TCN_SELCHANGE。处理这个通知,就用ON_NOTIFY宏固定拦下来。VC++2010的类向导操作方法是:在主对话框上选中Tab Control,右键“添加事件处理程序”,消息类型选TCN_SELCHANGE,函数名默认OnTcnSelchangeTabMain,完成后类向导自动在消息映射表中添加一行:
ON_NOTIFY(TCN_SELCHANGE, IDC_TAB_MAIN, &CTabDemoDlg::OnTcnSelchangeTabMain)这个方法最不容易出错。如果非要在代码里手写消息映射,格式一模一样,但手写顺序错了,比如把IDC_TAB_MAIN写成别的控件的ID,通知就不会进到这个函数,表现出来是点击标签完全没有反应。我在演示源码里用的是类向导生成,降低阅读门槛。
4.2 切换处理的核心思路:先取得当前选中页,再做页面显隐
处理TCN_SELCHANGE时,Tab Control已经把内部选中状态更新了,所以直接GetCurSel()拿到当前标签索引即可。然后按索引切换子页面的显示和隐藏。演示源码里的处理方式如下:
void CTabDemoDlg::OnTcnSelchangeTabMain(NMHDR *pNMHDR, LRESULT *pResult) { int nCur = m_tabMain.GetCurSel(); m_basicPage.ShowWindow(nCur == 0 ? SW_SHOW : SW_HIDE); m_alarmPage.ShowWindow(nCur == 1 ? SW_SHOW : SW_HIDE); *pResult = 0; }用三元运算符一行搞定,逻辑很干净。如果页面超过两个,sinwi不建议继续叠加三元,改用switch或者映射表更清晰。
这里有一个很多人会问的问题:要不要在切换时调用SetFocus把焦点放进子页面?我建议处理一下。演示源码里页面上的编辑框较多,点击标签切到新页面后,如果焦点还留在Tab Control上,用户按Tab键不会进入页面的控件,体验很怪异。基本做法是切换后用GetDlgItem取到当前子页面上第一个要获得焦点的控件,然后调用SetFocus,比如:
if (nCur == 0) { m_basicPage.GetDlgItem(IDC_EDIT_NAME)->SetFocus(); }如果不做这步,大部分情况下影响不大,但有键盘操作习惯的使用者会明显感到不顺手。
另外说一个进阶点:如果要在切换之前做一些校验拦截,比如当前页面有未保存数据时禁止离开,需要处理的是TCN_SELCHANGING通知而不是TCN_SELCHANGE。TCN_SELCHANGING在选中项改变之前触发,处理函数里可以对切换合法性做判断,不合法就返回,阻止切换。演示源码没有展开这层逻辑,但接口是同一个思路,按ON_NOTIFY增加一个处理函数就行。
5. 实际调试中反复出现的几个“页面不显示”类问题
5.1 页面变成独立顶层窗口,压根没嵌进Tab
这是最常见的现象:一运行,弹出的小对话框带着标题栏,左上角出现在屏幕上,而Tab控件区域却一片空白。出现这个现象基本是子对话框资源的Style属性没有设成Child,或者根本没有设。新建对话框默认Style是Popup,这种窗口一Create就作为顶层窗口存在。改法很简单:回到资源编辑器,选中子对话框属性,把Style从Popup改成Child,再把Border从Dialog Frame改成None,重新编译运行。
5.2 页面显示出来,但位置偏了半个标签栏高度
如果你看到页面区域整体往下偏移,顶部被标签栏挡住一截,问题几乎都出在MoveWindow之前没有用AdjustRect修正矩形。我之前反复强调的AdjustRect(FALSE, &rcTab)就是干这个的。有人会写出GetClientRect之后直接MoveWindow的代码,这在标签栏高度不算特别大时看着还行,但标签栏一旦加高或者加图标,底部就会多出一块空白,页面和标签栏还会重叠。建议老老实实GetWindowRect + ScreenToClient + AdjustRect三步走。
另外一个相关问题是子页面尺寸不对导致控件被裁掉一半。这种情况通常是MoveWindow的宽高参数写死导致的,比如直接传500x300,没跟Tab客户区对齐。用AdjustRect算出来的矩形,天然就是客户区的实际尺寸,不必关心具体数值。
5.3 点击页面上的按钮第一次没反应,第二次才触发
这个坑比较隐蔽。我调试时遇到过,第一次点击子页面上的按钮完全没反应,再用鼠标点一下才触发,像是控件响应慢了半拍。排查到最后,原因是切换页面时我用了SetWindowPos调整Z序,把主对话框上某个覆盖层窗口顶到了子页面之上,第一次点击实际上点到的是覆盖层,第二次点击穿透才点到按钮。
解决方法很简单:切换页面后调用BringWindowToTop把子页面窗口提到前台,或者干脆不要引入多余的覆盖窗口。如果子页面上存在特殊自定义控件、或者将来要做Page内的复杂动画,别忘检查Z序。
5.4 程序退出时崩溃,或者反复切换后内存不断增长
崩溃一般在主对话框关闭时发生,原因是子页面对象被重复创建或者手动释放了两次。演示源码里用对象成员来管理子页面,主对话框析构会自然销毁子页面对象,不需要在OnDestroy里额外delete。如果你用指针new出来的页面,一定要确保在主对话框销毁之前的时机Delete正常执行,否则容易二次释放。
内存增长则多和动态Create有关。如果切换页面时每次都新建子页面、再DestroyWindow,忘了删除旧页面,内存就会一路涨上去。我的建议是最简单的场景里坚持“预创建全部页面、切换只改显隐”的策略,这套演示就是这种实现,稳定且思路清楚。
如果页面数量实在多到预创建浪费资源,再做动态创建也不迟。到那时需要维护一套页面管理结构,按需Create、按需Destroy,配合缓存策略保证不会反复创建。这个属于进阶话题,以后可以单独写一篇。
最后再分享一个经验:页面多了以后,别让主对话框里的切换代码无限膨胀。我一般会给所有子页面定义一个统一的基类,里面放两个虚函数InitPage和OnPageVisible,分别处理页面初始化和页面可见性变化。Tab Control切页时,循环调用当前页的OnPageVisible(true)和其他页的OnPageVisible(false),这样加新页面只需继承基类、实现两个函数,主对话框的逻辑一行都不用改。这套演示源码还是用最直白的页面类写法,方便入门理解,一旦你决定把它应用到实际工程,建议尽早引入这层抽象。
本文还有配套的精品资源,点击获取