ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MFC SDI单文档多视图切换原理与实战:基于CSplitterWnd的视图池实现

MFC SDI单文档多视图切换原理与实战:基于CSplitterWnd的视图池实现 简介面向 MFC 开发者的 SDI 单文档多视图示例工程集中演示在单文档界面中创建并灵活切换多个视图解决同一文档需要多角度查看、对比编辑的实际需求。压缩包共 34 个文件以 10 个头文件和 9 个 C 源文件构成核心代码配合 rc 资源脚本、ico 图标及 bmp 工具栏位图等资源整体约 1.03MB工程结构清晰便于按模块阅读。目前已有 172 人学习下载。资源内含完整的 MFC 工程涉及文档类、视图类、主框架类及视图切换逻辑可直接编译运行通过断点调试和界面操作可直观理解 SDI 下多视图的创建、注册、切换与同步刷新机制也可作为扩展自定义视图布局的起点适合希望提升界面交互体验的 Windows 开发者参考。1. SDI 多视图切换到底在解决什么问题“单文档”和“多视图”放在一起第一反应往往是客户区被切成了四块左边目录、右边内容上面工具栏、下面状态栏。但 SDI 多视图切换真正处理的是同一份 CDocument 数据在多个 CView 表达之间随时更换任意时刻只让一个视图拿到焦点和用户输入。CAD 里模型空间与图纸空间共用同一份几何数据调试器里源码窗口和反汇编窗口盯着同一个地址上位机里同一批设备数据在表格、曲线、拓扑图之间按快捷键切换。这些场景的共同点是文档只有一份视图类却有两三个甚至更多。对维护 MFC 工程的人来说核心问题是视图怎么挂上去、切换怎么不崩、数据怎么同步对刚接手这种工程的新手来说则是 OnCreateClient 能不能改、改完之后新视图从哪里拿文档指针。下面按“原理、搭建、实战、排错、优化”的顺序把这条线走通。2. MFC 文档-视图架构与多视图的挂载原理多视图不是把几个窗口硬堆在框架里而是利用 MFC 固有的文档/视图解耦机制。先把启动链路里“谁创建了视图、文档指针从哪里来”这两件事说清楚后面写切换代码才不会踩到空指针和命令路由的坑。2.1 文档与视图解耦一份数据多套投影MFC SDI 程序的启动路径是固定的CWinApp::InitInstance 里创建 CSingleDocTemplateProcessShellCommand 打开文档时模板同时完成“创建框架窗口、创建初始视图、绑定文档与视图”三步。默认情况下一套 SDI 只产生一个 CView这个事实让很多人误以为“单文档”和“多视图”是对立的。实际不是。CDocument 持有数据负责序列化和通知CView 负责把数据画出来并解释鼠标键盘输入。文档不记录“有多少个视图在看着我”视图之间也不互相知道存在。UpdateAllViews 只是广播一条“数据变了”的消息谁收到谁刷新谁没显示谁就先记着下次显示时再补画。多视图切换能成立恰恰是因为数据只有一份而“画法”可以有很多套——同一个文档对象既可以被网格视图读成行和列也可以被曲线视图读成折线。2.2 CSingleDocTemplate 的第四个参数只是“初始视图”不是上限看一段最常见的 SDI 工程启动代码BOOL CMyApp::InitInstance() { CSingleDocTemplate* pDocTemplate new CSingleDocTemplate( IDR_MAINFRAME, RUNTIME_CLASS(CMyDoc), RUNTIME_CLASS(CMainFrame), RUNTIME_CLASS(CMyView)); AddDocTemplate(pDocTemplate); CCommandLineInfo cmdInfo; ParseCommandLine(cmdInfo); if (!ProcessShellCommand(cmdInfo)) return FALSE; return TRUE; }这里第 4 个参数 RUNTIME_CLASS(CMyView) 会被 CFrameWnd::OnCreateClient 的默认实现当作“唯一视图”来创建。也就是说它是一个默认值不是硬上限。一旦在 CMainFrame 里重写 OnCreateClient这个参数影响到的只是“第一个视图长什么样”后续要挂几个视图、挂成什么布局完全由 OnCreateClient 里的代码决定。这也是“单文档双视图”“单文档多视图”的最底层入口。2.3 CCreateContext视图拿文档指针的唯一通道BOOL CMainFrame::OnCreateClient(LPCREATESTRUCT lpcs, CCreateContext* pContext) { return CFrameWnd::OnCreateClient(lpcs, pContext); }pContext 是 CFrameWnd 创建客户区时传入的上下文内部装着这次视图创建需要的全部信息。下表列出与多视图切换直接相关的成员成员含义多视图场景下的作用m_pCurrentDoc当前文档指针新视图通过它拿到 GetDocument()m_pNewViewClass将要创建的视图类懒创建视图时指定类型m_pCurrentFrame当前框架窗口视图的父窗口链路m_pLastView上次激活的视图可用于复制视图状态m_pNewDocTemplate文档模板从模板取菜单和加速键资源提示pContext 指向的是栈对象OnCreateClient 返回后它就被销毁。如果希望在按钮事件或菜单事件里再创建新视图必须先把内容拷贝到 CMainFrame 的成员变量里。很多“第一次切换正常、第二次切换崩溃”的案例根因就是视图创建发生在 OnCreateClient 之后代码还在用那个已经失效的指针。3. 用 CSplitterWnd 搭出单文档双视图的静态框架切分窗口是 SDI 多视图最常见的载体。静态切分适合“每个区域视图类型固定”的布局动态切分适合“用户自己拉出多个同类型窗格”的场景。多视图切换要的是前者。3.1 CreateStatic行列参数决定 pane 拓扑CSplitterWnd::CreateStatic 的签名是BOOL CreateStatic(CWnd* pParentWnd, int nRows, int nCols, DWORD dwStyle, UINT nID)。nRows 和 nCols 确定 pane 网格(1, 2) 是左右两列一行(2, 1) 是上下两行一列(1, 3) 是左中右三列。静态切分一旦创建行列数就固定之后不能动态增加或删除 pane。pane 的初始尺寸在 CreateView 的 sizeInit 参数里给但真正的列宽由 SetColumnInfo / SetRowInfo 控制m_wndSplitter.SetColumnInfo(0, 180, 30); // 第 0 列理想宽度 180最小宽度 30 m_wndSplitter.SetRowInfo(0, 200, 40); // 第 0 行理想高度 200最小高度 40第二个参数是理想尺寸第三个是最小尺寸。最小尺寸的意义是用户拖动切分条时不能让某列消失。如果不设pane 的默认最小尺寸为 0用户可能把切分条拖到边界外界面看起来像“视图被切没了”。老手通常把左侧导航列的最小值设为 80 到 120。3.2 最小实现左树右内容两个视图类并排下面是一段可直接放进 CMainFrame 的 OnCreateClient 代码BOOL CMainFrame::OnCreateClient(LPCREATESTRUCT lpcs, CCreateContext* pContext) { if (!m_wndSplitter.CreateStatic(this, 1, 2)) { TRACE0(Failed to create splitter static\n); return FALSE; } if (!m_wndSplitter.CreateView(0, 0, RUNTIME_CLASS(CLeftTreeView), CSize(180, 200), pContext)) { TRACE0(Failed to create tree view\n); return FALSE; } if (!m_wndSplitter.CreateView(0, 1, RUNTIME_CLASS(CDataGridView), CSize(600, 200), pContext)) { TRACE0(Failed to create content view\n); return FALSE; } return TRUE; }CreateView 的 5 个参数依次是行号、列号、视图类、初始尺寸、创建上下文。pContext 原样透传所以 CreateView 创建的视图和默认视图一样能拿到 m_pCurrentDoc。需要注意RUNTIME_CLASS 指向的类必须从 CView 派生否则 CreateView 内部动态创建失败返回 FALSE。这段代码执行后客户区被一分为二左边 CLeftTreeView 显示导航右边 CDataGridView 显示主内容。严格说它已经是“单文档双视图”——同一份文档左侧树和右侧网格各自从文档取数据。但要支持“切换”还需要在右侧 pane 里容纳多于一个视图类。3.3 为什么多视图切换不用动态切分CSplitterWnd 还有一套 CreateDynamic 变体允许用户在运行时拖动切分条产生新 pane。但动态切分创建的视图类型来自文档模板里指定的那个类不能在同一位置随意换成另一个 CView 派生类。也就是说动态切分解决的是“同一视图类的窗口数量”问题不是“不同视图类的切换”问题。如果目标是“同一块区域在网格视图和图表视图之间切”正确做法是让这个区域成为一个 pane 宿主视图池里的多个视图全部挂到这个宿主下切换时用 ShowWindow 控制可见性。这个思路在第 4 章展开。很多从示例代码抄来的工程会在切换时直接销毁旧视图再 new 一个新视图反复几次就出现内存泄漏或悬挂指针——根因是把“切分”当成了“重建窗口”的触发器。CreateView 参数含义建议row, colpane 在网格中的行列位置0,0 表示左上pViewClass视图运行时类指针必须是 CView 派生类sizeInit初始像素尺寸按布局需要填 CSizepContext创建上下文原样透传 OnCreateClient 的参数4. 菜单与快捷键驱动的单文档多视图切换实战双视图是最小的多视图。只做两个视图时切换逻辑就是 if (是A) 显示B否则显示A但工程上一般上来就按枚举和数组抽象因为从两个视图扩到四个视图是产品需求里最常见的改动。把“视图类注册表”和“视图池数组”立起来后续只是加一行的事。4.1 视图注册表先把要切换的视图类登记起来enum ViewType { VIEW_GRID 0, VIEW_CHART, VIEW_TEXT, VIEW_COUNT }; struct ViewReg { CRuntimeClass* pClass; // 视图运行时类 UINT nCmd; // 切换菜单命令 ID }; static ViewReg s_viewReg[VIEW_COUNT] { { RUNTIME_CLASS(CDataGridView), ID_VIEW_GRID }, { RUNTIME_CLASS(CChartView), ID_VIEW_CHART }, { RUNTIME_CLASS(CTextView), ID_VIEW_TEXT }, };注册表把“视图类型”和“菜单命令”绑定。新增视图时不用改动切换函数只加一个枚举、一个注册表项、一条菜单和对应加速键。视图池在 CMainFrame 里声明为CView* m_pViewPool[VIEW_COUNT];初始全部置空。第一次切换到某个视图时才创建它这就是后面的惰性实例化。4.2 视图挂到宿主 paneSetParent 与子窗口 ID切换前需要先准备宿主窗口。下面代码里的m_wndSplitter.GetPane(0, 1)就是第 3 章切分出的右侧 panevoid CMainFrame::SwitchToView(ViewType newType) { if (newType 0 || newType VIEW_COUNT) return; CView* pNew GetOrCreateView(newType); if (pNew nullptr) return; CView* pOld GetActiveView(); if (pNew pOld) { pNew-SetFocus(); return; } CWnd* pHost m_wndSplitter.GetPane(0, 1); CRect rc; pHost-GetClientRect(rc); // 关键把视图重新挂到宿主窗口上 pNew-SetParent(pHost); pNew-SetDlgCtrlID(AFX_IDW_PANE_FIRST 1); pNew-MoveWindow(rc); pNew-ShowWindow(SW_SHOW); if (pOld ! nullptr pOld-GetParent() pHost) pOld-ShowWindow(SW_HIDE); SetActiveView(pNew); pNew-Invalidate(FALSE); pNew-SetFocus(); RecalcLayout(); m_curViewType newType; }这里三个细节值得单独强调。第一个是 SetParent视图池里的其他视图在创建时 parent 可能设置的是框架窗口切换时必须重新挂到 pane 上。第二个是 SetDlgCtrlID(AFX_IDW_PANE_FIRST 1)MFC 的命令路由会检查活动视图的子窗口 ID如果 ID 不符合框架对主 pane 的约定菜单和快捷键命令可能路由到隐藏的旧视图。第三个是显示顺序先 ShowWindow 新视图再隐藏旧视图能减少黑背景闪现。切换环节代码作用挂载SetParent(pHost)让视图成为 pane 的子窗口命令路由SetDlgCtrlID(AFX_IDW_PANE_FIRST 1)保证命令发给活动视图显示顺序先 SW_SHOW 新再 SW_HIDE 旧减少重绘闪现激活SetActiveView(pNew)切换 MFC 内部活动视图指针4.3 创建视图的公共路径CCreateContext 的保存与复用GetOrCreateView 内部要懒创建而 OnCreateClient 的 pContext 已经失效。所以必须在 OnCreateClient 里把 CCreateContext 拷贝到成员BOOL CMainFrame::OnCreateClient(LPCREATESTRUCT lpcs, CCreateContext* pContext) { if (!m_wndSplitter.CreateStatic(this, 1, 2)) return FALSE; if (!m_wndSplitter.CreateView(0, 0, RUNTIME_CLASS(CLeftTreeView), CSize(180, 200), pContext)) return FALSE; if (!m_wndSplitter.CreateView(0, 1, RUNTIME_CLASS(CDataGridView), CSize(600, 200), pContext)) return FALSE; if (pContext ! nullptr) m_ctxSaved *pContext; return TRUE; } CView* CMainFrame::GetOrCreateView(ViewType type) { if (m_pViewPool[type] ! nullptr) return m_pViewPool[type]; CCreateContext ctx m_ctxSaved; ctx.m_pNewViewClass s_viewReg[type].pClass; CView* pView static_castCView*( ctx.m_pNewViewClass-CreateObject()); if (pView nullptr) return nullptr; CWnd* pHost m_wndSplitter.GetPane(0, 1); if (!pView-Create(nullptr, nullptr, WS_CHILD, CRect(0, 0, 0, 0), pHost, AFX_IDW_PANE_FIRST 1, ctx)) { delete pView; return nullptr; } pView-SendInitialUpdate(); pView-ShowWindow(SW_HIDE); m_pViewPool[type] pView; return pView; }CreateObject 之后必须紧跟 CreateCreate 失败时要自己 delete因为 CView 不像对话框那样由框架管理生命周期。Create 成功后的 CView 由窗口系统管理窗口销毁时对象一并销毁不能手动 delete。SendInitialUpdate 必须手动调用它内部会触发 OnInitialUpdate让新视图完成数据绑定漏掉这一步视图画出来往往是一片空白。4.4 菜单勾选、禁止与快捷键切换入口绑定到菜单后需要让菜单项显示当前激活的视图类型并在视图创建失败时禁用void CMainFrame::OnUpdateViewGrid(CCmdUI* pCmdUI) { pCmdUI-SetCheck(m_curViewType VIEW_GRID); }加速键在资源编辑器的 Accelerator 表里加Ctrl1、Ctrl2、Ctrl3命令 ID 与菜单一致。SDI 的加速键表通常挂在 IDR_MAINFRAME 上不需要额外代码。切换的调用点统一走 SwitchToView菜单和加速键都只是把对应枚举传给它。到这里单文档多视图切换的骨架已经完整一个静态切分窗口左侧导航右侧视图池菜单和快捷键驱动 SwitchToView。剩下的问题集中在数据同步和生命周期上。5. 数据同步、焦点与视图生命周期切换背后的三个暗坑5.1 UpdateAllViews 的两种用法多视图切换只是让另一个视图可见。如果用户之前改了数据新视图显示的内容必须和文档一致。CDocument::UpdateAllViews 是这里的唯一信使void CMyDoc::NotifyItemChanged(ItemId id) { UpdateAllViews(nullptr, HINT_ITEM_CHANGED, reinterpret_castCObject*(id)); }UpdateAllViews 同步执行它遍历所有关联视图逐个调用 OnUpdate。pHint 指向的栈对象在 UpdateAllViews 返回后失效所以只能在 OnUpdate 内部读取不能保存指针供以后用。视图的 OnUpdate 默认实现是 Invalidate(TRUE)但多视图场景建议自己重写场景写法说明全量刷新UpdateAllViews(nullptr)所有视图全部失效重画定向通知UpdateAllViews(nullptr, HINT, pHint)OnUpdate 里按 lHint 细分安全取数OnDraw 里调用 GetDocument()不依赖成员缓存5.2 隐藏视图的重绘时机OnUpdate 与 OnDraw 的拆伙隐藏视图在不可见时不收 WM_PAINT所以 OnUpdate 里的 Invalidate 是“把脏标记记下来”等视图重新显示时由 WM_PAINT 补画。只依赖 OnDraw 而不调用 Invalidate切换回来大概率看到旧内容。正确做法是在 OnUpdate 里根据 lHint 决定失效范围void CChartView::OnUpdate(CView* pSender, LPARAM lHint, CObject* pHint) { if (lHint HINT_SERIES_CHANGED) { Invalidate(FALSE); } } void CChartView::OnDraw(CDC* pDC) { CMyDoc* pDoc GetDocument(); if (pDoc nullptr) return; const SeriesData series pDoc-GetSeries(); // 实时取数 // 根据 series 绘制曲线不使用成员变量缓存 }OnDraw 不能被假设成 OnUpdate 的后续步骤。WM_PAINT 随时可能产生窗口被遮挡后还原、切分条拖动、SetFocus都会触发重绘。所以 OnDraw 每次进入都必须从头读取文档数据不能依赖上一次 OnUpdate 留下的成员变量。5.3 生命周期谁销毁视图谁保存指针视图池中的 CView 在窗口销毁时由框架统一清理不需要也不应该手动 delete。真正会出问题的是销毁顺序关闭 SDI 程序时框架先销毁视图再销毁文档。如果 CMainFrame::OnDestroy 里还留着视图指针去访问文档就会踩到空文档。标准做法是视图池的清理全部交给框架CMainFrame 只在 OnDestroy 里把 m_pViewPool 全部置空防止后续误用。CView 派生类的析构函数里不要访问文档对象MFC 对“先销毁视图还是先销毁文档”没有跨版本统一的保证析构顺序的假设一旦写进代码换个编译选项就可能崩。6. 视图池的惰性创建与 SDI 多视图切换的启动恢复6.1 把创建成本摊到第一次切换一次 CView 创建的开销包含CreateWindow、OnCreate 里的子控件创建、OnInitialUpdate 里的数据绑定重的视图可能有几百毫秒。如果启动时全部创建程序会变慢如果每次切换都销毁重建高频操作时会有明显卡顿。视图池的思路和 YOLO 这类“只看一次”的思路相反这里是“只建一次”——把重复切换时的创建成本前置到第一次打开那个视图的瞬间用一次可接受的等待换后续的无缝切换体验。实现上要做的就是前面的 GetOrCreateView 检查 m_pViewPool 非空。首次创建时显示的视图走完 SendInitialUpdate 再 ShowWindow后续切换的直接 ShowWindow(SW_SHOW)不需要再走 OnInitialUpdate。如果某个视图创建失败把池里的指针置空并弹一次诊断信息比反复尝试创建更省时间。6.2 启动时恢复用户上次使用的视图最后一个实用技巧用注册表或 INI 记住最后一次激活的视图类型。保存时机放在 SwitchToView 末尾void CMainFrame::SwitchToView(ViewType newType) { // ... 原有切换逻辑 ... CWinApp* pApp AfxGetApp(); if (pApp ! nullptr) pApp-WriteProfileInt(_T(MainFrame), _T(LastView), static_castint(newType)); }启动时在 OnCreateClient 创建完默认视图后读取并恢复UINT lastView AfxGetApp()-GetProfileInt( _T(MainFrame), _T(LastView), static_castUINT(VIEW_GRID)); if (lastView static_castUINT(VIEW_COUNT)) SwitchToView(static_castViewType(lastView));注意OnCreateClient 里已经创建了默认内容视图恢复时如果目标视图恰好就是默认视图SwitchToView 会命中 pNew 等于 pOld 的分支只 SetFocus 不做重建。如果目标视图还没有创建走 GetOrCreateView 的惰性路径补建。这个恢复动作对天天开着同类型监控视图的操作员来说体验提升比优化几十毫秒启动时间更明显。本文还有配套的精品资源点击获取
返回列表