MVVM

深入介绍 MVVM Light Messenger

Laurent Bugnion

此系列介绍了 Model-View-ViewModel (MVVM) 模式和 MVVM Light Toolkit,自从大约一年前我开始撰文以来,已经涉及了相当多的基础知识,从在 MVVM 应用程序中使用 IOC 容器到如何处理跨线程访问和 MVVM Light 的 DispatcherHelper 组件。我还介绍了(通过 RelayCommand 和 EventToCommand)发出命令、视图服务(如导航和对话服务),并简要讨论了 Messenger 组件。

Messenger 组件实际上是 MVVM Light Toolkit 的一个功能相当强大的元素,它由于简单易用而受到开发人员的青睐,但也由于误用会带来风险而引发了一些争议。此组件需要用一篇单独的文章来介绍它的工作方式、存在哪些风险以及在哪些场景下最具价值。

在本文中,我将讨论 Messenger 实施的一般原则,并介绍为什么此实施比传统方法更容易使用。我还将探讨这种方法在不采取具体防范措施的情况下会对内存造成怎样的影响。最后,我将更详细地讨论 MVVM Light Messenger 本身,特别是一些内置消息及其用途。

事件聚合和 Messenger 简化

诸如 Messenger 之类的系统有时称为事件总线或事件聚合器。此类组件连接接收端和发送端(有时分别称为“发布服务器”和“订阅服务器”)。在 MVVM Light 创建后,许多消息系统都要求接收端或发送端实施具体的方法。例如,可能存在指定了接收方法的 IReceiver 接口,而为了向消息系统注册,对象就需要实施此接口。这种限制很烦人,因为它限制了消息系统的实际使用者。例如,如果您使用的是第三方程序集,则无法向消息系统注册此库中的实例,因为您无权访问代码,并且无法修改第三方类来实施 IReceiver。

MVVM Light Messenger 旨在通过简单的前提来精简此场景:任何对象都可以是接收端;任何对象都可以是发送端;任何对象都可以是消息。

词汇也得到了简化。使用消息这类容易理解的词语,而不是使用难以定义的词语(如“事件聚合”)。订阅服务器变为接收端,发布服务器则变为发送端。消息取代了事件。通过语言与实施上的简化,您可以更轻松地开始使用 Messenger 并了解它的工作方式。

例如,请考虑使用图 1 中的代码。如您所见,MVVM Light Messenger 是在两个独立对象中使用。注册对象将消息发送给所有的 RegisteredUser 实例。可以通过多种方式来实施这种场景,Messenger 可能并不一定是最好的解决方案。不过,它也可以成为实施此功能的理想工具,特别是当发送端和接收端属于应保持分离状态的应用程序时,具体取决于您的体系结构。请注意,注册实例不会将消息明确发送给 RegisteredUser 实例。相反,它会通过 Messenger 广播消息。任何实例都可以注册此类型的消息,并在消息发送时收到通知。在本示例中,发送的消息是一个 RegistrationInfo 实例。然而,任何类型的消息都可以发送,从简单数值(整数、布尔等)到专用消息对象。稍后,我将讨论如何使用消息,并回顾 MVVM Light 中的一些内置消息类型。

图 1:发送和接收消息

