本章描述MVVM如何支持一些复杂的使用场景,以及如何组织命令和子视图来满足用户需求。本章还描述了如何处理异步数据请求以及之后的UI交互。
Commands
复合命令(Composite Commands)
通常,一个定义在View Model中的命令能够通过绑定到控件来实现直接命令调用。但是,有些情况下,可能使用一个父视图的控件调用一个或多个View Model的多个命令。
比如,如果应用程序允许用户同时编辑多个数据项,就需要允许用户通过点击一个按钮(命令)来保存所有的数据项。这种情况下Save All命令将会调用每一个数据项的Save命令。
Prism提供了CompositeCommand类来实现复合命令。
这个命令类由对个子命令组成。当复合命令被调用时,所有子命令将会依次调用。这个命令类可以应用于使用一个逻辑命令调用多个命令和使用一个命令来表示一组命令两种使用场景。
Stock Trader RI例子中的SubmitAllOrder命令就是一个复合命令。
CompositeCommand命令维护一个子命令(DelegateCommand实例)的列表,它的Execute方法只是遍历调用了子命令的Execute。CanExecute方法类似,不过如果有一个子命令不可运行,就返回false。
注册和注销子命令
通过RegisterCommand和UnregisterCommand方法来注册和注销子命令。Stock Trader RI中每一个订单的Submit和Cancel命令都注册到SubmitAllOrders和CancelAllOrders组件命令:
// OrdersController.cs
commandProxy.SubmitAllOrdersCommand.RegisterCommand( orderCompositeViewModel.SubmitCommand );
commandProxy.CancelAllOrdersCommand.RegisterCommand( orderCompositeViewModel.CancelCommand );
在活动的子视图上运行命令
Prism的CompositeCommand和DelegateCommand类可以与Prism的regions一起工作。
下图展示了一个子视图如何添加到EditRegion的。UI设计者可以选择使用Tab控件在Region中布局视图:
有时可能需要运行当前活动子视图的命令:实现一个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行为
:
为命令传入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>
<prism:InteractionRequestTrigger SourceObject="{Binding ConfirmationRequest, Mode=OneWay}">
<prism:PopupWindowAction IsModal="True" CenterOverAssociatedObject="True"/>
</prism:InteractionRequestTrigger>
</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);
注意
:使用这种测试方法仅仅能保证覆盖功能测试,并不能测试代码是否是线程安全的。