简介:面向WPF开发者的AvalonDock入门资源包,基于开源停靠布局控件库,演示类似Visual Studio的文档与工具窗口管理,适合需要在项目中实现灵活停靠、浮动、隐藏和拖拽布局的开发者。ZIP压缩包共43个文件,大小约591KB,以dll程序集、cs源码、xaml界面定义为主,同时包含sln/csproj工程文件、exe可执行文件及pdb调试信息,可直接打开示例项目查看效果。资源围绕AvalonDock核心用法展开,覆盖DockingManager、LayoutDocumentPane等关键组件,以及XAML声明、动态添加Anchorable/Document、常用布局模式、MVVM数据绑定和布局状态保存等内容。对于初次接触AvalonDock的开发者,可以从XAML声明和后台代码中看到布局如何初始化;对于有MVVM项目经验者,也可借鉴数据绑定与布局持久化的实现思路。目前已有2388人学习下载,整体来看是一份能帮助快速上手WPF复杂窗口布局的参考工程。 WPF 里做多窗口工作台,绕不开停靠布局这个需求。左边项目树、右边属性面板、中间文档标签,用户还希望拖拽浮出、自动隐藏、重启后恢复布局,一套组合拳下来,自己拿 TabControl 拼会非常痛苦。我在几个中大型桌面工具项目里都用了 AvalonDock,踩了不少坑,也总结了一些能直接用起来的经验。这篇就把 AvalonDock 从对象模型、XAML 搭建、布局持久化到 MVVM 集成和避坑,完整串一遍。
1. 为什么选 AvalonDock:它是解决停靠问题的完整方案,不是蹩脚补丁
1.1 从真实需求看选型
大多数 WPF 软件做到一定规模,界面都会“工具化”。不是简单的几个窗口堆在一起,而是用户需要自由调整面板位置、把文档拖到另一个屏幕、临时把某个工具面板收起来。这些行为背后涉及拖拽预览、停靠位置计算、浮动窗口生命周期、布局序列化,逻辑量非常大。
我自己早期做过一版类似功能,用 TabControl 加 Grid 加手动拖拽实现,结果只支持固定布局,没办法浮动,几个面板一多就乱。后来换成 AvalonDock,等于把整个停靠系统的地基都接手了,界面只负责提供内容,窗口行为交给框架统一管理,开发成本降了不是一星半点。
AvalonDock 是开源项目,MIT 协议,商用没负担。核心包在 NuGet 上直接搜 AvalonDock 就行,扩展主题有几个独立包,按需引用。社区活跃度一直不错,在 WPF 生态里,停靠布局这块基本是事实标准。如果你去看很多老牌 WPF IDE、编辑器、运维工具,界面上都能看到它的影子。
1.2 版本选择直接决定 API 差异
AvalonDock 版本演进过程中 API 有过调整。老版本和 4.x 之间的序列化接口、命名空间、主题挂载方式都有区别。网上很多教程贴的是老写法,你拿新版本跑就会报编译错误。
我建议直接使用 NuGet 上的最新稳定版,不要再碰 3.x 或更早的代码。新版本里序列化、浮动窗口、主题切换都更稳。装包的时候顺手把主题包也装上,比如 AvalonDock.Themes.VS2013、VS2015、Metro 这类,后面换皮肤非常方便。有一点要注意:不同子版本的主题资源地址略有差异,装完之后确认一下 DockingManager 上能不能直接引用到主题实例。
2. 对象模型决定一切:先把 LayoutDocument 和 LayoutAnchorable 的分工搞明白
2.1 布局树的基本结构
AvalonDock 的布局不是扁平化的,而是一棵树。树的根是 LayoutRoot,根下面挂 LayoutPanel,LayoutPanel 决定子区域是水平排列还是垂直排列。再往下才是具体的窗格:LayoutDocumentPane 放文档,LayoutAnchorablePane 放可停靠工具面板,窗格里才是真正的内容对象 LayoutDocument 和 LayoutAnchorable。
这个树形结构初看会觉得繁琐,但好处是布局能力特别灵活。你可以嵌多层 LayoutPanel,实现左边工具区、右边属性区、中间文档区这种经典布局。每一层用 DockWidth、DockHeight 控制初始尺寸,用 DockMinWidth、DockMinHeight 限制最小尺寸,用户在运行时拖拽调整后,这些值也会跟着变化。
理解这棵树是后面所有操作的基础。很多人直接上手写 XAML,结果想加一个面板不知道加在哪个层级,就是因为没弄明白 LayoutPanel 是“分割区域”的,Pane 是“容纳内容”的,两者职责完全不同。
2.2 文档和工具窗口的核心差异
LayoutDocument 和 LayoutAnchorable 是两种最容易混淆的类型,但它们的生命周期有本质区别。
LayoutDocument 适合文档型内容,比如代码编辑器、表格、网页预览。这类内容关闭后一般就是销毁,像文件关掉一样。LayoutAnchorable 适合工具窗口,比如工具箱、属性栏、图层列表。这类内容关闭时通常是“隐藏”而不是销毁,下次还能通过菜单或按钮重新唤出。
默认行为上,LayoutDocument 的关闭按钮触发 Close,LayoutAnchorable 的关闭按钮触发 Hide。如果你把一个属性面板设计成 LayoutDocument,用户点完关闭,面板从界面里彻底没了,再想找回来就得重新创建实例,体验非常糟糕。
我总结的选型原则很简单:主工作区内容用 Document,周边辅助功能用 Anchorable。如果界面是编辑器类型,文档区用 LayoutDocumentPane 固定占住中间最核心的位置,其他工具围绕它排布。
2.3 尺寸、最小值和拖拽后的变化
停靠布局里,面板尺寸不是你写一次就完事。用户拖拽分隔条、浮动窗口、调整大小之后,AvalonDock 会把实时尺寸写回布局树。所以不要在代码里硬编码位置和尺寸去覆盖用户操作,否则每次刷新都会把用户布局打回原形。合理的做法是设置初始值和最小值,之后完全交给用户和布局系统。
DockWidth和DockHeight支持绝对像素,也支持星号比例。比如左右结构里左侧设置固定 260,右侧设置*,右侧就能自动填充剩余空间。如果没有特殊需求,不要把左侧也设成*,不然窗口拉伸时两个区域按比例放大,工具区会显得很空,实际用起来并不舒服。
3. 从零搭建可停靠工作台:XAML 结构、命名空间和主题挂载
3.1 引入包和命名空间
项目里先通过 NuGet 安装 AvalonDock。需要主题的话,另外安装对应主题包。
XAML 里加上命名空间引用,核心的是 AvalonDock 程序集。不同版本命名空间字符串有差异,常见写法是:
xmlns:avalon="clr-namespace:AvalonDock;assembly=AvalonDock" xmlns:avalonLayout="clr-namespace:AvalonDock.Layout;assembly=AvalonDock" xmlns:avalonThemes="clr-namespace:AvalonDock.Themes;assembly=AvalonDock"如果你装的版本里主程序集结构变了,编译不过就直接看程序集里的公开命名空间,照着改就行。
3.2 一份最小可运行的布局 XAML
我以一个典型的开发工具界面为例:左侧是项目树,中间是文档区,底部是输出面板。
<avalon:DockingManager x:Name="DockManager"> <avalon:LayoutRoot> <avalon:LayoutPanel Orientation="Horizontal"> <avalon:LayoutAnchorablePane DockWidth="240"> <avalon:LayoutAnchorable Title="项目" ContentId="project"> <views:ProjectTreeView /> </avalon:LayoutAnchorable> </avalon:LayoutAnchorablePane> <avalon:LayoutPanel Orientation="Vertical"> <avalon:LayoutDocumentPane> <avalon:LayoutDocument Title="欢迎" ContentId="welcome"> <views:WelcomeView /> </avalon:LayoutDocument> </avalon:LayoutDocumentPane> <avalon:LayoutAnchorablePane DockHeight="160"> <avalon:LayoutAnchorable Title="输出" ContentId="output"> <views:OutputView /> </avalon:LayoutAnchorable> </avalon:LayoutAnchorablePane> </avalon:LayoutPanel> </avalon:LayoutPanel> </avalon:LayoutRoot> </avalon:DockingManager>这段 XAML 表达了几个要点:最外层 LayoutPanel 水平分割,左边是项目树,右边是一个垂直分割区域;垂直方向上方文档区,下方输出面板。ContentId 是后面做布局持久化时用来匹配内容的核心标识,建议从第一步就规范命名,不要留空。
运行起来之后,你可以直接拖动左侧工具面板把它浮出或停靠到其他位置,也可以把底部的输出面板自动隐藏成侧边按钮。这些交互行为框架默认都实现了,不需要你写一行拖拽逻辑。
3.3 主题挂载的几种方式
AvalonDock 默认外观比较朴素,要商用还得套主题。主题挂载有两种常见方式,一种是直接在 DockingManager 上指定 Theme 属性,另一种是在 App.xaml 里合并主题资源字典。
直接用实例的写法比较干净:
<avalon:DockingManager Theme="{x:Static avalonThemes:VS2013LightTheme.Instance}">如果项目本身有皮肤切换机制,还可以在运行时动态换。DockingManager 的 Theme 属性是依赖属性,绑上之后切换主题实例就能全局生效。注意主题包里的资源会和自带的默认样式冲突,如果窗口里有些自定义控件样式异常,优先检查资源合并顺序,确保主题资源在业务资源之前加载。
4. 布局持久化和 MVVM 集成:保存、恢复、ContentId 映射的完整闭环
4.1 把用户工作区保存下来
用户花时间调好的布局,关闭应用后必须能恢复。AvalonDock 提供序列化方案,核心思路是把 LayoutRoot 整棵树写到流里。最直接的用法是 SaveLayout 和 LoadLayout。
private void SaveLayout(string path) { using (var stream = new FileStream(path, FileMode.Create, FileAccess.Write)) { DockManager.SaveLayout(stream); } }如果你还在用老版本没有这两个方法,可以用 XmlLayoutSerializer 手动序列化,效果一样。保存的 XML 会记录每个窗格、尺寸、停靠位置、浮动窗口位置,甚至隐藏状态,内容本身不会序列化进去,只存 ContentId 和 Title 这类标识。
这里有个关键认知:序列化保存的是“壳”和“引用标识”,不是内容对象。恢复时框架拿到 ContentId,去当前运行环境里找对应内容,找不到就会出问题。所以布局恢复才必须和内容映射机制配合。
4.2 LoadLayout 时怎么把 ContentId 映射到真实内容
加载布局的代码不长,但核心在于处理内容映射。新版本支持在加载前注册回调:
DockManager.LoadLayout(stream, (contentId, title) => { if (contentId == "project") return new ProjectViewModel(); if (contentId == "output") return new OutputViewModel(); return null; });这个回调只负责“根据标识创建内容”,框架会处理后续的 View 和 DataTemplate 绑定。如果你的内容已经在某个容器里维护着,也可以不创建新的,直接从容器里取现成实例返回,避免状态丢失。
我在项目里常把回调做成一个根据 ContentId 分发的映射表,而不是一堆 if。内容多了以后,if 分支会变得很乱。用字典注册、工厂创建的方式,扩展新面板时只需要加一条注册记录。
4.3 MVVM 集成:内容放哪、DataTemplate 怎么挂
AvalonDock 本身不强制 MVVM,但配合 MVVM 很自然。LayoutDocument 和 LayoutAnchorable 的 Content 属性可以直接放 ViewModel,界面层通过 DataTemplate 把 ViewModel 渲染成对应 View。
在 App.xaml 或窗口资源里加 DataTemplate:
<DataTemplate DataType="{x:Type viewModels:ProjectViewModel}"> <views:ProjectTreeView /> </DataTemplate>这样布局 XAML 里内容只写 ViewModel 就行:
<avalon:LayoutAnchorable Title="项目" ContentId="project"> <avalon:LayoutAnchorable.Content> <vm:ProjectViewModel /> </avalon:LayoutAnchorable.Content> </avalon:LayoutAnchorable>MVVM 模式下要特别关注 ContentId 的一致性。布局文件里存的是 ContentId,加载时回调创建的是 ViewModel,这两个必须对得上。很多人在开发环境一切正常,发布后用户那边布局恢复失败,大部分原因就是 ContentId 后来被改动,旧布局文件里存的还是老标识。
4.4 恢复失败时的兜底策略
布局文件可能因为版本升级、开发期调试、用户手动清理等原因损坏或失效。加载时一定不要裸调 LoadLayout 不处理异常,至少用 try/catch 包一层。加载失败后,直接给用户一个默认布局,而不是弹一个难看的错误框。
我习惯在加载前先判断文件是否存在,存在就 try 加载,失败就调用 ResetLayout 方法重新初始化 LayoutRoot。这样用户永远有一个可用界面,顶多是恢复不了上次布局,不会卡死。
5. 让行为更接近商业软件:行为限制、面板找回和隐藏状态管理
5.1 限制用户过度自由操作
停靠框架默认给了用户很大的自由度:可以拖、可以浮、可以关、可以自动隐藏。但业务上不一定全都要放开。
LayoutAnchorable 有几个属性可以限制行为:
- CanClose 设为 false,面板关闭按钮直接不可用,强制用户保留这个区域。
- CanHide 设为 false,隐藏按钮不可用,面板不能从界面上消失。
- CanFloat 设为 false,面板不能被拖拽成独立浮动窗口。
具体用法:
<avalon:LayoutAnchorable Title="日志" ContentId="log" CanClose="False" CanHide="False" CanFloat="False"> <views:LogView /> </avalon:LayoutAnchorable>这种锁定要克制使用。我见过有的产品把所有面板都锁死,结果用户想调整布局完全动不了,体验反而很差。合理场景是核心编辑区面板锁定,辅助工具放开。如果只是为了不让用户误关,优先选择关闭后还能找回的交互方案,而不是直接禁用按钮。
5.2 面板被误关后的找回机制
LayoutAnchorable 默认关闭行为是隐藏,不是销毁。但隐藏之后,用户不知道怎么找回来,这是产品设计问题,不是框架问题。
标准做法是在“视图”菜单里列出一组可用的工具面板,通过菜单项切换显示状态。代码里拿到对应 LayoutAnchorable 后,调用 IsVisible 判断当前是否显示,用 Show 方法重新显示,或者用 IsActive 激活面板。
如果是 MVVM 架构,别把 LayoutAnchorable 直接暴露进 ViewModel。我建议封装一个界面服务,负责“按 ContentId 定位面板”“显示面板”“隐藏面板”这些操作。ViewModel 层只调用服务,不直接引用 AvalonDock 类型。
5.3 自动隐藏和浮动窗口的状态处理
自动隐藏是一种特殊的显示形态:面板缩成侧边按钮,鼠标划过时展开。这个状态由用户点击图钉按钮触发,开发时不需要额外编码。但要注意自动隐藏和浮动状态在保存布局时会被持久化,下次加载自动恢复。如果你的业务逻辑依赖布局状态,比如工具栏隐藏时某些功能不可用,最好在布局加载完成后统一读取状态,而不是实时监听一堆事件。
浮动窗口是另一个容易出问题的地方。用户把面板拖到副屏,副屏断电或者分辨率变化时,浮动窗口位置可能超出可见范围。老版本有浮动窗口停不回主窗口的情况,新版本改善了很多。稳妥起见,可以在加载布局后检查浮动窗口的位置,发现主窗口不在工作区范围内就强制拉回来,或者直接把该面板重新停靠回默认位置。
6. 我在实战中踩过的坑和排查思路
6.1 加载布局报 ItemNotFoundException
这是我遇到最多的问题。现象是布局文件存在,但 LoadLayout 抛异常,提示找不到某个项目。根因基本都是 ContentId 对应内容没有在加载时完成映射。
排查思路很固定:先打开布局文件,找到抛异常上下文附近的 ContentId,再回去看代码里的加载回调是否覆盖了这个 ContentId。比如布局文件里有 ContentId="property",回调里只处理了 project 和 output,那加载必然失败。
还有一种隐蔽情况:面板在 XAML 里预先创建了内容,但加载回调用 new 又创建了一遍,导致同一个 ContentId 对应了多个内容实例。框架对重复内容没有任何提示,但界面会出现奇怪的空白面板或绑定失效。解决方法是统一数据源,要么全部靠回调创建,要么全部预先注册,不要两套逻辑混着来。
6.2 关闭主窗口时的偶发崩溃
浮动窗口和主窗口生命周期叠加时,偶尔会崩。典型场景是用户开着浮动窗口直接关主程序,浮动窗口的关闭事件先后顺序不稳定,导致布局在释放过程中再次访问到已销毁内容。
我的处理办法是:主窗口关闭时,先让 DockingManager 把所有浮动窗口并回主窗口或者显式关闭,再保存布局,最后释放 DockingManager 资源。顺序问题靠事件钩子很难百分之百保证,不如在明确的关闭流程里控制。
6.3 布局保存失败,原因是用户改了面板标题
这是一个非常容易忽略的问题。AvalonDock 的布局序列化会把 Title 和 ContentId 一起存下来。加载时如果回调只判断 Title,不判断 ContentId,一旦界面层因为多语言切换或业务改动改了面板标题,加载就会失配。
所以我在代码规范里明确要求:加载回调和内容匹配只能依赖 ContentId,绝对不能依赖 Title。Title 只是显示层的东西,ContentId 才是稳定标识。这个规范在团队协作里尤其重要,谁改了界面显示文字,也不至于把用户的布局搞坏。
6.4 布局版本升级后的兼容处理
AvalonDock 自己的序列化格式在小版本升级时基本兼容,但大版本升级可能会有结构变化。我给长线维护项目的建议是,布局文件不直接覆盖用户的旧文件,加载前先备份一份。升级后首次加载如果失败,可以从备份文件里解析出 ContentId 再做增量迁移。
开发期调试还有一个常见尴尬:旧布局文件指向的 ViewModel 已经被你重命名,回调里没处理,加载失败。这种情况不算框架问题,是开发期自己埋的雷。调整好回调映射后,旧布局也能恢复,不用让用户手动删除配置文件。
最后分享一个我觉得很实用的小技巧
如果你把 AvalonDock 用在插件式架构里,布局的注册一定要做成可扩展的。我通常用 ContentId 前缀做模块区分,例如moduleA_editor、moduleB_property,加载回调里先按前缀路由到不同模块的注册中心,再转发给具体工厂。这样插件新增面板时,不需要改主程序里那串 if 映射,只在一个常量注册表里登记即可。
AvalonDock 的学习曲线主要在前两周。等把 LayoutRoot 那棵树和 ContentId 的映射逻辑真正理顺了,后面做任何带工具面板的 WPF 程序,基本都是在同一个套路里添砖加瓦。这篇如果对你有帮助,照着第三节的 XAML 先跑通一个最小 demo,再逐步加持久化和 MVVM,会比硬啃文档顺畅很多。
本文还有配套的精品资源,点击获取