Messenger和MVVM中的View Services
在前面的文章IoC容器和MVVM中,介绍了IoC容器如何在大量用户类中帮助创建和分配用户类的实例。本文将介绍IoC容器如何帮助应用程序解耦,比如那些根据MVVM模式开发的应用。此模式广泛应用在基于XAML的应用程序(Silverlignt, WPF, Windows Phone, Windows 8)中,因为此模式与数据绑定系统和用于这类程序设计的工具匹配的很好,尤其是在VS 设计器和Blend中。
在典型的XAML程序中,开发者利用数据绑定系统声明一个XAML UI元素的属性和应用程序中其他对象的属性之间的同步。这种绑定可以是单方向和双方向的。数据绑定非常方便,特别是在用可视化设计器的时候,比如VS设计器或Blend。但这也有局限性。例如:一个简单的数据绑定不能触发UI中的动画或触发一个显示给用户的对话框。即使是基本的动作比如迁移到其他页面,这也不能通过数据绑定来实现。
Figure 1 shows two-way data binding between a XAML view and its ViewModel (point 1). In addition, other possible interactions are represented.
Figure 1. Dependencies between Layers in MVVM
箭头2表明了一个普通的事件流。这可能是在XAML程序中和后台代码进行交互的最有名的方式。很多来自传统开发环境中的开发者对此也都很熟悉,比如Windows Forms甚至是HTML/JavaScript。事件方式很有用,但是在XAML程序需要和代码进行解耦时则会引发问题。一种情况是XAML中的DataTemplate需要移动到ResourceDictionary中的时候,另一种情况是处理事件的代码需要从后台移动到另一个对象,比如ViewModel。在XAML和后台代码之间,事件导致了紧耦合,并且限制了代码的重构。
箭头3表明了XAML在代码中触发一个动作的另外一种方式。通常情况下,命令通过ViewModel的一个属性(实现了 ICommand接口)暴露出来,然后绑定到XAML UI元素上。例如:按钮支持命令属性,当按钮按下时,绑定的命令将会被触发。命令也有它的局限性,尤其是只有少数的UI元素才有Command属性并且只能为一个事件(通常是Click事件)使用。如果有其他的事件需要处理,默认的命令显然是不够的。在这个系列文章中也会讨论命令局限性的解决办法,尤其是使用MVVM Light的RelayCommand组件。
箭头4表明View的后台代码在ViewModel中直接调用方法,这听起来好像和View需要和ViewModel解耦是相违背的。事实上,View知道它对应的ViewModel并不是问题,因为View很少需要抽象。一个View的后台代码很少需要单元测试。因为编程地触发一个事件来测试它的动作不是那么简单。因此,ViewModel应该不知道View的存在,而反过来则没必要。下面的代码在MVVM程序中经常看到。根据MVVM的一个原则,开发者应该保持后台代码量少,但有时用一点点后台代码来处理特殊情况比寻求一个复杂的解决方案来的更简单。
Figure 2. Getting the ViewModel in the View’s Code-Behind
public MainViewModel Vm { get { return (MainViewModel)DataContext; } }
在Windows 8中,从View中调用ViewModel是为了减轻一个问题:在绑定中没有UpdateSourceTrigger属性。而在WPF则不是这样。当一个TextBox的Text属性绑定到ViewModel中的String类型字段时,我们可以使用下面这样的代码(只能在WPF中)
Figure 3. UpdateSourceTrigger Property in WPF XAML Markup
<TextBox Text="{Binding ZipCode, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Style="{StaticResource ZipCodeTextBoxStyle}" />
上面的代码能够运行是因为UpdateSourceTrigger的PropertyChanged,这个绑定在用户每次输入字符的时候都会触发。在UpdateSourceTrigger中还有一个值是LostFocus,这意味着当用户把焦点移动到其他控件的时候,此绑定将会被触发。
在Windows RT中,绑定系统中并没有这个属性。而双向绑定在TextBox失去焦点时总会被触发。大部分情况下这都没问题,但这也会引发多余的问题。例如:假设显示一个验证对话框给用户,在用户输入的情况下更新验证对话框会是一个不错的体验。
Figure 4. Working Around the Lack of UpdateSourceTrigger.PropertyChanged in Windows RT
<!-- XAML markup --> <TextBox Text="{Binding ZipCode, Mode=TwoWay}" TextChanged="ZipCodeTextChanged" Style="{StaticResource ZipCodeTextBoxStyle}" />
// Code behind private void ZipCodeTextChanged(object sender, TextChangedEventArgs e) { var textbox = (TextBox)sender; Vm.ZipCode = textbox.Text; }
此时,ViewModel的ZipCode属性在用户输入字符的都回更新,这也会更新显示给用户的验证对话框的内容。
ViewModel到View的通信
有些人可能已经主要到在图1中没有从ViewModel到View的通信方式。文章的前面也说过,ViewModel应该对于使用它的View的存在是未知的。事实上,将一个ViewModel用于多个View是非常普遍的。
解决这个问题有很多途径。在这里要介绍的两个方案是使用MVVM Light的Messenger类和使用view services。
首先,要使用MVVM Light的Messenger实现一个状态消息系统。此类是消息总线的一个实现,它实现了消息发送方和消息接收方之间的解耦。这对于状态消息系统来说是十分方便的,因为应用程序的每一部分都可以选择显示给用户一个状态而不用担心依赖性。
一个关于Messenger的警告
Messenger是一个强大的组件,它可以极大地促进通信,但同时也会使得代码很难调试。因为消息的发送方和接收方相互之间都是未知的,所有也就很难第一眼就看出哪个接收方接收了消息。需要小心使用。
在MVVM Light工具包的GalaSoft.MvvmLight.Messaging名称空间下有很多预定义的消息类。同时也很方便定义自己的消息。比如在RssReader例子,在此会使用一个状态消息显示给用户来提示用户当前应用的状态。
首先,定义一个状态消息类,代码如下所示。
Figure 5. Defining the Message Type
public class StatusMessage { public StatusMessage( string status, int timeoutMilliseconds) { Status = status; TimeoutMilliseconds = timeoutMilliseconds; } public string Status { get; private set; } public int TimeoutMilliseconds { get; private set; } }
MainViewModel的Refresh方法要轻微地修改一下,代码如下。在调用异步方法之前,发送一个StatusMessage。在数据接收和处理之后,再发送一个生命期为3秒的StatusMessage。这个前提条件是有消息接收方注册了并且接收方知道处理这个消息。StatusMessage是发送方和接收方之间的合约。
Figure 6. Setting the Status
public async Task Refresh() { Items.Clear(); Messenger.Default.Send(new StatusMessage("Getting articles", 0)); var list = await _rssService.GetArticles(); foreach (var item in list) { Items.Add(item); } Messenger.Default.Send(new StatusMessage("Done", 3000)); }
在view侧,需要一个类来显示这个状态(此时这里是MainPage),并且需要注册接收StatusMessage类型。为了尽可能地保持Messenger简单清晰,我们将在OnNavigatedTo方法中注册消息处理方法,在OnNavigatingFrom注销此方法。这个方法确保一次只有一个页面注册显示状态信息。
Figure 7. Showing the Status in Windows RT
protected override void OnNavigatedTo(NavigationEventArgs e) { Messenger.Default.Register<StatusMessage>( this, HandleStatusMessage); base.OnNavigatedTo(e); } protected override void OnNavigatingFrom(NavigatingCancelEventArgs e) { Messenger.Default.Unregister<StatusMessage>( this, HandleStatusMessage); base.OnNavigatingFrom(e); } private void HandleStatusMessage(StatusMessage msg) { Status.Message = msg.Status; Status.Show(msg.TimeoutMilliseconds); }
页面使用一个名字为Status的UserControl来显示消息。当需要显示消息是,设置此控件的Visibility属性为Visibility。当显示时间完了,将Visibility属性设置为Collapsed。
实现一个DialogService
使用Messenger服务从ViewModel显示一个消息到view是一个比较不错的方案。但是其中一个缺点就是对于当一个第一次看此代码的开发者来说就不是那么清晰。因为消息的发送方和接收方互不知道。调试的时候工作流很难跟踪。一个有意思的替代方案就是向IoC容器中注册一个抽象的服务。在RssService中,DialogService就是一个面向view的服务。
首先,在服务接口中定义一些常用到的方法。比如显示状态信息,错误信息等。在示例代码中,定义的方法如下。
Figure 8. The IDialogService Interface
public interface IDialogService { void ShowMessage(string message, string title, string buttonText); void ShowError(string errorMessage, string title, string buttonText); void ShowError(Exception error, string title, string buttonText); }
接口的实现可以根据使用平台的不同而不同。为了保持简单,示例代码Windows Phone中使用了默认的MessageBox,Windows 8中使用了默认的MessageDialog。在现实工程中,可以使用自定义的消息框。DialogService的Windows RT实现版本如下所示。
Figure 9. Implementation of IDialogService in MainPage for Windows RT
public sealed partial class MainPage : IDialogService { public MainPage() { InitializeComponent(); } // More methods removed for brevity... // IDialogService implementation public async void ShowMessage(string message, string title, string buttonText) { // Using the same MessageDialog for errors and messages. // In a real-life production implementation, a custom // dialog box would be designed, and these methods would probably // be passed into a Page base class (for instance in // the LayoutAwarePage class in the Common folder. var dialog = new MessageDialog(message, title); dialog.Commands.Add(new UICommand(buttonText)); dialog.CancelCommandIndex = 0; await dialog.ShowAsync(); } public void ShowError(string errorMessage, string title, string buttonText) { // Using the same MessageDialog for errors and normal messages. ShowMessage(errorMessage, title, buttonText); } public void ShowError(Exception error, string title, string buttonText) { ShowMessage(error.Message, title, buttonText); } }
MainPage需要将它自己视为一个IDialogService注册到IoC容器中。因为一次只能由一个页面显示,所以同时需要在迁移到其他页面时注销。我们在OnNavigatedTo和OnNavigatedFrom方法中做这两个操作。
Figure 10. Registering and Unregistering the IDialogService in MainPage
protected override void OnNavigatedTo(NavigationEventArgs e) { Messenger.Default.Register<StatusMessage>( this, HandleStatusMessage); SimpleIoc.Default.Register<IDialogService>(() => this); base.OnNavigatedTo(e); } protected override void OnNavigatedFrom(NavigationEventArgs e) { Messenger.Default.Unregister<StatusMessage>( this, HandleStatusMessage); SimpleIoc.Default.Unregister<IDialogService>(); base.OnNavigatedFrom(e); }
在MainPageViewModel中,通过从IoC容器获取IDialogService并且通过一个属性字段暴露出来。代码如下。
Figure 11. Refresh Method with Error Handling
public IDialogService DialogService { get { return ServiceLocator.Current.GetInstance<IDialogService>(); } } public async Task Refresh() { Items.Clear(); try { Messenger.Default.Send(new StatusMessage("Getting articles", 0)); var list = await _rssService.GetArticles(); if (list.Count == 0) { DialogService.ShowMessage( "We couldn't find any articles", "Nothing found", "Too bad!"); } foreach (var item in list) { Items.Add(item); } Messenger.Default.Send(new StatusMessage("Done", 3000)); } catch (Exception ex) { // Hide status Messenger.Default.Send(new StatusMessage(string.Empty, 1)); DialogService.ShowError( ex, "Error when loading", "Oops"); } }