public class Registration { public void SendUpdate() { var info = new RegistrationInfo { // ...一些属性 }; Messenger.Default.Send(info); } } public class RegisteredUser { public RegisteredUser() { Messenger.Default.Register<RegistrationInfo>( this, HandleRegistrationInfo); } private void HandleRegistrationInfo(RegistrationInfo info) { // 更新已注册的用户信息 } } public class RegistrationInfo { // ...一些属性 }

图 1 中的代码展示了通过代理 (HandleRegistrationInfo) 完成注册消息类型 (RegistrationInfo)。这是 Microsoft .NET Framework 中的常见机制。例如,在 C# 中注册事件处理程序也可以通过向事件传递代理来完成,代理可以是命名方法或是匿名的 lambda 表达式。同样地,您可以使用命名方法或匿名的 lambda 表达式向 Messenger 注册接收端,如图 2 所示。

图 2:使用命名方法或 Lambda 进行注册

public UserControl() { InitializeComponent(); // 使用命名方法进行注册 ---- Loaded += Figure2ControlLoaded; Messenger.Default.Register<AnyMessage>( this, HandleAnyMessage); // 使用匿名 Lambda 进行注册 ---- Loaded += (s, e) => { // 执行某操作 }; Messenger.Default.Register<AnyMessage>( this, message => { // 执行某操作 }); } private void HandleAnyMessage(AnyMessage message) { // 执行某操作 } private void Figure2ControlLoaded (object sender, RoutedEventArgs e) { // 执行某操作 }

跨线程访问

Messenger 无法观察到消息是从哪个线程发送的。如果您看过我之前的文章“MVVM 应用程序中的多线程与调度”(bit.ly/1mgZ0Cb),就会知道如果某线程上运行的对象试图访问另一线程上的对象,则需要采取一些防范措施。此问题通常是在后台线程和 UI 线程拥有的控件之间产生。在之前的文章中,您了解了如何使用 MVVM Light DispatcherHelper“调度”UI 线程上的操作,并避免出现跨线程访问异常。

通过一些事件聚合器,您可以自动调度发送到 UI 线程的消息。不过,MVVM Light Messenger 绝不会这么做,因为它旨在简化 Messenger API。添加用于自动调度发送到 UI 线程的消息的选项会向注册方法添加更多参数。此外,这也会降低调度的明确性,并且可能会导致经验欠缺的开发人员更加难以了解内部发生了什么。

相反,您应该根据需要明确调度发送到 UI 线程的消息。为此,最好使用 MVVM Light DispatcherHelper。如之前的文章所述,CheckBeginInvokeOnUI 方法只会在必要时调度操作。如果 Messenger 已经在 UI 线程上运行,那么消息会在不进行调度的情况下立即发送:

public void RunOnBackgroundThread() { // 执行某后台操作 DispatcherHelper.CheckBeginInvokeOnUI( () => { Messenger.Default.Send(new ConfirmationMessage()); }); }

内存处理

允许对象在互不相识的情况下进行通信的所有系统都要完成保存对消息接收端的引用的艰巨任务。例如,假设 .NET 事件处理系统可以在引发事件的对象和订阅事件的对象之间建立强引用。图 3 中的代码可以在 _first 和 _second 之间建立强关联。也就是说,如果系统调用清理方法,且 _second 设置为空,那么垃圾回收器无法将它从内存中删除,因为 _first 仍引用它。垃圾回收器依赖于通过计算对对象的引用来确定能否将它从内存中删除,但 _second 实例不会遭到删除,因此会出现内存泄漏。随着时间的推移,这可能会导致很多问题发生;应用程序的速度可能会大幅减慢,最终甚至会发生故障。

图 3:实例之间的强引用

public class Setup { private First _first = new First(); private Second _second = new Second(); public void InitializeObjects() { _first.AddRelationTo(_second); } public void Cleanup() { _second = null; // 即使 _second 实例设置为空,它也 // 仍会保留在内存中,因为引用计数不为 // 零(_first 仍引用它)。} } public class First { private object _another; public void AddRelationTo(object another) { _another = another; } } public class Second { }

为了缓解此问题,.NET 开发人员提出了使用 WeakReference 对象。此类允许“弱”存储对对象的引用。如果将对该对象的其他所有引用都设置为空,则垃圾回收器仍可以收集该对象,即使 WeakReference 还在使用它,也是如此。这样就非常方便:如果合理使用,便可以缓解内存泄漏问题,但它并不一定能够解决所有问题。为了说明这一点,图 4 展示了一个简单的通信系统,其中 SimpleMessenger 对象将对接收端的引用存储在 WeakReference 中。请注意务必在处理消息之前检查 IsAlive 属性。如果接收端已删除且之前作为垃圾回收过,则 IsAlive 属性为 false。这表明 WeakReference 不再有效,应该遭到删除。

图 4:使用 WeakReference 实例

public class SuperSimpleMessenger { private readonly List<WeakReference> _receivers = new List<WeakReference>(); public void Register(IReceiver receiver) { _receivers.Add(new WeakReference(receiver)); } public void Send(object message) { // 锁定接收端以免发生多线程问题。 lock (_receivers) { var toRemove = new List<WeakReference>(); foreach (var reference in _receivers.ToList()) { if (reference.IsAlive) { ((IReceiver)reference.Target).Receive(message); } else { toRemove.Add(reference); } } // 删除无效引用。// 在其他循环中执行此操作, // 以免在修改当前循环访问的集合时发生异常。 foreach (var dead in toRemove) { _receivers.Remove(dead); } } } }

MVVM Light Messenger 的原则大致相同,但当然要更加复杂!值得注意的是,由于 Messenger 不要求接收端实施任何给定接口,因此它需要存储对用于传输消息的方法(回调)的引用。在 Windows Presentation Foundation (WPF) 和 Windows 运行时中,这并不是个问题。不过,在 Silverlight 和 Windows Phone 中,该框架更加安全,且 API 阻止特定操作发生。在特定情况下,这些限制中的任何一项都会对 Messenger 系统产生影响。

要理解这一点,您需要知道可以注册哪些类型的方法来处理消息。简而言之,接收方法可以是静态的(这绝不是个问题);也可以是实例方法,在这种情况下,您需要区分公共、内部和专用方法。在许多情况下,接收方法是匿名的 lambda 表达式,与专用方法一样。

如果是静态或公共方法,则不会有内存泄漏的风险。如果要处理的是内部或专用方法(或匿名 lambda),则 Silverlight 和 Windows Phone 中可能有风险。很遗憾,在这种情况下,Messenger 无法使用 WeakReference。同样地,这在 WPF 或 Windows 运行时中并不是个问题。图 5 对此信息进行了总结。

图 5:未取消注册时的内存泄漏风险

可见性 WPF Silverlight Windows Phone 8 Windows 运行时
静态 无风险 无风险 无风险 无风险
公共 无风险 无风险 无风险 无风险
内部 无风险 风险 风险 无风险
专用 无风险 风险 风险 无风险
匿名 Lambda 无风险 风险 风险 无风险

请务必注意,即使存在图 5 中指明的风险,取消注册失败也不一定会造成内存泄漏。也就是说,为了确保不会引起内存泄漏,要在不再需要的情况下明确从 Messenger 取消注册接收端。这可以通过取消注册方法来完成。请注意,取消注册方法有多个重载。您可以从 Messenger 完全取消注册接收端,也可以选择只取消注册一个给定方法,而保持其他方法有效。

使用 Messenger 时需要面临的其他风险

如前所述,虽然 MVVM Light Messenger 是一个功能非常强大且用途广泛的组件,但请务必注意,使用它也需要面临一些风险。我已经提到过 Silverlight 和 Windows Phone 中存在的内存泄漏潜在风险。另一项风险与技术不太相关:过多地使用 Messenger 分离对象会导致您很难了解在消息发送和接收时到底发生了什么。经验欠缺的开发人员(以前从未使用过事件总线)也可能难以遵循操作流程。例如,如果您要分析某方法的调用,并且此方法调用 Messenger.Send 方法,那么除非您知道要搜索相应的 Messenger.Receive 方法并在其中放置一个断点,否则会漏掉调试流程。也就是说,Messenger 操作是同步进行的,如果您了解 Messenger 的工作方式,则仍可以调试此流程。

当更多常规编程技术用不了或让我要尽可能保持分离状态的应用程序部件互相过多依赖时,我往往会将 Messenger 用作“最后的解决办法”。但有时最好使用其他工具(如 IOC 容器)和服务,以更明确的方式获得类似结果。我在本系列的第一篇文章中 (bit.ly/1m9HTBX) 就介绍了 IOC 和视图服务。

一个或多个 Messenger

消息系统(如 MVVM Light Messenger)的优势之一在于甚至可以跨程序集(例如,在插件场景中)使用。这是一种用于构建大型应用程序的常见体系结构,尤其是在 WPF 中。不过,插件系统对规模较小的应用程序也非常有用,例如无需重新编译主要部分即可轻松添加新功能。只要 DLL 在应用程序的 AppDomain 中加载,它包含的类便可以使用 MVVM Light Messenger 与同一应用程序中的其他任何组件进行通信。此功能非常强大,尤其是当主应用程序不知道加载的子组件数量时(通常在基于插件的应用程序中发生)。

应用程序通常只需要使用一个 Messenger 实例来覆盖所有通信。您可能只需要存储在 Messenger.Default 属性中的静态实例。不过,您可以根据需要新建 Messenger 实例。在这种情况下,每个 Messenger 都用作单独的信道。如果您想确保给定对象绝不会收到不是发送给它的消息,便会发现这样做非常有用。例如,图 6 中的代码展示了两个类注册同一消息类型。收到消息时,两个实例都需要执行一些检查来了解消息的用途。

图 6:使用默认的 Messenger 并检查发送端

 

public class FirstViewModel { public FirstViewModel() { Messenger.Default.Register<NotificationMessage>( this, message => { if (message.Sender is MainViewModel) { // 此消息是要发送给我。 } }); } } public class SecondViewModel { public SecondViewModel() { Messenger.Default.Register<NotificationMessage>( this, message => { if (message.Sender is SettingsViewModel) { // 此消息是要发送给我 } }); } }

图 7 展示了专用 Messenger 实例的实施。在这种情况下,SecondViewModel 绝不会收到消息,因为它订阅其他 Messenger 实例并侦听其他信道。

图 7:使用专用 Messenger

public class MainViewModel { private Messenger _privateMessenger; public MainViewModel() { _privateMessenger = new Messenger(); SimpleIoc.Default.Register(() => _privateMessenger, "PrivateMessenger"); } public void Update() { _privateMessenger.Send(new NotificationMessage("DoSomething")); } } public class FirstViewModel { public FirstViewModel() { var messenger = SimpleIoc.Default.GetInstance<Messenger>("PrivateMessenger"); messenger.Register<NotificationMessage>( this, message => { // 此消息是要发送给我。 }); } }

另一种避免向特定接收端发送给定消息的方法是使用令牌,如图 8 所示。这是一种由发送端和接收端达成的协定。令牌通常是一个唯一标识符(如 GUID),但也可以是任意对象。如果发送端和接收端使用相同的令牌,则这两个对象之间会打开一条专用信道。在这种情况下,没有使用令牌的 SecondViewModel 绝不会收到消息正在发送的通知。主要优势在于接收端不需要编写用于确保消息确实是发送给它的逻辑。相反,Messenger 会根据令牌筛选出消息。

图 8:使用令牌的不同信道

public class MainViewModel { public static readonly Guid Token = Guid.NewGuid(); public void Update() { Messenger.Default.Send(new NotificationMessage("DoSomething"), Token); } } public class FirstViewModel { public FirstViewModel() { Messenger.Default.Register<NotificationMessage>( this, MainViewModel.Token, message => { // 此消息是要发送给我。 }); } }

使用消息

令牌是用于筛选消息的理想方法,但这并不会改变消息为了达到让人理解的目的而应携带一些上下文的事实。例如,您可以使用包含布尔内容的发送和接收方法,如图 9 所示。不过,如果有多个接收端发送布尔消息,那么接收端该如何知道消息的接收端以及该如何处理?这就是最好使用专用的消息类型以便明确上下文的原因所在。

图 9:使用消息类型来定义上下文

public class Sender { public void SendBoolean() { Messenger.Default.Send(true); } public void SendNotification() { Messenger.Default.Send( new NotificationMessage<bool>(true, Notifications.PlayPause)); } } public class Receiver { public Receiver() { Messenger.Default.Register<bool>( this, b => { // 不确定如何处理此布尔。}); Messenger.Default.Register<NotificationMessage<bool>>( this, message => { if (message.Notification == Notifications.PlayPause) { // 对消息执行某操作。Content. Debug.WriteLine(message.Notification + ":"+ message.Content); } }); } }

图 9 还展示了在使用的特定消息类型。NotificationMessage<T> 是最常用的 MVVM Light Toolkit 内置消息类型之一,它允许将任何内容(在本示例中为布尔)与通知字符串一起发送。通常,通知是在名为“通知”的静态类中定义的唯一字符串。这样便可以将说明与消息一起发送。

当然,也可以从 NotificationMessage<T> 派生、使用其他内置消息类型或实施您自己的消息类型。MVVM Light Toolkit 包含可出于此目的进行派生的 MessageBase 类,但绝不强制要求将它用于代码。

另一个内置消息类型是 PropertyChanged­Message<T>。这对于通常用作绑定操作中涉及的对象的基类的 Observable­Object 和 ViewModelBase 类特别有用。这些类是 INotifyPropertyChanged 接口的实施,这对于使用数据绑定的 MVVM 应用程序至关重要。例如,在图 10 的代码中,BankAccountViewModel 定义了一个名为“余额”的可观测属性。当此属性发生变化时,RaisePropertyChanged 方法会采用布尔参数让 ViewModelBase 类“广播”包含此属性相关信息(如名称、旧值和新值)的 PropertyChangedMessage。其他对象可以订阅此消息类型,并相应地做出响应。

图 10:发送 PropertyChangedMessage

public class BankViewModel :ViewModelBase { public const string BalancePropertyName = "Balance"; private double _balance; public double Balance { get { return _balance; } set { if (Math.Abs(_balance - value) < 0.001) { return; } var oldValue = _balance; _balance = value; RaisePropertyChanged(BalancePropertyName, oldValue, value, true); } } } public class Receiver { public Receiver() { Messenger.Default.Register<PropertyChangedMessage<double>>( this, message => { if (message.PropertyName == BankViewModel.BalancePropertyName) { Debug.WriteLine( message.OldValue + " --> " + message.NewValue); } }); } }

MVVM Light 中的其他内置消息在各种场景下都非常实用。此外,用于构建您自己的自定义消息的基础结构也可用。从本质上说,主要思想就是通过提供充足的上下文让接收端知道该如何处理消息内容,从而简化接收端的工作。

总结

在不使用完全分离的消息传递解决方案就难以实施的许多场景中,使用 Messenger 已证实非常有用。然而,由于此为高级工具,您应该谨慎使用此工具,以免生成后期难以调试和维护的造成混淆的代码。

本文介绍了 MVVM Light Toolkit 组件。令人兴奋的是,.NET 开发人员能够在多个基于 XAML 的平台上使用相同的工具和技术。使用 MVVM Light,您可以在 WPF、Windows 运行时、Windows Phone、Silverlight(甚至是适用于 Android 和 iOS 的 Xamarin 平台)之间共享代码。我希望本系列的文章有助于您了解 MVVM Light 如何能够帮助您有效开发应用程序,同时轻松设计、测试和维护这些应用程序。

Laurent Bugnion 是 IdentityMine Inc. 的高级主管,目前在瑞士苏黎世工作。该公司是从事 Windows Presentation Foundation、Silverlight、Pixelsense、Kinect、Windows 8、Windows Phone 和用户体验等技术开发工作的。Microsoft 合作伙伴公司。他还是 Microsoft MVP 和 Microsoft 区域主管。

衷心感谢以下 Microsoft 技术专家对本文的审阅:Jeffrey Ferman
Jeffrey Ferman 目前担任 Visual Studio 项目经理。在四年多中,Jeff 一直专注研究 Visual Studio 和 Blend 中的 XAML 工具。他喜欢构建业务线应用程序并尝试不同的设计模式和做法。他还对扩展性有着浓厚的兴趣,并且喜欢与客户一起构建设计时控件体验。