
简介针对WPF开发者的一份AvalonDock使用示例工程内含完整可运行的解决方案与源码帮助快速掌握类似Visual Studio的停靠、浮动、隐藏等窗口布局管理适合入门与中级开发者作为上手参考。资源共43个文件压缩包约591KB。dll运行库与pdb调试文件负责支持程序执行与调试XAML界面定义和C#逻辑代码展示了核心布局代码sln/csproj工程文件让项目可直接编译打开另有入口与主窗口源码目录结构清晰便于对照学习。已有2388人学习下载。从内容预览可知工程包含完整解决方案、主窗口相关代码及bin/obj编译输出可直接打开研究DockingManager、LayoutAnchorable等核心组件的实际用法。结合描述中提到的MVVM绑定、布局保存与加载等高级特性实践时可以围绕现有示例分别验证DockLeft、DockRight、DockTop、Floating等布局模式并进一步扩展为自定义停靠面板。对于想快速搭建类似IDE界面、减少从零配置布局框架成本的开发者来说这是一份很直观的参考资料。 记得我第一次正儿八经研究 avalondock 使用是在做一个桌面端工具型产品的时候。那会儿产品经理给的原型图几乎就是照着 Visual Studio 的窗口布局画的——左边有工具面板右边有属性面板中间是文档编辑区窗口可以自由拖动、停靠、浮动关闭了还得能在菜单里找回来。当时第一反应是自己写一套布局系统但认真评估了一晚上就放弃了。停靠布局这种东西水比想象中深得多拖拽过程中的吸附预览、停靠位置的智能判断、布局状态的序列化恢复、浮动窗口在多显示器之间的摆放……每一块单独拿出来都够写好几个星期。后来换了个思路开始调研现成的开源方案。AvalonDock 就是这个领域里绕不开的名字。如果你也是 WPF 开发者应该多少听过它这是 .NET 生态里最成熟的停靠布局库之一从 CodePlex 时代就存在后来由 Dirkster 接手维护对 3.x、4.x 的 API 做了不少重构。界面效果不说 100% 复刻 Visual Studio也至少能做到 90% 的观感。关键是它的布局数据可以完整序列化保存用户调整过的窗口位置、大小、停靠关系用户下次打开软件还是熟悉的布局这一点对桌面生产力工具而言几乎是刚需。这篇文章我打算把 avalondock 使用的关键路径完整捋一遍从核心概念、方案选型到实际搭建一个可停靠界面再到布局持久化和常见坑的排查。不是那种 API 文档式的罗列而是我实际折腾过的经验包括踩过的坑和总结出的固定套路。看完你至少能判断自己的项目要不要用它以及怎么快速跑通第一个可停靠布局。1. 先搞清楚AvalonDock 到底帮你解决了什么1.1 典型使用场景与痛点桌面应用里有一类界面需求非常固定一个容量有限的窗口要容纳很多功能区块用户还得能按自己的习惯去组织视图。比如代码编辑器要同时打开多个文件文档旁边还要有工程目录、搜索面板、输出日志工具窗那用户大概率希望“文件”可以拖到右边“日志”可以拖到下面甚至拖出主窗口变成独立窗口放在副屏。如果用原生 WPF 做通常的做法是 Grid 分栏 TabControl 做文档。但问题是用户拖不了布局写死了。一旦用户想调整分栏宽度、Tab 顺序就得动用大量代码想让某个面板“浮动出来”或者“自动隐藏”又得写复杂的窗口宿主逻辑。这里真正的技术难点不在能画出来而在交互和状态的可靠管理——停靠、浮动的判定规则非常细腻手写很难做到完善。AvalonDock 的价值就是把整套机制封装好了。它提供了一个 DockingManager 容器控件把“区域划分”“窗口拖拽”“停靠吸附”“浮动窗口”“自动隐藏”这些能力全部内置。你只需要声明初始布局模板剩下的拖拽行为、布局变更、序列化由框架自己处理。1.2 为什么说非必要不自研布局系统我不太建议团队在这种模块上自研除非你只是想做一个“假的”可停靠界面比如固定几个面板让用户改改宽高那用 Grid 几个 GridSplitter 也足够了。但如果你要对抗的是 Visual Studio 级别的交互预期自研成本会快速失控。多窗口状态管理是第一个深坑。浮动窗口本质上是独立 Window它要和主窗口之间同步焦点、激活、关闭等状态窗口被拖回主窗口时判断要停靠在哪个位置依赖的是拖拽时鼠标相对目标区域的热区检测。这些逻辑单独挑一个出来都有难度更别说全部串起来。AvalonDock 作为维护了十几年的开源项目已经把这些交互细节打磨得比较成熟了。对我这种主要精力还是要花在业务功能上的人来说用它的性价比非常高。当然它也有自身的学习曲线和风格限制比如外观定制不如从零写的那么自由——这需要心理预期。2. 核心概念速览先认识这些名词后面代码才看得懂2.1 DockingManager 与整个 Layout 体系的层次关系用 AvalonDock 搭建界面核心思路和普通 WPF 控件树不太一样。普通界面是“控件嵌控件”而它是“布局树嵌内容”。最外层是一个 DockingManager 控件它负责整个布局状态的协调。DockingManager 内部放一个 LayoutRoot这是所有布局面板的根节点。LayoutRoot 里面可以放LayoutPanel一个可切分区域的容器方向可以是水平或垂直。相当于自动把屏幕划分成左块、右块、上块、下块。LayoutDocumentPaneGroup专门管理文档区的面板组里面可以有多个 LayoutDocumentPane。LayoutAnchorablePane管理工具窗/可停靠内容的面板比如“工具箱”“属性”就是放这里的。刚开始不用记那么死你只需要理解整个布局树是一个递归嵌套结构最外层是 LayoutRoot中间用 LayoutPanel 决定切分方向叶子节点是 LayoutDocumentPane 或 LayoutAnchorablePane里面再放具体的文档或工具窗内容。这和 HTML 的“块级元素嵌套”很像外层定区块内层放内容。注意一个容易混淆的点LayoutPanel 对应的是“不可拖拽的静态分栏”它只是做初始切分。用户拖拽窗口之后真正承载内容的 LayoutDocumentPane 和 LayoutAnchorablePane 会发生增删和迁移DockingManager 会自动维护这套结构。而 LayoutPanel 更像是“布局的骨架”提供的是初始相对位置。2.2 LayoutDocument 和 LayoutAnchorable 的分工这两兄弟是实际承载“内容”的节点也是最容易被搞混的。LayoutDocument对应文档。特征是可以关闭、可以停靠在文档区、一般有标签页效果关闭后内容可以被销毁。我习惯把需要频繁编辑、有独立状态的内容放这里比如代码文件、表格编辑器。LayoutAnchorable对应工具窗/面板。特征是可以停靠、可以浮动、可以自动隐藏像 Visual Studio 右侧那个小竖条鼠标移上去才展开。更关键的是LayoutAnchorable 即使你把它“关闭”了它也是被隐藏到侧边或从浮动静默移除通常不会销毁内容实体方便用菜单重新调出。一个通俗类比LayoutDocument 像书架上的书关掉一本就是拿走一本LayoutAnchorable 像抽屉里的工具收起来不代表丢了随时可以再拉出来。2.3 浮动、自动隐藏、停靠这些能力从哪来默认情况下你声明一个 LayoutDocumentPane 或者 LayoutAnchorablePane用户就已经可以在里面拖动窗口了——但真正让布局“活起来”的能力关联在 DockingManager 的属性上。比如 DockingManager 默认允许用户将某个 LayoutAnchorable 拖出区域变成浮动窗口这靠的是 FloatingWindow 机制。自动隐藏也内置了LayoutAnchorable 的左上角有个小图钉按钮点一下切换为自动隐藏模式。我们自己在代码里做的主要是“设置初始布局”和“对布局变化做出反应”不需要模拟这些交互细节。当你在 XAML 里放置 DockingManager 后这些交互框架都会自动生效。这一点也是我推荐用 AvalonDock 的原因上手门槛其实没有想象中高。3. 具体实操跑通一个可停靠的界面3.1 安装与命名空间准备我用的是 NuGet 上的 Dirkster.AvalonDock 包。在 Visual Studio 里右键项目引用选“管理 NuGet 程序包”搜索“AvalonDock”安装即可。当前我用的版本是 4.x命名空间已经从老的 Xceed.Wpf.AvalonDock 改成了using Xceed.Wpf.AvalonDock; using Xceed.Wpf.AvalonDock.Layout;注意版本差异老项目如果用的是 2.x 或 3.x部分 API 名称会不太一样网上搜到的老帖子和新代码混着看容易头晕。我建议直接上 4.x老代码迁移量也不会太大。XAML 里也别忘了加命名空间xmlns:xcadhttp://schemas.microsoft.com/winfx/2006/xaml/presentation/avalondock这个 URI 是 AvalonDock 为了接入 XAML 声明而注册的拼写比较特殊建议直接从文档复制。3.2 设计一个初始布局模板下面这个模板是我常用的初始布局左边是一个停靠面板放导航/工具中间是文档区右边是属性面板。xcad:DockingManager x:NamedockManager xcad:LayoutRoot xcad:LayoutPanel OrientationHorizontal xcad:LayoutAnchorablePane DockWidth260 xcad:LayoutAnchorable Title导航 ContentIdnavPanel TextBlock Text这里放导航内容 / /xcad:LayoutAnchorable /xcad:LayoutAnchorablePane xcad:LayoutDocumentPane xcad:LayoutDocument Title文档1 ContentIddoc1 TextBox Text这里放文档内容 / /xcad:LayoutDocument /xcad:LayoutDocumentPane xcad:LayoutAnchorablePane DockWidth240 xcad:LayoutAnchorable Title属性 ContentIdpropertyPanel TextBlock Text这里放属性内容 / /xcad:LayoutAnchorable /xcad:LayoutAnchorablePane /xcad:LayoutPanel /xcad:LayoutRoot /xcad:DockingManager这里的关键点是 ContentId它是每个节点的唯一标识非常重要。布局序列化和恢复时框架就是靠 ContentId 来识别“这个窗口应该去哪一根布局树上”的。你叫 abc 还是 nav_panel_main 无所谓但必须全局唯一。一个值得注意的细节LayoutDocumentPane 的宽度可以由所属 LayoutPanel 控制但也可以给 LayoutDocumentPane 设置 DockWidth/DockHeight优先级以实际停靠状态为准。初始布局表现更像是“建议值”用户拖拽之后以用户调整后的状态为准。3.3 把布局和代码串联起来XAML 里写好控件树只是第一步。实际业务中窗口内容通常不是静态的而是由业务模块动态生成甚至要支持用户从菜单里点开新窗口。这时候就需要在代码里操作 DockingManager 的布局。动态打开一个文档窗口常规做法是public void OpenDocument(string contentId, string title) { // 内容已经存在就先激活 var existing dockManager.Layout.Descendents() .OfTypeLayoutDocument() .FirstOrDefault(d d.ContentId contentId); if (existing ! null) { existing.IsActive true; existing.IsSelected true; return; } var newDoc new LayoutDocument { Title title, ContentId contentId, Content new MyEditorView() // 这里替换成你的业务控件 }; // 找到第一个文档面板把新文档加进去 var documentPane dockManager.Layout.Descendents() .OfTypeLayoutDocumentPane() .FirstOrDefault(); documentPane?.Children.Add(newDoc); }对应的打开工具窗Anchorable思路类似只不过加到 LayoutAnchorablePane 里。有一个差异要特别注意LayoutDocument 关闭后内容可能会被销毁所以再次调用 OpenDocument 时应该重新创建内容实例而 LayoutAnchorable 最好是复用一个长期实例避免工具窗状态丢失。3.4 布局保存与恢复一个很简单但也最容易出问题的功能布局持久化是 AvalonDock 一个非常核心的能力。窗口位置、停靠关系、浮动状态、甚至自动隐藏状态都可以序列化成 XML 存下来下次启动时原样还原。保存可以这样using (var writer new StringWriter()) { dockManager.Layout.SaveLayout(writer); string xml writer.ToString(); // 写入本地文件或配置中心 File.WriteAllText(_layoutPath, xml); }恢复的时候if (File.Exists(_layoutPath)) { using (var reader new StringReader(File.ReadAllText(_layoutPath))) { dockManager.Layout.LoadLayout(reader); } }是不是看着非常简单但这里有个大坑SaveLayout 保存的是布局结构也就是“哪个 ContentId 在哪个位置、尺寸多大”它不会保存 Content 属性指向的控件实例。如果加载保存的布局时你的代码仍然只有 XAML 里声明的那些静态内容那布局一旦引用了动态添加的窗口加载就会静默失败或内容变成空白。所以正确的做法是每次启动程序时先加载布局文件再用 ContentId 为每一个布局节点“重新关联内容”。这一步缺失布局恢复就会变成一场灾难。这部分我在下一节详细讲。4. 踩坑实录这些问题我基本都遇到过4.1 保存的布局加载后内容丢失这是我第一次用 AvalonDock 时最头疼的问题。现象是保存布局后重启程序窗口位置、标签页宽度都对但文档区域全是白板内容没了。排查下来根本原因就是上面说的——Content 不参与序列化。AvalonDock 保存的是“布局骨架”不是“业务内容”。加载布局之后你需要手动给每一个布局节点塞回内容。对策是加载后做一次“内容匹配”比如private void RestoreContent() { foreach (var doc in dockManager.Layout.Descendents().OfTypeLayoutDocument()) { if (doc.Content null) { doc.Content CreateContentForContentId(doc.ContentId); } } foreach (var anchor in dockManager.Layout.Descendents().OfTypeLayoutAnchorable()) { if (anchor.Content null) { anchor.Content CreateContentForContentId(anchor.ContentId); } } }这里的 CreateContentForContentId 就是一个工厂方法根据 ContentId 返回对应的 View。项目里维护一张“ContentId → 工厂”的映射关系表之后所有动态窗口都从它创建会比较清晰。4.2 文档关闭后“打不开”了另一个常见问题用户关闭了某个文档窗口菜单里再次点击打开时没有反应。排查日志也没报错。这通常是因为你做了“重复打开检测”也就是判断 ContentId 是否已存在。但 LayoutDocument 被关闭后它不一定立刻从布局树里移除某些版本下它可能处于挂起状态。此时 Descendents() 查不到但实际重新 Add 又会产生冲突。我的做法是关闭动作监听 DocumentClosing 事件在事件里把对应的业务内容清理掉打开动作则始终走工厂方法创建新实例并且在创建之前确保旧节点已经从布局树里移除干净。另外比较推荐对 LayoutAnchorable 区分对待。工具窗口一般不要销毁隐藏就好。AvalonDock 里隐藏工具窗其实是把 IsVisible 设成 false而不是从树里删掉。这样再次打开只需要把 IsVisible 重新设为 true或者调用 Show()。省心很多。4.3 浮动窗口在双屏场景下表现诡异用户把某个面板拖到副屏上变成浮动窗口关机重启后再打开程序浮动窗口不见了。这种情况多半不是 AvalonDock 的 bug而是布局保存时浮动窗口记录的坐标在另一台机器上不成立。原因很简单窗口位置超出了当前屏幕的工作区WPF 会把它放到一个不可见的位置。规避方案加载布局后枚举所有浮动窗口做一个“出界修正”foreach (var win in dockManager.FloatingWindows) { var bounds win.Window.GetWindowRect(); if (!IsVisibleOnAnyScreen(bounds)) { win.Window.Top 100; win.Window.Left 100; } }这个方法不要求完美的坐标纠正至少能保证窗口回到可见区域。4.4 性能与初始化时机的控制AvalonDock 的布局树本身不算重但如果文档内容里塞了大量复杂控件图表、表格、第三方编辑器一次性在启动时全部实例化会让软件启动变得非常卡。我建议采用“延迟实例化 缓存”两手策略初始化布局时只加载默认需要的窗口其他按需打开。同一个 ContentId 需要多次打开时如果内容不敏感可以考虑缓存控件实例避免反复创建。不要在主线程里加载太大体积的内容需要时配合异步初始化。有个细节DockingManager 对布局内容改变的反应是即时的所以在主线程以外修改布局树容易触发跨线程异常。我通常用 Dispatcher.Invoke 包裹布局修改操作保证它跑在 UI 线程上。5. 在项目中做二次封装的经验参考5.1 用一个 LayoutManager 统一管理布局生命周期直接在业务代码里到处写 dockManager.Layout.Descendents().OfType () 会变得很难维护尤其是项目大了以后。我比较推荐封装一个 LayoutManager把 DockingManager 的常用操作收敛起来。核心 API 就四个RegisterView(contentId, 创建控件的工厂)ShowDocument(contentId, title)ShowTool(contentId)SaveLayout() / LoadLayout()这样业务层只知道“我要打开某个文档”不需要关心布局树内部长什么样。内部再维护一个字典把 ContentId 和工厂函数存起来动态创建内容时就有统一入口了。5.2 与 MVVM 结合的推荐姿势如果你的项目是 MVVM 架构AvalonDock 本身并没有绑定任何 Model所以需要自己做一层适配。我的习惯是让 LayoutDocument 和 LayoutAnchorable 的 Content 只放一个简单 ContentControl然后通过 ContentControl 的 ContentTemplate 来映射 ViewModel。具体说主界面布局 XAML 可以简化为xcad:LayoutDocument Title{Binding Title} ContentId{Binding ContentId} ContentControl Content{Binding} / /xcad:LayoutDocument然后在 DataTemplate 里定义不同 ViewModel 对应的 View。这样动态创建窗口时只需要 new 一个 ViewModel 塞进布局就行View 的解析交给 WPF 的资源机制解耦非常彻底。有一点要提醒在 MVVM 下LayoutAnchorable 的“关闭按钮”事件会直接触发布局树移除并不会通知 ViewModel。所以最好挂钩 DockingManager.Hiding 或 LayoutAnchorable.Hiding 事件把 ViewModel 的“可见性状态”同步一下。否则用菜单重新打开时布局树里其实已经没有这个节点了状态容易不一致。5.3 工具栏与菜单栏的联动最后一个小技巧。既然工具窗可以从布局中隐藏那菜单栏里“视图”菜单就必不可少。每个工具窗应该对应一个 ToggleMenuItem绑定到它的可见性状态。实现路径可以这样布局里 LayoutAnchorable 的 Title 作为菜单显示名菜单项的 IsChecked 绑定到该 Anchorable 的 IsVisible点击菜单时调用 xcad:DockingManager 的 Show() 或 Hide()。这种“视图⇆布局”的联动在 AvalonDock 里实现成本很低但对用户体验的提升很直接。用户躲进几层菜单也能找回所有打开过的面板这才是成熟桌面工具该有的体验。写在最后的一点体会从我第一次接触 avalondock 使用到现在最大的感受是这个库把停靠布局的复杂度从“业务代码”里剥离了出来你不需要理解拖拽吸附的全部细节就能做出很专业的多窗口产品界面。但引入它不等于一劳永逸布局序列化、内容关联、生命周期管理这些边角问题还是需要自己用心处理的。这篇文章里写的每一个坑我都是实际踩过之后才真正理解清楚的。如果你正在评估或者已经在集成 AvalonDock希望这篇能帮你少走一些弯路。最后再分享一个操作习惯每次改完布局相关代码我会手动测一遍“拖拽 → 保存布局 → 重启 → 恢复到原布局”的完整流程这个流程能覆盖绝大多数布局问题。本文还有配套的精品资源点击获取