简介:这份资源是 WeifenLuo.WinFormsUI.Docking 的源代码与示例工程,面向使用 C# 开发 Windows Forms 应用的开发者,尤其是需要实现类似 Visual Studio 可停靠面板、多文档界面(MDI)的中高级程序员。它解决了自研布局逻辑成本高、停靠与浮动窗口难以统一管理的问题,核心组件 DockPanel 支持顶部、底部、左侧、右侧及浮动等多种停靠模式,并具备自动隐藏能力。压缩包共 196 个文件,约 540KB,以 85 个 cs 源码、15 个 resx 资源、55 个 bmp 与 19 个 ico 界面素材为主,另含 sln、csproj、bat 脚本及 nuspec 等工程与打包文件,便于直接编译调试。已有 489 人学习。通过源码可深入理解 DockState 枚举、DockControlArea 属性、DockWindows 集合与自定义 DockContent 的实现方式,示例项目则演示了文档窗口与浮动子窗口的创建流程,适合边读边改、快速落地到实际项目。
1. 从一次“窗口乱飞”的调试说起:这套停更多年的 WinForms 布局库为什么还值得翻
前阵子帮一个做工业上位机的朋友看代码,他的主界面有十几个功能面板——实时曲线、报警列表、设备树、日志窗口,用户要求这些面板能拖拽、能停靠、能浮动、能保存布局,下次打开还原成上次的样子。他一开始用 TabControl 硬拼,结果用户把窗口拖到屏幕边缘就“乱飞”,布局还原更是玄学,重启后位置全错。我让他换成 C# DockPanel 这套方案,也就是 WeifenLuo.WinFormsUI.Docking,两天就把停靠、浮动、自动隐藏全跑通了。
这套库是 WinForms 时代做 IDE 风格多文档界面的经典选择,Visual Studio 那种可拖拽停靠的窗口布局,本质上就是它的思路。它虽然停更多年,但在 .NET Framework 的 WinForms 项目里依然稳,源码加例子的组合意味着你能直接读实现、改行为,而不是对着一个黑匣子 DLL 猜。适合谁?做上位机、工控组态、医疗设备界面、内部工具这类需要多面板协同的桌面开发者,尤其是被原生控件布局折磨过的人。下面我按“先搞懂它怎么组织窗口,再动手跑起来,最后说坑”的顺序拆一遍。
2. 拆开 DockPanel 的窗口模型:DockContent、DockState 与停靠算法
2.1 三个核心对象撑起整个布局
这套库的模型不复杂,抓住三个东西就够:DockPanel 是容器,负责管理所有子窗口的停靠关系;DockContent 是可停靠的窗体基类,你的每个功能面板都继承它;DockState 是状态枚举,描述一个面板当前是停靠、浮动还是自动隐藏。三者关系是:你把 DockContent 子类实例交给 DockPanel,DockPanel 根据你指定的 DockState 和停靠目标,算出它在布局树里的位置。
布局树是理解一切的关键。DockPanel 内部维护一棵树,叶子节点是具体的 DockContent,中间节点是分割方向(水平或垂直)。当你把一个面板停靠到另一个面板的左侧,实际上是在那个叶子旁边插入一个新的分割节点。这解释了为什么拖拽时会出现各种预览框——它在实时计算插入点。常见做法是:主窗口放一个 DockPanel 填满,然后按业务优先级依次 DockTo 或 DockTo 到已有面板,形成初始布局。
// 主窗体里初始化 DockPanel public partial class MainForm : Form { private DockPanel dockPanel; public MainForm() { InitializeComponent(); // DockPanel 必须填满宿主窗体,否则停靠区域计算会出错 dockPanel = new DockPanel { Dock = DockStyle.Fill, DocumentStyle = DocumentStyle.DockingWindow, // 文档区样式 Theme = new VS2015BlueTheme() // 主题,源码里带多套 }; Controls.Add(dockPanel); } }这段代码里Dock = DockStyle.Fill是硬性要求,DockPanel 不填满父容器,停靠命中区域就会偏。DocumentStyle决定中间文档区是 Tab 还是 MDI 风格,Theme是外观,源码包里通常带 VS2015、VS2012 等几套,换主题只改这一行。
2.2 DockState 的取值与停靠目标怎么选
DockState 的常用值有DockLeft、DockRight、DockTop、DockBottom、Document、Float、AutoHide、Hidden。新手最容易混的是Document和DockLeft的区别:Document进中间文档区,多个文档面板会以 Tab 形式叠在一起;DockLeft是贴边停靠,占的是侧边栏位置。选错了表现就是“面板跑到中间去了”或者“侧边栏被文档区挤没”。
停靠目标有两种写法:Show(dockPanel, DockState.DockLeft)是相对整个面板停靠,Show(prevPanel, DockState.DockRight)是相对某个已有面板停靠。后者更常用,因为它能精确控制面板之间的相对位置。我一般会先放一个主文档面板,再让其他面板相对它或相对彼此停靠,这样初始布局稳定,不会因为添加顺序不同而变样。
// 先显示主文档面板 var mainDoc = new MainDocumentForm(); mainDoc.Show(dockPanel, DockState.Document); // 设备树停靠在主文档左侧 var deviceTree = new DeviceTreeForm(); deviceTree.Show(mainDoc.PanelPane, DockState.DockLeft); // 报警列表停靠在设备树下方,形成上下分割 var alarmList = new AlarmListForm(); alarmList.Show(deviceTree.PanelPane, DockState.DockBottom);PanelPane是面板所在的窗格对象,把它作为停靠目标,新面板就会插到这个窗格旁边。注意DockBottom相对deviceTree.PanelPane时,是在设备树所在窗格的下方分割,而不是整个窗口底部,这个细节决定了布局是否符合预期。
2.3 布局序列化:为什么你的还原总是错位
DockPanel 自带布局持久化,SaveAsXml和LoadFromXml一对方法就能把整棵布局树存成 XML。但很多人第一次用会发现还原后位置不对,原因通常是加载时机太早——窗体还没显示、句柄还没创建,DockPanel 算不出正确的尺寸。正确做法是在Load事件或窗体Shown之后加载,并且加载前确保所有 DockContent 子类已经能通过类型名反序列化。
// 保存布局 private void OnFormClosing(object sender, FormClosingEventArgs e) { dockPanel.SaveAsXml("layout.xml"); } // 加载布局,必须在窗体显示后 protected override void OnShown(EventArgs e) { base.OnShown(e); if (File.Exists("layout.xml")) { // 第二个参数是反序列化回调,按类型名创建面板实例 dockPanel.LoadFromXml("layout.xml", DeserializeDockContent); } } private IDockContent DeserializeDockContent(string persistString) { // persistString 是保存时写入的类型全名 switch (persistString) { case "MyApp.DeviceTreeForm": return new DeviceTreeForm(); case "MyApp.AlarmListForm": return new AlarmListForm(); default: return null; } }DeserializeDockContent这个回调是还原的关键,它负责把 XML 里的类型名映射回实际对象。如果你新增了面板类型却忘了在这里加分支,还原时那个面板就会消失,而且不报错——这是最隐蔽的坑之一。
3. 把源码包跑起来:编译、引用与第一个可停靠面板
3.1 源码结构与你该关注哪几个文件
拿到源码包,别急着全编译。核心文件集中在几个地方:DockPanel.cs是容器主逻辑,DockContent.cs是面板基类,DockPane.cs和DockPaneStrip.cs管窗格和标签绘制,DockWindow.cs管浮动窗口。例子工程通常在WinFormsUI.Docking同级的 Demo 目录里,直接打开就能看到各种停靠场景。我一般先编译核心库,再跑 Demo,确认环境没问题再往自己项目里引。
编译时注意目标框架。这套库原生是 .NET Framework 的,如果你用 .NET Core 或 .NET 5+ 的 WinForms,需要确认源码里的 API 是否兼容,常见问题是System.Drawing的某些调用和设计器序列化行为有差异。稳妥做法是先用 .NET Framework 4.x 跑通,再考虑迁移。
3.2 引用方式:项目引用还是编译成 DLL
两种方式各有场景。项目引用适合你要改源码、调行为,比如自定义标签绘制或改停靠动画;编译成 DLL 引用适合只想用、不想维护源码的情况。我一般推荐先项目引用跑通,稳定后再抽成 DLL 固定版本。引用时除了主库,还要注意主题资源文件是否一起带上,否则换主题会抛异常。
# 用 MSBuild 编译核心库(在源码根目录) msbuild WinFormsUI.Docking.csproj /p:Configuration=Release # 编译产物在 bin/Release 下,通常是 WinFormsUI.Docking.dll编译前确认csproj里的目标框架和你的运行环境一致。如果报缺少System.Design之类的引用,是设计器支持相关的程序集没装全,补上即可。
3.3 从零写一个可停靠面板的完整步骤
第一步,新建 WinForms 项目,引用编译好的库或直接项目引用。第二步,主窗体放 DockPanel 并设 Fill。第三步,新建一个继承 DockContent 的窗体作为功能面板。第四步,在主窗体里实例化并 Show。第五步,加布局保存还原。这五步走完,一个最小可用的停靠界面就成立了。
// 功能面板,继承 DockContent public partial class DeviceTreeForm : DockContent { public DeviceTreeForm() { InitializeComponent(); // TabText 是标签上显示的文字,不设就用窗体 Text this.TabText = "设备树"; // 允许浮动和自动隐藏 this.DockAreas = DockAreas.DockLeft | DockAreas.DockRight | DockAreas.Float | DockAreas.DockBottom; } }DockAreas这个属性控制面板能被拖到哪些区域,不设的话默认全允许。实际项目里我建议按业务限制,比如日志窗口只允许停底部和浮动,避免用户拖到左边把主布局搞乱。TabText和Text分开设的场景是:窗体标题栏要显示完整名称,标签上要显示短名称。
4. 避坑与排查:五个让我加班到深夜的停靠问题
4.1 面板显示后一片空白或直接不出现
现象是调用Show之后面板没出来,或者出来了但内容空白。原因通常有两个:一是 DockPanel 没有 Fill 父容器,停靠区域算出来是零尺寸;二是面板的DockAreas限制和传入的 DockState 冲突,比如只允许 Float 却传了 DockLeft,库会静默忽略。解决方法是先检查 DockPanel 的 Dock 属性,再核对 DockAreas 是否包含目标状态,冲突时库不报错,只能靠自查。
4.2 布局还原后面板顺序错乱或丢失
现象是保存时好好的,加载后顺序变了或者少面板。原因一是DeserializeDockContent回调没覆盖所有类型,返回 null 的面板被丢弃;原因二是加载时机在窗体句柄创建前,尺寸计算异常导致布局树重建。解决办法是把加载放到OnShown之后,并确保回调里每个持久化类型都有对应分支,新增面板时同步更新这个 switch。
4.3 浮动窗口关闭后无法再次打开
现象是用户把面板浮动后关掉,再想打开发现没反应。原因是浮动窗口关闭时面板对象被释放,但你的菜单或按钮还持有旧引用。解决办法是每次打开都新建实例,或者用Show前判断对象是否已释放。我一般封装一个ShowPanel<T>()泛型方法,内部判断实例状态,避免到处写重复逻辑。
4.4 主题切换后部分控件绘制异常
现象是换主题后标签文字看不清或边框错位。原因是主题资源和控件绘制逻辑绑定,某些自定义绘制的面板没适配新主题的颜色方案。解决办法是优先用库自带主题,自定义绘制时从主题对象取颜色而不是硬编码。如果必须自定义,参考源码里DockPaneStrip的绘制流程,按主题的ColorPalette取值。
4.5 高 DPI 下停靠预览框偏移
现象是在 4K 屏或缩放 150% 的环境下,拖拽时的预览框和实际落点对不上。原因是库内部用的是物理像素计算,而 WinForms 高 DPI 下需要逻辑像素转换。解决办法是在 app.manifest 里声明 DPI 感知,并确认库版本是否包含高 DPI 修复。老版本可能需要手动改DockPanel里的坐标换算,这块改动要小心,建议先在小范围测试。
注意:这套库停更多年,遇到问题时优先查源码而不是搜网上答案,很多帖子针对的是不同分支版本,直接套用可能引入新问题。
5. 进阶技巧:自定义停靠行为与布局校验的实用手法
5.1 限制拖拽区域与自定义停靠预览
实际项目里经常要限制某些面板的拖拽范围,比如主文档区不允许浮动,工具面板不允许进文档区。除了前面说的DockAreas,还可以重写DockContent的OnDockStateChanging事件,在状态变更前拦截。这个事件在拖拽落点确定前触发,能拿到目标状态,适合做业务级校验。
// 在 DockContent 子类里拦截状态变更 protected override void OnDockStateChanging(DockState oldState, DockState newState) { // 禁止工具面板进入文档区 if (this is ToolPanelForm && newState == DockState.Document) { // 取消这次变更,具体做法视版本而定,常见是设 e.Cancel return; } base.OnDockStateChanging(oldState, newState); }不同分支版本这个事件的签名可能不同,有的用DockStateChangingEventArgs,有的直接传状态。拿到源码后先搜OnDockStateChanging确认签名,再决定怎么写。拦截逻辑要轻,别在里面做耗时操作,否则拖拽会卡。
5.2 布局文件的版本兼容与校验
布局 XML 会随面板增减而变化,用户升级软件后旧布局文件可能加载失败。我的习惯是在保存时写入一个版本号,加载时先校验版本,不匹配就丢弃旧布局用默认布局,而不是让用户面对一个错乱的界面。具体做法是在 XML 根节点加自定义属性,加载前先读出来比对。
// 保存时附加版本信息 var doc = new XmlDocument(); dockPanel.SaveAsXml("layout.xml"); doc.Load("layout.xml"); doc.DocumentElement.SetAttribute("layoutVersion", "2"); doc.Save("layout.xml"); // 加载前校验 doc.Load("layout.xml"); var ver = doc.DocumentElement.GetAttribute("layoutVersion"); if (ver != "2") { File.Delete("layout.xml"); // 版本不符,丢弃旧布局 } else { dockPanel.LoadFromXml("layout.xml", DeserializeDockContent); }这个手法不复杂,但能省掉大量“升级后界面乱了”的售后问题。版本号规则我一般用主版本号,面板结构大改才递增,小调整不动,避免频繁丢弃用户布局。
5.3 用反射简化 DeserializeDockContent 的维护
前面那个 switch 分支,面板一多就难维护,新增类型忘了加就丢面板。可以用反射按类型全名动态创建,省掉手工分支。代价是类型必须在当前程序集里,且构造函数无参。
private IDockContent DeserializeDockContent(string persistString) { // 按类型全名反射创建,要求类型有无参构造函数 var type = Type.GetType(persistString); if (type == null || !typeof(DockContent).IsAssignableFrom(type)) return null; return (IDockContent)Activator.CreateInstance(type); }反射版本的好处是新增面板不用改这里,坏处是类型名变了(比如改了命名空间)就找不到。我一般折中:核心面板用反射,特殊初始化的面板保留手工分支。这样既减少维护量,又保留灵活性。
5.4 一个校验布局是否正常的小习惯
每次改完停靠逻辑,我都会做一遍固定动作:打开软件,把每个面板拖一遍,浮动、自动隐藏、关闭再打开,然后保存布局、重启、确认还原一致。这套动作花不了两分钟,但能挡住绝大多数布局回归。从那以后我每次提交涉及 DockPanel 的改动前,都强制走一遍这个流程,比事后被用户反馈“界面又乱了”强得多。希望帮到你。
本文还有配套的精品资源,点击获取