简介这份资源是 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 子类实例交给 DockPanelDockPanel 根据你指定的 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:ConfigurationRelease # 编译产物在 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前判断对象是否已释放。我一般封装一个ShowPanelT()泛型方法内部判断实例状态避免到处写重复逻辑。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 的改动前都强制走一遍这个流程比事后被用户反馈“界面又乱了”强得多。希望帮到你。本文还有配套的精品资源点击获取