使用 WPF 构建复合应用程序的模式
Windows ® Presentation Foundation (WPF) 和 Silverlight™ 等技术为开发人员提供了一种简单的声明性方法,使他们可以快速轻松地开发出具有丰富用户体验的应用程序。但是,尽管这些技术有助于进一步将表示层从逻辑层中分离出来,但它们无法解决如何构建易于维护的应用程序这一老问题。
对于一些较小的项目,具备一定经验的开发人员应该能够设计和构建出便于进行维护和扩展的应用程序,此要求并不过分。但是,随着移动部件的数量(以及使用这些部件的人员)的不断增加,对项目实施控制的难度开始呈指数级增长。
复合应用程序是专门针对此问题提出的解决方案。在本文中,我将对复合应用程序的定义进行解释,并说明如何才能构建一个利用 WPF 功能的复合应用程序。随后,我还会为您介绍 Microsoft 模式和实施方案小组提供的全新 WPF 复合应用程序指南(以前的代号为 "Prism")。
问题:单一应用程序
让我们通过一个示例来了解复合应用程序的需求。Contoso Financial Investments 提供了一个应用程序用来管理股票投资组合。借助此应用程序,用户可以查看当前的投资以及与这些投资相关的新项目,还可以将新项目添加到观察列表以及执行购买/销售交易。
如果将其构建成具有用户控件的传统 WPF 应用程序,首先应构建一个顶层窗口并针对上述各个功能添加用户控件。在这种情况下,您需要添加以下用户控件:PositionGrid、PositionSummary、TrendLine 和 WatchList(参见图 1)。在设计过程中,各个用户控件都通过在 XAML 中手动操作或使用设计器(如 Expression Blend™)等方式排列在主窗口中。
图 1 单一应用程序中的用户控件(单击图像可查看大图)
然后,使用 RoutedEvents、RoutedCommands 和数据绑定将所有内容连接起来。有关此主题的详细信息,请参阅 Brian Noyes 在本期撰写的文章“了解 WPF 中的路由事件和命令” (msdn.microsoft.com/magazine/cc785480)。PositionGrid 有一个相关联的 RoutedCommand 可供选择。在命令的 Execute 处理程序中,只要选择了某个位置就会发生 TickerSymbolSelected 事件。TrendLine 和 NewsReader 被连接在一起,以侦听 TickerSymbolSelected 事件并根据所选的股票代号呈现相应的内容。
在这种情况下,应用程序与每个控件都紧密耦合在一起。UI 中存在大量用于协调各个部分的逻辑。控件之间还存在着相互依赖关系。
由于存在这些依赖关系,因此无法通过某种简单的方法将应用程序分解成可在其中分别开发各个不同部分的窗体。虽然可以将所有用户控件都放在一个单独的程序集中以提高可维护性,但这种做法只是将问题从主应用程序转移到了控件程序集,治标不治本。在这种模型中,进行重大更改或引入新功能都非常困难。
现在,让我们增加两个新业务需求以使问题变得更复杂一些。第一个需求是添加一个基金债券屏幕,当双击某个基金时它会显示有关所选基金的个人债券。第二个需求是添加一个新屏幕,显示与所选基金相关的超链接列表。由于时间有限,这些功能必须由不同的团队并行开发。
每个团队都开发单独的控件:FundNotes 和 FundLinks。要将这两个控件添加到相同的控件程序集,必须将它们添加到控件项目中。更重要的是,必须将其添加到主窗体中,这意味着每个控件中对代码和 XAML 的更改都必须合并到主窗体中。此类操作可能会非常脆弱,尤其是对已有的应用程序。
如何将所有更改都应用到主应用程序中?要完成此任务,您可能需要花费大量的时间在源控件中执行合并和拆分操作。如果在应用变更时出错或意外覆盖了某些内容,应用程序就会遭到破坏。补救方法是重新考虑应用程序设计。
复合应用程序
复合应用程序由运行时动态发现和构成的松散耦合模块组成。模块包含代表系统的不同垂直片段的可视和非可视组件(参见图 2)。可视组件(视图)被组合在一个常规外壳中,可用作应用程序所有内容的宿主。复合应用程序可提供各种服务,将这些模块级组件结合在一起。模块可提供与应用程序的特定功能相关的其他服务。
图 2 复合应用程序的组件(单击图像可查看大图)
从较高的层次来看,复合应用程序是“复合视图”设计模式的实现,此模式可描述包含子项的视图的递归 UI 结构,这些子项本身也是视图。这些视图然后通过某种机制组合起来 — 通常是在运行时而非设计时静态组合。
为了说明此模式的优点,让我们以其中具有多个订单实例的订单输入系统为例。每个实例都可能非常复杂,需要显示标题、详细信息、运输和收据等信息。随着系统的发展变化,它可能还需要显示更多信息。并且还要考虑根据订单类型的不同而显示订单的不同部分。
如果以静态方式构建此类屏幕,则最终可能会需要大量用于显示订单不同部分的条件逻辑。并且,添加新功能时也会增大使现有逻辑遭到破坏的可能性。但是,如果将其作为复合视图来实现,则只需动态组合相关片段的订单屏幕即可。这意味着我们可以不使用条件显示逻辑,而且无需修改订单视图本身即可添加新的子屏幕。
模块会影响在其中创建主复合视图(也称为外壳)的视图。模块永远不会相互直接引用,也不会直接引用外壳。相反,它们会利用服务在彼此之间以及与外壳之间进行通信,以响应用户操作。
使用模块来组成系统有很多好处。模块可聚合来自同一应用程序中不同后端系统的数据。此外,系统可随着时间的推移更加方便地发展演变。在系统需求发生变化而需要向系统中添加新模块时,与非模块化系统相比,模块化系统面临的冲突要少很多。而且还可以对现有模块进行独立性更强的改进,从而改善可测试性。最后,模块可由不同的团队开发、测试和维护。
复合应用程序指南
Microsoft 模式和实施方案小组最近发布了第一个版本的“WPF 复合应用程序指南”(网址为microsoft.com/CompositeWPF)。这一新指南旨在充分利用 WPF 的功能和编程模型。同时,团队还根据内部产品团队、客户以及 .NET 社区的反馈,在之前复合应用程序指南的设计基础上进行了完善。
“WPF 复合应用程序指南”包括一个参考实现(之前讨论的 Stock Trader 应用程序)、一个复合应用程序库 (CAL)、快速入门应用程序以及设计和技术文档。
CAL 提供了用于构建复合应用程序的服务和探测功能。它使用的组合模型允许逐个使用或作为 CAL 设计的应用程序的一部分同时使用它的每个服务。并且,无需重新编译 CAL 即可轻松替换任何一个服务。例如,CAL 随附了一个扩展,它可以使用 Unity Application Block 实现依赖注入,但也允许您将其替换为自己的依赖注入服务。
快速入门提供了一些小型的专用应用程序,以展示每个不同 CAL 组件的使用方法。它们的设计目的在于提纲挈领,帮您快速了解主要概念,而不是面面俱到,试图马上就掌握所有知识。
在本文的剩余内容中,我将介绍 Stock Trader 参考实现中所出现的一些复合应用程序的多种技术概念。本文的所有代码均可从 MSDN® 的“WPF 复合应用程序指南”下载部分获得,网址为msdn.microsoft.com/library/cc707819。
引导程序和容器
使用 CAL 构建复合应用程序时,首先必须初始化几个核心复合服务。这就引入了引导程序。它可以执行发生复合所需的全部功能(如图 3 所示)。在许多方面,它都类似于 CAL 应用程序的 Main 方法。
图 3 引导程序初始化任务(单击图像可查看大图)
首先,初始化容器。对于容器,我指的是控制反转 (IoC) 容器/依赖关系注入 (DI) 容器。如果不太熟悉这一术语,请参阅 James Kovacs 撰写的《MSDN 杂志》文章“通过理顺软件的依赖关系提高应用程序灵活性”(msdn.microsoft.com/magazine/cc337885)。
容器在 CAL 应用程序中起着关键作用。容器存储着复合中使用的所有应用程序服务。它负责在需要的位置注入这些服务。默认情况下,CAL 包括一个抽象 UnityBootstrapper,它使用模式和实施方案小组提供的 Unity 框架作为容器。但是,构建 CAL 的目的是为了使用其他容器(如 Windsor、Structure Map 和 Sprint.NET)。CAL 中的任何类(除了 Unity 扩展)都不依赖于某个特定的容器。
在配置容器的同时,还会自动注册几个用于复合的核心服务(包括记录程序和事件聚合器),基本的引导程序允许您覆盖其中的任何服务。例如,自动注册 ImoduleLoader 服务。如果在引导程序中覆盖 ConfigureContainer 方法,即可注册自己的模块加载程序。
protected override void ConfigureContainer() { Container.RegisterType<IModuleLoader, MyModuleLoader>(); base.ConfigureContainer(); }
如果不希望默认注册服务,也可以关闭此功能。只需针对引导程序调用 Run 方法重载,为 useDefaultConfiguration 参数传递一个 false 值即可。
接下来,配置区域适配器。区域在 UI 中是指一个特定位置(通常它是一个容器,如面板),模块可以在其中注入 UI 元素。区域适配器负责连接将被访问的不同区域类型。这些适配器被映射到容器中的 RegionAdapterMappings 单例实例中。
现在创建外壳。外壳是指顶层窗口,可在其中定义区域。我们没有在 App.Xaml 中声明它,而是在应用程序特定的引导程序中通过 CreateShell 方法进行创建。这可以确保在外壳显示出来之前引导程序的初始化即已完成。
当您发现应用程序中实际并不需要外壳时,您可能会感到很吃惊。例如,您可能有一个现有的 WPF 应用程序,而您希望向其中添加一些 CAL 功能。您可能不想让 CAL 控制整个屏幕,而是希望添加一个面板作为顶层区域。在这种情况下,您就不需要定义外壳。如果不定义外壳,引导程序不会显示它,直接将其忽略。
模块初始化
最后,初始化模块。在 CAL 应用程序中,模块是复合应用程序的分离单位,可将其部署为单独的程序集(尽管并非必需)。在 CAL 应用程序中,模块包含了大部分的功能。
加载模块分为两个步骤,涉及以下两个服务:IModuleEnumerator 和 ImoduleLoader。枚举器负责定位可用的模块。它将返回几个包含模块元数据的 ModuleInfo 对象的集合。UnityBootstrapper 包含一个 GetModuleEnumerator,必须将其覆盖才能返回正确的枚举器;否则,运行时会抛出异常。CAL 包括一些枚举器,可从目录扫描和配置中静态定位模块。
对于加载,CAL 包括一个默认由 UnityBootstrapper 使用的 ModuleLoader。它加载每个模块程序集(如果尚未加载)然后初始化它们。模块可指定与其他模块的依赖关系。ModuleLoader 将构建依赖关系树并根据这些规范以正确的顺序初始化模块。
使用引导程序
由于 UnityBootstrapper 是一个抽象类,因此 StockTraderRIBootstrapper 会覆盖它(参见图 4)。引导程序有多个受保护的虚拟方法,可利用它们来插入您自己的特定于应用程序的功能。
图 4 Stock Trader 引导程序
public class StockTraderRIBootstrapper : UnityBootstrapper { private readonly EntLibLoggerAdapter _logger = new EntLibLoggerAdapter(); protected override IModuleEnumerator GetModuleEnumerator() { return new StaticModuleEnumerator() .AddModule(typeof(NewsModule)) .AddModule(typeof(MarketModule)) .AddModule(typeof(WatchModule), "MarketModule") .AddModule(typeof(PositionModule), "MarketModule", "NewsModule"); } protected override ILoggerFacade LoggerFacade { get { return _logger; } } protected override void ConfigureContainer() { Container.RegisterType<IShellView, Shell>(); base.ConfigureContainer(); } protected override DependencyObject CreateShell() { ShellPresenter presenter = Container.Resolve<ShellPresenter>(); IShellView view = presenter.View; view.ShowView(); return view as DependencyObject; } }
必须要注意到,EntlibLoggerAdapter 是在 _logger 变量中定义和存储的。然后,代码将覆盖 LoggerFacade 属性以返回此记录程序(它将实现 IloggerFacade)。在本例中,我使用的是 Enterprise Library 的记录程序,但您也可以方便地替换为自己的适配器。
接下来,覆盖 GetModuleEnumerator 方法以返回 StaticModuleEnumerator,它已使用四个参考实现模块进行了预填充。参考实现使用静态模块加载,但也可以通过一些其他方法来枚举模块,包括目录查找和配置。要使用其他枚举方法,只需更改此方法来实例化一个不同的枚举器即可。
然后,覆盖 ConfigureContainer 以注册外壳。此时,也可以根据需要通过编程方式注册其他服务。最后,使用特定的逻辑覆盖 CreateShell 以创建外壳。在此例中,代码实现的是 Model View Presenter 模式,因此外壳具有相关联的表示器。
图 4 所示的引导程序展示了从头开始构建 CAL 应用程序的常见模式,它将创建一个特定于应用程序的引导程序。此方法的主要好处是特定于应用程序的引导程序可增强应用程序的可测试性。除了 DependencyObject 以外,引导程序与 WPF 不存在任何依赖关系。例如,您可以创建一个继承自特定于应用程序引导程序的测试引导程序,并覆盖 CreateContainer 方法以返回 AutoMocking 容器,从而模仿出所有服务。
此外,由于引导程序可提供用于初始化复合应用程序的单个入口点,并且由于 CAL 并不依靠应用程序中框架类的继承关系,因此在将 CAL 集成到现有应用程序时所面临的冲突要比之前的框架少很多。请注意,CAL 本身完全不依赖于引导程序,因此如果引导程序不适合您的需求,您可以弃用它。
模块和服务
正如我之前提到的,在使用 CAL 构建的复合应用程序中,大部分应用程序逻辑都位于模块内。Stock Trader 参考实现包括以下四个模块:
- NewsModule,提供与所选的每项基金相关的新闻源。
- MarketModule,提供所选基金的趋势数据以及实时市场数据。
- WatchModule,提供一个观察列表,显示所监视的基金的列表。
- PositionModule,显示已投资基金的列表并允许执行购买/销售交易。
在 CAL 中,模块是实现 IModule 接口的类。此接口仅包含一个方法,称为 Initialize。如果把引导程序看作应用程序的 Main 方法,那么 Initialize 方法就是每个模块的 Main。例如,以下显示的是 WatchModule 的 Initialize 方法:
public void Initialize() { RegisterViewsAndServices(); IWatchListPresentationModel watchListPresentationModel = _container.Resolve<IWatchListPresentationModel>(); _regionManager.Regions["WatchRegion"].Add(watchListPresentationModel.View); IAddWatchPresenter addWatchPresenter = _container.Resolve<IAddWatchPresenter>(); _regionManager.Regions["MainToolbarRegion"].Add(addWatchPresenter.View); }
在深入研究模块细节之前,有两件事值得讨论一下,那就是对 _container 和 _regionManager 的引用。如果接口并未定义它们,那它们究竟从何而来?我是否要将逻辑硬编码到模块中以找出这些依赖关系?
幸运的是,后一问题的答案是“否”。这时候,IoC 容器就派上用场了。加载模块时,它从容器中被解析出来,同时会将所有指定的依赖关系注入到模块的构造函数中:
public WatchModule(IUnityContainer container, IRegionManager regionManager) { _container = container; _regionManager = regionManager; }
在这里您可以看到容器本身被注入到模块中。这可能是因为引导程序在其 ConfigureContainer 方法中注册了容器:
Container.RegisterInstance<IUnityContainer>(Container);
通过让模块直接访问容器,可以允许模块以一种强制性方式在容器中注册和解析依赖关系。
您无需执行这一强制性注册。相反,您可将所有服务放在全局配置中。这样做意味着必须在最初创建容器时注册所有服务。但是,大多数模块都拥有特定于模块的服务。而且,通过将注册环节放在模块中,可使那些特定于模块的服务仅在模块加载时才会被注册。
对于之前向您展示的模块,首先调用的是 RegisterViewsAndServices。在此方法中,WatchModule 的每个特定视图都是在容器中注册的,同时注册的还有一个接口:
protected void RegisterViewsAndServices() { _container.RegisterType<IWatchListService, WatchListService>( new ContainerControlledLifetimeManager()); _container.RegisterType<IWatchListView, WatchListView>(); _container.RegisterType<IWatchListPresentationModel, WatchListPresentationModel>(); _container.RegisterType<IAddWatchView, AddWatchView>(); _container.RegisterType<IAddWatchPresenter, AddWatchPresenter>(); }
通过要求必须指定接口可以帮助分散关注点,允许系统中的其他模块无需直接引用即可与视图进行交互。通过将所有内容都放入容器中可允许自动注入不同对象的各种依赖关系。例如,WatchListView 永远不会在代码中直接实例化 — 相反,它会作为一种依赖关系在 WatchListPresentationModel 构造函数中加载:
public WatchListPresentationModel(IWatchListView view...)
除了视图外,WatchModule 还会注册 WatchListService,其中包含列表数据,可用来添加新项。待注册的特定视图包括观察列表以及观察列表工具栏。注册完毕后即可使用区域管理器,并且刚刚注册的两个视图会被添加到 WatchRegion 和 ToolbarRegion 中。
区域和 RegionManager
模块本身并不会让人太感兴趣,除非它们可以将内容呈现给 UI。在上一部分中,您看到了 Watch 模块使用一个区域来添加它的两个视图。使用区域后,模块将不再需要拥有对 UI 的特定引用,也无需再了解注入的视图的布局和显示方式。例如,图 5 展示了 WatchModule 要注入的区域。
图 5 将模块注入应用程序(单击图像可查看大图)
CAL 包括一个 Region 类,大体说来,此类就是涵盖这些位置的一个句柄。Region 类包含一个 Views 属性,它是要在区域中进行显示的视图的只读集合。视图通过调用区域的 add 方法被添加到区域中。Views 属性实际包含对象的泛型集合;它并非局限于仅包含 UIElement。此集合将实现 InotifyPropertyCollectionChanged,以使与区域相关联的 UIElement 能够与之绑定并观察其变更。
您可能想知道为什么 Views 集合为弱类型而非 UIElement 类型。由于 WPF 可提供丰富的模板支持,因此您可以将模型直接添加到区域。然后,将会为该模型定义一个相关联的 DataTemplate,它会定义模型的呈现方式。如果添加的项目是 UIElement 或用户控件,则 WPF 会按原样呈现它。这意味着如果某个区域是未结订单的选项卡,则只需将 OrderModel 或 OrderPresentationModel 添加到区域,然后定义一个自定义 DataTemplate 即可控制显示,而并非必须创建一个自定义的 OrderView 用户控件。
区域可通过以下两种方式进行注册。第一种是在 XAML 中通过使用附带属性的 RegionName 来注释 UIElement 进行定义。例如,定义 MainToolbarRegion 的 XAML 如下所示:
<ItemsControl Grid.Row="1" Grid.Column="1" x:Name="MainToolbar" cal:RegionManager.RegionName="MainToolbarRegion"> <ItemsControl.ItemsPanel> <ItemsPanelTemplate> <WrapPanel /> </ItemsPanelTemplate> </ItemsControl.ItemsPanel> </ItemsControl>
通过 XAML 定义了区域后,它会在运行时自动注册 RegionManager,这是由引导程序注册的一个复合服务。RegionManager 实质上是一个 Dictionary,其中关键字为区域的名称,值为 IRegion 接口的实例。RegionManager 附加的属性使用 RegionAdapter 来创建此实例。
但是要注意,如果使用附加属性不起作用,或需要动态注册其他区域,您可以手动创建 Region 类或派生类的实例,并将其添加到 RegionManager 的 Regions 集合中。
请注意,在 XAML 代码段中,MainToolbarRegion 是 ItemsControl。CAL 随附了由引导程序注册的三个区域适配器 — ContentControlRegionAdapter、ItemsControlRegionAdapter 和 SelectorRegionAdapter。这些适配器已注册到 RegionAdapterMappings 类。所有适配器都从实现 IRegionAdapter 接口的 RegionAdapterBase 继承而来。
图 6 显示了 ItemsControlRegionAdapter 的实现。适配器本身的实现方式完全取决于它所应用到的 UIElement 的类型。对于 ItemsControlRegionAdapter,它的实现主要位于 Adapt 方法中。Adapt 方法接受两个参数。第一个参数是 RegionManager 创建的 Region 类本身的实例。第二个参数是代表区域的 UIElement。Adapt 方法将执行相关探测以确保区域可与元素协同工作。
图 6 ItemsControlRegionAdapter
public class ItemsControlRegionAdapter : RegionAdapterBase<ItemsControl> { protected override void Adapt(IRegion region, ItemsControl regionTarget) { if (regionTarget.ItemsSource != null || (BindingOperations.GetBinding(regionTarget, ItemsControl.ItemsSourceProperty) != null)) throw new InvalidOperationException( Resources.ItemsControlHasItemsSourceException); if (regionTarget.Items.Count > 0) { foreach (object childItem in regionTarget.Items) { region.Add(childItem); } regionTarget.Items.Clear(); } regionTarget.ItemsSource = region.Views; } protected override IRegion CreateRegion() { return new AllActiveRegion(); } }
对于 ItemsControl,适配器将从 ItemControl 本身自动删除所有子项,然后将其添加到区域。接下来,区域的 Views 集合被绑定到控件的 ItemsSource。
第二种覆盖方法是 CreateRegion,它会返回新的 AllActiveRegion 实例。区域可包含处于活动状态或非活动状态的视图。对于 ItemsControl,它的所有项目始终都处于活动状态,因为它没有选择意识。但是,对于其他类型的区域(如 Selector),一次仅选择一项。视图可实现 IActiveAware 接口,以便能够从其区域处得到已被选中的通知。只要视图被选中,它就会将其 IsSelected 属性设为 true。
在复合应用程序的整个开发过程中,您可能不得不创建一些附加的区域和区域适配器,例如需要用来适应第三方供应商所提供的控件的适配器。要注册新的区域适配器,需覆盖引导程序中的 ConfigureRegionAdapterMappings 方法。完成后,添加类似如下所示的代码:
protected override RegionAdapterMappings ConfigureRegionAdapterMappings() { RegionAdapterMappings regionAdapterMappings = base.ConfigureRegionAdapterMappings(); regionAdapterMappings.RegisterMapping(typeof(Selector), new MyWizBangRegionAdapter()); return regionAdapterMappings; }
定义完区域后,可通过控制 RegionManager 服务来从应用程序中的任意类访问它。在 CAL 应用程序中执行此操作时,常见方法是让依赖关系注入容器将 RegionManager 注入到需要它的类的构造函数中。要将视图或模型添加到区域中,只需调用区域的 Add 方法即可。添加视图时,可传递一个可选名称:
_regionManager.Regions["MainRegion"].Add( somePresentationModel, "SomeView");
随后,可通过区域的 GetView 方法使用该名称从区域中检索视图。
本地作用区域
默认情况下,应用程序中只有一个 RegionManager 实例,因而使得每个区域的作用范围都全局有效。这适用于很多情况,但有时您可能希望定义仅在特定范围内有效的区域。如果应用程序有一个员工详细信息视图,其中可同时显示视图的多个实例,这就是此情形的一个示例。如果这些视图非常复杂,其行为方式类似于迷你外壳或 CompositeView。在这些情形下,您可能希望每个视图都像外壳一样拥有其自己的区域。CAL 允许您为视图定义本地 RegionManager,这样在其中或其子视图中定义的任何区域都会在该本地区域中自动注册。
指南中包括的“UI 复合”快速入门描述了这种员工情形(参见图 7)。快速入门中有一个员工列表。单击每位员工时,您会看到相关联的员工详细信息。每次选择员工时,都会为该员工创建一个新的 EmployeeDetailsView 并将其添加到 DetailsRegion(参见图 8)。此视图包含一个本地 TabRegion,EmployeesController 在其 OnEmployeeSelected 方法中会将 ProjectListView 注入其中。
图 7 通过 RegionManager 实现 UI 复合(单击图像可查看大图)
图 8. 创建新的员工视图
public virtual void OnEmployeeSelected(BusinessEntities.Employee employee) { IRegion detailsRegion = regionManager.Regions[RegionNames.DetailsRegion]; object existingView = detailsRegion.GetView( employee.EmployeeId.ToString(CultureInfo.InvariantCulture)); if (existingView == null) { IProjectsListPresenter projectsListPresenter = this.container.Resolve<IProjectsListPresenter>(); projectsListPresenter.SetProjects(employee.EmployeeId); IEmployeesDetailsPresenter detailsPresenter = this.container.Resolve<IEmployeesDetailsPresenter>(); detailsPresenter.SetSelectedEmployee(employee); IRegionManager detailsRegionManager = detailsRegion.Add(detailsPresenter.View, employee.EmployeeId.ToString(CultureInfo.InvariantCulture), true); IRegion region = detailsRegionManager.Regions[RegionNames.TabRegion]; region.Add(projectsListPresenter.View, "CurrentProjectsView"); detailsRegion.Activate(detailsPresenter.View); } else { detailsRegion.Activate(existingView); } }
区域作为 TabControl 呈现出来,同时包含静态和动态内容。General 和 Location 选项卡是在 XAML 中静态定义的。但是,Current Projects 选项卡已注入了自己的视图。
在代码中您会看到,从 detailsRegion.Add 方法返回了新的 RegionManager 实例。另外还要注意,我使用的是 Add 的重载来传入视图名称并将 createRegionManagerScope 参数设置为 true。这样做会创建一个本地 RegionManager 实例,它将被用于在子项中定义的任意区域。TabRegion 本身是在 EmployeeDetailsView 的 XAML 中定义的:
<TabControl AutomationProperties.AutomationId="DetailsTabControl" cal:RegionManager.RegionName="{x:Static local:RegionNames.TabRegion}" .../>
即使未使用实例区域,使用本地区域也会带来另一种好处。可使用它们来定义顶层边界,这样模块就不会自动向其他各方公开其区域。要实现此目的,只需将该模块的顶层视图添加到某个区域并将其指定为拥有自己的范围即可。这样做之后,即可有效地将该模块的区域与其他各方隔离开来。访问它们并非不可能,但要困难很多。
如果没有视图,复合应用程序也就没有存在的必要。视图是需要在复合应用程序中构建的最重要元素,因为它们是用户使用您的应用程序所提供的各种功能的通道。
视图通常是应用程序的屏幕。视图可包含其他视图,从而成为复合视图。视图的另一个用途是放置菜单和工具栏。例如,在 Stock Trader 中,OrdersToolbar 是一个包含 Submit、Cancel、Submit All 和 Cancel All 按钮的视图。
WPF 支持的视图概念要比 Windows 窗体领域中的约定丰富得多。在 Windows 窗体中,基本上仅限于将控件用于直观表示。在 WPF 中,此模型仍然受到支持,并且您可以创建自定义的用户控件来代表不同的屏幕。纵观整个 Stock Trader 应用程序,这是用于定义视图的主要机制。
另一方法是使用模型。WPF 允许您将任意模型绑定到 UI,然后使用 DataTemplate 来呈现它。这些模板会递归呈现,即如果模板呈现一个绑定到某个模型属性的元素,该属性将使用模板(如果可用)来呈现。
对于它的工作原理,让我们来看一看以下代码示例。此示例实现的 UI 与“复合”快速入门相同,但它使用的完全是模型和 DataTemplate。整个项目中没有一个用户控件。图 9 展示了 EmployeeDetailsView 的处理方法。此视图现在是已在 ResourceDictionary 中定义的一组三个 DataTemplate。一切都从 EmployeeDetailsPresentationModel 开始。其模板声明它应被呈现为 TabControl。作为模板的一部分,它将 TabControl 的 ItemsSource 绑定到 EmployeeDetailsPresentationModel 的 EmployeeDetails 集合属性。构建员工详细信息时,此集合由两部分信息填充而成:
public EmployeesDetailsPresentationModel() { EmployeeDetails = new ObservableCollection<object>(); EmployeeDetails.Insert(0, new HeaderedEmployeeData()); EmployeeDetails.Insert(1, new EmployeeAddressMapUrl()); ... }
图 9 创建 EmployeeDetailsView
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:EmployeesDetailsView= "clr-namespace:ViewModelComposition.Modules.Employees.Views.EmployeesDetailsView"> <DataTemplate DataType="{x:Type EmployeesDetailsView:HeaderedEmployeeData}"> <Grid x:Name="GeneralGrid"> <Grid.ColumnDefinitions> <ColumnDefinition></ColumnDefinition> <ColumnDefinition Width="5"></ColumnDefinition> <ColumnDefinition Width="*"></ColumnDefinition> </Grid.ColumnDefinitions> <Grid.RowDefinitions> <RowDefinition></RowDefinition> <RowDefinition></RowDefinition> <RowDefinition></RowDefinition> <RowDefinition></RowDefinition> </Grid.RowDefinitions> <TextBlock Text="First Name:" Grid.Column="0" Grid.Row="0"> </TextBlock> <TextBlock Text="Last Name:" Grid.Column="2" Grid.Row="0"> </TextBlock> <TextBlock Text="Phone:" Grid.Column="0" Grid.Row="2"></TextBlock> <TextBlock Text="Email:" Grid.Column="2" Grid.Row="2"></TextBlock> <TextBox x:Name="FirstNameTextBox" Text="{Binding Path=Employee.FirstName}" Grid.Column="0" Grid.Row="1"></TextBox> <TextBox x:Name="LastNameTextBox" Text="{Binding Path=Employee.LastName}" Grid.Column="2" Grid.Row="1"></TextBox> <TextBox x:Name="PhoneTextBox" Text="{Binding Path=Employee.Phone}" Grid.Column="0" Grid.Row="3"></TextBox> <TextBox x:Name="EmailTextBox" Text="{Binding Path=Employee.Email}" Grid.Column="2" Grid.Row="3"></TextBox> </Grid> </DataTemplate> <DataTemplate DataType="{x:Type EmployeesDetailsView:EmployeeAddressMapUrl}"> <Frame Source="{Binding AddressMapUrl}" Height="300"></Frame> </DataTemplate> <DataTemplate DataType="{x:Type EmployeesDetailsView:EmployeesDetailsPresentationModel}"> <TabControl x:Name="DetailsTabControl" ItemsSource="{Binding EmployeeDetails}" > <TabControl.ItemContainerStyle> <Style TargetType="{x:Type TabItem}" BasedOn="{StaticResource RoundedTabItem}"> <Setter Property="Header" Value="{Binding HeaderInfo}" /> </Style> </TabControl.ItemContainerStyle> </TabControl> </DataTemplate> </ResourceDictionary>
对集合中的每一项都会呈现一个单独的选项卡。在第一项开始呈现时,WPF 将使用为 HeaderedEmployeeData 指定的 DataTemplate。HeaderedEmployeeData 模型包含员工姓名和联系人信息。它的关联模板会将模型呈现为用于显示信息的一系列标签。第二项将使用为 EmployeeAddressMapUrl 指定的模板进行呈现,在本例中,它会呈现一个帧,其中包含一个显示员工所在位置的地图的网页。
这是一个相当典型的转换,正如您之前所了解的,视图仅在运行时通过模型及其关联模板的组合而实际存在。您也可以实现两种方法的混合体(如 Stock Trader 中所演示),其中的用户控件也包含控件,并且随后被绑定到通过模板呈现的模型中。
单独的表示
在本文之前的内容中,我曾提到过构建复合应用程序的其中一个好处是它使代码更具可维护性和可测试性。您可以在视图中应用多个已建立的表示模式以达到这一目的。纵观整个“WPF 复合应用程序指南”,您会看到在 UI 中使用了两种重复模式:Presentation Model 和 Supervising Controller。
Presentation Model 模式会假定一个模型,它同时包含 UI 的行为和数据。然后,视图将表示模型的状态投射“到玻璃上”。
在后台,模型会与业务和域模型进行交互。模型还包括其他状态信息,如已选择项或某个元素是否被选中。然后,视图被直接绑定到 Presentation Model 并进行呈现(参见图 10)。WPF 中对于数据绑定、模板和命令的丰富支持使得 Presentation Model 模式成为一种颇具吸引力的开发方案。
图 10 Presentation Model 模式(单击图像可查看大图)
Stock Trader 应用程序明智地选择了 Presentation Model,例如在位置摘要中:
public class PositionSummaryPresentationModel : IPositionSummaryPresentationModel, INotifyPropertyChanged { public PositionSummaryPresentationModel( IPositionSummaryView view,...) { ... } public IPositionSummaryView View { get; set; } public ObservableCollection<PositionSummaryItem> PositionSummaryItems { get; set; } }
您可以看到 PositionSummaryPresentationModel 实现 INotifyPropertyChanged 以通知视图所发生的任何更改。在容器中解析 PositionSummaryPresentationModel 的时候,视图本身通过其 IPositionSummaryView 接口被注入到构造函数中。此接口允许在单元测试中模拟视图。Presentation Model 将公开一个可观察的 PositionSummaryItem 的集合。这些项目被绑定到 PostionSummaryView 且被呈现出来。
在 Supervising Controller 模式中,存在着模型、视图以及表示器;如图 11 所示。模型是数据;在大多数情况下,它是一个业务对象。视图是模型直接绑定的 UIElement。最后,表示器是包含 UI 逻辑的一个类。在此模式中,除了委派给表示器并响应来自表示器的回调以执行一些简单操作(包括显示或隐藏控件)外,视图几乎不包含任何逻辑。
图 11 Supervising Controller 模式(单击图像可查看大图)
Supervising Controller 模式也用于 Stock Trader 应用程序中一些支持 Presentation Model 的实例。其中的一个示例是趋势线(参见图 12)。与 PositionSummaryPresentationModel 类似,TrendLinePresenter 通过 ITrendLineView 接口注入到 TrendLineView。表示器将公开视图通过其委派逻辑调用的 OnTickerSymbolSelected 方法。请注意,在该方法中,表示器随后会回调视图,以调用其 UpdateLineChart 和 SetChartTitle 方法。
图 12 显示趋势线
public class TrendLinePresenter : ITrendLinePresenter { IMarketHistoryService _marketHistoryService; public TrendLinePresenter(ITrendLineView view, IMarketHistoryService marketHistoryService) { this.View = view; this._marketHistoryService = marketHistoryService; } public ITrendLineView View { get; set; } public void OnTickerSymbolSelected(string tickerSymbol) { MarketHistoryCollection historyCollection = _marketHistoryService.GetPriceHistory(tickerSymbol); View.UpdateLineChart(historyCollection); View.SetChartTitle(tickerSymbol); } }
实现单独的表示时的一个挑战就是视图与表示模型或表示器之间的通信。有多种方法可以解决此问题。经常采用的一种方法是,让视图中的事件处理程序或者直接调用表示模型或表示器,或者引发调用表示模型或表示器的事件。在 UI 中,通常必须根据状态变更或权限来启用或禁用对表示器发起调用的相同 UIElement。这要求视图拥有可用于回调它的方法,以便禁用这些元素。
另一方法是使用 WPF 命令。这些命令提供了一种解决这些问题的简单方法,并且不需要任何往复委派逻辑。WPF 中的元素可绑定到命令以处理执行逻辑以及启用或禁用元素。当 UIElement 被绑定到命令时,如果命令的 CanExecute 属性为 false,则它会自动被禁用。在 XAML 中可通过声明的方式绑定命令。
WPF 预设即提供了 RoutedUICommand。要使用这些命令,在视图的源代码中必须包含 Execute 和 CanExecute 方法的处理程序 — 这意味着在往复通讯中仍需要修改代码。RoutedUICommand 还有其他限制,如要求接收方必须位于 WPF 的逻辑树中(此限制在构建复合应用程序时会出现问题)。
幸运的是,RoutedUICommand 只是命令的其中一个实现。WPF 提供了 ICommand 接口并将其绑定到实现它的所有命令。这意味着您可以创建自定义命令以满足您的所有需求,而且无需更改源代码。缺点是对于 SaveCommand、SubmitCommand 和 CancelCommand 等自定义命令在各个位置必须都分别实现。
CAL 包括许多新命令(如 DelegateCommand<T>,它允许您为构造函数中的 Execute 和 CanExecute 方法指定两个委派)。通过使用此命令,在连接各个视图时,您不必再通过在视图本身定义的方法进行委派,也不必为每个操作分别创建自定义命令。
在 Stock Trader 应用程序中,DelegateCommand 在多个位置(包括观察列表)都被用到。WatchListService 使用此命令来向观察列表中添加项目:
public WatchListService(IMarketFeedService marketFeedService) { this.marketFeedService = marketFeedService; WatchItems = new ObservableCollection<string>(); AddWatchCommand = new DelegateCommand<string>(AddWatch); }
除了在视图和表示器或表示模型之间路由命令外,在复合应用程序中还需处理其他一些类型的通信(如事件发布)。在这些情况下,发行者与订阅者是完全分离的。例如,模块可能会公开一个从服务器接收通知的 Web 服务端点。接收到该通知后,它需要触发一个事件,位于相同或不同模块中的组件都可以订阅此事件。
为支持此功能,CAL 有一个向容器注册的 EventAggregator 服务。通过使用此服务(它是 Event Aggregator 模式的一个实现),发行者和订阅者可通过松散耦合的方式进行通信。EventAggregator 服务包含一个事件存储库,其中的事件是抽象 EventBase 类的实例。此服务有一个用于检索事件实例的 GetEvent<TEventType> 方法。
CAL 包括 CompositeWPFEvent<TPayload> 类,它继承了 EventBase 并提供对 WPF 的特定支持。此类使用委派而非完整的 .NET 事件来执行发布。实质上,它使用默认起弱委派作用的 DelegateReference 类(有关弱委派的详细信息,请参阅 msdn.microsoft.com/library/ms404247)。这将允许对订阅者进行垃圾回收,即使在他们并未显式取消订阅。
CompositeWPFEvent 类包含 Publish、Subscribe 和 Unsubscribe 方法。每种方法都使用事件的泛型类型信息以确保发布者传递正确的参数 (TPayload) 并且 Subscriber 属性会接收它们 (Action<TPayload>)。Subscribe 方法允许传入 ThreadOption(可设为 PublisherThread、UIThread 或 BackgroundThread)。此选项可确定将针对哪个线程调用订阅委派。此外,Subscribe 方法将被重载以允许传入 Predicate<T> 过滤器,从而确保只有在满足过滤器条件时订阅者才会收到事件通知。
在 Stock Trader 应用程序中,EventAggregator 被用来广播消息(当在位置屏幕中选择了某个代号时)。News 模块订阅此事件并显示该基金的新闻。以下是此功能的实现过程:
public class TickerSymbolSelectedEvent : CompositeWpfEvent<string> { }
首先,在 StockTraderRI.Infrastructure 程序集中定义事件。以下是所有模块都引用的一个共享程序集:
public void Run() { this.regionManager.Regions["NewsRegion"].Add( articlePresentationModel.View); eventAggregator.GetEvent<TickerSymbolSelectedEvent>().Subscribe( ShowNews, ThreadOption.UIThread); } public void ShowNews(string companySymbol) { articlePresentationModel.SetTickerSymbol(companySymbol); }
News 模块的 NewsController 在其 Run 方法中订阅此事件:
private void View_TickerSymbolSelected(object sender, DataEventArgs<string> e) { _trendLinePresenter.OnTickerSymbolSelected(e.Value); EventAggregator.GetEvent<TickerSymbolSelectedEvent>().Publish( e.Value); }
然后,只要选择某个代号,PositionSummaryPresentation 模型就会触发事件。
结束语
从 microsoft.com/compositewpf 下载指南。要运行代码,只需安装 .NET Framework 3.5 即可。
指南中包含的工具可帮助您初窥门径。快速入门提供了易于理解的示例,它们主要关注的是构建复合应用程序时涉及的各种知识。参考实现为您提供了一个全面的示例,其中涵盖了所有不同的方面。最后,在文档中提供了一些背景信息、一组完整的特定任务操作方法以及动手练习教程。
在使用指南的同时,请将您的意见发布到 CodePlex 论坛或通过电子邮件发送到 cafbk@microsoft.com。
Glenn Block 是 .NET Framework 4.0 中新增的“托管可扩展性框架”(MEF) 团队的一名项目经理。在 MEF 工作之前,他是负责 Prism 以及其他客户指南的模式和实施方案小组中的一名产品计划员。Glenn 在内心里是个奇客,他花费了大量时间在各种会议和小组(如 ALT.NET)中不遗余力地传播着他的这份狂热。