Prism初研究之使用Prism实现WPF的MVVM的高级应用

Prism初研究之使用Prism实现WPF的MVVM的高级应用


本章描述MVVM如何支持一些复杂的使用场景,以及如何组织命令和子视图来知足用户需求。本章还描述了如何处理异步数据请求以及以后的UI交互。

Commands

复合命令(Composite Commands)

一般,一个定义在View Model中的命令可以经过绑定到控件来实现直接命令调用。可是,有些状况下,可能使用一个父视图的控件调用一个或多个View Model的多个命令。
好比,若是应用程序容许用户同时编辑多个数据项,就须要容许用户经过点击一个按钮(命令)来保存全部的数据项。这种状况下Save All命令将会调用每个数据项的Save命令。
实现Save All复合命令
Prism提供了CompositeCommand类来实现复合命令。
这个命令类由对个子命令组成。当复合命令被调用时,全部子命令将会依次调用。这个命令类能够应用于使用一个逻辑命令调用多个命令和使用一个命令来表示一组命令两种使用场景。
Stock Trader RI例子中的SubmitAllOrder命令就是一个复合命令。
CompositeCommand命令维护一个子命令(DelegateCommand实例)的列表,它的Execute方法只是遍历调用了子命令的Execute。CanExecute方法相似,不过若是有一个子命令不可运行,就返回false。编程

注册和注销子命令

经过RegisterCommand和UnregisterCommand方法来注册和注销子命令。Stock Trader RI中每个订单的Submit和Cancel命令都注册到SubmitAllOrders和CancelAllOrders组件命令:设计模式

 
 
 
 
// OrdersController.cscommandProxy.SubmitAllOrdersCommand.RegisterCommand( orderCompositeViewModel.SubmitCommand );commandProxy.CancelAllOrdersCommand.RegisterCommand( orderCompositeViewModel.CancelCommand );

在活动的子视图上运行命令

Prism的CompositeCommand和DelegateCommand类能够与Prism的regions一块儿工做。
下图展现了一个子视图如何添加到EditRegion的。UI设计者能够选择使用Tab控件在Region中布局视图:
用TabControl定义EditRegion
有时可能须要运行当前活动子视图的命令:实现一个Zoom命令来引发活动视图的缩放。

Prism提供了IActiveAware接口来支持这种使用状况。该接口定义了一个IsActive属性(在实现者活动是返回true)和一个IsActiveChanged事件(active状态改变时发生)。
能够在子视图或者视图模型类上实现IActiveAware接口。视图的活动状态由特定区域控件中的区域适配器(region Adapter)决定。上图中,有一个region Adapter将选中标签中的视图设置为活动的。
DelegateCommand类也实现了IActiveAware接口。CompositeCommand能够经过拥有参数monitorCommandActivity的构造函数来配置是否评估子命令的活动状态)。
若是monitorCommandActivity参数是true,CompositeCommand类会有如下行为:安全

  • CanExecute。全部Active的命令均可以被执行时返回true。不活动的子命令不会被考虑。
  • Execute。运行全部的活动命令。不活动的子命令不会被考虑。

集合中绑定命令

另外一个场景的使用情景是, 在视图中显示一组数据的集合,同时须要为每个数据项绑定一个命令,可是这个命令是在父视图(视图模型)中实现的(不是数据项类中实现的)。
好比,下图的视图使用ListBox显示了一组数据,使用数据模板为每个数据项显示了一个Delete按钮:
在集合中绑定命令
困难在于将视图模型实现的Delete命令绑定到每个项。因为每一项的数据上下文(ListBox中)引用的是集合中的项,而Delete命令在父View Model中实现。
一种解决方案是在数据模板中绑定命令。网络

 
 
 
 
<Grid x:Name="root"> <ListBox ItemsSource="{Binding Path=Items}"> <ListBox.ItemTemplate> <DataTemplate> <Button Content="{Binding Path=Name}" Command="{Binding ElementName=root, Path=DataContext.DeleteCommand}" /> </DataTemplate> </ListBox.ItemTemplate> </ListBox></Grid>

触发器和命令的交互

使用Blend来设计触发器交互:框架

 
 
 
 
<Button Content="Submit" IsEnable="{Binding CanSubmit}"> <i:Interaction.Triggers> <i:EventTrigger EventName="Click"> <i:InvokeCommandAction Command="{Binding SubmitCommand}"/> </i:EventTrigger> </i:Interaction.Triggers></Button>

这种方法能够用于任何能够附加交互触发器的控件。若是想要将命令绑定到没有实现ICommandSource接口的控件,或者想要调用自定义的事件来触发命令时,这种方式尤为有用。
下面代码显示了配置ListBox来监听SelectionChanged事件。事件发生时会地调用绑定的命令:异步

 
 
 
 
