裸金属 MVVM - 代码与现实的碰撞 - 第三部分






4.87/5 (16投票s)
模板以及我们如何使用它们来支持 MVVM 应用程序的开发
引言
关于 MVVM 的文章已经数不胜数,你可能会想我为什么要再写一篇。原因很简单,市面上存在很多关于 MVVM 的错误信息,我希望这一系列文章能帮助大家消除一些对这个非常、非常直观的模式的误解。
现在,你可能已经知道 MVVM 最初是为 WPF 应用程序设计的模式,并且它已经从最初的起点扩展到了其他语言、框架和平台。然而,由于这些不同的实现都依赖于我们开发者无法直接看到的特性,我们往往会忽略 MVVM 究竟是什么让我们成为开发工具箱中如此有用的工具,以及我们需要从 MVVM 开始具备哪些特性。因此,在这篇文章中,我们将放弃所有那些花哨的平台,而是从头开始构建一个 MVVM 应用程序,作为控制台应用程序。是的,没错,我们将重现一些幕后发生的事情,来展示一些让 MVVM 成为可能的“魔法”。
在这篇文章中,我们将开始研究模板以及如何使用它们来支持 MVVM 应用程序的开发,包括对“视图优先”和“视图模型优先”开发的讨论。由于模板是一项非常强大的功能,并且它有很多“隐藏”在底层的东西,我们将花几篇文章来讲解模板。因此,到本文结束时,我们应该能理解什么是模板以及如何创建自己的模板,以及我们需要提供的用于支持模板注册和查找的基本基础结构。
系列文章
- 裸机 MVVM - 代码落地之处 - 第 1 部分 - 介绍绑定操作和
INotifyPropertyChanged
的作用 - 裸机 MVVM - 代码落地之处 - 第 2 部分 - 更佳的绑定和 ICommand
- 裸机 MVVM - 代码落地之处 - 第 3 部分 - 成功的模板(第一部分)- 本文
开发环境
我使用 VS 2015 开发了这个应用程序,并大量使用了 C#6 的特性。如果你没有安装 VS2015,我希望你至少能够跟随我的思路,因为我将在这里解释整个代码库。
推倒重来
那些认真阅读了我前两篇文章的读者可能会惊讶地听到,我们将要抛弃到目前为止写过的代码,重新开始。等等!说什么?抛弃代码。是的,我们将抛弃我们写过的代码;不是因为它错了或者不能用。抛弃代码(目前)的简单原因是因为我们想专注于使用模板来控制我们在屏幕上看到的内容,所以我们将先构建一个能够让我们纯粹专注于模板方面的东西——别担心,我们会重新添加我们已经遇到过的绑定功能的一个超大型版本。
为什么选择模板?
如果我们搜索“模板”,我们会看到一个定义是“用作他人复制的范例”;这就是我们将要做的。通过将模板支持添加到我们的应用程序中,我们将构建机制来使用简单的构建块来控制用户看到的内容。对于那些习惯于使用 WPF 等语言编程的人来说,模板随处可见,我们常常注意不到它们,但模板为我们提供了一个我们即将重新创建的便捷小工具(这将解释为什么我们移除了前几篇文章的内容)。
视图 vs 视图模型 - 哪个先出现,是鸡还是蛋?
你可能会听到有人谈论我们在创建应用程序时应该是“视图优先”还是“视图模型优先”。这指的是我们如何将 UI 显示在屏幕上;在“视图优先”中,我们通常会创建一个视图,然后在视图中显式地设置其 DataContext
。换句话说,我们可以认为这是在视图中控制 ViewModel
的生命周期,并且我们有责任显式地做一些事情来确保视图被显示。这基本上就是我们在前两篇文章中所做的——我们让视图控制 DataContext
,应用程序对此做出响应。
神话终结者 #3。你不需要创建一个 UserControl 来实现“视图优先”。从模板设置 DataContext 是完全可能的。
那么,“视图优先”看起来相当直接,那“视图模型优先”又是怎么回事呢?巧妙的是,这颠倒了“视图优先”的思路,我们渲染 DataContext
到 UI,让底层的应用程序框架(无论是 WPF、Silverlight 还是其他)来负责选择最适合该 DataContext
的视图来显示。我知道,这听起来很混乱,但它其实非常直观,也是我们将要在这里构建的。正如我们已经看到的,本文是关于模板的,因此,显示合适视图的底层机制就是为该视图注册一个模板,并在我们设置视图上的 DataContext
时让框架拾取它,这应该不足为奇。
让我们开始创建一个简单的基模板类,所有模板都将继承自它。这个类将公开一个 TargetType
,我们将使用它来设置在解析 ViewModel
与模板匹配时所需的类型,以及一个 Render
方法,我们将用它来渲染我们的模板。在这个方法中,我们将把 dataContext
设置为 dynamic
,这是一个方便的省时器,当以后我们要处理 ViewModel
中的属性时。请注意,我们是将 DataContext
传递给这个方法,而不是像前两篇文章那样将 DataContext
显式地设置到模板上。我们这样做是因为每次我们“看到”合适的类型时,我们都会返回同一个模板实例(这样做还有一个原因,当我们将模板系统做得更强大时,我们会再来讨论,那时我们将把 UI 分解成树状结构——这将在后面介绍)。
public abstract class TemplateBase
{
public Type TargetType { get; set; }
public abstract void Render(dynamic dataContext);
}
ViewModel
优先解析的一个特性是屏幕上总会有显示的内容。如果我们显示一个我们没有注册模板的 ViewModel
,底层框架会回溯 ViewModel
的继承层次结构,直到找到一个已注册的、可以显示的项——而且总有东西可以显示,因为任何类的基类型都是 object,并且我们会有一个打印 ViewModel
的 ToString()
的模板。这听起来比实际情况更复杂。首先,让我们定义我们的 DefaultTemplate
。
public class DefaultTemplate : TemplateBase
{
public DefaultTemplate()
{
TargetType = typeof (object);
}
public override void Render(dynamic dataContext)
{
Console.WriteLine($"{dataContext}");
}
}
就是这样,当我们需要渲染 object
类型的内容时,我们的默认模板只是将上下文写入控制台。
发动机室
创建模板固然很好,但我们需要某种方式来连接这些模板,并根据 ViewModel
找到合适的模板。这意味着我们需要一些通用的基础设施,可以说是一个访问模板的单一入口点,将它们整合在一起。我们将创建一个 Singleton
类(我知道,好恶心,但这是一种方便的方式,将所有东西集中在一个易于访问的位置),我们将在其中注册我们的模板,并使用它来查找最适合渲染到 UI 的模板。首先,我们需要提供该类的单个实例。
public class TemplateEngine
{
public static TemplateEngine Instance { get; } = new TemplateEngine();
static TemplateEngine()
{
}
private TemplateEngine()
{
}
}
有了这个,我们应用程序中的所有类都可以使用 TemplateEngine
的一个实例。现在我们需要提供一种添加模板的机制。
public List<TemplateBase> Templates { get; } = new List<TemplateBase> { new DefaultTemplate() };
public void Add(TemplateBase template)
{
Templates.Add(template);
}
细心的读者会注意到,我们使用 DefaultTemplate
的实例来初始化模板列表。有了这个,即使没有注册匹配的模板,无论我们将什么 ViewModel
传递给我们的应用程序,我们总会有一个模板可以渲染。这引出了我们 TemplateEngine
拼图的最后一块,即如何找到合适的模板。
public TemplateBase FindTemplate<T>(T instance)
{
Type targetType = instance.GetType();
while (true)
{
TemplateBase template = Templates.FirstOrDefault(x => x.TargetType == targetType);
if (template != null)
{
return template;
}
targetType = targetType?.BaseType;
}
}
FindTemplate
只是遍历对象层次结构,直到找到匹配的模板;它通过比较 TargetType
和当前对象层次结构类型来实现这一点。最终,如果我们没有注册合适的模板,它将回退到 DefaultTemplate
。将这一切放在一起,这就是我们当前的 TemplateEngine
。
public class TemplateEngine
{
public static TemplateEngine Instance { get; } = new TemplateEngine();
static TemplateEngine()
{
}
private TemplateEngine()
{
}
public List<TemplateBase> Templates { get; } =
new List<TemplateBase> { new DefaultTemplate() };
public void Add(TemplateBase template)
{
Templates.Add(template);
}
public TemplateBase FindTemplate<T>(T instance)
{
Type targetType = instance.GetType();
while (true)
{
TemplateBase template = Templates.FirstOrDefault(x => x.TargetType == targetType);
if (template != null)
{
return template;
}
targetType = targetType?.BaseType;
}
}
}
应用程序
我们的应用程序需要一个挂钩点,作为我们运行代码的入口点。我们将创建一个名为 Application
的类,它将作为我们代码的“核心”,为我们提供挂载模板的地方。在我们看这个类之前,让我们创建一个 ViewModel
,它将作为我们在应用程序运行时想要在控制台窗口中看到的数据的基础。
public class PersonViewModel
{
public string Name { get; } = "Bobby Tables";
public DateTime DateOfBirth { get; } = new DateTime(1980, 03, 24);
}
这是一个非常轻量级的 ViewModel
(并且请注意它没有实现 INotifyPropertyChanged
- 记住我们之前的神话终结者,如果没有任何改变,ViewModel 不必实现 INotifyPropertyChanged)。好了,准备好之后,让我们创建 Application
类。我们要做的第一件事是引入一个 DataContext
,我们将把 PersonViewModel
挂载到其中,以及一个 Run
方法来触发应用程序的运行状态。
public object DataContext { get; set; }
public void Run()
{
TemplateBase template = TemplateEngine.Instance.FindTemplate(this);
template.Render(DataContext);
}
像所有好的可执行程序一样,我们需要一个 Main
方法来实际触发我们应用程序的运行。
private static void Main()
{
Application application = new Application { DataContext = new PersonViewModel() };
TemplateEngine.Instance.Add(new ApplicationTemplate());
application.Run();
Console.ReadKey();
}
正如我们在代码中所看到的,我们实例化了 Application
类,并将 DataContext
设置为 PersonViewModel
的实例。最终,我们调用 Run
方法来实际触发我们应用程序模板的渲染。虽然所有这些(可能)都很有趣,但它错过了中间那一行——注册新模板的那一行。如果我们没有那一行,当我们运行应用程序时,我们唯一看到的将是 ToString()
方法的结果;还记得我们上面的代码,我们沿着对象层次结构向上回溯,直到找到一个合适类型的模板,在这种情况下就是默认模板。通过注册这个 ApplicationTemplate
,我们有了可以显示的内容。让我们创建我们的模板;它所做的就是将目标类型设置为 Application,并在调用 Render
方法时,写出应用程序已启动的适当消息,然后找到并渲染 DataContext
的合适模板。
public class ApplicationTemplate : TemplateBase
{
public ApplicationTemplate()
{
TargetType = typeof (Application);
}
public override void Render(dynamic dataContext)
{
Console.WriteLine("Inside Application");
TemplateEngine.Instance.FindTemplate(dataContext).Render(dataContext);
}
}
这段代码的美妙之处在于我们的模板是多么简单。
此时,我们应该运行应用程序,看看应用程序和默认模板的实际效果。
在这里,我们可以清楚地看到我们的应用程序模板和默认模板被调用了。但我们想要更多。我们希望将 ViewModel
中的信息写入屏幕。让我们来创建对应的模板。
public class PersonTemplate : TemplateBase
{
public PersonTemplate()
{
TargetType = typeof (PersonViewModel);
}
public override void Render(dynamic dataContext)
{
Console.WriteLine(dataContext.Name);
Console.ForegroundColor = ConsoleColor.DarkYellow;
Console.WriteLine($"Was born on {dataContext.DateOfBirth,0:dddd, MMMM d, yyyy}");
}
}
这显然是一个更复杂的模板。在使用它之前,我们需要注册它,所以我们将它重新添加到 Application
类中,如下所示:
private static void Main()
{
Application application = new Application { DataContext = new PersonViewModel() };
TemplateEngine.Instance.Add(new ApplicationTemplate());
TemplateEngine.Instance.Add(new PersonTemplate());
application.Run();
Console.ReadKey();
}
现在,当我们运行应用程序时,我们会看到类似这样的内容:
总结
正如我们所见,只需少量代码,我们就构建了一个相当直观的模板系统。我们故意避免让这里的注册过于像 XAML,因为我们的目标是理解 MVVM,而不会陷入过多的特定于框架的实现。当然,还有一个问题我们还没有解决,让我们现在来处理它。
哪个更好,视图优先还是视图模型优先?
答案是两者都不是,又都是。现实情况是,它们都有各自的优缺点,明智的 MVVM 开发者会根据需要同时使用这两种技术。以下是一些需要注意的事项,应该有助于说明我们应该何时考虑使用哪种技术:
使用“视图优先”,可以很容易地看出在任何给定时间点正在使用哪个 ViewModel
,因为它与视图紧密相关。因此(我真不敢相信我能在文章中使用“因此”这个词),这只是一个查看视图以获取上下文的简单案例。ViewModel
和视图绑定在一起的事实也意味着我们可以轻松地看到我们的视图有哪些可用属性——这对于图形化框架中的设计者来说是一个很好的帮助。我最绝望的是看到人们误以为我们在开发“视图优先”时必须将视图和 ViewModel
放在同一个程序集中。这简直是胡说八道!只要我们能够在运行时解析出视图的 DataContext
,我们就可以仅与接口交互——依赖注入甚至服务定位器都可以让我们在完全不同的程序集中处理具体的 ViewModel
。我看到的另一个关于“视图优先”的错误说法是它不够 DRY(Don't Repeat Yourself)。如果有一个一边摇头一边用头撞墙的笑脸,我现在就会用它了。
神话终结者 #4。与你可能读到的相反,“视图优先”并不一定意味着 ViewModel
必须与视图 living 在同一个项目中。
神话终结者 #5。“视图优先”并不意味着你的代码需要不那么 DRY。
使用 ViewModel
优先,我们可以利用模板的全部威力,而模板是我们武器库中强大的武器。在这篇文章中,我们只是触及了模板功能的皮毛,所以现在谈论我们在接下来的几篇文章中将要添加的内容是合适的。由于我们正在使用模板,我们可以使用模板触发器来自动响应绑定更改(这可能意味着不仅仅是 ViewModels
中属性的变化)。这意味着我们可以将大量工作交给触发器,从而使我们能够轻松构建极其复杂的界面。
正如我们之前讨论的,使用 ViewModel
优先允许我们回溯对象层次结构,直到找到一个合适的类型来显示。这一点我已经在很多地方利用过,来提供能够降级到单个视图的专用 ViewModel
。当开发使用列表显示项目以进行编辑,但每个 ViewModel
都有特殊要求时,这种技术节省了我大量的时间。基本上,我使用这种技术提供一个通用的基类 ViewModel
,然后为每种类型使用专门的版本。例如,我在开发一个图像编辑应用程序时使用了这种技术,该应用程序能够编辑不同类型文件的属性(某些 RAW 文件有非常特殊的访问图像元数据的方式)。使用“视图优先”作为开发技术,要实现这一点将非常、非常困难。
下一步是什么?
我们在这里将模板开发保持得非常独立。有很多东西需要学习,我希望这段代码尽可能完整,以展示如何渲染模板以及它与 ViewModel
优先的关系。但这并没有涵盖模板提供的所有内容。在下一篇文章中,我们将重新引入绑定支持,然后看看如何将触发器添加到我们的模板中以响应信息的更改。我希望我们已经打破了一些 MVVM 的神话,并给大家带来了一些思考。
历史
- 2017 年 2 月 23 日:初始版本