VC2008 MFC 多页签对话框:CTabSheet 封装与避坑实践
简介本资源面向使用 VC2008 与 MFC 进行桌面界面开发的初学者和中级程序员聚焦一个常见需求在单窗口内以选项卡组织多个功能页面。资源围绕 CTabSheet 类展开演示如何通过 CPropertySheet 派生类、页面类继承、AddPage 添加页面以及消息映射搭建可切换的多标签对话框帮助读者理解选项卡界面的组织方式与数据交换处理避免直接操作底层 Windows API。压缩包共 23 个文件约 200KB以 8 个 h 头文件与 6 个 cpp 源文件为主体另有 sln 解决方案与 vcproj 工程文件用于直接打开编译rc 资源脚本、ico 图标、aps 与 suo 等工程辅助文件以及一份 ReadMe 说明并附带编译好的 exe 可先行运行查看效果。目前已有 307 人学习。读者可获得一套结构完整、可直接编译调试的示例工程参照其中的类划分与页面注册方式快速把选项卡布局迁移到自己的对话程序里。1. 一个设置对话框被拆成六页之后tab 切换就成了必须自己承担的事VC2008 的 MFC 工程里做窗口 tab最省事的做法往往不是最省心的做法。某公司内部工具的参数设置对话框从最初的一屏五个控件两年里长成了四十多个控件挤在一起、滚动条拉两屏的局面测试同学的原话是“找个超时参数要翻半天”。产品最终拍板拆页签六个功能块对应六个页。原生 CTabCtrl 只能提供那条带页签的横条页面本身是空白的页面怎么创建、怎么摆、切换时谁隐藏谁显示全得自己写。CTabSheet 这类封装要解决的问题就一句话把“页签条”和“若干个子对话框”绑成一个整体AddPage 注册、Show 一次性创建、点页签自动切。适合正在用 VC2008 维护老 MFC 项目、又不想引入新框架的人。2. 方案选型三条路线与 CTabSheet 的职责边界页签式界面在 MFC 里有三种常见做法选错路线后面全是补丁。2.1 原生 CTabCtrl、CTabSheet 封装、CPropertySheet 三者的取舍原生 CTabCtrl 是最底层的一条路控件资源里拖一个 Tab Control在 TCN_SELCHANGE 里判断当前选中项手动ShowWindow(SW_HIDE)掉旧页面、ShowWindow(SW_SHOW)出新页面。这条路代码量不大但逻辑分散在宿主对话框的消息映射里页面一多OnSelChange 里就是一串 if-else 或者一个 switch谁改都得先读懂整段。CPropertySheet 是另一条路它天然支持“向导式”和“标签式”两种外观页面对应 CPropertyPage。优点是几乎零代码缺点是外观被系统主题绑死页签样式、按钮区、页面边距都不太好改而且要嵌进一个已有的模态对话框里当子区域用别扭得很——它本身就是个顶层窗口。CTabSheet 走的是中间路线派生自 CTabCtrl内部持有一组 CDialog 指针把创建、摆放、切换三件事收进类里宿主只负责 AddPage 和响应尺寸变化。对于“老工程里加一块页签区域”这种需求这条路的改造成本最低。路线代码量页面布局自由度外观可控性适合的场景原生 CTabCtrl少但分散高中只有两三页、逻辑很简单CTabSheet 封装中等一次性高高老工程嵌入页签区、页数会增长CPropertySheet极少中低独立向导、对样式无要求我一般会这样判断页面数超过三个、或者以后还会加页就直接上封装只有两页且永远不动的原生写两行反而清楚。CPropertySheet 只在要做“上一步/下一步”流程时才考虑。2.2 类结构设计宿主、页签容器、页面三者各管什么把职责划清楚后面改起来才不会互相牵扯。整套结构分三层宿主对话框比如 CHostDlg持有 CTabSheet 成员负责把页签控件摆到正确位置、处理窗口尺寸变化、以及在页面之间转发数据。它不认识页面内部的控件只认识页面的公有方法。CTabSheet 继承 CTabCtrl是容器。它知道有几页、每页的标题是什么、模板 ID 是多少它负责在 Show() 时把每个 CDialogCreate出来父窗口设成自己它负责在选中项变化时隐藏旧页、显示新页它负责在宿主尺寸变化时把所有页面重新贴到内容区。页面类CPageGeneral、CPageNetwork 之类继承一个薄薄的 CPageBase只管自己的控件、自己的 DDX/DDV、自己的业务校验。它不关心自己排第几页也不关心别的页面长什么样。这个划分带来的直接好处是新增一个页只需要加一个对话框资源、一个页面类、一行 AddPage宿主的代码一个字都不用动。反之如果把页面指针和切换逻辑全塞在宿主里加到第六页时 OnSelChange 基本就不可维护了。提示CTabSheet 的成员变量用固定数组而不是std::vector是因为 MFC 老工程常关掉异常数组越界的表现虽然难看但至少不会在析构时炸出一堆看不懂的模板报错。上限给 8 页实际用 6 页留两个余量。3. 工程搭建资源属性、初始化顺序与页面生命周期这套方案能不能跑起来八成的问题出在资源属性上而不是代码里。3.1 对话框资源的两项必改属性每个要当作页面的对话框资源打开资源视图右键属性两处必须改第一处是 Style从 Popup 改成 Child。Popup 是顶层窗口Create到 CTabCtrl 上会变成一个独立的小窗浮在外面父窗口约束不住它页签一换它就留在原地。第二处是 Border从 Dialog Frame 改成 None。带边框的对话框塞进页签内容区会看到一圈灰色的立体边而且边框会吃掉几个像素控件贴边时被裁掉。改完之后对话框资源的 Visible 属性建议保持默认的勾选状态。有人在资源里取消 Visible 想“让代码控制显示”结果Create出来的窗口初始就是隐藏的后面ShowWindow(SW_SHOW)也生效但调试时容易看花眼分不清是没创建还是没显示。另外资源 ID 最好统一前缀比如 IDD_PAGE_GENERAL、IDD_PAGE_NETWORK一眼能看出这是页面模板而不是普通对话框。这在页数多了以后非常省事。3.2 宿主对话框里的声明与初始化顺序宿主对话框的头文件里把 CTabSheet 和所有页面对象都声明成成员变量不要用 new// HostDlg.h #include TabSheet.h #include PageGeneral.h #include PageNetwork.h #include PageAbout.h class CHostDlg : public CDialog { DECLARE_DYNAMIC(CHostDlg) public: CHostDlg(CWnd* pParent NULL); enum { IDD IDD_HOST_DIALOG }; protected: virtual BOOL OnInitDialog(); afx_msg void OnSize(UINT nType, int cx, int cy); DECLARE_MESSAGE_MAP() private: CTabSheet m_tab; // 页签容器绑定到 IDC_TAB_SHEET CPageGeneral m_pageGeneral; // 第 0 页 CPageNetwork m_pageNetwork; // 第 1 页 CPageAbout m_pageAbout; // 第 2 页 };用成员对象而不是指针是因为页面对象的生命周期和宿主完全一致析构顺序由编译器保证不需要在 OnDestroy 里手动 delete。这一点在第六节讲销毁坑的时候还会提到。OnInitDialog 里的顺序有讲究四步不能颠倒BOOL CHostDlg::OnInitDialog() { CDialog::OnInitDialog(); // 第一步把资源里的 Tab Control 绑定到成员对象 // 注意如果 DoDataExchange 里已经写了 DDX_Control(pDX, IDC_TAB_SHEET, m_tab) // 这里就不要再 SubclassDlgItem否则会重复子类化消息映射错乱 m_tab.SubclassDlgItem(IDC_TAB_SHEET, this); // 第二步注册页面调用顺序就是页签的左右顺序 m_tab.AddPage(_T(常规), m_pageGeneral, IDD_PAGE_GENERAL); m_tab.AddPage(_T(网络), m_pageNetwork, IDD_PAGE_NETWORK); m_tab.AddPage(_T(关于), m_pageAbout, IDD_PAGE_ABOUT); // 第三步统一创建所有页面并显示第一页 if (!m_tab.Show()) { AfxMessageBox(_T(页面创建失败请检查对话框资源是否为 Child 且 Border 为 None)); return TRUE; } // 第四步按当前客户区摆放一次防止首帧位置不对 CRect rcTab; GetClientRect(rcTab); rcTab.DeflateRect(8, 8); rcTab.bottom - 48; // 底部留给确定/取消按钮 m_tab.MoveWindow(rcTab); m_tab.ResizePages(); return TRUE; }第一步的绑定方式二选一这是最容易重复操作的地方。用 SubclassDlgItem 就不用写 DDX_Control用 DDX_Control 就别再 Subclass。两种都写的结果是控件句柄被绑了两次页签点击有时响应有时不响应属于典型的“玄学”问题。第三步的 Show() 必须放在窗口尺寸已经确定之后调用。如果在构造函数里就 Show那时控件句柄还是空的Create会失败。放在 OnInitDialog 末尾、且宿主对话框的初始尺寸已经从资源里读出来之后位置才是对的。3.3 页面类的创建与销毁时机页面的Create由 CTabSheet::Show() 统一发起父窗口传this也就是 CTabCtrl 本身。这意味着所有页面在宿主对话框一打开时就全部创建完毕只是除了当前页之外都处于隐藏状态。这种“一次全建”的策略有代价启动会慢一点内存占用高一点。但它换来的是切页零延迟而且页面之间的控件句柄一直有效宿主可以随时通过GetPage(i)-SetXxx()往隐藏页里写数据不用担心“页面还没创建”。对于设置类对话框页数不多、每页控件也不算多这是划算的。如果页面特别重比如某一页里嵌了个列表控件要加载几千条数据可以改成延迟创建AddPage 只记模板 ID第一次切到该页时才Create。实现上给 CTabSheet 加一个m_bPageCreated[]数组判断即可但代码复杂度会上一个台阶建议先用全建方案跑通真的卡了再优化。销毁则是反过来的宿主对话框关闭时CTabSheet 的析构先执行然后是各页面成员的析构。CDialog 的析构本身不会销毁窗口所以稳妥的做法是在 CTabSheet 的析构里遍历一遍对每个页面调用DestroyWindow()。窗口先销毁对象再析构顺序才对。4. 核心实现AddPage、Show 与切换逻辑的完整代码这一章把 CTabSheet 从头写一遍代码可以直接贴进工程编译。4.1 头文件与成员变量设计// TabSheet.h #pragma once #define MAX_PAGE_COUNT 8 // 页面上限按项目实际页数留余量 class CTabSheet : public CTabCtrl { DECLARE_DYNAMIC(CTabSheet) public: CTabSheet(); virtual ~CTabSheet(); // 注册一页页签标题 页面对象地址 对话框模板 ID BOOL AddPage(LPCTSTR lpszTitle, CDialog* pPage, UINT nIDTemplate); // 统一创建全部页面并显示第一页返回 FALSE 表示有页面创建失败 BOOL Show(); int GetPageCount() const { return m_nPageCount; } int GetCurPageIndex() const { return m_nCurrentPage; } CDialog* GetPage(int nIndex) const; // 宿主窗口尺寸变化时调用把每个页面重新贴到页签内容区 void ResizePages(); protected: afx_msg void OnSelChange(NMHDR* pNMHDR, LRESULT* pResult); DECLARE_MESSAGE_MAP() private: void MovePageToTabRect(int nIndex); // 把第 nIndex 页摆到内容区 CDialog* m_pPages[MAX_PAGE_COUNT]; // 页面对象指针生命周期由宿主管理 UINT m_nIDTemplates[MAX_PAGE_COUNT]; int m_nPageCount; // 已注册页数 int m_nCurrentPage; // 当前显示页-1 表示还没显示 BOOL m_bCreated; // 防止 Show() 被重复调用 };成员里没有用 vector前面说过原因。m_nCurrentPage初值给 -1是为了区分“还没显示过”和“显示的是第 0 页”——如果不区分第一次点第 0 页的页签时会被误判成“已经在第 0 页”而不做任何操作。4.2 AddPage 与 Show创建、摆放与首次显示// TabSheet.cpp #include stdafx.h #include TabSheet.h IMPLEMENT_DYNAMIC(CTabSheet, CTabCtrl) BEGIN_MESSAGE_MAP(CTabSheet, CTabCtrl) ON_NOTIFY_REFLECT(TCN_SELCHANGE, CTabSheet::OnSelChange) END_MESSAGE_MAP() CTabSheet::CTabSheet() : m_nPageCount(0), m_nCurrentPage(-1), m_bCreated(FALSE) { ::ZeroMemory(m_pPages, sizeof(m_pPages)); ::ZeroMemory(m_nIDTemplates, sizeof(m_nIDTemplates)); } CTabSheet::~CTabSheet() { // 窗口先销毁页面对象的析构由宿主负责 for (int i 0; i m_nPageCount; i) { if (m_pPages[i] ! NULL ::IsWindow(m_pPages[i]-GetSafeHwnd())) m_pPages[i]-DestroyWindow(); } } BOOL CTabSheet::AddPage(LPCTSTR lpszTitle, CDialog* pPage, UINT nIDTemplate) { if (m_bCreated) return FALSE; // Show 之后不允许再加页 if (m_nPageCount MAX_PAGE_COUNT) return FALSE; if (pPage NULL) return FALSE; InsertItem(m_nPageCount, lpszTitle); // 标题先占位界面立刻可见 m_pPages[m_nPageCount] pPage; m_nIDTemplates[m_nPageCount] nIDTemplate; m_nPageCount; return TRUE; } BOOL CTabSheet::Show() { if (m_bCreated) return TRUE; // 幂等重复调用直接返回 if (m_nPageCount 0) return FALSE; for (int i 0; i m_nPageCount; i) { // 父窗口传 this页面成为页签控件的内容区子窗口 if (!m_pPages[i]-Create(m_nIDTemplates[i], this)) return FALSE; MovePageToTabRect(i); m_pPages[i]-ShowWindow(SW_HIDE); // 先全隐藏最后只显示第一页 } m_nCurrentPage 0; m_pPages[0]-ShowWindow(SW_SHOW); SetCurSel(0); m_bCreated TRUE; return TRUE; } void CTabSheet::MovePageToTabRect(int nIndex) { if (nIndex 0 || nIndex m_nPageCount) return; CRect rcClient; GetClientRect(rcClient); // AdjustRect(FALSE, ...) 把整个客户区收缩成页签下方的内容区 // 参数为 TRUE 时是反过来由内容区推算整个控件区域 AdjustRect(FALSE, rcClient); rcClient.InflateRect(-2, -2); // 内缩 2 像素避免控件压住页签下沿的分隔线 m_pPages[nIndex]-MoveWindow(rcClient); } CDialog* CTabSheet::GetPage(int nIndex) const { if (nIndex 0 || nIndex m_nPageCount) return NULL; return m_pPages[nIndex]; } void CTabSheet::ResizePages() { if (!m_bCreated) return; for (int i 0; i m_nPageCount; i) MovePageToTabRect(i); }几个关键点解释一下。AdjustRect是 CTabCtrl 自带的成员作用是算“去掉页签条之后还剩多少可用区域”。很多人图省事直接用GetClientRect当页面区域结果页面顶部被页签条盖住一截标题栏文字刚好压在页签底下看起来像渲染坏了。FALSE这个参数的意思是“从整体算内容区”别传反了。InflateRect(-2, -2)是经验值。AdjustRect 算出来的矩形是紧贴页签下沿的不加内缩的话页面里如果有 Group Box 之类的控件边框会和页签线粘在一起视觉上很脏。InsertItem放在 AddPage 里而不是 Show 里是为了让标题在创建阶段的调试中就能看到——万一某个页面Create失败提前 return至少能通过页签数量判断是第几个出的问题。4.3 切换与尺寸变化OnSelChange 与 OnSize 的配合void CTabSheet::OnSelChange(NMHDR* /*pNMHDR*/, LRESULT* pResult) { int nNew GetCurSel(); if (nNew 0 || nNew m_nCurrentPage) { *pResult 0; return; } // 页面重写 OnKillPage 返回 FALSE 可以阻止切换用于当前页数据不合法 CPageBase* pOld static_castCPageBase*(m_pPages[m_nCurrentPage]); if (!pOld-OnKillPage()) { SetCurSel(m_nCurrentPage); // 把选中项拉回旧页视觉上等于没切 *pResult 0; return; } m_pPages[m_nCurrentPage]-ShowWindow(SW_HIDE); MovePageToTabRect(nNew); // 切换时重算一次防止宿主刚 resize 过 m_pPages[nNew]-ShowWindow(SW_SHOW); m_nCurrentPage nNew; *pResult 0; }这里用的是ON_NOTIFY_REFLECT而不是ON_NOTIFY。原因是 TCN_SELCHANGE 通知是 Tab 控件发给父窗口的而 CTabSheet 自己就是那个“父窗口”——用 REFLECT 让通知反射回自己处理宿主就不用再写一遍消息映射。如果哪天你发现点了页签没反应先检查是不是写成了ON_NOTIFY(TCN_SELCHANGE, IDC_TAB_SHEET, ...)却放在 CTabSheet 的映射表里那样是收不到的。OnKillPage这个拦截点值得单独说。TCN_SELCHANGE 是在选中项已经变了之后才发出来的也就是说等到你收到这个消息页签的高亮已经跳到新页了这时再想“不允许切走”唯一的办法就是SetCurSel拉回去。不加这个拉回动作用户会看到页签亮着新页、内容却还是旧页比不拦截更难受。宿主的 OnSize 配合void CHostDlg::OnSize(UINT nType, int cx, int cy) { CDialog::OnSize(nType, cx, cy); // 窗口最小化时 cx/cy 为 0且控件可能还没创建必须判空 if (m_tab.GetSafeHwnd() NULL) return; CRect rcTab(8, 8, cx - 8, cy - 8); rcTab.bottom - 48; // 底部按钮区 if (rcTab.Width() 100 || rcTab.Height() 100) return; m_tab.MoveWindow(rcTab); m_tab.ResizePages(); }GetSafeHwnd() NULL这个判断看起来多余实际上很关键WM_SIZE 在 OnInitDialog 期间就可能被触发一次对话框按资源尺寸调整时那时候m_tab还没绑上句柄直接 MoveWindow 会断言失败。这个坑几乎每个用 MFC 写可调整大小对话框的人都踩过。4.4 页面之间传数据的两条路页面之间不应该互相持有指针否则页数一多就是一张网状依赖图。常用两种做法。第一种宿主做中转。页面把变化发给宿主宿主决定通知谁// 在 HostDlg.h 里定义一个自定义消息 #define WM_PAGE_VALUE_CHANGED (WM_USER 100) // 常规页里用户改完主机名点应用 void CPageGeneral::OnBnClickedApply() { if (!UpdateData(TRUE)) return; // DDX/DDV 校验失败直接返回 // GetParent() 是 CTabSheet再上一层才是宿主 CHostDlg* pHost static_castCHostDlg*(GetParent()-GetParent()); if (pHost ! NULL) pHost-SendMessage(WM_PAGE_VALUE_CHANGED, 0, (LPARAM)(LPCTSTR)m_strHost); } // 宿主收到后转给需要同步的那一页 LRESULT CHostDlg::OnPageValueChanged(WPARAM /*wParam*/, LPARAM lParam) { LPCTSTR lpszHost (LPCTSTR)lParam; m_pageNetwork.SetHost(lpszHost); // 页面提供公有 setter return 0; }GetParent()-GetParent()这条链要记清楚页面 → CTabSheet → 宿主。中间少一层就会转错类型运行时不一定崩但调用的方法全是野的。如果觉得这条链太脆可以在Show()之后让宿主遍历一遍GetPage(i)把宿主指针通过SetHostDlg(this)注入到每个页面里之后页面直接用自己的成员指针不再依赖窗口父子关系。我倾向后者改起来更放心。第二种宿主主动拉取。切页前宿主调GetPage(i)-UpdateData(TRUE)把页面数据刷到页面成员里再统一读走。这种适合“最后一页提交”的场景不适合实时联动。两条路不冲突通常的做法是表单类数据用宿主中转实时同步最终提交时再统一UpdateData(TRUE)收口一遍。5. 避坑排查这套方案在 VC2008 里最容易翻车的五处下面五条都是实际调试出来的按现象、原因、解决三段写。5.1 资源与初始化阶段坑一页签显示出来了下面的内容区一片灰白什么都没有。现象是对话框打开后能看到页签条和三个标题点哪一页都是空白没有任何报错。原因基本锁定在页面对话框资源的属性上Style 还是 Popup。Popup 窗口创建后是独立的顶层窗口坐标相对于屏幕而不是父窗口MoveWindow传进去的是客户区坐标结果页面被摆到屏幕左上角或者干脆跑到屏幕外。解决办法就是第 3.1 节那两步Style 改 Child、Border 改 None改完重新编译资源。坑二点了页签没反应光标移上去也没有手型变化。先排除资源问题——Tab Control 本身的 Visible 是不是被取消了或者它被另一个控件比如一个 Group Box在 z-order 上盖住了。资源视图里控件的顺序决定 z-order后添加的在上层。把 Tab Control 用 CtrlShift方向键的话不好使正规做法是在资源编辑器里把它剪切再粘贴一次或者调用SetWindowPos(wndTop, ...)提到最前。还有一个可能Tab Control 的样式里勾了 Disabled这个虽然低级但确实有人点错过。坑三程序启动时弹出断言指向 MoveWindow 那一行。断言信息通常是assert((pWnd NULL) || ::IsWindow(pWnd-m_hWnd))之类。原因是 OnSize 在 OnInitDialog 完成之前就被触发了一次那时m_tab的控件句柄还是空的。解决就是 4.3 节那句if (m_tab.GetSafeHwnd() NULL) return;。加在 OnSize 的第一行不要加在 CDialog::OnSize 之前否则基类该做的事也被跳过了。5.2 运行与销毁阶段坑四切到第三页再切回来第二页里输入框的内容全没了。现象很迷惑看起来像页面的成员变量被重置了。真实原因是第二页的对话框在切换时被销毁了——如果你在 OnSelChange 里写的是DestroyWindow加Create的“重建式切换”那控件当然是新建的DDX 绑定的成员值虽然还在但控件里显示的是初始值。CTabSheet 采用的是“全部创建、只切显隐”正常不会有这个问题。如果你确实改成了延迟创建那就在隐藏前调一次UpdateData(TRUE)把控件值刷回成员变量切回来再UpdateData(FALSE)刷回去。坑五关闭对话框时崩溃调试器停在析构或者 ExitInstance 里。这是最典型的一个。原因有两个方向。一是页面对象是用new出来的CTabSheet 里只存了指针析构时既没DestroyWindow也没delete窗口句柄泄漏程序退出时句柄回收顺序错乱。二是页面对象是成员变量正确做法但 CTabSheet 在析构时没有先DestroyWindow导致窗口还挂在已经析构的对话框对象上。解法就按第 3.3 节和 4.2 节的析构函数写先在析构里遍历销毁窗口再让编译器按声明逆序析构成员对象。另外m_nPageCount要在销毁循环里作为上界使用别用 MAX_PAGE_COUNT否则会把空指针也走一遍。顺手再记一条不算坑但很容易被忽略的Unicode 工程里InsertItem的第一个参数是LPCTSTR直接写常规这种窄字符串会编译不过或者显示乱码统一用_T(常规)包一下成本为零。6. 验收与答辩怎么证明这套 tab 方案是真的稳交作业或者交付项目时“能跑”和“经得起演示”是两回事。我在验收这套方案时会走一张固定清单逐项打勾。验收项操作方法通过标准页签数量与顺序打开对话框肉眼核对顺序与 AddPage 调用顺序一致无遗漏首帧位置冷启动程序不拖动窗口直接看第一页内容完整没有被页签条压住页面切换每页轮流点两轮无闪烁、无残影、无旧页控件残留尺寸自适应拖拽窗口到最大化和最小尺寸页面跟随内容区缩放控件不被裁切数据保持第 1 页输入内容切到第 3 页再切回输入框内容仍为之前输入的值拦截校验在第 1 页故意留空必填项点第 2 页页签页签高亮被拉回第 1 页弹出提示关闭退出反复开关对话框 10 次再退出程序无崩溃、无断言、任务管理器里句柄数不持续增长跨页联动第 1 页改主机名切到第 2 页查看第 2 页对应字段同步更新答辩讲解的顺序也有讲究。别一上来就念代码先说清楚“为什么不用 CPropertySheet”——因为要嵌进已有对话框当子区域、因为页签样式要和现有界面一致这两句一出选型就立住了。然后讲三层结构宿主管布局、CTabSheet 管容器、页面管自己顺手在屏幕上指出三者在哪个文件。最后现场演示一次“新增一页只要三行代码”这个动作比任何架构图都有说服力因为它证明了这个设计是真的可扩展而不是事后补的。如果被问到AdjustRect为什么要内缩 2 像素、为什么用数组不用 vector别答“习惯”答“为了让 Group Box 的边框不压页签线”和“老工程关了异常数组越界好定位”这类具体到场景的理由比任何教科书答案都更能说明你真的写过。当年我第一次把这套东西交上去演示时一切正常讲师随手把窗口拉到最小页面里的按钮全被裁掉了因为我在 OnSize 里只判断了宽高不小于 100却忘了页面内部还有一列固定宽度的控件。从那以后我每次做可调整大小的对话框都强制走一遍“拖到最小再拖回最大”的检查不管界面看起来多简单。希望帮到你。本文还有配套的精品资源点击获取