使用服务定位器处理 MVVM 应用程序中的 MessageBox






4.91/5 (37投票s)
审查一种简单且可测试的方法,
引言
本文介绍了一种简单且可测试的技术,用于在基于 Model-View-ViewModel 设计模式的 WPF 或 Silverlight 应用程序的 ViewModel 层中处理消息框。 演示应用程序是在 Visual Studio 2008 Service Pack 1 中创建的。
背景
我在 MVVM 应用程序设计中看到的关于如何从 ViewModel 对象显示消息框的最常见问题之一。 乍一看,这个问题似乎很荒谬。 直接调用 MessageBox.Show()
,对吧? 在某些情况下,这个答案是完全正确的。 在其他情况下,这个答案则完全行不通。
为什么从 ViewModel 对象调用 MessageBox.Show()
可能行不通? 最常见的两个问题是自定义消息框和单元测试。 我在我的书《高级 MVVM》中详细解释了第一个问题,所以本文不再赘述。 如果您想知道我所说的自定义消息框是什么意思,下面这张来自本书演示应用程序 BubbleBurst 的截图展示了一个例子。
后者,即单元测试,是许多开发人员更普遍的要求。 从 ViewModel 对象显示消息框的问题在于,它可能在运行单元测试时显示。 这会阻止测试完成,直到有人走过来关闭消息框。 如果您的单元测试在构建服务器上运行,该服务器不受任何人监控,或者可能根本没有连接显示器,这可能是一个严重的问题。
我们面临的问题是双重的。 首先,ViewModel 如何提示用户做出决定,例如在应用程序关闭前是否保存他们的工作,而无需经过一番周折? 其次,我们如何测试这些 ViewModel 对象,而不会因为打开的消息框而导致测试套件无法完成?
服务定位器 前来救援
解决问题的办法很简单:不要从 ViewModel 对象调用 MessageBox.Show()
。 相反,为 ViewModel 提供一个“消息框服务”对象,该对象调用 MessageBox.Show()
。 这层间接调用似乎并没有解决在运行单元测试时打开消息框的问题,但实际上是可以解决的。
关键在于 ViewModel 对象不知道,也不关心消息框服务的作用。 在运行单元测试时,您可以为 ViewModel 提供一个假的(mock)消息框服务,该服务不调用 MessageBox.Show()
,而只是返回您预先指定的任何 MessageBoxResult
值。 ViewModel 对象永远不会察觉到差异;它只获得一个消息框服务,调用 Show
方法,并得到一个返回值来处理。
我在这里描述的是一种称为服务定位器的技术。 关于服务定位器是什么以及不是什么,已经写了整本书,创建了框架,发动了战争,打击了自尊,并基于此形成了哲学。 所以,我将不再深入解释。 相反,我为您呈现以下图片,我用它作为类比的基础。
在这个类比中,手臂以及连接到手臂的身体的其他部分,就是您的应用程序。 身体的每个器官,如心脏和大脑,都相当于应用程序中的一个模块或子系统。 出于某种原因,这个人的身体需要一些放射性白蛋白。 身体器官依赖于放射性白蛋白,这使得这种物质成为器官的依赖项。
为了将放射性白蛋白输送到身体器官,我们将其放入人体的血液中,血液将其散布到全身。 血液可以被认为是定位器官的任何依赖项,或者将其作为容器存储起来。 一旦血液中含有放射性白蛋白,器官就可以根据需要从中提取。 为了将放射性白蛋白注入血液,我们依赖于 某个东西来加载/放入/注入它到容器中,该容器可以从中定位,在本例中是医疗注射器,它引入了这种物质。
到目前为止,类比还算贴切,但最后一点我要提及的是有点牵强。 使用服务定位器的一个重要考虑因素是容器何时被填充了服务依赖项。 在我们这个医疗注射类比中,止血带代表了依赖项被放入容器中的时间。 通常,它们在应用程序模块加载并尝试定位它们之前就被注入到容器中。 从这个意义上说,注射发生在应用程序启动时,就像止血带一样,可以防止身体的其余部分在注射过程中引起问题。
现在让我们看看这如何应用于处理 ViewModel 对象中的消息框。
示例场景
本文讨论的演示应用程序可在页面顶部下载。 它包含一个非常简单的应用程序,允许用户输入一个人的姓名。 如果用户输入了有效姓名(即输入了名字和姓氏),“保存”按钮就会启用,以便他们可以保存数据。 数据保存后,“保存”按钮会再次禁用,直到姓名被更改。 当用户输入了有效姓名但尚未单击“保存”按钮时,如果他们尝试关闭窗口,应用程序会显示一个消息框,询问他们是否要在关闭前保存。
Person
数据模型类代表一个具有姓名的人。 它会跟踪是否被编辑过,并且可以自行验证。 下面的类图显示了其重要成员。
Person
类的实例由 PersonViewModel
包装。 该类负责显示 Person
数据并允许其保存。 它还实现了我的 IScreen
接口,该接口用于让 PersonViewModel
有机会询问用户是否应在关闭前保存未保存的更改。
需要注意的一个重要事项是 PersonViewModel
继承自 ViewModelBase
。 该类有两个值得关注的地方。 它继承了我 MVVM Foundation 库中的 ObservableObject
类,以便免费获得属性更改通知的支持。 在此演示应用程序中,无需发送属性更改通知,但所有有自尊心的 ViewModel 基类都必须支持此功能,因为大多数 ViewModel 对象都需要它。 ViewModelBase
的另一件事是公开了一个 GetService
方法,我们稍后将对此进行介绍。
如上所述,PersonViewModel
实现 IScreen
接口,以便被询问是否可以关闭。 “关闭”的意思是显示它的 View 可以从用户界面中移除。 当 PersonViewModel
被询问是否可以关闭时,它会检查其 Person
数据对象是否可以保存。 如果可以,它会询问用户该怎么做,如下所示。
bool IScreen.TryToClose()
{
if (this.CanSave)
{
var msgBox = base.GetService<IMessageBoxService>();
if (msgBox != null)
{
var result = msgBox.Show(
"Do you want to save your work before leaving?",
"Unsaved Changes",
MessageBoxButton.YesNoCancel,
MessageBoxImage.Question);
if (result == MessageBoxResult.Cancel)
return false;
if (result == MessageBoxResult.Yes)
this.Save();
}
}
return true;
}
上面看到的方法使用 ViewModelBase
的 GetService
方法来获取对消息框服务的引用。 这就是 PersonViewModel
依赖于 IMessageBoxService
的地方。 本文的下一部分将解释应用程序如何实现一个轻量级的 服务定位器 来解析此依赖项。
探索服务容器
此时,我们已经了解了演示应用程序如何依赖 服务定位 来为 ViewModel 对象提供显示消息框的功能。 现在,让我们转向我如何实现此支持。
在一个大型的生产应用程序中,我会认真考虑使用一个预先存在的类或框架来满足我的服务定位需求。 但对于一个简单的应用程序的简单演示来说,这将是矫枉过正。 所以我编写了我自己的简单定位器。 它只有不到五十行闪电般快速的代码。 下面显示了 ServiceContainer
类。
public class ServiceContainer
{
public static readonly ServiceContainer Instance = new ServiceContainer();
private ServiceContainer()
{
_serviceMap = new Dictionary<Type, object>();
_serviceMapLock = new object();
}
public void AddService<TServiceContract>(TServiceContract implementation)
where TServiceContract : class
{
lock (_serviceMapLock)
{
_serviceMap[typeof(TServiceContract)] = implementation;
}
}
public TServiceContract GetService<TServiceContract>()
where TServiceContract : class
{
object service;
lock (_serviceMapLock)
{
_serviceMap.TryGetValue(typeof(TServiceContract), out service);
}
return service as TServiceContract;
}
readonly Dictionary<Type, object> _serviceMap;
readonly object _serviceMapLock;
}
为了演示应用程序的目的,围绕服务使用的锁是不必要的,因为应用程序启动后服务实现永远不会被替换。 我包含了锁,以帮助防止线程问题,对于那些在更动态的系统中使用此类但可能忘记添加锁的人来说。
之前我提到 ViewModelBase
有一个名为 GetService
的方法,该方法为子类解析服务依赖项。 该方法存在是为了让所有 ViewModel 对象依赖于相同的服务依赖项解析策略。 如果以后您决定更改这些依赖项的定位方式,您只需要更新那个基类方法。 该方法在此处显示。
public TServiceContract GetService<TServiceContract>()
where TServiceContract : class
{
return ServiceContainer.Instance.GetService<TServiceContract>();
}
到目前为止,我们已经看到了 PersonViewModel
如何依赖一个服务接口 IMessageBoxService
来显示消息框。 我们看到它的 IScreen.TryToClose
方法调用了其基类的 GetService
方法,该方法只是将调用委托给 ServiceContainer
的 GetService
方法。 接下来,我们将研究服务容器是如何填充的,以及如何利用此设计来创建良好的单元测试。
测试时
由于 PersonViewModel
依赖于服务依赖项来显示消息框,因此我们可以编写不引起消息框显示的单元测试。 这可以通过遵循三个简单的步骤来实现。
首先,我们创建一个实现 IMessageBoxService
接口的类。 此类仅用于测试目的,因此我将其放在 UnitTests 项目中。 下面显示了 MockMessageBoxService
类。
class MockMessageBoxService : IMessageBoxService
{
public MessageBoxResult ShowReturnValue;
public int ShowCallCount;
public MessageBoxResult Show(
string message,
string title,
MessageBoxButton buttons,
MessageBoxImage image)
{
++ShowCallCount;
return this.ShowReturnValue;
}
}
接下来,我们需要将 MockMessageBoxService
的一个实例放入 PersonViewModel
用于定位其服务依赖项的同一个 ServiceContainer
中。 为了确保止血带系得非常紧,我们可以在一个用 AssemblyInitializeAttribute
标记的方法中执行此步骤。 该属性是 Visual Studio 单元测试框架的一部分。 它标记了一个方法,该方法应该在程序集中的任何测试运行之前执行一次。
[TestClass]
static class MockServiceInjector
{
// This method is called once before any test executes.
[AssemblyInitialize]
public static void InjectServices(TestContext context)
{
ServiceContainer.Instance.AddService<IMessageBoxService>(
new MockMessageBoxService());
}
}
最后一步是编写测试来执行 PersonViewModel
。 这些测试可以验证 PersonViewModel
的 TryToClose
方法是否行为正常。
[TestMethod]
public void ShowsMessageBoxWhenClosedAndCanSave()
{
var personVM = new PersonViewModel(new Person
{
FirstName = "Josh",
LastName = "Smith"
});
var personScreen = personVM as IScreen;
var msgBox =
personVM.GetService<IMessageBoxService>()
as MockMessageBoxService;
// User clicks the Cancel button -- should not close or save
msgBox.ShowReturnValue = MessageBoxResult.Cancel;
msgBox.ShowCallCount = 0;
Assert.IsTrue(personVM.CanSave);
Assert.IsFalse(personScreen.TryToClose());
Assert.IsTrue(personVM.CanSave);
Assert.AreEqual(1, msgBox.ShowCallCount);
// User clicks the No button -- should close but not save
msgBox.ShowReturnValue = MessageBoxResult.No;
msgBox.ShowCallCount = 0;
Assert.IsTrue(personVM.CanSave);
Assert.IsTrue(personScreen.TryToClose());
Assert.IsTrue(personVM.CanSave);
Assert.AreEqual(1, msgBox.ShowCallCount);
// User clicks the Yes button -- should close and save
msgBox.ShowReturnValue = MessageBoxResult.Yes;
msgBox.ShowCallCount = 0;
Assert.IsTrue(personVM.CanSave);
Assert.IsTrue(personScreen.TryToClose());
Assert.IsFalse(personVM.CanSave);
Assert.AreEqual(1, msgBox.ShowCallCount);
}
现在,让我们将焦点转移到应用程序运行时服务容器的配置方式。
运行时
当应用程序运行时,我们使用 IMessageBoxService
的不同实现。 此服务版本实际上会显示一个消息框并返回用户选择的结果。 下面是该类的代码。
internal class MessageBoxService : IMessageBoxService
{
MessageBoxResult IMessageBoxService.Show(
string text,
string caption,
MessageBoxButton buttons,
MessageBoxImage image)
{
return MessageBox.Show(text, caption, buttons, image);
}
}
MessageBoxService
类的一个实例在应用程序首次创建时被放入服务容器中。
public partial class App : Application
{
public App()
{
ServiceInjector.InjectServices();
}
}
// In the Demo.Services project
public static class ServiceInjector
{
// Loads service objects into the ServiceContainer on startup.
public static void InjectServices()
{
ServiceContainer.Instance.AddService<IMessageBoxService>(
new MessageBoxService());
}
}
在演示应用程序中,除了 mock 服务之外,所有与服务相关的代码都在 Demo.Services 类库项目中。 这允许 MessageBoxService
类被标记为 internal
,这样可执行文件就不能直接引用它的类型。 相反,可执行文件必须引用 IMessageBoxService
,这有助于减少耦合并允许 控制反转 发挥其魔力。
修订历史
- 2010 年 4 月 1 日 – 在 CodeProject 上发布文章