简介面向使用 VC2008 与 MFC 开发桌面界面的初中级开发者这份示例围绕 CTabSheet 构建多选项卡窗口帮助理解选项卡对话框从工程搭建、页面组织到消息响应的实现路径。压缩包共 23 个文件含 8 个头文件、6 个源文件以及解决方案与工程配置、可执行文件、资源脚本、图标和说明文本等整体约 200KB目录紧凑适合打开工程对照源码学习。目前已有 307 人学习。内容呈现属性表与选项卡派生类的组织方式、页面标题设置、添加页面、数据交换、初始化及消息映射等关键环节涵盖工程创建、派生类声明、页面切换、控件布局与响应处理便于对照源码理解选项卡页面的注册、显示与交互细节便于快速上手并查漏补缺也能继续扩展按钮响应和复杂窗口布局。1. 从「一堆叠在一起的对话框」说起CTabSheet 在 VC2008 里到底省了什么活在 VC2008 里做一个带页签的配置窗口最直接的做法是拿几十行代码自己算坐标、挨个 ShowWindow 一堆 CDialog但更省事的方案是找一个现成的 CTabSheet 类把 tab 窗口的活儿接过去。它本质是 CTabCtrl 的派生类内部维护一个 CDialog* 数组AddPage 负责登记页、Show 负责统一创建、TCN_SELCHANGE 负责切换主对话框里三行代码就能跑起来。适合手上已经有一批 IDD_ 对话框资源、想直接拼成多页窗口的人不适合页签头要自绘图标、页签要拖拽重排的场景。下面按我实际落地的顺序拆开讲。2. 把 CTabSheet 装进 VC2008 工程三个文件、五处资源属性与一段能跑的主对话框拿到这个类的第一反应往往是双击打开看源码然后复制粘贴到自己的 .cpp 里。这么干十有八九会编译报一堆 ON_NOTIFY_REFLECT 相关的错误因为消息映射宏必须在类声明和实现分离的前提下才能展开。正确姿势是把它当成一个独立类文件来加。2.1 文件怎么进工程以及为什么不选 CPropertySheetCTabSheet 是流传很广的单类实现通常就两个文件TabSheet.h和TabSheet.cpp。在解决方案资源管理器里右键项目 → 添加 → 现有项把两个文件一起加进去然后在用到它的对话框头文件里#include TabSheet.h。放路径这件事有个小讲究。我一般把它们放在工程目录下的3rdparty\tabsheet\全英文、无空格、尽量短。VC2008 的预编译头机制对中文路径和带空格路径偶尔会犯病报的错还特别有迷惑性比如fatal error C1083: 无法打开预编译头文件查半天发现是路径问题。为什么不直接用 MFC 自带的 CPropertySheet / CPropertyPage三个实际理由第一CPropertySheet 本质是个独立弹出窗口想把它嵌到主对话框的某个区域里得改 WS_CHILD 风格、手动 SetParent、再去掉标题栏和按钮区改完比重新写一遍还费劲。第二CPropertyPage 要求页类从 CPropertyPage 派生。项目里往往已经有一堆基于 CDialog 的 IDD 资源类想复用就得改继承关系牵连 DoDataExchange、消息映射、甚至 OnInitDialog 里的初始化顺序。第三CPropertySheet 的页签样式只能整体换做不了「第二页暂时禁用、第三页按条件隐藏」这类细粒度控制。CTabSheet 的思路正好相反它只接管「显示与切换」每一页仍然是普通的 CDialog资源、类、DDX 全都不动。代价是它不管确定/取消按钮、不管向导式流程、不管页签自绘这些都得自己补。2.2 资源视图里五个必须改的属性这一步是整个流程里最容易漏的漏了之后现象千奇百怪但原因都在这张表里。资源属性设成不改会怎样子对话框 IDD_PAGE_*StyleChildCreate 直接返回 FALSE页签里空一片子对话框 IDD_PAGE_*BorderNone子页多一圈系统边框和页签边界错开两三个像素子对话框 IDD_PAGE_*Visible不勾程序一启动所有页同时显示糊成一团子对话框 IDD_PAGE_*Clip children / Clip siblings都勾在子页边缘拖动控件会画到页签区域外面主对话框的 Tab ControlTab stop勾键盘 Tab 键跳不进页签最容易漏的是 Visible。VC2008 的对话框编辑器新建 IDD 时默认勾上它因为默认是按「顶层对话框」的思路给的而页签里的子对话框恰恰不能可见。第一次跑出来满屏控件糊在一起八成就是这一条。还有一个隐性检查每个子对话框类的DoDataExchange里绑定的控件 ID 必须真实存在。绑定了一个被删掉的 IDCCreate 阶段会触发 ASSERT调试版直接断下来发布版静默失败页就是不出来。2.3 主对话框里跑通第一版的最小代码先看头文件。这里最关键的一点是三个页对象必须声明成主对话框的成员变量不能是局部变量也不能是new出来又没人管的东西。// MyDlg.h #pragma once #include TabSheet.h #include PageBasic.h // 三个从 CDialog 派生的页类 #include PageAdvanced.h #include PageAbout.h class CMyDlg : public CDialog { DECLARE_DYNAMIC(CMyDlg) public: CMyDlg(CWnd* pParent NULL); enum { IDD IDD_MY_DIALOG }; protected: virtual void DoDataExchange(CDataExchange* pDX); virtual BOOL OnInitDialog(); // 页签控件本身资源里先摆一个 Tab Control 占位 CTabSheet m_tab; // 三个页对象生命周期必须长于页签控件所以放成员 CPageBasic m_pageBasic; CPageAdvanced m_pageAdvanced; CPageAbout m_pageAbout; DECLARE_MESSAGE_MAP() };再看实现BOOL CMyDlg::OnInitDialog() { CDialog::OnInitDialog(); // 1. 把资源里已有的 Tab Control 接管给 m_tab 对象 // SubclassDlgItem 是「接管已有窗口」不是新建 m_tab.SubclassDlgItem(IDC_TAB_MAIN, this); // 2. 登记三页页签标题、页对象指针、对话框资源 ID m_tab.AddPage(_T(基础设置), m_pageBasic, IDD_PAGE_BASIC); m_tab.AddPage(_T(高级选项), m_pageAdvanced, IDD_PAGE_ADVANCED); m_tab.AddPage(_T(关于), m_pageAbout, IDD_PAGE_ABOUT); // 3. 统一创建并显示第 0 页 m_tab.Show(); return TRUE; }SubclassDlgItem的第二个参数是父窗口这里传this主对话框因为资源里的 Tab Control 就是主对话框的子窗口。如果不想在资源里摆控件也可以m_tab.Create(WS_CHILD | WS_VISIBLE | TCS_TABS, rc, this, IDC_TAB_MAIN)但那样得自己算初始矩形反而更绕。AddPage的第三个参数是对话框模板 IDIDD_开头不是控件 IDIDC_开头。这两个前缀长得像我第一次接这个类的时候就传错了编译能过运行时Create返回 FALSE页签里空着查了很久才发现是参数传错。Show()之前不要调SetCurSel因为此时页窗口还没 CreateShowWindow一个空句柄什么也不会发生也不报错。3. 拆开 CTabSheet 的实现AddPage 记账、Show 建窗、TCN_SELCHANGE 换页能跑起来只是第一步。要改得动、排得了错得知道这三段代码各自在干什么。市面上的 CTabSheet 实现版本略有差异但骨架基本一致。3.1 AddPage 只记账别指望它创建窗口int CTabSheet::AddPage(LPCTSTR lpszCaption, CDialog* pDialog, UINT nID) { // 上限保护超了直接失败而不是覆盖掉已有的页 if (m_nNumOfPages MAXPAGES) { TRACE(_T(CTabSheet: 页数已达上限 %d\n), MAXPAGES); return -1; } // 空指针保护这里传 CDialog 临时对象是经典翻车点 if (pDialog NULL) { ASSERT(FALSE); return -1; } m_pPages[m_nNumOfPages] pDialog; // 页对象指针 m_nID[m_nNumOfPages] nID; // 对话框模板 IDIDD_ 开头 m_sCaption[m_nNumOfPages] lpszCaption; // 页签头文字 return m_nNumOfPages; // 返回登记后的总页数失败返回 -1 }三件事存指针、存模板 ID、存标题。返回值是登记后的总页数调用方可以用它判断有没有加进去。要特别注意的是这个阶段m_pPages[i]只是一个还没创建的 C 对象GetSafeHwnd()返回 NULL。所以任何在AddPage之后、Show之前对页对象调用SetDlgItemText、GetDlgItem、UpdateData的操作都会失败而且失败得很安静——SetDlgItemText对空句柄只是返回 FALSE不会崩数据就那么丢了。这个坑在第 5 章还会展开。3.2 Show 里真正发生的三件事void CTabSheet::Show() { if (m_nNumOfPages 0) return; // ① 逐页创建父窗口传 this页才活在页签控件内部 for (int i 0; i m_nNumOfPages; i) { if (!m_pPages[i]-Create(m_nID[i], this)) { TRACE(_T(CTabSheet: 第 %d 页创建失败检查 IDD 是否为 Child 风格\n), i); continue; // 跳过这一页但页签头也不插避免出现空页签 } // ② 插入页签头索引要和 m_pPages 的下标一一对应 InsertItem(i, m_sCaption[i]); } // ③ 只让第 0 页可见其余强制隐藏 for (int i 0; i m_nNumOfPages; i) m_pPages[i]-ShowWindow(i 0 ? SW_SHOW : SW_HIDE); m_nCurrentPage 0; LayoutPages(); // 见 4.1插完页签头才能算准显示区 CTabCtrl::SetCurSel(0); // 明确写类名避免调到自己的重载版本 }第 ① 步里Create的第二个参数决定父子关系。传this页签控件之后子对话框的坐标系是相对页签控件客户区的GetParent()返回的也是页签控件而不是主对话框。这一点在第 4.2 节会展开。第 ② 步的InsertItem必须在Create成功之后。因为LayoutPages依赖页签行高而页签行高在没有 item 的时候AdjustRect给出的结果不可靠——有的系统上会返回一个只减掉边框的矩形结果子页把页签头整个盖住。第 ③ 步的隐藏循环不能省。哪怕资源里没勾 VisibleCreate之后再显式SW_HIDE一遍也是最稳的因为不同版本的 MFC 对Create之后窗口是否可见处理略有差异。写这一行不花什么成本。3.3 切换页靠 ON_NOTIFY_REFLECT不是 ON_NOTIFY用户点页签头时Tab Control 会把TCN_SELCHANGE通过 WM_NOTIFY 发给它的父窗口。而父窗口是主对话框——如果主对话框里写了ON_NOTIFY通知就被主对话框吃掉了CTabSheet 内部收不到页不会切。MFC 的解法是消息反射父窗口处理 WM_NOTIFY 时会先把通知反射回子控件自己的消息映射里前提是子控件是 MFC 创建的窗口。所以 CTabSheet 内部要写ON_NOTIFY_REFLECTBEGIN_MESSAGE_MAP(CTabSheet, CTabCtrl) ON_NOTIFY_REFLECT(TCN_SELCHANGING, CTabSheet::OnSelChanging) ON_NOTIFY_REFLECT(TCN_SELCHANGE, CTabSheet::OnSelChange) END_MESSAGE_MAP() // 切换前返回 TRUE 可以拦住切换用来做「当前页有未保存修改」的拦截 void CTabSheet::OnSelChanging(NMHDR* pNMHDR, LRESULT* pResult) { *pResult FALSE; // FALSE 允许切换 } // 切换后真正把旧页藏起来、新页显示出来 void CTabSheet::OnSelChange(NMHDR* pNMHDR, LRESULT* pResult) { int nSel GetCurSel(); if (nSel 0 nSel ! m_nCurrentPage) SetCurSel(nSel); // 走自己的重载版本内部会同步页签头 *pResult 0; }OnSelChanging里返回 TRUE 就能阻止切换做表单校验拦截很顺手在 CTabSheet 里加一个虚函数virtual BOOL CanChangePage(int nFrom, int nTo) { return TRUE; }再由主对话框的页去覆写比在每个页里都挂一遍消息映射干净得多。另一个容易忽略的点主对话框里不要再对同一个控件写一遍ON_NOTIFY(TCN_SELCHANGE, IDC_TAB_MAIN, ...)。两边都接切换逻辑会走两次轻则界面闪一下重则一次跳两页。统一放在 CTabSheet 里主对话框只负责 AddPage 和 Show。3.4 SetCurSel 里 Hide 和 Show 的顺序不能反void CTabSheet::SetCurSel(int nItem) { if (nItem 0 || nItem m_nNumOfPages) return; // 同一页重复设置直接返回减少无谓的闪烁 if (nItem m_nCurrentPage ::IsWindow(m_pPages[nItem]-GetSafeHwnd())) return; // 必须先隐藏旧页再显示新页 m_pPages[m_nCurrentPage]-ShowWindow(SW_HIDE); m_nCurrentPage nItem; m_pPages[m_nCurrentPage]-ShowWindow(SW_SHOW); CTabCtrl::SetCurSel(nItem); // 同步页签头的选中项 }顺序反了会看到两页叠一帧的画面在慢一点的机器上肉眼能捕到。最后一行CTabCtrl::SetCurSel(nItem)里的类名限定不能省。CTabCtrl::SetCurSel只改页签头的选中状态不发送 TCN_SELCHANGE所以这里不会递归回OnSelChange。但如果写成不带类名的SetCurSel(nItem)编译器会解析到 CTabSheet 自己的重载版本形成无限递归最终栈溢出——崩溃现场是0xC00000FD看调用栈能清楚看到自己调自己。4. 布局与边界AdjustRect、父子关系、页数上限与缩放页签类跑通之后剩下的问题几乎都集中在一个地方矩形。子页该占多大、从哪儿开始、窗口拉大之后怎么跟着变。4.1 页签行的高度是算出来的AdjustRect 的正确用法void CTabSheet::LayoutPages() { if (m_nNumOfPages 0) return; if (GetItemCount() 0) return; // 没插页签头之前 AdjustRect 结果不可靠 CRect rect; GetClientRect(rect); // 先拿整个客户区 // FALSE 入参是「窗口矩形」出参是「页签头下方的显示区矩形」 AdjustRect(FALSE, rect); for (int i 0; i m_nNumOfPages; i) { if (::IsWindow(m_pPages[i]-GetSafeHwnd())) m_pPages[i]-MoveWindow(rect, FALSE); } }参数说明第一个参数bLarger传 TRUE 时把「显示区矩形」放大成「窗口矩形」一般用于按内容反算控件该多大传 FALSE 时把「窗口矩形」缩小成「显示区矩形」。这里要的是后者。rect既是入参也是出参。传进去的是GetClientRect拿到的客户区出来后左上角通常变成 (2, 22) 这类值右下角也相应内缩——具体数值随字体、DPI、页签样式变化不要写死。MoveWindow的第二个参数 FALSE 表示不立即重绘。连续移动多页时能少闪几下但外层最好在最后补一次Invalidate。不调AdjustRect、直接拿GetClientRect的结果去 MoveWindow子页会把页签头整个盖住。看起来像「页签消失了」实际上是子对话框画在它上面——这个现象第一次遇到特别容易误判成控件没创建成功然后一头扎进 Create 的返回值里查半天。4.2 子对话框的父窗口是页签控件不是主对话框Create的第二个参数传this之后父子链变成了「主对话框 → 页签控件 → 各子页」。这带来两个必须知道的后果。第一子页里访问主对话框的数据不能直接AfxGetMainWnd()那个拿到的是应用主窗口对对话框程序来说可能是别的东西正确路径是往上走两层。我一般这么写BOOL CPageBasic::OnInitDialog() { CDialog::OnInitDialog(); // 父是 CTabSheet祖父才是主对话框 CWnd* pTab GetParent(); CWnd* pOwner pTab ? pTab-GetParent() : NULL; // 需要工程开启 RTTIVC2008 默认 /GR 开启 m_pHost dynamic_castCMyDlg*(pOwner); ASSERT(m_pHost ! NULL); // 为 NULL 说明父子链没搭对 return TRUE; }这段代码在Show()内部Create时就会执行此时父子链已经建立好了GetParent()拿得到。第二子页的坐标原点是页签控件客户区的左上角不是屏幕坐标。所以子页里弹右键菜单、算 Tooltip 位置这类需要绝对坐标的地方必须ClientToScreen转换一次直接把子页里的控件矩形当屏幕坐标用菜单会飘到窗口外面去。4.3 MAXPAGES 与运行期增删页参数 / 成员含义常见改法MAXPAGES页数上限多数实现用 16 或 32 的定长数组页数设计期确定就保持 16页数由配置决定就换 CArrayCDialog*, CDialog*m_nNumOfPages已登记页数AddPage 里自增不要在外部直接改一律走 AddPagem_nCurrentPage当前显示页下标通过 SetCurSel 改不要直接赋值m_nID[i]第 i 页的 IDD 模板 ID必须是 IDD_不是 IDC_m_sCaption[i]页签头文字支持运行期改但要同步调 SetItemText定长数组版的实现运行期删页很别扭要把后面的元素整体前移、本文还有配套的精品资源点击获取