<ListBox ItemsSource="{Binding Items}" SelectionModel="Single"> <i:Interaction.Triggers> <i:EventTrigger EventName="SelectionChanged"> <i:InvokeCommandAction Command="{Binding SelectedCommand}"/> </i:EventTrigger> </i:Interaction.Triggers></ListBox>

命令vs行为async

为命令传入EventArgs参数

当想要调用命令来响应控件触发的事件时,可使用Prism类库中的InvokeCommandAction。Prism类库的InvokeCommandAction与Blend SDK中的同名方法的区别以下:首先Prism类库的InvokeCommandAction方法根据命令CanExecute方法的返回值更新控件的enable状态。第二,若是没有设置CommandParameter,Prism类库的InvokeCommandAction方法能够从父触发器传递EventArgs参数(依赖项属性TriggerParameterPath)。
有些状况下,须要从父触发器传递参数给命令,好比EventTrigger的EventArgs。这种状况下不能使用Blend SDK中的InvokeCommandAction方法。代码以下:异步编程

 
 
 
 
<ListBox Grid.Row="1" Margin="5" ItemsSource="{Binding Items}" SelectionModel="Single"> <i:Interaction.Triggers> <i:EventTrigger EventName="SelectionChanged"> <!-- 调用选择命令,而且传递参数 --> <prism:InvokeCommandAction Command="{Binding SelectedCommand}" TriggerParameterPath="AddedItems"/> </i:EventTrigger> </i:Interaction.Triggers></ListBox>

处理异步交互

view Model常常面临异步的交互,好比请求网络服务和网络资源,或者后台的的计算或IO任务。使用异步能够提供好的用户体验。
当用户启动了一个异步请求或后台任务时,预测任务什么时候完成很是困难。可是UI只能在UI线程中更新,因此须要频繁的调度UI线程。函数

经过网络服务获取数据和进行交互

在异步编程模式中,须要调用一对方法而不是一个。为了启动异步调用,首先调用BeginXXX方法,当调用结束时调用EndXXX方法。
为了决定调用EndXXX方法的时机,能够选择轮询是否完成或者在调用BeginXXX方法时指定回调函数。回调方法以下:工具

 
 
 
 
IAsyncResult asyncResult = this.service.BeginGetQuestionnaire(GetQuestionnaireCompleted, null);private void GetQuestionnaireCompleted(IAsyncResult result){ try { questionnaire = this.service.EndGetQuestionnaire(ar); } catch(Exception ex) { //报错 }}

注意,在End方法中,可能会遇到一些异常。须要处理这些异常,而且以线程安全的方式报告给UI。
因为远程响应通常都再也不UI线程,因此若是想要改变UI的状态,必须将响应调度到UI线程(使用Dispatcher或者SynchronizationContext对象)。WPF中通常使用Dispatcher。
下面示例中,Questionnaire对象经过异步请求得到,而后将它设置为QuestionnaireView的数据上下文。使用CheckAccess方法来判断目前是否处于UI线程。

 
 
 
 
var dispatcher = System.Windows.Deployment.Current.Dispatcher;if(dispatcher.CheckAccess()){ QuestionnaireView.DataContext = questionnaire;}else{ dispatcher.BeginInvoke(()=>{ Questionnaire.DataContext = questionnaire; });}

用户交互模式

有许多交互的方式,好比显示对话框或MessageBox,可是在基于MVVM的应用中实现概念分离的交互是一个很大的挑战。举例来讲,在非MVVM应用中经常使用的MessageBox,在MVVM应用中不能被使用,由于它会破坏view和view model概念之间的分离。
在MVVM模式中有两种通用的方法来实现用户交互。一种是实现一种View model使用的用户交互服务,而且保持它和view的独立性。另外一种方法是在view Model中经过触发事件来向UI传达意图,这须要view中的组件绑定到这些事件。

使用交互服务

这种方法,view model一般依赖于交互服务接口。它会经过依赖注入容器或service Locator频繁的请求交互服务。
一旦view Model得到了交互服务的引用,它就能在必要时请求交互服务。交互服务事项了交互的视觉效果,以下图所示:
使用交互服务
模态交互,好比显示一个MessageBox或弹出一个模态窗口,在运行继续前须要一个指定的响应,因此能够以同步的方式进行实现:

 
 
 
 
var result = interactionService.ShowMessageBox("Are you sure you want to cancel this operation?"), "Confirm", MessageBoxButton.OK);if(result == MessageBoxResult.Yes){ CancelRequest();}

这种方法的劣势是强制了同步的编程模型。一个可选的异步实现是以下:

 
 
 
 
var result = interactionService.ShowMessageBox("Are you sure you want to cancel this operation?"), "Confirm", MessageBoxButton.OK,result =>{ if(result == MessageBoxResult.Yes)});

