6 MVVM进阶
阅读原文时间:2023年07月12日阅读:2

1. 背景

MVVM是一种常用的设计模式,它的最主要功能是将数据与代码隔离,实现viewmodel的可测试。架构图如下:

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)
{
    try
    {
        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;
}
else
{
    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(
    6,
    NotifyDataErrorInfoBehavior.Nothing);
//测试ErrorChanged事件是否触发以及HasErrors是否有误
helper.ValidatePropertyChange(
    20,
    NotifyDataErrorInfoBehavior.FiresErrorsChanged
    | NotifyDataErrorInfoBehavior.HasErrors
    | NotifyDataErrorInfoBehavior.HasErrorsForProperty);//?

5.3. 测试异步服务调用

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

6.感想

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