✦ 架构起点:为什么需要区域导航(Why Region Navigation)
在构建复杂 [[Avalonia]] 应用时,我们常常面临一个底层逻辑问题:如何管理多个视图的切换与加载。传统的硬编码方式(如直接设置 Content 属性)在小型项目中或许可行,但随着页面数量增长,这种做法会迅速演变为维护噩梦。
[[RegionNavigation]] 的本质,是将视图的创建与切换职责从使用方剥离出来,交由一个外部的 [[IRegionManager]] 统一管理。这样做的好处显而易见:视图解耦更彻底、切换逻辑更集中、生命周期管理更可控。对于深耕 .NET 生态的开发者来说,这是构建可扩展 UI 架构的标配方案。
本文将介绍两种实现区域导航的路径:使用 [[Prism]] 框架(项目推荐)和自定义实现。两种方案各有适用场景,读者可根据项目需求灵活选择。
✦ 方法一:使用 Prism 框架(Prism Framework)
[[Prism]] 是微软开源的 [[MVVM]] 框架,提供了依赖注入、区域导航、事件聚合器等企业级功能。对于新项目,这是最稳妥的选择。
✦ 第一步:安装 Prism.Avalonia(Install Prism.Avalonia)
首先通过 [[NuGet]] 安装 Prism 的 Avalonia 适配包:
1
| dotnet add package Prism.Avalonia --version 8.1.97.11073
|
这个包提供了 PrismApplication、IRegionManager 等核心类型,是整个区域导航体系的基石。
✦ 第二步:修改 App.axaml.cs(Modify App.axaml.cs)
将 App 类的基类从 Application 改为 PrismApplication,并重写必要的生命周期方法:
1 2 3 4 5 6 7 8 9
| public partial class App : PrismApplication { protected override void RegisterTypes(IContainerRegistry containerRegistry) { }
protected override AvaloniaObject CreateShell() => Container.Resolve<MainWindow>(); }
|
这里有两个关键点:
RegisterTypes:用于注册服务和页面类型,类似于传统 [[DI]] 容器的配置入口。
CreateShell:指定应用启动时加载的主窗口,通过容器解析确保依赖正确注入。
✦ 第三步:注册导航页面(Register Navigation Pages)
假设已经创建了两个页面 FirstPageView 和 SecondPageView(类型为 [[UserControl]]),需要在 RegisterTypes 中注册为可导航视图:
1 2 3 4 5 6 7 8 9 10
| public partial class App : PrismApplication { protected override void RegisterTypes(IContainerRegistry containerRegistry) { containerRegistry.RegisterForNavigation<FirstPageView>(); containerRegistry.RegisterForNavigation<SecondPageView>(); }
protected override AvaloniaObject CreateShell() => Container.Resolve<MainWindow>(); }
|
RegisterForNavigation 方法将页面类型注册到 [[Prism]] 的导航服务中,后续可通过名称进行动态解析。
✦ 第四步:设置导航区域与命令(Setup Region and Commands)
在 XAML 中定义导航按钮和区域容器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| <Grid RowDefinitions="Auto,*"> <StackPanel Margin="10" Orientation="Horizontal" Spacing="5"> <Button Command="{Binding OpenViewCommand}" CommandParameter="FirstPageView" Content="first page" /><!--OpenViewCommand是导航命令,可以自己命名;CommandParameter参数必须要与你导航页面的完整限定名一致,比如我这里需要导航到 FirstPageView页面,那么我的CommandParameter的参数值就必须是"FirstPageView"--> <Button Command="{Binding OpenViewCommand}" CommandParameter="SecondPageView" Content="second page" /> </StackPanel>
<Border Grid.Row="1"> <ContentControl regions:RegionManager.RegionName="ContentRegion" /><!-- 设置导航区域的名称 --> </Border> </Grid>
|
关键配置:
CommandParameter:必须与注册的页面名称完全一致(如 "FirstPageView")。
RegionName:附加属性,标识该 [[ContentControl]] 是一个导航区域,名称为 "ContentRegion"。
接下来在 [[ViewModel]] 中实现导航命令(使用 CommunityToolkit.Mvvm 的 [[RelayCommand]]):
1 2 3 4 5
| [RelayCommand] private void OpenView(string viewName) { _regionManager!.Regions["ContentRegion"].RequestNavigate(viewName); }
|
RequestNavigate 方法接收页面名称,通过 [[Prism]] 内部的区域管理器解析并加载对应视图。
✦ 方法二:自定义实现区域导航(Custom Region Navigation)
对于不想引入 [[Prism]] 依赖的项目,可以自定义实现区域导航。这需要先完成 [[DI]] 容器的配置,具体方法见前文《DependencyInjection: 在 Avalonia 中实现依赖注入》。
前提条件:
- 已完成 [[DI]] 容器的配置。
- 项目已创建两个视图页面
FirstPageView 和 SecondPageView(类型为 [[UserControl]]),并成功注册到容器中。
✦ 第一步:规划服务架构(Plan Service Architecture)
创建 Services 文件夹用于存放各种服务,在其中创建 Navigate 子文件夹存放导航相关服务:
1 2 3 4 5
| Services/ └── Navigate/ ├── IRegionManager.cs ├── RegionManager.cs └── NavigationResult.cs
|
✦ 第二步:定义 IRegionManager 接口(Define IRegionManager Interface)
创建区域管理器的抽象契约:
1 2 3 4 5 6
| public interface IRegionManager { void RegisterRegion(ContentControl regionTarget, string regionName); NavigationResult RequestNavigate(string regionName, Type pageType); ContentControl? this[string regionName] { get; } }
|
三个核心方法:
RegisterRegion:注册区域控件与名称的映射关系。
RequestNavigate:执行导航操作,返回导航结果。
- 索引器:通过区域名称快速获取对应的 [[ContentControl]]。
✦ 第三步:定义导航结果枚举(Define NavigationResult Enum)
创建导航操作的结果类型:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| public enum NavigationResult { Success, Failed, AlreadyActive, }
|
三态设计覆盖了所有可能的导航场景,调用方可根据结果执行后续逻辑。
✦ 第四步:实现 RegionManager 类(Implement RegionManager Class)
创建 [[RegionManager]] 类,实现 IRegionManager 接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86
| public class RegionManager(IServiceProvider serviceProvider) : IRegionManager { private static ConcurrentDictionary<string, WeakReference<ContentControl>> _regions = new();
public static readonly AvaloniaProperty RegionNameProperty = AvaloniaProperty.RegisterAttached<AvaloniaObject, string>("RegionName", typeof (RegionManager)); public static void SetRegionName(ContentControl regionTarget, string regionName) { if (regionTarget == null) throw new ArgumentNullException(nameof (regionTarget)); regionTarget.SetValue(RegionNameProperty, regionName); if(!_regions.TryAdd(regionName, new WeakReference<ContentControl>(regionTarget))) throw new InvalidOperationException($"区域名称 '{regionName}' 已经存在。请确保每个区域名称唯一。"); }
public static string? GetRegionName(ContentControl regionTarget) => regionTarget != null ? regionTarget.GetValue(RegionNameProperty) as string : throw new ArgumentNullException(nameof(regionTarget));
public void RegisterRegion(ContentControl regionTarget, string regionName) { if(regionTarget is null) throw new ArgumentNullException(nameof(regionTarget)); if(string.IsNullOrEmpty(regionName)) throw new ArgumentException("区域名称不能为空", nameof(regionName)); SetRegionName(regionTarget, regionName); if (_regions.TryAdd(regionName, new WeakReference<ContentControl>(regionTarget))) throw new InvalidOperationException($"区域名称 '{regionName}' 已经存在。请确保每个区域名称唯一。"); }
public NavigationResult RequestNavigate(string regionName, Type pageType) { if(string.IsNullOrEmpty(regionName)) throw new ArgumentException("区域名称不能为空", nameof(regionName)); if(pageType is null) throw new ArgumentException("视图名称不能为空", nameof(pageType.ToString));
ContentControl regionTarget = this[regionName] ?? throw new InvalidOperationException($"区域 '{regionName}' 不存在或未注册。"); Type? currentPage = regionTarget.Content?.GetType(); if (currentPage == pageType) return NavigationResult.AlreadyActive;
UserControl viewInstance = serviceProvider.GetService(pageType) as UserControl ?? throw new InvalidOperationException($"无法创建视图实例。请确保视图类型 '{pageType}' 已注册到 DI 容器。"); regionTarget.Content = viewInstance; return NavigationResult.Success; } public ContentControl? this[string regionName] { get { if(string.IsNullOrEmpty(regionName)) throw new ArgumentException("区域名称不能为空", nameof(regionName)); if(_regions.TryGetValue(regionName, out WeakReference<ContentControl>? weakRef)) { if(weakRef.TryGetTarget(out ContentControl? regionTarget)) return regionTarget; } return null; } } }
|
核心设计要点:
- [[ConcurrentDictionary]] + [[WeakReference]]:线程安全且支持 GC 自动回收,避免内存泄漏。
- 附加属性:通过
AvaloniaProperty.RegisterAttached 为 [[ContentControl]] 扩展 RegionName 属性,支持 XAML 绑定。
- DI 解析:通过
IServiceProvider.GetService 从容器中获取页面实例,保持与 [[DI]] 体系的一致性。
✦ 第五步:注册导航服务(Register Navigation Service)
将导航服务注入到程序生命周期中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| public static class ServiceCollectionExtensions { public static void AddServices(this IServiceCollection services) { services.AddSingleton<MainWindow>(); services.AddSingleton<FirstPageView>(); services.AddSingleton<SecondPageView>();
services.AddTransient<MainWindowViewModel>(); services.AddSingleton<IRegionManager, RegionManager>(); } }
|
[[RegionManager]] 注册为单例是因为它内部使用静态字典存储区域映射,整个应用生命周期内只需要一个实例。
✦ 第六步:设置导航区域(Setup Navigation Region)
在 XAML 中注册导航区域:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| <Grid Margin="5" RowDefinitions="Auto,*"> <StackPanel Margin="5" Orientation="Horizontal" Spacing="5"> <Button Command="{Binding NavigateCommand}" CommandParameter="{x:Type views:FirstPageView}" Content="FirstPage" /><!--CommandParameter参数必须是需要导航页面的页面类型--> <Button Command="{Binding NavigateCommand}" CommandParameter="{x:Type views:SecondPageView}" Content="SecondPage" /> </StackPanel> <Grid Grid.Row="1"> <TransitioningContentControl navigate:RegionManager.RegionName="MainContent" /> </Grid> </Grid>
|
与 [[Prism]] 方案不同,这里的 CommandParameter 直接绑定页面类型({x:Type views:FirstPageView}),而非字符串名称。
✦ 第七步:实现导航命令(Implement Navigation Command)
在 [[ViewModel]] 中实现导航命令:
1 2 3 4 5 6 7 8 9 10 11
| public partial class MainWindowViewModel(IServiceProvider serviceProvider) : ObservableObject { private readonly IRegionManager _regionManager = serviceProvider.GetRequiredService<IRegionManager>(); [RelayCommand] private void Navigate(Type pageType) { _regionManager.RequestNavigate("MainContent", pageType); } }
|
通过构造函数注入 IServiceProvider,在初始化时解析 [[IRegionManager]] 实例。Navigate 命令接收页面类型参数,调用 RequestNavigate 执行导航。
✦ 第八步:修改 App.axaml.cs(Modify App.axaml.cs)
最后修改应用入口,接入 [[DI]] 容器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| public partial class App : Application { public override void Initialize() { AvaloniaXamlLoader.Load(this); }
public override void OnFrameworkInitializationCompleted() { BindingPlugins.DataValidators.RemoveAt(0); ServiceCollection services = new ServiceCollection(); services.AddServices();
ServiceProvider serviceProvider = services.BuildServiceProvider(); MainWindow mainWindow = serviceProvider.GetRequiredService<MainWindow>(); mainWindow.DataContext = serviceProvider.GetRequiredService<MainWindowViewModel>();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { desktop.MainWindow = mainWindow; }
base.OnFrameworkInitializationCompleted(); } }
|
关键点:
- 服务构建:通过
services.BuildServiceProvider() 创建 [[DI]] 容器。
- 窗口解析:主窗口和 [[ViewModel]] 都从容器中获取,确保依赖链完整。
- 生命周期绑定:通过
IClassicDesktopStyleApplicationLifetime 将窗口绑定到桌面应用生命周期。
✦ 方案对比与选型建议(Comparison and Recommendations)
两种方案各有适用场景:
| 维度 |
Prism 框架 |
自定义实现 |
| 依赖成本 |
需引入 Prism.Avalonia 包 |
无额外依赖 |
| 功能完整度 |
开箱即用,支持区域嵌套、导航日志等 |
基础功能,需自行扩展 |
| 学习曲线 |
需掌握 Prism 概念体系 |
代码直观,易于理解 |
| 适用场景 |
企业级复杂应用 |
轻量级或定制化需求 |
对于新项目,推荐使用 [[Prism]] 方案,它提供了成熟的导航体系和丰富的扩展点。对于存量项目或对依赖敏感的场景,自定义实现是更灵活的选择。
✦ 总结(Summary)
区域导航是构建可扩展 [[Avalonia]] 应用的核心模式。无论是采用 [[Prism]] 框架还是自定义实现,其底层逻辑都是将视图的创建与切换职责从使用方剥离出来,交由统一的 [[IRegionManager]] 管理。
掌握这一模式后,我们可以轻松应对页面动态加载、视图解耦、生命周期管理等复杂场景,为构建企业级应用奠定坚实的架构基础。
作者:Arturia
声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!