VC++2010 Tab Control控件实战:从页签创建到子页面切换
简介面向Windows C开发者的一份VC2010示例工程重点演示Tab Control选项卡控件的创建、消息映射与多页面切换同时附带DLL注入模式的外挂框架适合研究MFC对话框程序、界面分页设计及进程扩展技术的读者。压缩包共55个文件体量约174KB内含头文件、C源文件、资源脚本、图标、解决方案文件等其中头文件与源文件构成工程核心逻辑资源脚本和图标负责界面外观解决方案文件则便于在VS2010中一键加载。工程中以多个对话框类作为不同选项卡的页面载体并配有公共变量模块与辅助文本说明可直接在Visual Studio 2010中编译运行直观对比各页面的交互逻辑DLL注入部分包含注入模块的调用入口与接口设计可辅助理解跨进程代码注入的基本实现思路。压缩包内还保留了两套相对独立的界面演示与注入框架模块方便分别研读。已有258人学习/浏览适合想快速上手Tab Control控件、同时对外挂框架实现感兴趣的初学者或进阶Windows开发者。1. Tab Control控件演示源码VC2010先把页签和子窗口联动跑通Tab Control控件演示源码VC2010名字听着像入门教程实际拿到手的开发者多半已经在MFC项目里撞过墙——不是不会拖控件而是页签选中“基本设置”时底下那块区域怎么变出对应子页面切到“高级设置”时前一个页面又该怎么退场。这份VC2010工程演示的正是最常用的一套做法主对话框挂一个Tab Control三个子对话框对应三个页签用TCN_SELCHANGE通知驱动ShowWindow显隐。适合正在用MFC写配置界面的人或者接手老工程要在设置窗口里加页签导航的开发者。跑通之后替换页面、调整样式都有章可循。2. 先搞懂三个基础点CTabCtrl职责范围、切换通知、焦点边界CTabCtrl在MFC里的角色容易被高估——它本质上只负责画一排页签头外加记录当前选中的是哪一项页签对应的“页面内容”它一概不管。动手改Demo前这个边界必须刻在脑子里整个流程是Tab Control当底盘子对话框贴着内容区显隐。理解错这一层后面所有代码都会绕弯这也是为什么有些人把InsertItem和Create写在同一个函数里却一直调不对位置。2.1 CTabCtrl的创建资源编辑器拖完之后要补的三段代码VC2010里最快的起步方式是从工具箱把Tab Control拖到对话框资源上类向导会自动生成一个CTabCtrl成员变量默认ID通常是IDC_TAB1。但拖完直接编译你会发现控件是空的——页签文字、页签数量、初始选中项、子页面摆放区域全都要在OnInitDialog里用代码补。第一段是插入页签用InsertItem// 主对话框 OnInitDialog 中 m_tab.InsertItem(0, _T(基本设置)); m_tab.InsertItem(1, _T(高级设置)); m_tab.InsertItem(2, _T(关于));这个重载最常用第一个参数是索引从0开始后续GetCurSel拿到的返回值按这个顺序解释第二个参数是页签文字可以是字符串字面量也可以是CString。索引一旦定下不要中途改因为后面所有子页的显隐判断都依赖这个索引。如果页签要带图标就得换TCITEM结构体版本TCITEM tcItem {0}; tcItem.mask TCIF_TEXT | TCIF_IMAGE; tcItem.pszText _T(基本设置); tcItem.iImage 0; m_tab.InsertItem(0, tcItem);mask字段必须显式声明你用了哪些成员少写一个TCIF_IMAGEiImage就会被忽略。演示源码如果不需要图标第一种写法就够。第二段是设置初始选中项第三段是拿内容区矩形m_tab.SetCurSel(0); // 默认停在第一页 CRect rcTab; m_tab.GetClientRect(rcTab); // 先拿整个控件客户区 m_tab.AdjustRect(FALSE, rcTab); // FALSE把控件矩形换算成内容矩形AdjustRect的bLarger参数是第一个容易看反的地方FALSE时传入的是整个Tab Control的矩形函数把它换算成页签头下方的内容区TRUE则反过来传入内容区得到整个控件矩形。后面子对话框MoveWindow用的是换算后的rcTab。这里还牵扯到一个选择子对话框的父窗口传谁。常见做法是Create(IDD_PAGE1, m_tab)把Tab控件当父窗口这样AdjustRect算出来的rcTab和子页的坐标是同一个体系MoveWindow(rcTab)直接落位。如果父窗口传this就得先把rcTab从Tab客户区换算到主对话框客户区多两步坐标系换算不值当。另外如果你不是在资源编辑器里拖Tab而是想动态Create一个Tab Control那就要用CreateEx并手工指定WS_CHILD | WS_VISIBLE这些样式组合。动态创建时类向导帮不上忙所有样式位都要自己把握TCS_FIXEDWIDTH一旦加上页签宽度就由控件自行均分不再以文字长度为准。把Demo改成动态创建时这两行差异最容易丢。2.2 TCN_SELCHANGE页签切换的唯一入口页签被点击后CTabCtrl向父窗口发送WM_NOTIFY通知通知码TCN_SELCHANGE。VC2010类向导里选中IDC_TAB1添加通知消息自动生成类似OnTcnSelchangeTab1(NMHDR* pNMHDR, LRESULT* pResult)的函数。这个函数是整个页签联动的入口比在PreTranslateMessage里手动判断点击位置可靠得多。需要特别强调的是TCN_SELCHANGE是“已经切换完成”的通知此时调用GetCurSel拿到的是新索引。有些新手以为它和按钮的BN_CLICKED一样“点了之后告诉你去读状态”概念上差不多但时序上它是切换完成之后才发的千万别在它之前调GetCurSelvoid CSettingsDlg::OnTcnSelchangeTab1(NMHDR* pNMHDR, LRESULT* pResult) { int nSel m_tab.GetCurSel(); // 切换完成后这里就是新索引 m_pageBasic.ShowWindow(nSel 0 ? SW_SHOW : SW_HIDE); m_pageAdv.ShowWindow(nSel 1 ? SW_SHOW : SW_HIDE); m_pageAbout.ShowWindow(nSel 2 ? SW_SHOW : SW_HIDE); *pResult 0; }还有一个兄弟通知TCN_SELCHANGING发生在切换动作之前处理函数返回TRUE可以阻止切换。复杂界面会用它在“当前页有未保存修改”时拦截跳转演示源码一般不涉及但你可以知道它存在。如果你不是拖资源而是动态创建Tab控件类向导生成的消息映射就不存在了必须在主对话框头文件和实现文件里手动补// 头文件 afx_msg void OnTabSelChange(NMHDR* pNMHDR, LRESULT* pResult); // 实现文件消息映射 ON_NOTIFY(TCN_SELCHANGE, IDC_TAB1, CSettingsDlg::OnTabSelChange)否则页签切了毫无反应。这也是很多人把Demo里的代码抄走之后发现不生效的最常见原因——资源编辑器自动生成的映射换到动态创建场景就不灵了。再说一个消息反射的细节TCN_SELCHANGE不会反射到子页它始终发给父窗口。如果你试图在子页里拦截这个通知方向就错了。正确做法是在主对话框处理完之后由主对话框决定子页要不要收到“变成前台页”的调用。有些Demo会在切换函数里直接调用子页的OnActivated之类的公开方法这种设计比ShowWindow之后什么都不做要好至少子页知道自己什么时候拿到了激活权。2.3 WS_EX_CONTROLPARENT子页签里按Tab键焦点为什么跑丢这是初用Tab Control最典型的翻车现场切到子页面后鼠标点进编辑框按Tab键焦点不是跳到下一个控件而是直接跳主对话框的“确定”按钮。原因不在CTabCtrl而在子对话框的扩展样式。MFC对话框默认不带WS_EX_CONTROLPARENT这个样式告诉Dialog Manager这个窗口是子控件的容器允许在其范围内继续进行Tab键遍历。少了它子页里面的控件就像不存在一样焦点直接穿透到父级。// 子对话框如 CPageBasic的 OnInitDialog 里 BOOL CPageBasic::OnInitDialog() { CDialogEx::OnInitDialog(); ModifyStyleEx(0, WS_EX_CONTROLPARENT); return TRUE; }ModifyStyleEx第一个参数是去掉的样式第二个是添加的样式所以这里写成ModifyStyleEx(0, WS_EX_CONTROLPARENT)。加了这行之后Tab键会在子对话框内部循环走到最后一个控件才跳出去。要注意的是这个样式加在子对话框上不是加在主对话框上。有人只改主对话框页签里照样跑丢。判断标准很简单焦点丢失发生时哪个对话框的界面卡住了就是哪个对话框缺这个样式。3. 源码拆解演示工程里最值得抄的三个函数一份典型的VC2010 Tab Control演示源码工程结构不会太复杂。真正值钱的不是那几十行事件处理而是初始化、切换、尺寸适应这三段代码的先后顺序。3.1 工程文件清单与职责划分通常分成两类文件主对话框类和子页对话框类。SettingsDlg.h / SettingsDlg.cpp —— 主设置对话框持有Tab控件和三个子页对象负责页签创建和切换PageBasic.h / PageBasic.cpp —— 页签1子对话框基本设置页PageAdv.h / PageAdv.cpp —— 页签2子对话框高级设置页PageAbout.h / PageAbout.cpp —— 页签3子对话框关于页resource.h / .rc —— 资源描述包含IDD_SETTINGS、IDD_PAGE_BASIC等对话框资源先别急着看代码把这个分工记住主对话框只做三件事——创建页签、计算内容区、切换时显隐子页子对话框只做自己页面的控件布局和业务逻辑。谁也不要越界。很多二次开发的人改着改着就把子页的业务逻辑塞进主对话框的OnTcnSelchange里代码腐化得特别快。3.2 OnInitDialog页签初始化的标准动作序列主对话框的OnInitDialog里四步是固定顺序插入页签、设置初始选中、计算内容区、创建并摆放所有子页。BOOL CSettingsDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 1. 插入页签 m_tab.InsertItem(0, _T(基本设置)); m_tab.InsertItem(1, _T(高级设置)); m_tab.InsertItem(2, _T(关于)); // 2. 初始选中第一页 m_tab.SetCurSel(0); // 3. 计算内容区 CRect rcTab; m_tab.GetClientRect(rcTab); m_tab.AdjustRect(FALSE, rcTab); // 4. 创建并摆放子页 m_pageBasic.Create(IDD_PAGE_BASIC, m_tab); m_pageBasic.MoveWindow(rcTab); m_pageBasic.ShowWindow(SW_SHOW); m_pageAdv.Create(IDD_PAGE_ADV, m_tab); m_pageAdv.MoveWindow(rcTab); m_pageAdv.ShowWindow(SW_HIDE); m_pageAbout.Create(IDD_PAGE_ABOUT, m_tab); m_pageAbout.MoveWindow(rcTab); m_pageAbout.ShowWindow(SW_HIDE); return TRUE; }四个步骤的顺序别乱尤其是AdjustRect必须在GetClientRect之后Create必须在AdjustRect之后。有人把Create放前面拿到的rcTab还带着旧状态子页位置会整体偏移一个页签头的高度表现出来就是子页的上边缘被页签条盖住一截。Create的第二个参数m_tab值得多说一句父窗口传给Tab控件子页坐标直接使用Tab客户区坐标系和AdjustRect算出来的内容区完全匹配。如果父窗口传this同样一个rcTab坐标含义就变成主对话框的客户区了子页会歪到右下角。3.3 OnTcnselchangeTab显隐切换的核心逻辑void CSettingsDlg::OnTcnSelchangeTab1(NMHDR* pNMHDR, LRESULT* pResult) { int nSel m_tab.GetCurSel(); m_pageBasic.ShowWindow(nSel 0 ? SW_SHOW : SW_HIDE); m_pageAdv.ShowWindow(nSel 1 ? SW_SHOW : SW_HIDE); m_pageAbout.ShowWindow(nSel 2 ? SW_SHOW : SW_HIDE); *pResult 0; }这段逻辑的理解门槛在两处一是GetCurSel的返回值必须在TCN_SELCHANGE之后取二是三个ShowWindow调用必须把所有子页状态都刷新不能只操作当前页。漏掉任何一行就会出现“切走之后旧页面还露着半截”的怪相。这里有个性能细节值得说三个ShowWindow每次都执行即使页签没变——比如用户点了当前页签的头部TCN_SELCHANGE一样会发GetCurSel返回的还是当前索引。三个ShowWindow等于把当前页隐藏再显示了一遍对配置界面来说开销可以忽略但如果你在子页的OnShowWindow里挂了重量级操作最好在切换函数最前面加个判重if (nSel m_nLastSel) return; m_nLastSel nSel;这个判断还能拦住一种边界情况初始化阶段SetCurSel(0)就会触发一次TCN_SELCHANGE那一刻子页对话框还没Create三个ShowWindow全落在空句柄上。空句柄调用虽然不崩但如果你把业务逻辑写进子页的OnShowWindow就会被这一声假通知白触发一次。判重之后这条多余的路径直接被短路。3.4 OnSize窗口拉伸时子页怎么跟着走主对话框大小被拖动时Tab控件和子页的位置需要跟着重排void CSettingsDlg::OnSize(UINT nType, int cx, int cy) { CDialogEx::OnSize(nType, cx, cy); if (!m_tab.GetSafeHwnd()) return; CRect rcClient; GetClientRect(rcClient); m_tab.MoveWindow(rcClient); CRect rcTab rcClient; m_tab.AdjustRect(FALSE, rcTab); if (m_pageBasic.GetSafeHwnd()) m_pageBasic.MoveWindow(rcTab); if (m_pageAdv.GetSafeHwnd()) m_pageAdv.MoveWindow(rcTab); if (m_pageAbout.GetSafeHwnd()) m_pageAbout.MoveWindow(rcTab); }OnSize在窗口初始化过程中会被多次调用那时Tab控件还没创建完成直接MoveWindow会访问到无效句柄。用GetSafeHwnd()判断一下等于给这段代码上了保险。演示源码里如果不带这个判断调试阶段窗口首次弹出就会偶然崩溃重启一次又好了属于典型的“偶现崩溃”来源。4. 三种子页签实现方案的取舍从预创建到懒加载演示源码通常只演示一种做法但你在实际工程里会面临选型子页到底什么时候Create。这个决定影响启动速度、内存占用和代码结构不是一个可以随手挑的细节。4.1 方案A预创建全部子页OnInitDialog里把三个子页全部Create之后只靠ShowWindow切换。这是第3章代码的做法也是多数Demo默认的。优点切换瞬间没有创建耗时逻辑最直白。缺点所有子页的对话框资源、控件句柄全都在内存里页面一多内存和GDI句柄开销明显。适用场景页签固定且不超过五六个界面不追求极致内存占用。配置对话框基本都符合。4.2 方案B懒加载首次选中才创建把Create挪到首次选中时之后缓存复用。适合页面多、很多页面用户根本不会点到的场景// 主对话框头文件中 CPageBase* m_pPages[3]; // 用基类指针统一管理 BOOL m_bCreated[3]; // 创建标记 UINT m_nPageIDs[3]; // 对话框资源ID表 // OnInitDialog 里初始化数组 m_pPages[0] m_pageBasic; m_pPages[1] m_pageAdv; m_pPages[2] m_pageAbout; ZeroMemory(m_bCreated, sizeof(m_bCreated)); m_bCreated[0] TRUE; // 第一页提前建好保证界面一亮出来就有内容 // 切换处理 void CSettingsDlg::OnTcnSelchangeTab1(NMHDR* pNMHDR, LRESULT* pResult) { int nSel m_tab.GetCurSel(); // 先隐藏所有已经创建过的子页 for (int i 0; i 3; i) { if (m_bCreated[i]) m_pPages[i]-ShowWindow(SW_HIDE); } // 目标页首次打开时才创建 if (!m_bCreated[nSel]) { CRect rcTab; m_tab.GetClientRect(rcTab); m_tab.AdjustRect(FALSE, rcTab); m_pPages[nSel]-Create(m_nPageIDs[nSel], m_tab); m_pPages[nSel]-MoveWindow(rcTab); m_bCreated[nSel] TRUE; } m_pPages[nSel]-ShowWindow(SW_SHOW); *pResult 0; }两个参数要特别注意m_pPages数组元素必须是指针不能是对象数组因为CDialog对象不支持在数组内直接赋值构造m_nPageIDs里存放三个子对话框的资源ID用数组的好处是新增页签只动这两张表不用在切换函数里堆if-else。懒加载有个隐藏收益子页对话框第一次被创建时它的OnInitDialog才执行。这意味着耗时初始化、数据库连接这类操作可以延后到用户真正点到那个页签时才发生整个设置框弹出的速度会明显改善。4.3 方案C每次切换销毁重建老代码里偶尔能见到切走就DestroyWindow切回来再Create。它的好处是内存永不膨胀子页状态每次都是全新的天然满足“关闭设置窗口后所有中间状态清零”的需求。缺点是卡顿和句柄风险。每次切换都要重新创建对话框页面控件多时肉眼可见卡一下而且DestroyWindow之后如果忘了重置m_hWnd下一轮创建会撞上断点。对一般业务界面来说这个方案性价比不高我看过的工程里它多半是从一个写崩了的懒加载版本退化成这样的。4.4 三种方案怎么选方案创建时机首次切换耗时内存/GDI占用编码量推荐度A 预创建OnInitDialog无高低页签少的配置界面B 懒加载缓存首次选中首次略卡中中页签多默认推荐C 销毁重建每次切换每次都卡最低高不推荐我的习惯是页签不超过五个用方案A超过五个用方案B方案C只在子页里全是敏感数据、必须每次重置时才会考虑。还有一个值得注意的实现细节懒加载方案里m_pPages用基类指针前提是三个子对话框都继承自同一个基类比如CPageBase继承CDialogEx。如果三个子页类互不相关C类型上没法用同一个指针数组只能三组独立指针分别判断代码会啰嗦不少。演示源码通常不会提前搭好这个基类因为它是教学代码保留粒度实际工程里我一般第一件事就是抽这个基类把WS_EX_CONTROLPARENT设置、背景擦除这类公共逻辑收进基类三个子页各写各的界面。5. 常见问题避坑焦点、闪烁、尺寸、句柄四个高发雷区这一章列出的四个问题是我见过在Tab Control相关工程里出现频率最高的。每条都按现象、原因、解决的顺序来方便你直接对号入座。5.1 子页里Tab键焦点直接跳主对话框现象切到子页签后光标在子页的编辑框里按Tab键焦点直接跳到主对话框的“确定”按钮上子页中间的其它控件完全被跳过。原因子对话框缺少WS_EX_CONTROLPARENT扩展样式对话框管理器把它当成普通窗口Tab遍历直接穿透到了父级。解决在子对话框的OnInitDialog里调用ModifyStyleEx(0, WS_EX_CONTROLPARENT);注意是子对话框不是主对话框也不是Tab控件。改完重新编译Tab键立刻变正常。5.2 每次切页签界面都闪一下白现象切页签的瞬间内容区整块闪白或者子页控件在闪烁后延迟出现。原因主对话框背景擦除时没有跳过子窗口区域。Windows默认背景擦除是全客户区擦除子页窗口在擦除之后才重绘这个时间差就变成了闪烁。解决主对话框OnInitDialog里加WS_CLIPCHILDRENModifyStyle(0, WS_CLIPCHILDREN);效果是主对话框擦背景时自动跳过被子窗口覆盖的区域。如果加了还闪再检查子对话框的OnEraseBkgnd常见做法是直接返回TRUE阻止擦除前提是子页背景由控件本身绘制。这两招配合基本能把闪烁压到肉眼不可见。5.3 子页面比内容区小一圈或者右下角露白边现象子页不是正好铺满页签内容区而是右下角露出一条边缘或者子页整体向上偏移被页签头盖住一截。原因MoveWindow用的矩形是GetClientRect直接拿到的包含了页签头的高度。内容区其实是从页签头下方开始整矩形直接搬过去子页的下边缘就会超出可视范围。解决MoveWindow之前必须过一遍AdjustRectCRect rcTab; m_tab.GetClientRect(rcTab); m_tab.AdjustRect(FALSE, rcTab); // 关键这里拿到的才是内容区 m_pageBasic.MoveWindow(rcTab);如果AdjustRect的bLarger参数写成TRUE结果就反过来——传入内容区放大成控件矩形子页会缩在左上角。调试这个问题时记住口诀FALSE是拿内容区TRUE是拿整个控件区。5.4 反复切换后崩溃指向Create或GetSafeHwnd现象切页签几十次后程序在某个子页的Create处断言崩溃或者懒加载方案里第二次切回某个页签时直接崩。原因常见两个。一是懒加载的m_bCreated标记位置写错Create还没成功就置了TRUE下一次切换走了复用分支操作一个创建失败的半成品窗口。二是销毁重建方案里DestroyWindow之后m_hWnd没有置空CDialog析构时对悬空句柄再次清理导致双重释放。解决创建标记必须在Create调用成功之后写。销毁之后补一句m_pageAdv.DestroyWindow(); m_pageAdv.m_hWnd NULL; // 重置句柄避免悬空再一次被清理另外反复切换崩溃还有一个容易被忽略的来源如果子页里做了OnShowWindow反射处理别在这个处理函数里调用主对话框的成员否则主对话框先析构、子页后析构的顺序会报复你。6. 进阶收尾自绘页签和切换日志验证6.1 自绘页签TCS_OWNERDRAWFIXED如果觉得系统页签样式太单调可以在Tab控件上追加TCS_OWNERDRAWFIXED样式然后重写DrawItem。DrawItem是CWnd的虚函数自绘Tab的WM_DRAWITEM会路由到这里。void CSettingsDlg::DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct) { // 先按选中状态画背景再画文字 // 常见做法FillRect 之后用 DrawText 输出页签标题 }自绘之后页签头的高度、宽度、文字对齐都要自己维护TCS_FIXEDWIDTH可以配合固定宽度使用。第一次自绘建议只改选中页签的背景色其余保持系统绘制降低实现复杂度。6.2 键盘方向键切换与验证CTabCtrl获得焦点后左右方向键默认能切换页签但如果你给Tab控件加了TCS_BUTTONS样式键盘行为会变。处理办法是收到VK_LEFT和VK_RIGHT时手动SetCurSel并确保同步触发显隐逻辑。验证切换逻辑最可靠的方法不是靠肉眼盯界面而是给子页加一个显示计数void CPageBasic::OnShowWindow(BOOL bShow, UINT nStatus) { CDialogEx::OnShowWindow(bShow, nStatus); if (bShow) { CString strLog; strLog.Format(_T([PageBasic] 显示第 %d 次\n), m_nShowCount); OutputDebugString(strLog); // 用调试输出不要用 MessageBox } }用OutputDebugString而不是MessageBox是血泪教训MessageBox会在OnShowWindow的调用链里重新进入消息循环让页签状态在弹窗期间发生二次变化调试时看到的索引永远滞后一步。输出到调试器再配合GetCurSel对比新旧索引哪一次切换走了哪条分支一目了然。去年做四页签的配置面板我曾在切换函数里放MessageBox调试结果消息框一弹页签其实已经切完了点掉之后GetCurSel拿到的又是新索引整个下午都在跟自己的调试代码打架。从那以后每次改页签切换逻辑我都强制走一遍OutputDebugString记日志、GetCurSel对比前后索引、再ShowWindow三步缺一不可。这套习惯帮我挡住过好几次隐蔽的索引错位希望帮到你。本文还有配套的精品资源点击获取