2. 命令-Command

2.1 WPF 路由命令

WPF提供一种内置的命令实现称为路由命令。这与MVVM设计模式中的命令不同。路由命令通过UI Tree进行路由。路由命令可沿着UI Tree向上或者向下路由,但是不会路由到UI Tree以外部分,如与view关联的View Model。

2.2 CompositeCommand

有时我们希望点击Shell中的一个按钮,Shell包含的多个view对应的view model都执行相应命令,也就是一个命令包含多个命令。Prism提供类CompositeCommand,它由多个子命令组成。当组合命令被激活,它所有子命令按顺序执行。 CompositeCommand包含成员:

  • 属性,子命令集合
  • 方法,Execute,执行命令
  • 方法,CanExecute,如果任意子命令不能被执行,那么组合命令也无法执行。
  • 2.2.1 注册和注销子命令

    可以通过方法RegisterCommand和UnRegisterCommand实现命令的注册和注销。

    2.2.2 在活跃的子View上执行命令

    使用组合命令我们可以在多个view model上执行命令,但是有时我们只需要在激活的子View上执行即可。为了实现该种特性,Prism提供接口IActiveAware,该接口包含属性IsActive和事件IsActiveChanged,属性IsActive表明当前是否处于激活状态,事件用于处理状态转变情况。子view,view model均可实现该接口,Prism提供的DelegateCommand也继承至该接口。基于提供的属性,我们可以配置组合命令是否检测子命令状态,方法是在构造函数中为monitorCommandActivity赋值TRUE。

    2.3 在集合中使用命令

    有时我们需要在集合中使用命令,但这些集合的项目需要使用父容器的命令,这就有点棘手,项目中的控件只能绑定到项目DataContext,解决的方法有两种,一是使用ElementName强制指定到父容器上,如下:

    <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提供的interaction triggers,见 5-学习MVVM。

    2.3.1 传递参数

    传统上来说使用CommandParameter向命令传入参数,但是如果你需要的参数来自父类事件的参数,这就麻烦了。Prism提供InvokeCommandAction,这个有别于Blend的同名类,前者能实时更新绑定该命令控件的状态,同时能传入父触发器的事件参数,如下:

    <ListBox Grid.Row="1" Margin="5" ItemsSource="{Binding Items}"
    SelectionMode="Single">
        <i:Interaction.Triggers>
            <i:EventTrigger EventName="SelectionChanged">
                <!-- This action will invoke the selected command in the view model and
                pass the parameters of the event to it. -->
                <prism:InvokeCommandAction Command="{Binding SelectedCommand}"
                TriggerParameterPath="AddedItems" />
            </i:EventTrigger>
        </i:Interaction.Triggers>
    </ListBox>
    

    2.4. 处理异步交互

    当你需要与远程Web 服务或者远程服务器交互时,你将需要经常面对IAsyncResult模式。在这个模式中,相比于直接调用方法,你使用方法对BeginGet*和EndGet*来获取结果。使用BeginGet*来初始化异步请求,然后使用EndGet*来获取请求结果或者发生的异常。为了决定什么时候调用EndGet*,你可以直接使用轮询或者在BeginGet*中指定回调。通过指定回调方法,当目标方法完成或者异常中断会自动调用回调。

    IAsyncResult asyncResult =
    this.service.BeginGetQuestionnaire(GetQuestionnaireCompleted, null // object state,
    not used in this example);
    private void GetQuestionnaireCompleted(IAsyncResult result)
            questionnaire = this.service.EndGetQuestionnaire(ar);
        catch (Exception ex)
            // Do something to report the error.
    

    获取完数据以后,如果需要更新UI,你需要调用Dispatcher或者SynchronizationContext。如下:

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

    3. 用户交互

    设计出的程序是为了供用户使用,这就需要与用户交互,比如弹出一个对话框或者一个消息框,在非MVVM型程序,这个很容易实现,直接在后台代码使用MessageBox.Show等即可。但是在MVVM型架构中,这个是比较困难的,view model不能直接调用MessageBox,逻辑与界面UI必须保证解耦。view model 负责初始化交互请求,获取或者处理响应。View实际管理与用户交互逻辑。为了保证解耦,解决方法有两种:

  • 实现一种交互服务,view model能使用该服务初始化交互,然后在view的实现中保持独立
  • 使用交互请求对象,view model引发事件表达希望交互的意愿,view中与这些事件绑定的控件管理交互的可视化部分
  • 3.1 交互服务

    这种方法中view model依赖一个交互服务组件来初始化交互。该服务组件封装了交互中调用可视化逻辑的代码,可以使用DI容器获取该服务。由于服务已经封装了相应功能,我们可以用模态和非模态方式进行交互,也可以以同步或者异步方式交互,如下:

    //同步方式
    var result =
    interactionService.ShowMessageBox(
    "Are you sure you want to cancel this operation?",
    "Confirm",
    MessageBoxButton.OK );
    if (result == MessageBoxResult.Yes)
        CancelRequest();
    

    异步方式:

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

    3.2 交互请求对象

    这种方法允许view model使用封装了行为的交互请求对象直接与view交互。交互请求对象封装了交互请求和相应,使用事件与view进行交互。view订阅这些事件初始化交互。典型的view将交互封装在行为中,这些行为绑定到交互请求对象上。
    这种方法提供一个简单,灵活机制保持解耦。它允许view model封装应用呈现逻辑,view封装交互的可视化逻辑。这种实现可以使交互逻辑能够被轻松测试,UI Designer也可以更灵活选择需要的交互。这种方法与MVVM模式是一致的,允许view反应观测到的状态变化,使用双向数据绑定与view model交互。
    这种方式也是Prism使用的交互方法,包含接口IInteractionRequest以及InteractionRequest。接口IInteractionRequest定义了初始化交互的事件。view绑定该接口,并订阅事件。类InteractionRequest实现前面接口,定义两个Raise方法,允许view model初始化交互,并为请求指定内容。

    3.2.1 原理

    Prism提供类InteractionRequest将view model的交互请求送达view。该类的方法Raise允许view model初始化交互,并指定一个T类型的context对象。context对象允许 view model向view传入数据和状态。如果view需要回传数据给view model,方法Raise有一个重载,允许传入需要的回调函数。当交互完成时,自动调用回调函数。该类在命名空间Prism.Interactivity.InteractionRequest,类原型如下:

    public interface IInteractionRequest
        event EventHandler<InteractionRequestedEventArgs> 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,所有Context对象均需实现该接口。该接口包含两个属性Tile和Content。典型的,通知是单向的,所以该交互过程中Context只读。类Notification是该接口的默认实现。
    接口IConfirmation扩展接口INotification,并添加属性Confirmed,表明用户是否确认或者取消该操作。类Confirmation提供IConfirmation实现,实现了消息框类型交互逻辑。

    3.2.2 实战-MVVM模式实现

    3.2.2.1 ViewModel

    在MVVM模式中,view model负责创建InteractionRequest 对象,定义一个只读属性,用于数据绑定,这里泛型T可以是通知类型接口INotification,消息框型接口IConfirmation,也可以是自定义类型接口。当view model需要初始化请求时,调用类InteractionRequest的Raise方法,并传入需要的Context,以及可选的回调委托。以弹出对话框型窗口为例:

    public class InteractionRequestViewModel
        public InteractionRequest<IConfirmation> ConfirmationRequest { get; private set; }
        public ICommand RaiseConfirmationCommand;
        public InteractionRequestViewModel()
            this.ConfirmationRequest = new InteractionRequest<IConfirmation>();
            // Commands for each of the buttons. Each of these raise a differen t interaction  request.
            this.RaiseConfirmationCommand = new DelegateCommand(this.RaiseConfirmation);
        private void RaiseConfirmation()
            this.ConfirmationRequest.Raise(
                new Confirmation { Content = "Confirmation Message", Title = "Confirmation"
                c => { InteractionResultMessage = c.Confirmed ? "The user accepted." : "The
                user cancelled."; });
    
    3.2.2.2 View

    任何一次交互我们都可以把其分解为逻辑交互以及界面交互。所谓逻辑交互指的是状态和数据交互,这个已经封装在交互请求对象中。所谓界面交互是用户实际看到的内容。行为经常用于封装界面交互。
    view必须能探测交互请求事件,然后呈现合适的显示。触发器用于实现该逻辑,一旦有事件产生,立即做出相应行动。由Blend提供的标准EventTrigger能够监听由view model暴露的事件。更进一步,Prism框架提供一个扩展的EventTrigger,名为InteractionRequestTrigger,开发者只需为该触发器绑定数据源,就能自动连接交互请求对象的Raised事件,避免输入事件名称。
    当一个事件触发,InteractionRequestTrigger激活指定的动作。对于WPF,Prism框架提供类PopupWindowAction,向用户呈现对话窗口。窗口的Data Context为交互请求对象的context。通过使用PopupWindowAction的WindowContent属性,你可以指定需要在窗口中显示的view。窗口的标题则是context的Title。不同类型的context具有不同的类型窗口,对于Notification类型Context,弹出窗口类型为DefaultNotificationWindow,这种类型窗口仅包含通知消息;对于Confirmation类型context,弹出窗口类型为DefaultConfirmationWindow,包含取消和确认按钮,捕获用户反馈。可以在默认窗口类型上实现自定义类型。如下:

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

    PopupWindowAction有3个重要属性,IsModal表明窗口是否为模态,CenterOverAssociatedObject,为TRUE时在父窗口中央显示弹出窗口。WindowContent,指定在窗口显示的view,为空显示DefaultConfirmationWindow。PopupWindowAction设定Notification对象为DefaultNotificationWindow的datacontext,并在窗口显示Notification的Content属性内容。当交互完成,使用回调将结果返回view model。

    4. 高级构造,组合

    为了实现MVVM设计模式,你需要知道每个部分view,view model,model的具体 职责,同时也需要很好将各个部分组装起来。DI容器的使用是非常有必要的。一般使用Unity。我们可以使用构造注入和属性注入,在WPF中使用属性注入是非常有必要的,一方面保留默认构造函数,方便设计时调用。另一方面建立view与view model的依赖关系。如下:

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

    5. 测试MVVM

    测试MVVM的Model,view model与普通类没有区别,可以使用一些Mock类帮助测试。相比于普通类,MVVM使用一些特殊通信模式,有一些功能或者机制需要单独测试。

    5.1. 测试INotifyPropertyChanged实现

    由于需要使用数据绑定机制,所以需要测试某个属性值是否正确发生改变。

    5.1.1. 单个属性

    我们可以使用类PropertyChangeTracker来跟踪某个类的属性是否正确发生改变,如下:

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

    如果ViewModel正确实现接口INotifyPropertyChanged,上述测试通过。

    5.1.2 完整对象

    当你实现接口INotifyPropertyChanged,如果需要表明当前对象所有属性均发生过改变,只需要向Contains方法传入null或者空字符,如下。

    var changeTracker = new PropertyChangeTracker(viewModel);
    //some change
    CollectionAssert.Contains(changeTracker.ChangedProperties, "");
    

    5.2. 测试INotifyDataErrorInfo实现

    测试该接口包含两部分:一是测试验证规则是否正确实现,二是测试接口需要的内容是否正常工作。

    5.2.1 测试验证规则

    验证规则是保证Model数据处于一个正常范围。一条验证规则是否正常工作我们可以调用接口INotifyDataErrorInfo的方法GetErrors进行测试,前提是测试类需要实现接口。对于一些使用标记声明的共享验证规则,只需要测试一次即可,对于自定义验证规则则需要单独测试。

    // Invalid case
    var notifyErrorInfo = (INotifyDataErrorInfo)question;
    question.Response = -15;
    Assert.IsTrue(notifyErrorInfo.GetErrors("Response").Cast<ValidationResult>().Any());
    // Valid case
    var notifyErrorInfo = (INotifyDataErrorInfo)question;
    question.Response = 15;
    Assert.IsFalse(notifyErrorInfo.GetErrors("Response").Cast<ValidationResult>().Any());
    
    5.2.2. 测试接口的触发条件

    除了GetErrors方法需要被测试,让接口INotifyDataErrorInfo正常工作还需要保证ErrorChanged事件正确触发。除此之外属性HasErrors也需要反应对象的全局状态。测试类NotifyDataErrorInfoTestHelper可以帮助接口的触发条件,如下:

    //question是待测试的Model
    var helper =
        new NotifyDataErrorInfoTestHelper<NumericQuestion, int?>(
            question,
            q => q.Response);
    //测试任何条件
    helper.ValidatePropertyChange(
        NotifyDataErrorInfoBehavior.Nothing);
    //测试ErrorChanged事件是否触发以及HasErrors是否有误
    helper.ValidatePropertyChange(
        NotifyDataErrorInfoBehavior.FiresErrorsChanged
        | NotifyDataErrorInfoBehavior.HasErrors
        | NotifyDataErrorInfoBehavior.HasErrorsForProperty);//?
    

    5.3. 测试异步服务调用

    在MVVM模式中,view model经常需要异步调用服务。一般的测试方式是用模拟替换真实服务。

    使用MVVM模式最重要的作用是实现解耦和封装,Winform设计出来软件基本是一个整体,你中有我,我中有你。这就会带来很多问题,特别是多人协作的情况下。确实,把好好的一个软件整体解耦出来,分成一个一个独立模块,每个模块只执行相应任务,并保持对其他模块的最小引用,解耦完成以后又引入大量通信模式,如数据绑定,命令,通知等,表面上是增加了软件的复杂度,但是随着软件功能增多,复杂度越来越高,解耦的牺牲就非常必要了。舍小逐大。