交互服务的异步实现更灵活一些。

使用交互请求对象

另外一个在MVVM模式中实现UI的方式是容许view model经过交互请求对象(与view中的行为耦合)直接向view请求交互。交互请求对象封装交互请求的细节和响应,而且经过事件来和view通讯。view通常将交互封装在一个行为中。
使用交互请求对象
Prism采用这种方式。Prism框架经过IInteractionRequest接口和InteractionRequest 类支持交互请求对象方式。IInteractionRequest接口定义额一个事件(Raise)来启动交互。view中的行为绑定到这个接口,并订阅这个事件。InteractionRequest 类实现了IInteractionRequest接口,而且定义了两个Raise方法来容许view Model启动交互同时指定请求上下文,还能够选择传递一个回调函数。

从view Model初始化交互请求

上下文对象容许View Model传递数据和状态给view。若是指定了回调,上下文对象还能够回传给View Model。

 
 
 
 
public interface IInteractionRequest{ event EventHandler<InteractionRequestionRequestedEventArgs> Raised;}public class InteractionRequest<T> : IInteractionRequest where T : INotification{ public event EventHandler<InteractionRequestedEventArgs> Raised; public void Raise(T context) { this.Raise(context, c => { }); } public void Raise(T context, Action<T> callback) { var handler = this.Raised; if(handler != null) { handler( this, new InteractionRequestedEventArgs( context, () => { if(callback != null) callback(context);} ) ); } }}

Prism提供了一些预约义的上下文类来支持通用的交互请求。INotification接口用来通知用户发生了一个重要的事件。它提供了两个属性——Title和Content。一般通知都是单向的,因此不须要用户在交互过程当中更改这些值。Notification类是该接口的默认实现。
IConfirmation接口扩展了INotification接口而且提供了第三个属性——Confirmed,这个属性用来标识用户是确认仍是取消了操做。Confirmation类是该接口的默认实现,它实现了MessageBox风格的交互。能够自定义上下文类来实现INotification接口封装任何须要的数据和状态。
View Model类会建立一个InteractionRequest 的实例,而且定义一个只读的属性(用来view绑定)。当View Model启动交互请求时,会调用Raised方法,传递上下文对象,和可选的回调委托。

 
 
 
 
