连接 ViewModel






4.85/5 (7投票s)
在视图优先的 MVVM 应用程序中使用附加属性和少量依赖注入来定位 ViewModel
引言
我觉得 Model View ViewModel (MVVM) 设计模式的命名相当奇怪。对我来说,一个像“ViewModel View Controller Model”这样的名字才更有意义。毕竟,MVVM 模式的创新之处在于引入了 ViewModel 作为 View 和 Model 之间的 中介者,而是否使用 Controller 的选择对该设计来说很大程度上是无关紧要的。然而,这个模式已经变得如此流行,以至于如今它被视为客户端 Windows 应用的标准架构。
MVVM 设计模式之所以流行,有几个原因,但我最喜欢的是 View 和 Model 之间出色的关注点分离。数据绑定和 XAML 的声明式特性激发了创建可重用 View 组件的灵感,而 MVVM 模式则提供了将应用程序特定、不可重用的东西卸载到 ViewModel 的机会,从而保持 Model 和 Services 的可重用性。太棒了!
既然 ViewModel 是 MVVM 模式的中心,那么决定何时实例化它也就不足为奇了。基本上有两种方法可以做到这一点:View First 或 ViewModel First。正如您所想象的,名称描述了创建的顺序;在 View First 设计中,View 先被实例化,然后实例化并连接 ViewModel,而在 ViewModel First MVVM 设计中则相反。这种区别可能看起来相当学术化,但选择将以非常根本的方式影响您的设计。例如,如果您使用 ViewModel First 方法,可以使用 Data Templates “自动”创建相应的 Views,而提供设计时数据可能比 View First 方法更具挑战性。
当然,这两种设计都有其支持者,但我偏爱 View First 方法,在这篇文章中,我将解释我通常如何连接 ViewModel 到 View。这个想法非常简单,所以我确信它在很多地方都在被使用,尽管我还没有看到它被描述过。希望通过分享我的想法,我可以为某人节省一些时间。
背景
在得出本文所述方法之前,我已经尝试了几种连接 ViewModel 到 View 的方法。我将在下面列出它们以及我偏爱不使用它们的原因。下面的所有示例都使用了这个非常简单的 ViewModel。
public class ViewModel
{
public string Message { get; set; }
public ViewModel(string message)
{
Message = message;
}
public ViewModel()
{
Message = "Hello ViewModel default constructor!";
}
}
后台代码
在我看来,连接 ViewModel 最直接的方法是在 View 的构造函数或事件处理程序中简单地设置 DataContext
。就像这样
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
DataContext = new ViewModel("Hello MainWindow constructor!");
}
private void TextBlock_Loaded(object sender, RoutedEventArgs e)
{
var textBlock = sender as TextBlock;
if(textBlock != null)
{
textBlock.DataContext = new ViewModel("Hello TextBlock Loaded!");
}
}
}
这种方法的主要缺点是,您要么必须为每个 View 创建一个新的子类,要么处理一个事件来设置 DataContext
。在一个简单的应用程序中,这可能还可以,但我更喜欢一种无论是在小应用程序还是大应用程序中都同样有效的方法。下图显示了使用事件设置 DataContext
的另一个大缺点。
顶部的窗口是 Visual Studio 中的设计器,底部的窗口是同一应用程序的运行状态。这个区别表明 Loaded
事件在设计器中不触发,因此将 DataContext
设置在事件处理程序中不适用于您希望 ViewModel 提供设计时数据的情况。
在 XAML 中实例化
这可能是实例化 ViewModel 最简单的方法,并且它通过 d: namespace
支持设计数据。
<Window.DataContext>
<local:ViewModel/>
</Window.DataContext>
这种方法的主要缺点是,您只能使用 ViewModel 的默认的、无参数的构造函数。在非平凡的应用程序中,您很可能会使用一个依赖注入 (DI) 容器,例如 Unity,来解析 ViewModel 对 Services 的依赖。在使用依赖注入容器时,我更喜欢使用构造函数注入而不是属性注入,原因很简单,就是方便编写 ViewModel 的单元测试。如果我使用构造函数注入,在单元测试中就完全不必费心 DI 容器,而是创建 Services 的存根版本,并将它们作为 ViewModel 构造函数的参数在我的测试用例中使用。
ViewModel 定位器
使用ViewModel 定位器是在 View First MVVM 应用程序中处理 ViewModel 实例化的非常常见的方法。定位器只是一个对象,它提供可以绑定以检索 ViewModel 的属性。这是一个非常简单的 ViewModel 定位器实现。
public partial class Locator
{
public ViewModel ViewModel
{
get
{
return new ViewModel("Hello ViewModel Locator!");
}
}
}
可以在 App.xaml 中将定位器定义为静态资源。
<Application.Resources>
<ResourceDictionary>
<local:Locator x:Key="Locator"/>
</ResourceDictionary>
</Application.Resources>
并且像这样使用它来设置 DataContext
。
<TextBlock DataContext="{Binding Source={StaticResource Locator}, Path=ViewModel}"
Text="{Binding Message}"/>
此设计支持 designtime
数据并允许使用构造函数参数,但缺点是需要为添加到应用程序的每个 ViewModel 添加一个新属性。您可以向部分类添加新属性,这样就不必更改现有文件,但我仍然不喜欢为每个添加的 ViewModel 更改定位器。另一个问题是,如果定位器突然返回的对象类型与 View 期望的类型不同,直到您意识到 UI 根本无法正常工作,否则可能完全不会被注意到。
使用这里描述的巧妙设计,您可以完全避免为每个添加的 ViewModel 编写新的定位器代码。诀窍是使用索引器从唯一的定位器 ViewModel 属性中选择要返回的 ViewModel。这种实现定位器的方式有一个缺点,一些人认为它是有益的:View 和 ViewModel 之间的契约非常薄弱,因为索引只是一个 string
。通常您希望 View 和 ViewModel 之间松耦合,但是耦合如此之松,您只是像一种信任一样绑定到一个属性,对我来说有点太松了。
标记扩展
我为几个项目使用了这里描述的标记扩展,并且我对此很满意。然而,这项技术不能在 Windows 应用中使用,因为 Windows Runtime XAML 不支持自定义标记扩展。
Using the Code
因此,总结一下:我希望有一种方法可以在 View First MVVM 应用程序中将 ViewModel 连接到 View,并且具有以下特性
- 独立于其他框架
- 在 XAML 中声明式使用
- 设计时数据支持
- View 和 ViewModel 之间有明确的契约
- 添加新 View/ViewModel 时无需更改现有代码
- 能够使用构造函数参数以促进单元测试
- 兼容 WPF 和 WinRT 应用程序
- 简单轻量,但需要时可扩展为更强大
我提出的解决方案是这个
using System;
using System.Reflection;
#if !NETFX_CORE
using System.Windows;
#else
using Windows.UI.Xaml;
#endif
namespace WpfApplication4
{
public static class DataContext
{
#region Type ResolvedType
public static Type GetResolvedType(DependencyObject obj)
{
return (Type)obj.GetValue(ResolvedTypeProperty);
}
public static void SetResolvedType(DependencyObject obj, Type value)
{
obj.SetValue(ResolvedTypeProperty, value);
}
public static readonly DependencyProperty ResolvedTypeProperty =
DependencyProperty.RegisterAttached("ResolvedType", typeof(Type), typeof(DataContext),
new PropertyMetadata(null, OnResolvedTypeChanged));
private static void OnResolvedTypeChanged(DependencyObject d,
DependencyPropertyChangedEventArgs e)
{
if(!_resourcesLoaded)
{
Assembly.GetExecutingAssembly().GetCustomAttributes(false);
_resourcesLoaded = true;
}
var elt = d as FrameworkElement;
if (elt != null)
{
elt.DataContext = CurrentResolver((Type)e.NewValue);
}
}
#endregion
public delegate object ResolverDelegate(Type type);
public static ResolverDelegate CurrentResolver = ActivatorResolver;
/// <summary>
/// Resolves the type by simply creating an instance using 'Activator'.
/// </summary>
/// <param name="type">The type to use as DataContext.</param>
/// <returns>An instance of 'type'.</returns>
public static object ActivatorResolver(Type type)
{
return Activator.CreateInstance(type);
}
}
}
上面的代码实现了一个名为 ResolvedType
的附加属性,它包含一个类型。当属性持有的类型更改时,设置了附加属性的对象将把其 DataContext
属性设置为从提供的类型解析的对象。默认行为是简单地实例化所提供类型的对象,但可以通过应用不同的 ResolverDelegate
来更改。
如果您碰巧注意到 OnResolvedTypeChanged
中获取自定义属性的奇异代码,请不要担心!我将在下面解释它是什么以及为什么需要它。
这是 XAML 中的用法(理论上,您也可以从代码隐藏中使用附加属性,但没有意义)。
首先是 WPF
<TextBlock local:DataContext.ResolvedType="{x:Type local:ViewModel}" Text="{Binding Message}"/>
然后是 WinRT
<TextBlock local:DataContext.ResolvedType="local:ViewModel" Text="{Binding Message}"/>
WinRT 系统上的 XAML 解析器似乎存在一个问题,导致解析器在某些情况下无法将 ResolvedType
值识别为类型。有一个方法可以解决这个问题,通过为有问题类型定义一个样式来向 XAML 解析器显示表达式确实是一个类型。
<Page.Resources>
<Style TargetType="local:ViewModel"/>
</Page.Resources>
这个 bug 很不幸,但我非常喜欢附加属性的方法,以至于暂时接受了这个解决方法。我希望这个问题最终会得到解决(尽管我不会屏息以待),并且在修复之前,我只会忍受并在需要时应用这个解决方法。
好的,现在我们有了一个使用附加属性设置 DataContext
的工作示例,但说实话,它并不比直接在 XAML 中实例化 ViewModel 更好,因为
- View 和 ViewModel 之间存在硬耦合(您需要提供 ViewModel 的实际类型)。
- 需要 ViewModel 的默认构造函数。
- 无法控制 ViewModel 的生命周期。
- 不支持设计时数据。实际上,ViewModel 可以检测到自己处于设计器中并在此情况下提供设计时数据,但我更喜欢使用单独的 ViewModel 类进行设计,以避免弄乱我的生产 ViewModel。
幸运的是,所有这些不足都可以通过使用 DI 容器来解决。所以让我们开始将 DI 容器添加到解决方案中。在附加的源代码中,我使用的是Unity Application Block (Unity),可以通过 NuGet 添加。
每个 DI 容器实现使用的语法不同,但它们都提供相同的基本服务,即根据合同规范查找具体实现。通常,有几种方法可以设置 DI 容器:您可以使用源代码中的属性、配置文件或所谓的Fluent Interface来设置合同与实现之间的关系。属性提供了一种分散容器设置的好方法,但我更喜欢使用 Fluent Interface,因为我不想在我的源代码中到处散布容器特定的属性。
首先,让我们创建 ViewModel 合同
public interface IViewModelContract
{
string Message { get; }
}
这足够简单,合同只是一个名为 Message
的 string
属性!现在让我们创建实现此合同的生产 ViewModel
public interface IDummyService
{
}
public class DummyService : IDummyService
{
}
public class RunTimeViewModel : IViewModelContract
{
public string Message
{
get
{
return "Run-Time ViewModel";
}
}
private IDummyService _dummyService;
public RunTimeViewModel(IDummyService dummyService)
{
_dummyService = dummyService;
}
}
这有点复杂;除了实现所需的 ViewModel 合同外,RunTimeViewModel
还使用一个名为 DummyService
的服务,它通过构造函数参数获得对其的引用。非常适合构造函数注入!
这是设计时的对应版本。
public class DesignTimeViewModel : IViewModelContract
{
public string Message
{
get
{
return "Design-Time ViewModel";
}
}
}
请注意,设计时 ViewModel 不使用任何服务,它只提供 ViewModel 合同所需的接口。
现在我们必须建立合同与 ViewModel 实现之间的关系,以便能够解析 ViewModel 合同。当使用 Fluent Interface 时,这通常在引导程序中完成。
interface IUnityContainerInitializer
{
void InitializeContainer(UnityContainer container);
}
public class Bootstrapper
{
#region static bool IsInDesignMode
/// <summary>
/// IsInDesignMode is true only when in the designer while in debug mode.
/// </summary>
public static bool IsInDesignMode
{
get
{
#if DEBUG
#if NETFX_CORE
return Windows.ApplicationModel.DesignMode.DesignModeEnabled;
#else
return DesignerProperties.GetIsInDesignMode(new DependencyObject());
#endif
#else
return false;
#endif
}
}
#endregion
private static UnityContainer _container;
private static IUnityContainerInitializer _initializer;
static Bootstrapper()
{
if (IsInDesignMode)
{
_initializer = new DesigntimeContainerInitializer();
}
else
{
_initializer = new RuntimeContainerInitializer();
}
_container = new UnityContainer();
_initializer.InitializeContainer(_container);
DataContext.CurrentResolver = t =>
{
return _container.Resolve(t);
};
}
}
这是一个非常简单的引导程序的示例。我故意避免对其进行泛化,以便代码更容易理解。唯一应该真正放在其他地方的是 IsInDesignMode
属性,但我选择将其保留在引导程序中,以使示例尽可能简单。顺便说一句,请注意 IsInDesignMode
属性在发布版本中始终返回 false
。在生产软件中保留死代码会破坏我发布代码时通常会有的那种温暖舒适的感觉。
正如您所见,我已经将引导序列分为三个部分
- 根据
IsInDesignMode
属性创建DesigntimeContainerInitializer
或RuntimeContainerInitializer
。 - 创建并初始化
UnityContainer
。 - 将
static DataContext
类中的CurrentResolver
委托设置为一个 lambda 表达式,该表达式通过容器解析类型。
现在,我们需要创建两个不同的容器初始化器,这里您可以看到著名的“Fluent Interface”在起作用,它本质上是下面对 RegisterType
的调用。它之所以被称为 Fluent,是因为接口中的每个函数都返回容器,因此函数调用可以以类似 LINQ 的方式链接起来,但它实际上没有什么花哨的。
public class RuntimeContainerInitializer : IUnityContainerInitializer
{
public void InitializeContainer(UnityContainer container)
{
container.RegisterType<IViewModelContract, RunTimeViewModel>();
container.RegisterType<IDummyService, DummyService>();
}
}
public class DesigntimeContainerInitializer : IUnityContainerInitializer
{
public void InitializeContainer(UnityContainer container)
{
container.RegisterType<IViewModelContract, DesignTimeViewModel>();
}
}
正如您所见,设计时容器中没有注册任何服务。原因很简单,因为设计时 ViewModel 在这个示例中不使用任何服务。然而,在某些情况下,保持设计时 ViewModel 与运行时 ViewModel 相同,但使用特殊的设计时服务是有意义的。这可以通过以与 ViewModel 相同的方式将设计时服务注册到容器中来实现。
好了,来个总结。
我开始描述一个附加属性,该属性可用于根据 Type
设置 FrameworkElement
的 DataContext
。接下来是 ViewModel 的合同定义(接口)以及该合同的两个不同实现:一个用于最终应用程序(运行时),另一个用于在 Blend 或 Visual Studio 设计器中提供示例数据(设计时)。下一步是创建一个引导程序,该引导程序设置一个 DI 容器,能够根据是在设计器中加载还是未加载来不同地解析合同。最后,附加属性的 CurrentResolver
委托被设置为使用 DI 容器查找 ViewModel 实现。
我们快到了,但还没有完全到达!为了使此设置正常工作,必须运行引导程序构造函数,否则 DI 容器将不会被初始化,并且 CurrentResolver
委托也不会被设置。由于应用程序的启动代码不会被设计器运行,因此我们必须采用不同的方法来实例化引导程序。我通常将其放在 App.xaml 中,如下所示
<Application.Resources>
<ResourceDictionary>
<local:Bootstrapper x:Key="Bootstrapper"/>
</ResourceDictionary>
</Application.Resources>
现在您可能会认为这足以确保引导程序在应用程序启动(或设计器开始执行)之前被实例化,但由于资源是惰性加载的,不幸的是事实并非如此。为了保证引导程序被加载,我们需要更明确的东西。首先,让我们定义一个程序集自定义属性。
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
public class LoadApplicationResourceAttribute : Attribute
{
public LoadApplicationResourceAttribute(string resourceKey)
{
Application.Current.TryFindResource(resourceKey);
}
}
此属性在实例化时尝试使用提供的资源键在当前程序集中查找命名资源。这足以确保资源被加载。我通常将其放在 AssemblyInfo.cs 中,但您可以将其放在任何您喜欢的地方,例如引导程序类文件中。
属性的使用方式如下
[assembly: LoadApplicationResource("Bootstrapper")]
正如您所见,此示例中的 resourceKey
与上面 App.xaml 中放在引导程序上的 x:Key
值匹配。这意味着一旦实例化属性,引导程序就会被加载。这就引出了下一个问题:属性何时被实例化?
答案是:在调用 GetCustomAttributes
时实例化自定义属性。
您可能还记得我曾承诺解释 OnResolvedTypeChanged
中“奇异”的代码?嗯,就是这段代码
if(!_resourcesLoaded)
{
Assembly.GetExecutingAssembly().GetCustomAttributes(false);
_resourcesLoaded = true;
}
我相信您现在已经理解了它的作用以及为什么需要它。它只是确保在解析第一个类型之前加载所有必需的资源。现在,这最终将形成一个完整的闭环,设置完成!
这是我们努力的结果,左边是运行的附加应用程序,右边是设计器中的应用程序
一如既往,我对自己用这么多字来传达一个简单的想法感到惊讶。我试图让示例简洁明了,并将解释降到最低,但即使如此,这仍然是一篇相当长的文章。我不敢说这是连接 ViewModel 的最佳方法,但这是目前最适合我的方法。明天我可能会发现所有这些都已过时——有一点是肯定的:我对“最佳”方法的看法会随着我的学习而改变!
希望您阅读愉快,并获得了一些新想法,即使您可能不赞同我在连接 ViewModel 方面的偏好。
关注点
使用属性加载资源的想法来自于 Josh Smith 的这篇文章。我将其泛化为加载资源,并添加了对 GetCustomAttributes
的调用,将其放在附加属性中,以在设计器和应用程序运行时获得相同的行为。稍后版本的 Visual Studio 无论如何都不会自动实例化自定义属性,因此您需要某种方法来触发该调用,即使您只想使用该机制加载设计时数据。
历史
- 2015-06-22 - 初始版本