十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

MFC拆分窗口详解:CSplitterWnd静态/动态拆分与数据交换

MFC拆分窗口详解:CSplitterWnd静态/动态拆分与数据交换 简介面向 MFC 开发者的拆分窗口示例工程包专门演示 CSplitterWnd 静态与动态拆分窗口的创建流程以及多个子视图之间交换数据的常用实现方式适合正在学习 VC/MFC 界面编程、需要构建多面板编辑或数据管理界面的开发者参考。包内共 29 个文件以 8 个头文件和 7 个 C 源文件为主体可直接查看窗口类继承、OnCreate 重写、Create/CreateIndirect 添加视图、消息映射等核心代码另有 dsp/dsw 工程文件、rc 资源描述文件和 docx 配套说明方便还原项目结构并对照阅读。压缩包仅 68KB轻量小巧适合快速下载后直接编译调试。目前已有 885 人浏览/学习。通过学习该项目可以掌握 MFC 拆分窗口的类设计思路并对比成员变量共享、自定义消息、文档视图架构等数据交换策略为实际开发中多视图协作与联动更新提供直接的代码参照。 接手老项目时最常碰到的一类界面需求就是“左边导航、右边内容”这种经典布局而且中间还要能拖动分隔条。如果你用的是 MFC那第一个想到的必然是CSplitterWnd也就是拆分窗口。它能把一个客户区分割成多个独立的窗格每个窗格里放不同的视图或对话框配合消息机制实现联动。这篇文章就把拆分窗口的创建方式、尺寸控制、嵌套拆分以及窗格之间数据交换的几种常见做法完整的梳理一遍适合正在维护 MFC 老项目、或者打算用 MFC 快速做桌面工具的开发者参考。1. 拆分窗口怎么选静态拆分与动态拆分1.1 两种拆分模式的本质区别CSplitterWnd提供了两种创建方式CreateStatic和CreateDynamic这两者的差别不仅仅是函数名不一样背后的交互逻辑和适用场景完全不同。CreateStatic是静态拆分窗格数量在创建时就写死了用户不能通过快捷键或者菜单动态增加窗格但每个窗格的内容可以完全不同。比如左边放CTreeView右边放CEditView这是最常见的“导航编辑”组合。静态拆分下用户只能拖动拆分条调整每个窗格的大小不能合并窗格。CreateDynamic则是动态拆分创建时最初只有一个窗格用户可以通过菜单“拆分”命令把窗口分成多个窗格每个窗格都是同一个视图类的实例。这种模式更偏向文档编辑场景比如把一个文本文件拆成上下两份对照着看。动态拆分对视图类有要求它必须能支持动态创建所以一般在CMultiDocTemplate或CSingleDocTemplate中注册过才行。1.2 选型建议和实际项目中的取舍我个人的习惯是只要界面布局是设计好的一律用静态拆分。原因很简单静态拆分可控性强每个窗格放什么类、占多大比例、消息结构怎么走代码里清清楚楚。动态拆分看起来灵活但实际项目里很少遇到用户需要自己拆分窗格的场景反而可能因为误操作把界面拆得乱七八糟。另外一个容易被忽略的点是动态拆分对视图类有隐藏要求视图类必须要有默认构造函数且框架需要通过RUNTIME_CLASS反射来动态创建。如果你的视图构造函数带参数或者视图初始化逻辑比较复杂动态拆分基本就不合适。静态拆分就没这个问题它要求你在OnCreateClient里手动调CreateView每个窗格什么时候创建、以什么方式创建完全可控。对比维度静态拆分 CreateStatic动态拆分 CreateDynamic窗格数量创建时固定不可变用户可随时拆分/合并每个窗格内容可放置不同类型视图所有窗格都是同一视图类视图类要求无特殊要求手动创建必须支持动态创建默认构造已注册模板典型场景左侧树右侧编辑、仪表盘、上位机监控面板文本对照、报表分屏浏览维护成本结构清晰易维护灵活但潜在问题多2. 创建拆分窗口的完整配置过程2.1 重写 OnCreateClient 挂载拆分条静态拆分的核心切入点就是重写框架窗口的OnCreateClient。这个函数在框架窗口创建客户区时被调用默认会创建一个普通视图而我们要做的就是在这里把原来的创建逻辑替换成拆分窗口。先说一个最基础的骨架在CMainFrame里添加一个CSplitterWnd成员变量然后重写函数BOOL CMainFrame::OnCreateClient(LPCREATESTRUCT lpcs, CCreateContext* pContext) { // 竖着切成两列一行、两列每个窗格大小约 300*300 if (!m_wndSplitter.CreateStatic(this, 1, 2, WS_CHILD | WS_VISIBLE)) { return FALSE; } // 创建左侧树视图窗格 if (!m_wndSplitter.CreateView(0, 0, RUNTIME_CLASS(CLeftTreeView), CSize(300, 300), pContext)) { return FALSE; } // 创建右侧编辑视图窗格 if (!m_wndSplitter.CreateView(0, 1, RUNTIME_CLASS(CRightEditView), CSize(600, 300), pContext)) { return FALSE; } // 设置初始列宽第一列 280 像素其余给第二列 m_wndSplitter.SetColumnInfo(0, 280, 100); m_wndSplitter.SetColumnInfo(1, 600, 100); return TRUE; }几个关键点需要说明一下。CreateStatic的参数依次是父窗口、行数、列数这里 1 行 2 列也就是左右两个窗格。CreateView的参数是行索引、列索引、视图运行时类、初始大小和上下文指针。pContext必须原样传下来因为视图的文档关联要靠它完成这个参数来自CView的文档模板机制。2.2 行高列宽的设置与最小尺寸限制拆分窗口创建好之后你会发现默认每个窗格尺寸是均匀分配的如果不设置想调成“左窄右宽”就得靠SetRowInfo和SetColumnInfo这两个函数。它们的参数是行/列索引、理想尺寸、最小尺寸。第三个参数 min 值特别重要它决定了用户拖动拆分条时窗格最小能缩到多小。比如你不想让左边树视图被压缩到 100 像素以下那就得这样写// 第一列理想宽度 300最小不能小于 150 m_wndSplitter.SetColumnInfo(0, 300, 150); m_wndSplitter.SetColumnInfo(1, 500, 200);这里有一个常见的坑SetColumnInfo必须在CreateView之后调用才会生效。因为CreateView会重算窗格尺寸如果你先设置列宽再创建视图设置的值会被覆盖掉。还有如果你在OnCreateClient里设置不能满足需求可以把设置逻辑放到OnSize里但要注意别在OnSize里频繁调用SetColumnInfo否则用户在拖动拆分条时你设置的理想值会不断把窗格拉回初始大小体验很差。2.3 嵌套拆分两层拆分窗口的实现思路有些界面需要更复杂的布局比如左侧树右上方是列表右下方是详细信息这种“三窗格”甚至“四窗格”布局就需要做嵌套拆分。嵌套拆分的思路和搭积木一样先创建一个外层拆分窗口然后在某个窗格里再创建一个内层拆分窗口。外层拆分的某个窗格它的“内容”不是视图而是另一个拆分窗口。看下面的做法还是重写OnCreateClientBOOL CMainFrame::OnCreateClient(LPCREATESTRUCT lpcs, CCreateContext* pContext) { // 外层一行两列左边树右边放内层拆分条 if (!m_wndSplitter.CreateStatic(this, 1, 2, WS_CHILD | WS_VISIBLE)) { return FALSE; } // 外层左边窗格树视图 if (!m_wndSplitter.CreateView(0, 0, RUNTIME_CLASS(CLeftTreeView), CSize(300, 300), pContext)) { return FALSE; } // 外层右边窗格内层拆分条注意行和列索引是 0,1 if (!m_wndSplitterInner.CreateStatic(m_wndSplitter, 2, 1, WS_CHILD | WS_VISIBLE, m_wndSplitter.IdFromRowCol(0, 1))) { return FALSE; } // 内层上方列表视图 if (!m_wndSplitterInner.CreateView(0, 0, RUNTIME_CLASS(CListCtrlView), CSize(400, 200), pContext)) { return FALSE; } // 内层下方编辑视图 if (!m_wndSplitterInner.CreateView(1, 0, RUNTIME_CLASS(CDetailEditView), CSize(400, 200), pContext)) { return FALSE; } return TRUE; }嵌套拆分最关键的一步是CreateStatic的最后一个参数ID。内层拆分窗口必须用IdFromRowCol(0, 1)指定的 ID这个 ID 对应外层拆分窗口右侧窗格。如果漏了这个 ID内层拆分窗口的父窗口虽然还是m_wndSplitter但窗格识别会出错消息路由也会跟着出问题。3. 核心环节窗格之间的数据交换3.1 方案一自定义消息最直接普适的做法拆分窗口本身不做数据交换它只负责把界面切分好。真正的数据联动要靠窗格之间互相发消息。自定义消息是最经典、也是我用的最多的方式特别是当你不想让两个视图类产生编译期依赖时消息机制是最干净的解耦手段。先定义一个自定义消息 ID一般放在全局头文件里// MainFrm.h 或全局定义区 #define WM_TREE_SELCHANGED (WM_USER 100)左边树视图在选中节点变化时向右边编辑视图发消息。树视图里持有拆分窗口指针通过拆分窗口拿到目标窗格然后 SendMessage// CLeftTreeView.cpp void CLeftTreeView::OnTvnSelchanged(NMHDR* pNMHDR, LRESULT* pResult) { // 获取当前选中项文本 CString strText; // ... 取选中项的文本 // 拿到主框架窗口的拆分条 CSplitterWnd* pSplitter (CSplitterWnd*)GetParent(); // 找到右侧窗格对应的视图窗口 CWnd* pRightPane pSplitter-GetPane(0, 1); // 发送自定义消息把数据带过去 pRightPane-SendMessage(WM_TREE_SELCHANGED, 0, (LPARAM)strText); }右侧视图重写WindowProc或者提供消息处理函数来接// CRightEditView.h afx_msg LRESULT OnTreeSelChanged(WPARAM wParam, LPARAM lParam); // CRightEditView.cpp BEGIN_MESSAGE_MAP(CRightEditView, CEditView) ON_MESSAGE(WM_TREE_SELCHANGED, CRightEditView::OnTreeSelChanged) END_MESSAGE_MAP() LRESULT CRightEditView::OnTreeSelChanged(WPARAM wParam, LPARAM lParam) { CString* pText (CString*)lParam; SetWindowText(*pText); return 0; }这里有两个容易踩的坑。第一个是强制类型转换lParam传指针接收方要转成对应的类型如果类型对不上或者生命周期结束就是内存问题。所以传字符串指针时最好保证接收方在处理消息期间不会释放这个字符串。第二个是SendMessage和PostMessage的区别前者同步等待处理完返回后者异步派发。如果数据是栈上临时变量必须用SendMessage因为PostMessage回来后字符串就销毁了接收方拿到悬空指针。3.2 方案二共享数据对象适合多窗格频繁访问如果窗格之间有大量数据需要共享而且不止两个窗格消息满天飞就容易乱。这时候可以抽一个共享数据对象所有窗格都持有一个指向它的指针读写都走这个对象。比如一个上位机监控界面左边设备列表中间实时曲线右边参数配置。三个窗格都要访问当前的设备状态数组与其让每个窗格互相发消息不如做一个CDeviceDataHub// DeviceDataHub.h class CDeviceDataHub { public: static CDeviceDataHub* Instance() { static CDeviceDataHub s_instance; return s_instance; } void UpdateDeviceStatus(int nDevId, const CString strStatus) { m_statusMap[nDevId] strStatus; } CString GetDeviceStatus(int nDevId) { return m_statusMap[nDevId]; } private: CMapint, int, CString, CString m_statusMap; }然后各视图在使用时直接通过单例访问void CMiddleCurveView::OnTimer(UINT_PTR nIDEvent) { // 从共享数据对象里取最新设备状态 CString strStatus CDeviceDataHub::Instance()-GetDeviceStatus(m_nDevId); // 更新曲线 CView::OnTimer(nIDEvent); }这样做的优势是数据流单一所有窗格都从一个地方读数据不会出现 A 改了数据、B 不知道的情况。缺点是要注意线程安全如果多线程访问需要加锁。纯 MFC 界面通常跑在主线程问题不大。3.3 三种数据交换方案横向对比方案耦合度适用场景注意点自定义消息低视图之间不需要互相 include两个窗格间的点对点通信注意消息参数生命周期、SendMessage/PostMessage 选择共享数据对象 / Hub中各视图依赖同一个类多窗格、高频读写必须做好线程同步避免数据不一致直接指针访问高视图之间直接 include 对方头文件快速验证原型或结构非常固定的父子关系可维护性差一改类结构牵连一片实际项目里我倾向于组合使用界面联动类的交互用自定义消息业务数据共享用 Hub 类。消息负责“通知”Hub 负责“存储”这样职责清晰不会把消息参数变成一个大杂烩结构体。4. 实操示例树形导航 编辑区联动4.1 搭建两个自定义视图类为了演示我们创建一个简单的示例左边是一个继承自CTreeView的类显示设备分组右边是一个继承自CEditView的类显示选中设备的描述信息。创建视图类时有一步很容易忽略确保这两个视图类在文档模板里被正确注册否则运行时CreateView会报“视图类未注册”的错误。RUNTIME_CLASS只是个编译期宏真正去创建视图还是需要CRuntimeClass::CreateObject在做支撑这个前提是类里有DECLARE_DYNCREATE/IMPLEMENT_DYNCREATE所以自定义视图类一定要保证这两个宏成对出现。4.2 在拆分窗口中挂载并完成初始化在CMainFrame::OnCreateClient里挂载和前面骨架一样。创建完成后还要分别拿到两个视图的指针做初始化。比如让树视图默认选中第一项编辑视图默认显示第一项的描述BOOL CMainFrame::OnCreateClient(LPCREATESTRUCT lpcs, CCreateContext* pContext) { if (!m_wndSplitter.CreateStatic(this, 1, 2, WS_CHILD | WS_VISIBLE)) return FALSE; if (!m_wndSplitter.CreateView(0, 0, RUNTIME_CLASS(CDeviceTreeView), CSize(250, 300), pContext)) return FALSE; if (!m_wndSplitter.CreateView(0, 1, RUNTIME_CLASS(CDescEditView), CSize(600, 300), pContext)) return FALSE; // 初始化树视图默认选中 CDeviceTreeView* pTreeView (CDeviceTreeView*)m_wndSplitter.GetPane(0, 0); pTreeView-SelectFirstItem(); // 初始化编辑视图为第一项对应的描述 CDescEditView* pEditView (CDescEditView*)m_wndSplitter.GetPane(0, 1); pEditView-ShowDescription(TEXT(还没有选择设备)); return TRUE; }这里用GetPane(0, 0)拿到窗格指针然后强转成视图类型。前提是你创建时用的是什么类型取出来就是什么类型。如果类型不匹配强转后调用函数就不是你想要的行为运行时会崩。4.3 打通数据流树选中触发编辑区更新数据流打通的核心逻辑是树视图发消息编辑视图接收消息并刷新显示。树视图里处理 TVN_SELCHANGED 通知void CDeviceTreeView::OnTvnSelchanged(NMHDR* pNMHDR, LRESULT* pResult) { LPNMTREEVIEW pNMTreeView reinterpret_castLPNMTREEVIEW(pNMHDR); // 拿到选中的树项文本 CString strItemText; HTREEITEM hItem pNMTreeView-itemNew.hItem; if (hItem ! NULL) { char szBuffer[256] {0}; TVITEM item {0}; item.hItem hItem; item.mask TVIF_TEXT; item.pszText szBuffer; item.cchTextMax sizeof(szBuffer) / sizeof(szBuffer[0]); TreeView_GetItem(GetSafeHwnd(), item); strItemText szBuffer; } // 找到右侧编辑视图发送消息 CSplitterWnd* pSplitter (CSplitterWnd*)GetParent(); CWnd* pEditPane pSplitter-GetPane(0, 1); pEditPane-SendMessage(WM_TREE_SELCHANGED, 0, (LPARAM)strItemText); *pResult 0; }编辑视图里响应消息并更新显示LRESULT CDescEditView::OnTreeSelChanged(WPARAM wParam, LPARAM lParam) { CString* pText (CString*)lParam; if (pText ! NULL) { CString strDesc; strDesc.Format(TEXT(当前选中的设备是%s), *pText); SetWindowText(strDesc); } return 0; }有读者可能会问为什么非要在OnTvnSelchanged里通过GetParent()拿拆分窗口而不是直接存一个全局指针。因为“在需要时才去获取依赖方”比“从初始化起就持有全局依赖”更抗变化。如果哪天布局改成嵌套拆分这个视图被移到了内层拆分条里GetParent()拿到的对象变了但拆分窗口的接口还是那几个影响小很多。不过也要注意一下如果窗格层级变了比如树视图的父窗口不再是拆分窗口而是某个容器GetParent()的类型就变了这种写法就会出问题。所以更稳妥的方式是在视图创建后让框架窗口把拆分窗口的指针设置进视图里或者视图通过AfxGetMainWnd()向上找但这样又引入了一层依赖取舍在于你对项目结构的掌控力。5. 常见问题与排查技巧实录5.1 CreateView 报错 视图类未注册这是拆分窗口最常见的报错。CreateView内部会调用CRuntimeClass::CreateObject如果视图类没有DECLARE_DYNCREATE和IMPLEMENT_DYNCREATE宏CreateObject返回 NULL函数直接返回 FALSE。排查思路很直接检查视图类的头文件和实现文件里有没有这两个宏宏的参数必须和类名一致。另外一个看似不起眼的原因是自定义视图类所在的 DLL 没有被正确加载或者类被声明成了静态库但没链接进去运行时找不到这个类。纯 SDK 风格的 MFC 程序里这类问题更频繁。5.2 拆分条拖不动或者初始大小不对拆分条能拖但总被“拽”回某个固定宽度多半是OnSize里反复设置了SetColumnInfo。程序在每次窗口尺寸变化时都会把拆分条位置重置用户拖完窗口一刷新位置又回去了。还有一种情况是初始大小不对比如左右两边一样宽或者列宽显示异常。这个基本是CreateView时传的CSize参数和SetColumnInfo设置值相互覆盖导致的结果。解决方法是先CreateView然后调SetColumnInfo和SetRowInfo顺序不能反。5.3 消息发不到目标窗格SendMessage都调了接收方就是不响应。先确认接收方消息映射有没有加ON_MESSAGE这个宏在BEGIN_MESSAGE_MAP和END_MESSAGE_MAP之间。然后确认消息 ID 有没有冲突WM_USER是允许用户自定义消息的起始值但它不是无限的如果你的程序引用了第三方控件库WM_USER到WM_APP之间的值可能被占用稳妥起见可以把自定义消息定义到WM_APP或更高。另外一个容易忽略的点是GetPane(0, 1)返回的 CWnd 指针到底是谁。如果嵌套拆分GetPane(0, 1)拿到的可能是内层拆分条CSplitterWnd而不是具体的视图。这时候你向它发消息除非它自己转发了消息否则接收方永远不会响应。排查时要先确认目标窗格是不是你想要的那个视图。5.4 ListCtrl 选中项失去焦点后变灰拆分窗口有多个窗格时焦点从一个窗格切到另一个窗格原来选中的列表项颜色会从蓝色变成灰色视觉上“丢了选中状态”。这个问题不在拆分窗口本身而在于CListCtrl的默认样式没有开启LVS_SHOWSELALWAYS。处理方案有两种一种是在创建CListCtrl时给样式加上LVS_SHOWSELALWAYS让它即使失去焦点也保留选中高亮另一种是在WM_KILLFOCUS和WM_SETFOCUS里自己控制选中态颜色比如重写OnNMCustomdraw。前者简单省事适合大多数场景后者适合要求选中态颜色完全一致的项目。5.5 嵌套拆分窗口的 ID 冲突与销毁顺序嵌套拆分时内层拆分窗口的 ID 必须通过IdFromRowCol获取不能随意指定一个 ID否则消息循环里主框架的CommandTarget路由会出问题。另外嵌套窗口的销毁顺序也有讲究先销毁内层再销毁外层不能用默认的窗口销毁顺序覆盖。如果项目里在CMainFrame的析构函数里手动清理窗口要确保先 delete 内层拆分窗口对象再 delete 外层。6. 踩坑后的几点实操心得拆分窗口这个功能用起来不难但要做得顺滑有几个小习惯我后来一直保持。一是能用静态拆分绝不用动态拆分界面布局固定是常态动态拆分看着灵活实际项目里反而容易给用户制造混乱。二是在窗格之间传数据时尽量用自定义消息而不是直接拿指针调函数消息能做解耦出了问题也好定位。三是一定要重视 ID 分配拆分窗口的 ID 不能随便写静态拆分时行和列的 ID 由IdFromRowCol决定嵌套时尤其关键。还有一个小技巧调试拆分窗口布局问题时可以在OnCreateClient里暂时加一段代码用GetPane(0,0)-GetWindowText之类的调用把每个窗格的类型打出来确认窗格创建是否正确。这个排查方法比一直盯着CreateView的返回值要直观得多。拆分窗口的坑大多不是单个点的问题而是多个环节叠加出来的先确认结构再查消息问题基本都能定位到。本文还有配套的精品资源点击获取
返回列表