public InteractionRequestViewModel(){ this.ConfirmationRequest = new InteractionRequest<IConfirmation>(); ... //为每一个按钮定义一个命令,每个按钮都引发不一样的交互请求。 this.RaiseConfirmationCommand = new DelegateCommand(this.RaiseConfirmation); ...}public InteractionRequest<IConfirmation> ConfirmationRequest {get; private set;}private void RaiseConfirmation(){ this.ConfirmationRequest.Raise( new Confirmation{ Content = "Confirmation Message", Title = "Confirmation"}, c => { InteractionResultMessage = c.Confirmed ? "The user accepted." : "The user cancelled.";}); }}

Interactivity QuickStart示例展现了如何使用这些接口和类来完成交互。

使用行为实现UI体验

交互请求对象表示了逻辑的交互,实际的UI体验被定义在view中。行为常常被用来封装UI体验。UI设计师能够将view Model中的交互请求对象绑定到行为上。
View必须探测到一个交互请求事件,而后呈现请求的视觉效果。事件触发器用来在探测到交互请求事件时进行初始化动做。
经过绑定到交互请求对象,Blend提供的标准EventTrigger能够监视一个交互请求事件。然而,Prism定义了一个EventTrigger——InteractionRequestTrigger,能够自动和IInteractionRequest接口的Raised事件进行链接。这减小了XAML的代码量,同时减小了错误输入事件名称的可能。
事件触发之后,InteractionRequestTrigger会调用指定的动做。Prism为WPF提供了PopupWindowAction类,这个类能够显示一个弹出窗口。当窗口显示时,它的数据上下文设置为交互请求的上下文参数。使用PopupWindowAction的WindowContent属性能够指定弹出窗口的视图。弹出窗口的标题被绑定到上下文对象的Title属性。

注意:默认状况下PopupWindowAction类显示的窗口与上下文对象相关。若是是一个Notification上下文对象,DefaultNotificationWindow将会显示,若是是Confirmation上下文对象,一个DefaultConfirmationWindow将会显示。能够经过WindowContent属性来指定弹出窗口的视图。

如何使用InteractionRequestTrigger和 PopupWindowAction:

 
 
 
 
<i:Interaction.Triggers> <prismInteractionRequestTrigger SourceObject="{Binding ConfirmationRequest, Mode=OneWay}"> <prism:PopupWindowAction IsModal="True" CenterOverAssociatedObject="True"/> </prismInteractionRequestTrigger></i:Interaction.Triggers>

Prism的InteractionRequestTrigger和PopupWindowAction类能够用做自定义触发器和动做的基础。

高级建立和装配

使用MEF建立View和ViewModel

使用MEF,能够经过使用Import特性来指定view的依赖,使用Export特性指定具体的View Model类型。
属性设置View的数据上下文。能够选择使用属性或者有参构造函数来为View传入View model。
好比,StockTrader RI的Shell view中声明了一个只写的属性(ViewModel,Import特性标注)。视图被实例化是,MEF穿件了指定View Model的实例,而且设置这个属性值。代码以下:

 
 
 
 
[Import]ShellViewModel ViewModel{ set { this.DataContext = value; }}

View Model定义以下:

 
 
 
 
[Export]public class ShellViewModel : BindableBase{ ...}

一个可选的方法是,在视图中定义一个Importing Constructor。

 
 
 
 
public Shell(){ InitializeComponent();}[ImportingConstructor]public Shell(ShellViewModel viewModel) : this(){ this.DataContext = viewModel;}

MEF将会实例化一个ShellViewModel,并传递给Shell的构造器。

使用Unity建立View和ViewModel

使用Unity一样有两种依赖注入的方式。区别在于,使用Unity没法在运行时被隐式地发现,它们必须被注册到DI容器中。
一般,你须要为view Model指定一个接口,这样ViewModel的具体类型才能从view中解耦。

 
 
 
 
public Shell(){ InitializeComponent();}public Shell(ShellViewModel ViewModel):this(){ this.DataContext = viewModel;}

固然,也能够定义一个只写的属性,Unity会实例化请求的View Model,而且调用属性设置器来指定数据上下文。

 
 
 
 
public Shell(){ InitializeComponent();}[Dependency]public ShellViewModel ViewModel{ set { this.DataContext = value; }}

注册到Unity容器的代码以下:

 
 
 
 
IUnityContainer container;container.RegisterType<ShellViewModel>();

view 能够经过容器来进行实例化:

 
 
 
 
IUnityContainer container;var view = container.Resolve<Shell>();

经过外部类来建立View和View Model

有些状况下,可能须要定义一个controller或服务类来实例化这些view和View Model类。好比实现导航功能:

 
 
 
 
private void NavigateToQuestionnaireList(){ this.uiService.ShowView(ViewNames.QuestionnaireTemplatesList);}

ShowView经过容器建立一个视图实例,而后显示它:

 
 
 
 
public void ShowView(string viewName){ var view = this.ViewFactory.GetView(viewName);}

注意:Prism为region的导航提供了支持。详情见View-Based Navigation。

测试MVVM应用

测试Model和View Model与测试其它类使用相同的工具和技术——好比单元测试和模拟框架。可是,对于Model和View Model有一些典型的测试模式。

测试INotifyPropertyChanged接口的实现类

该接口的实现类容许视图对模型和视图模型的改变作出反应。这些改变并不限于控件中的领域数据,还有控制视图的数据,好比视图模型的状态(控制动画的开始或控件的enable状态)。

简单状况

可以被测试代码直接更新的属性,能够经过为PropertyChanged事件设置一个事件处理函数来进行测试,在为属性设置一个新值,而后测试事件是否被触发。有一些帮助类能够用来附加事件处理函数而且收集测试结果,好比PropertyChangeTracker类。

 
 
 
 
var changeTracker = new PropertyChangeTracker(viewModel);viewModel.CurrentState = "newState";CollectionAssert.Contains(changeTracker.ChangedProperties, "CurrentState");

计算的和不可设定的属性

若是属性不能被测试代码设置——好比属性没有公开的设置器或者是只读的,计算的属性——测试代码须要模拟对象来引发属性的改变。

 
 
 
 
var changeTracker = new PropertyChangeTracker(viewModel);var question = viewModel.Questions.First() as OpenQuestionViewModel;question.Question.Response = "some text";CollectionAssert.Contains(changeTracker.ChangedProperties, "UnansweredQuestions");

整个对象

测试INotifyDataErrorInfo接口的实现类

实现绑定数据的输入验证有三种方式:在设置器中抛出异常、实现IDataErrorInfo接口、实现INotifyDataErrorInfo接口。第三种方式为每一个属性提供了多个错误报告的支持,同时意味着须要更多的测试。
INotifyDataErrorInfo接口有两个方面须要测试:测试验证规则的正确性和测试实现的接口,好比触发ErrorsChanged事件。

测试验证规则

验证逻辑一般很容易进行测试,由于它是自包含的过程(输出依赖于输入)。对于每个有验证规则的属性,须要测试合法值,非法值,边界值等等。

 
 
 
 
// 非法用例var notifyErrorInfo = (INotifyDataErrorInfo)question;question.Response = -15;Assert.IsTrue(notifyErrorInfo.GetErrors("Response").Cast<ValidationResult>().Any());// 合法用例var notifyErrorInfo = (INotifyDataErrorInfo)question;question.Response = 15;Assert.IsFalse(notifyErrorInfo.GetErrors("Response").Cast<ValidationResult>().Any());

跨属性的验证规则遵循相同的测试模式,通常须要更多的测试来组合不一样的属性值。

测试INotifyDataErrorInof接口的实现

实现INotifyDataErrorInfo接口必须确保ErrorsChanged事件在适当的时候被触发、HasErrors属性必须反应对象的整个错误状态。
并非全部被验证的属性都必须被测试。
测试接口的须要至少包括:

  • HasErrors属性反映对象的全局错误状态。为原先非法的属性设置一个合法值,其它的属性值保持非法,判断该属性的结果是否改变。
  • ErrorsChanged事件被触发(当一个属性的错误状态改变时,经过GetErrors方法的结果反映)。错误状态从一个合法状态到非法状态,还有反过来,还能够从一个非法状态到另外一个非法状态,若是GetErrors的结果发生改变,说明ErrorsChanged事件被触发了。

 
 
 
 
var helper = new NotifyDataErrorInfoTestHelper<NumericQuestion, int?>(question, q => q.Response);helper.ValidatePropertyChange( 6, NotifyDataErrorInfoBehavior.FiresErrorsChanged | NotifyDataErrorInfoBehavior.HasErrors | NotifyDataErrorInfoBehavior.HasErrorsForProperty);helper.ValidatePropertyChange( null, NotifyDataErrorInfoBehavior.FiresErrorsChanged | NotifyDataErrorInfoBehavior.HasErrors | NotifyDataErrorInfoBehavior.HasErrorsForProperty);helper.ValidatePropertyChange( 2, NotifyDataErrorInfoBehavior.FiresErrorsChanged);

测试异步服务调用

虽然基于事件的异步设计模式(Event-based Asynchronous design pattern)能确保事件在合适的线程上调用,可是IAsyncResult设计模式不能提供任何线程安全的保证。
处理线程关注点很复杂,所以一般也很难编写测试代码。一般要求测试代码自己也是异步的。当通知确实在UI线程发生时,不是由于使用了标准的基于事件的异步设计模式,仍是由于视图模型依赖于一个服务访问层(分发通知到合适的线程),测试本质上都扮演了UI线程调度(Dispatch for the UI thread)的角色。
模拟服务的方式依赖于实现服务操做的异步事件模式。若是使用的是基于方法的模式(method-based based pattern),用标准的mock框架来模拟一个服务接口就足够了;可是若是使用基于事件的模式(event-Based pattern),首选的方案是模拟一个定制的类(实现增长,移除服务处理事件的方法)。
下面的例子显示了经过模拟服务,在完成异步操做后通知UI线程进行适当行为的一个测试。这个示例中,测试代码获取View Model为异步服务调用提供的回调,而后经过调用这个回调来模拟异步服务调用完成。这种方法使测试一个组件的异步服务而无需编写复杂的异步测试。

 
 
 
 
questionnaireRepositoryMock .Setup( r => r.SubmitQuestionnaireAsync( It.IsAny<Questionnaire>(), It.IsAny<Action<IOperationResult>>())) .Callback<Questionnaire, Action<IOperationResult>>( (q, a) => callback = a);uiServiceMock .Setup(svc => svc.ShowView(ViewNames.QuestionnaireTemplatesList)) .Callback<string>(viewName => requestedViewName = viewName);submitResultMock .Setup(sr => sr.Error) .Returns<Exception>(null);ComplateQuestionnaire(viewModel);viewModel.Submit();//模拟callback(submitResultMock.Object);//测试行为——请求导航到list 视图Assert.AreEqual(ViewNames.QuestionnaireTemplatesList, requestedViewName);

注意:使用这种测试方法仅仅能保证覆盖功能测试,并不能测试代码是不是线程安全的。

扩展阅读



相关文章
相关标签/搜索