简介这是一份面向 MFC 学习者的示例源码包聚焦单文档界面SDI下多视图切换的实现适合正在做文档/视图结构开发或想扩展 MFC 界面能力的中初级程序员。压缩包采用 RAR 格式共 23 个文件、138KB其中包含 8 个 C 头文件、6 个 cpp 源文件、2 个图标资源以及 vcproj 工程文件、rc 资源脚本、suo、aps 等工程辅助文件和 ReadMe 说明构成一个可直接打开学习的完整 MFC 工程。目前已有 1301 人浏览学习。示例围绕视图类的创建与注册、文档模板CDocTemplate的配置、菜单命令触发视图切换、AddView/RemoveView/SetActiveView 的配合使用等关键环节展开同时给出视图生命周期和窗口布局管理的处理思路。通过阅读源码并对照运行可以理解同一文档下多视图共享数据时的刷新同步机制并在此基础上扩展出自定义的多视图界面。1. MFC 单文档视图切换先明白这是个什么事再决定要不要下做桌面工具这些年MFC 单文档SDI里做多视图切换是很多老项目绕不过去的一道坎。单文档框架默认只挂一个 CView但实际需求往往是同一份文档数据既要看表格又要看图形或者要在不同工作模式间来回切。这篇文章就是拆这个场景的——在 MFC 单文档视图框架里怎么挂多个视图类怎么通过菜单或按钮切换数据怎么同步以及我踩过的几个坑。适合正在维护老 MFC 项目、或者被分配了“在单文档里加个视图切换”这类任务的工程师看完能直接照做也能避开我当初翻车的几个地方。2. 文档视图架构切换不是换界面是换视图类的运行时调度2.1 三个角色视图类、文档类与框架窗口的协作关系MFC 单文档应用里CFrameWnd 是壳CDocument 是数据CView 是展示。默认情况下 SDI 模板只创建一个视图对象这个视图对象和框架窗口是一对一绑定的。要做多种视图切换核心思路不是销毁重建视图而是让框架窗口持有多个视图对象在需要的时候切换“当前活动视图”。这里要先理解 MFC 的视图创建机制。在 SDI 应用里视图对象是通过文档模板的 CView 类成员创建的。你需要在 CYourApp 的 InitInstance 里用 AddDocTemplate 注册文档模板时指定一个视图类。如果只有一个视图类那这个视图会随着框架窗口创建而自动创建。有多视图需求时常见做法是文档模板仍然只注册一个主视图类其余的视图类在框架窗口创建后手动 new 出来并用 CreateView 或者直接创建窗口的方式挂到框架上。// 在 CMainFrame::OnCreate 里创建第二个视图 if (!m_pSecondView-Create(NULL, NULL, AFX_WS_DEFAULT_VIEW, CRect(0, 0, 0, 0), this, AFX_IDW_PANE_FIRST 2, NULL)) { TRACE0(Failed to create second view\n); return -1; } m_pSecondView-ShowWindow(SW_HIDE);创建时注意几个点AFX_IDW_PANE_FIRST 2 是视图的子窗口 ID这个 ID 不能和主视图冲突否则视图切换时框架会把两个视图当成同一个窗格处理。CreateView 的最后一个参数传 NULL表示不指定运行时类这时窗口过程使用默认的 CView 消息映射。第二个视图创建后立即 ShowWindow(SW_HIDE)避免和主视图叠在一起显示。视图类指针要保存在 CMainFrame 成员变量里因为后续的切换操作要通过这两个指针来回调度。这样做的本质是框架窗口成为多个视图的容器切换操作变成“哪个视图可见且接收输入”的问题而不是“创建或销毁视图”的问题。2.2 为什么不用 DestroyWindow 重建视图很多新手会想切换视图时把旧视图 DestroyWindow然后 new 一个新视图这样最直接。这个思路在对话框程序里没错但在 SDI 文档视图架构里会踩两个问题视图和文档是绑定的视图销毁时如果处理不好 OnClose 或 OnDestroy 里的清理逻辑文档指针会悬空每次都重新创建视图意味着 OnInitialUpdate 会被反复调用如果你的视图在 OnInitialUpdate 里加载数据或初始化 GDI 资源性能和状态都会出问题所以在单文档多视图切换的场景下主流做法是所有视图在框架窗口创建时一次性创建完毕后续只切换显示状态和活动状态。视图隐藏了不代表数据丢了视图对象还留在内存里切换回来时数据还在这对数据量大的场景特别重要。你可能会有疑问两个视图同时在内存里会不会互相抢焦点或消息答案是会互相抢但可以通过下面的切换函数把这个问题控制住。关键是保持“同一时刻只有一个视图处于可见且激活状态”。2.3 视图切换的三种方案对比MFC 里做视图切换常用的有三条路SetActiveView 配合 ShowWindow、把视图塞进 CSplitterWnd 分割窗口、以及用 CPropertySheet 做选项卡式切换。单文档场景下最常用且改动最小的是第一种。方案优点缺点适用场景SetActiveView ShowWindow改动小逻辑直观视图对象常驻内存需手动管理焦点和消息路由单文档内 24 个视图切换CSplitterWnd 静态分割可同时显示多个视图单窗口内空间被分割不适合全屏切换需要并排对比的场景CPropertySheet 选项卡界面直观无焦点问题需要额外维护选项卡框架改动大视图内容相对独立且数量多我一般优先用第一种方案因为它在保持单文档框架不变的前提下改造成本最低。下面的章节就基于这个方案展开。3. 从零挂两个视图视图类创建、菜单触发与数据同步3.1 在单文档工程里新增一个视图类假设项目已经有一个主视图叫 CMyGraphView我们想新增一个 CMyDataView 用来展示表格数据。在 Visual Studio 里右键项目添加类基类选择 CView生成后确认继承关系。// CMyDataView.h class CMyDataView : public CView { protected: CMyDataView(); DECLARE_DYNCREATE(CMyDataView) public: virtual void OnDraw(CDC* pDC); virtual void OnInitialUpdate(); void UpdateDataFromDoc(); protected: afx_msg void OnSize(UINT nType, int cx, int cy); DECLARE_MESSAGE_MAP() };注意 DECLARE_DYNCREATE 宏是必须的虽然在这种手动创建的方案里不会走文档模板的动态创建路径但保持这个宏能让框架在需要序列化或运行时类型识别时正常工作。OnDraw 里要做的事很简单如果视图内容是列表数据用 TextOut 绘制如果是图形数据用 GDI 绘制。至于数据从哪来统一通过文档类获取。3.2 创建视图类实例构造函数与资源初始化的顺序在 CMainFrame 的头文件里声明两个视图指针class CMainFrame : public CFrameWnd { public: CMyGraphView* m_pGraphView; CMyDataView* m_pDataView; };在 CMainFrame 构造函数里初始化为 NULL然后在 OnCreate 里创建。这里要注意顺序必须先让框架创建主视图再创建副视图。因为主视图是文档模板创建并关联的如果先手动创建副视图框架在后续处理活动视图时会因为找不到主视图而报错。int CMainFrame::OnCreate(LPCREATESTRUCT lpCreateStruct) { if (CFrameWnd::OnCreate(lpCreateStruct) -1) return -1; // CMyGraphView 由文档模板自动创建此时从视图列表里取出来 CView* pFirstView (CView*)GetActiveView(); m_pGraphView dynamic_castCMyGraphView*(pFirstView); // 创建第二个视图初始隐藏 m_pDataView new CMyDataView(); m_pDataView-Create(NULL, NULL, AFX_WS_DEFAULT_VIEW, CRect(0, 0, 0, 0), this, AFX_IDW_PANE_FIRST 2, NULL); m_pDataView-ShowWindow(SW_HIDE); return 0; }这里 dynamic_cast 需要开启 RTTI如果你的工程关闭了 RTTI改用 static_cast 也行因为从 GetActiveView 返回的指针类型可以从文档模板注册参数确定。Create 里的窗口 ID 用 AFX_IDW_PANE_FIRST 2避免和工具条、状态栏等固定窗格的 ID 冲突。3.3 菜单触发切换命令消息映射与处理函数在 MainFrm.cpp 里给两个菜单项加消息映射一个切到视图 A一个切到视图 B。BEGIN_MESSAGE_MAP(CMainFrame, CFrameWnd) ON_COMMAND(ID_VIEW_GRAPH, CMainFrame::OnViewGraph) ON_COMMAND(ID_VIEW_DATA, CMainFrame::OnViewData) ON_UPDATE_COMMAND_UI(ID_VIEW_GRAPH, CMainFrame::OnUpdateViewGraph) ON_UPDATE_COMMAND_UI(ID_VIEW_DATA, CMainFrame::OnUpdateViewData) END_MESSAGE_MAP()切换函数的核心是 SetActiveView配合 ShowWindow 实现可见性和活动状态的双重切换。void CMainFrame::OnViewGraph() { if (m_pGraphView NULL) return; // 让当前活动视图失去输入焦点 CView* pOldView GetActiveView(); if (pOldView) { pOldView-ShowWindow(SW_HIDE); pOldView-SetFocus(FALSE); } // 切换到目标视图 SetActiveView(m_pGraphView); m_pGraphView-ShowWindow(SW_SHOW); m_pGraphView-SetFocus(TRUE); RecalcLayout(); }RecalcLayout 很重要它让框架重新计算客户区布局避免视图切换后出现区域错位。SetFocus(TRUE) 告诉 MFC 这个消息来自框架而不是应用这样视图收到的 WM_SETFOCUS 消息里的激活状态是正确的。3.4 数据同步UpdateAllViews 与视图内的数据刷新两个视图展示的是同一份文档数据切到数据视图时必须确保它显示的是最新数据。在文档类里修改数据后调用 UpdateAllViews(NULL)框架会调度所有视图的 OnUpdate。第二个视图也要响应 OnUpdate不能只依赖 OnInitialUpdate。void CMyDataView::OnUpdate(CView* pSender, LPARAM lHint, CObject* pHint) { UNREFERENCED_PARAMETER(pSender); UNREFERENCED_PARAMETER(lHint); UNREFERENCED_PARAMETER(pHint); UpdateDataFromDoc(); Invalidate(); }这里的 Invariants 是每次更新必须从文档对象重新拉数据而不是在视图里维护一份缓存。两个视图共享同一文档指针谁被激活谁拉数据缓存留在各自视图里容易导致显示不一致。4. 切换的硬骨头激活顺序、焦点恢复与状态栏联动4.1 GetActiveView 的坑什么时候拿不到正确的活动视图SetActiveView 调用之后MFC 内部会先发 WM_ACTIVATE 给旧视图再发 WM_ACTIVATE 给新视图最后才把 CFrameWnd 的内部指针 m_pActiveView 指向新视图。这意味着你在 SetActiveView 之后立即调用 GetActiveView拿到的还是旧视图。// 错误示范切换后立即取当前视图 SetActiveView(m_pDataView); CView* pNowActive GetActiveView(); // 可能还是旧视图 // 正确做法用局部变量记住目标视图或者用 PostMessage 延迟取这个坑在初始化阶段更明显。OnCreate 里框架还没有完成视图关联这时 GetActiveView 返回 NULL 是正常的。不要在 OnCreate 里尝试获取主视图指针要放到 OnInitialUpdate 或者 OnIdle 之后。我一般会用 PostMessage 延迟到空闲时再拿。4.2 视图切换后键盘输入失效焦点必须显式设置视图切换不仅仅是显示 / 隐藏还有一个容易被忽略的焦点问题。隐藏视图会带走键盘焦点而新显示出来的视图不会自动获得焦点尤其是当你用 ShowWindow(SW_HIDE) 隐藏旧视图时焦点会回到框架窗口键盘消息会被框架的消息循环吃掉新视图的 OnKeyDown 收不到。解决方式是在切换函数里显式调用新视图的 SetFocus。但这里有一个隐藏的细节SetFocus 本身是 Win32 API它只设置键盘焦点。MFC 的 CView 要处理键盘消息还需要视图框架处于激活状态。所以标准的切换顺序是SetActiveView(pNewView); // 先激活 pNewView-ShowWindow(SW_SHOW); // 再显示 pNewView-SetFocus(); // 最后设焦点这三个步骤缺一不可而且顺序不能换。如果先 ShowWindow 再 SetActiveView视图先显示出来了但还没激活键盘消息会短暂地落在旧视图或框架上用户会感觉到“第一次按键没反应”。4.3 OnInitialUpdate 与 OnUpdate 的触发顺序数据恢复的时机视图第一次创建时 OnInitialUpdate 先于第一次 OnDraw 调用这个阶段文档数据可能还没准备好。而视图切换回来时 OnInitialUpdate 不会再调用恢复数据的地方应该在 OnUpdate。void CMyDataView::OnInitialUpdate() { CView::OnInitialUpdate(); // 只做一次的资源初始化 m_nRows 0; m_nCols 0; } void CMyDataView::OnUpdate(CView* pSender, LPARAM lHint, CObject* pHint) { // 每次切换回来都会触发 UpdateDataFromDoc(); Invalidate(); }注意 OnUpdate 的第一个参数 pSender当数据是在 CMyGraphView 里修改时你可以在 pSender 里判断来源避免无意义的重绘。但在两个视图都依赖同一份文档内容的场景里我建议不去判断 pSender直接每次都刷新开销不大且能保证一致性。4.4 状态栏的显示同步把当前视图状态写进状态栏很多 MFC 程序里状态栏默认只有菜单提示和 Caps Lock 指示器。视图切换后建议把当前视图类型显示在状态栏这样用户能明确知道自己处于什么模式。在 CMainFrame 里重写 OnUpdateFrameTitle 或者直接在切换函数里更新状态栏窗格。void CMainFrame::OnViewData() { // ... 前面的切换逻辑 ... m_wndStatusBar.SetPaneText(0, _T(当前视图数据表格)); } void CMainFrame::OnViewGraph() { // ... 前面的切换逻辑 ... m_wndStatusBar.SetPaneText(0, _T(当前视图图形模式)); }这里如果你用了 SCROLL 分隔的多个状态栏窗格注意 SetPaneText 的第一个参数是窗格索引。MFC 默认状态栏有 4 个窗格第一个索引 0是菜单提示区第二个是状态区。建议把视图状态写到索引 1避免和菜单提示抢位置。5. 避坑五个常见问题与排查记录5.1 切到第二个视图时一片空白现象菜单点击后新视图区域是白色没有内容绘制。 原因Create 时窗口 rect 用了 CRect(0,0,0,0)视图虽然创建成功但尺寸为零ShowWindow 后没有触发 OnDraw 的完整重绘。 解决在切换函数里调用 m_pDataView-MoveWindow(rectClient) 手动调整视图尺寸或者在 OnSize 里处理。最稳的做法是切换时用 GetClientRect 重新设定视图大小。CRect rcClient; GetClientRect(rcClient); m_pDataView-MoveWindow(rcClient); m_pDataView-Invalidate();5.2 视图切换后出现残影或重叠现象两个视图的内容同时显示在屏幕上或者切换后旧内容没有清掉。 原因没有在切换时隐藏旧视图或者隐藏和显示的顺序不对。另一个可能性是视图的背景擦除没有触发 WM_ERASEBKGND。 解决严格按“隐藏旧视图 → 激活新视图 → 显示新视图”的顺序执行。同时检查视图的 OnEraseBkgnd如果你重写了并且返回 TRUE那新视图的背景不会被擦除需要手动填充。5.3 视图里按钮点击没有反应现象视图内嵌的 CButton 子控件可以画出来但点击没响应。 原因按钮的父窗口还是框架窗口而框架把鼠标消息路由给了当前活动视图按钮收不到时被当成了普通区域。 解决检查按钮创建时用的是什么父窗口。MFC 的 CButton::Create 第一个参数是父窗口指针如果你传的是 this视图指针那按钮的消息会被视图拦截再转发。如果传的是框架指针建议改成视图指针作为父窗口。5.4 打印和打印预览命令在某个视图里失效现象切换到 B 视图后文件菜单的打印功能变灰或者点了没反应。 原因CFrameWnd 的打印命令ID_FILE_PRINT默认路由给活动视图。如果活动视图没有重写 OnPreparePrinting消息链就断了。 解决给每个视图都保留默认的打印相关虚函数实现。其实根本原因是 CView 的默认打印函数依赖 OnDraw而这个函数正好是你要重写的。当视图处于隐藏状态时OnDraw 不会被调用打印内容为空。我一般会在打印函数里调用视图自己的绘制逻辑或者干脆切换到该视图再打印。5.5 用 iWebBrowser2 CreateEx 嵌浏览器视图时收不到交互消息现象在视图里通过 CreateEx 创建 IWebBrowser2 控件显示正常但点击页面里的按钮或链接没反应或者键盘输入不进网页里的输入框。原因IWebBrowser2 是一个 ActiveX 控件它需要窗口消息能够正确到达它的子窗口。视图切换时如果你用 ShowWindow 控制显示隐藏浏览器控件的子窗口焦点没有被正确转移而且 AFX_IDW_PANE_FIRST 2 这个窗格 ID 与 ActiveX 容器的控件站点 ID 冲突也会导致消息路由错乱。解决两个步骤配合使用。第一切换时不要手动 SetFocus 到 CView让浏览器控件自己获取焦点。第二严格控制窗格 ID使用 AFX_IDW_PANE_FIRST 10 这类偏移量比较大的 ID避免和 ActiveX 控件站点内部 ID 冲突。如果你的项目里 IWebBrowser2 是自己 CreateEx 的还需要确保视图类的 EnableActiveAccessibility 或者 IOleInPlaceActiveObject 的激活流程没有被断掉。我踩这个坑时走了弯路最后是把浏览器视图单独放在一个 CView 派生类里并让这个视图成为唯一使用该窗格 ID 的视图问题才消失。// 正确示例创建浏览器视图后切换时特别处理焦点 case ID_VIEW_WEB: SetActiveView(m_pWebView); m_pWebView-ShowWindow(SW_SHOW); // 不直接调用 SetFocus让 ActiveX 控件自己处理聚焦 // 通过消息让浏览器控件获得焦点 m_pWebView-SendMessage(WM_SETFOCUS); break;注意这里用 SendMessage 发 WM_SETFOCUS而不是直接 SetFocus这样 ActiveX 容器有机会做自己的焦点处理。6. 用视图类型标识位管住切换逻辑一个少走弯路的小习惯说一个我后来养成的习惯在 CMainFrame 里加一个成员变量保存当前视图类型用枚举或者简单的整数即可。enum ViewType { VIEW_GRAPH 0, VIEW_DATA, VIEW_WEB }; int m_nCurrentViewType; void CMainFrame::OnViewGraph() { m_nCurrentViewType VIEW_GRAPH; // ... } void CMainFrame::OnViewData() { m_nCurrentViewType VIEW_DATA; // ... }这个标识位看起来多余但它解决了一个很实际的问题菜单项的复选状态UI 更新不需要去猜当前活动视图是谁直接读这个变量就行。尤其是当你有三个以上视图时OnUpdateCmdUI 里判断当前视图类型来打勾会省掉很多 GetActiveView 的调用。void CMainFrame::OnUpdateViewGraph(CCmdUI* pCmdUI) { pCmdUI-SetCheck(m_nCurrentViewType VIEW_GRAPH); }另一个能用上这个标识位的地方是文档关闭前的状态保存。MFC 单文档程序关闭时会记录窗口位置和视图状态。如果你希望在下次打开时恢复上次的视图模式就可以把这个标识位序列化到注册表里。做法也比较简单在 CMainFrame 的析构函数里写注册表在 OnCreate 里读注册表然后根据读到的值决定默认激活哪个视图。// 保存当前视图类型到注册表 AfxGetApp()-WriteProfileInt(_T(ViewSettings), _T(ViewType), m_nCurrentViewType); // 读取并恢复 int nSavedViewType AfxGetApp()-GetProfileInt(_T(ViewSettings), _T(ViewType), VIEW_GRAPH);除了注册表也可以把视图类型存进文档的 Serialize 里这样每个文档记住自己上次的视图模式。不过要注意文档的 Serialize 在视图创建之后才调用所以不能在 OnCreate 里直接读文档数据来决定创建哪个视图。我试过把视图切换操作延时到 OnInitialUpdate 之后用 PostMessage 发一个自定义消息在消息处理里再根据文档记录的视图类型执行切换这样就能绕开初始化顺序的限制。还有个细节是我后来才注意到的视图切换时不要忘记调用 UpdateWindow。ShowWindow 之后窗口消息队列里可能还积压着绘制消息UpdateWindow 会强制同步重绘避免用户看到一瞬间的空白或旧画面。这个开销不大但在低配机器上效果明显。m_pGraphView-ShowWindow(SW_SHOW); m_pGraphView-UpdateWindow();做到这一步你已经能应付单文档多视图切换的大部分场景了。我自己第一次做这个需求时栽在状态栏和焦点问题上后来把切换函数统一封装void CMainFrame::SwitchView(int nViewType) { CView* pOldView GetActiveView(); CView* pNewView NULL; switch (nViewType) { case VIEW_GRAPH: pNewView m_pGraphView; break; case VIEW_DATA: pNewView m_pDataView; break; case VIEW_WEB: pNewView m_pWebView; break; } if (pOldView NULL || pNewView NULL || pOldView pNewView) return; m_nCurrentViewType nViewType; pOldView-ShowWindow(SW_HIDE); pOldView-SetFocus(FALSE); SetActiveView(pNewView); pNewView-ShowWindow(SW_SHOW); pNewView-UpdateWindow(); pNewView-SetFocus(TRUE); RecalcLayout(); Invalidate(); }从那以后我每次做 MFC 单文档多视图切换都强制走同一个切换入口函数视图类型标识位和状态栏更新也一并处理再也没出过焦点或者残影的问题。这个封装看起来简单但能把最容易翻车的几个环节一次性锁住。希望帮到你。本文还有配套的精品资源点击获取