动态 INPC, 清洁 INPC






4.93/5 (8投票s)
深入探讨动态(dynamic)作为自动化 INotifyPropertyChanged 实现的一种方式。一种使用 MVVM 模式中 INPC 的替代方法。
引言
文章的第一部分展示了一种简单的方法,通过使用 C# 4.0 中的动态扩展来使 MVVM 模式中的 ViewModel 摆脱基础设施代码。文章的第二部分提出了一些关于我们是否需要首先消除 INPC 的问题,并建议了一种编写 ViewModel 类时略有不同的方法。
背景
WPF/Silverlight 程序员都知道,数据绑定是 WPF 或 Silverlight 中 UI 的主要驱动力。它的普及催生了 Model-View-ViewModel(MVVM)模式。虽然 MVC 已经存在一段时间了(但在 WinForms 世界中很少见),但 WPF 出色的数据绑定支持使得该模式几乎 100% 地被承认是构建 WPF 和 Silverlight 应用程序 UI 的主要工具。让我提醒大家一下 MVVM 的整个理念(如果您不想再听一遍,可以跳到下一段)。
假设您有一个庞大的领域模型,更靠近您的存储系统(或在其内部),实现了领域的所有规则和逻辑。它很大。对于特定的屏幕或视图,您只需要该领域业务逻辑的一小部分。
为了改善用户体验,您还希望在将命令发送到领域模型之前,在用户附近提供一些验证。您可能有一些仅与 UI 相关的逻辑,例如“在我保存更改时禁用控件”。我们希望这不是 View 的一部分,因为……嗯,因为它会影响测试。
因此,我们实现这个逻辑在一个独立的类中,以提供可读性、易维护性和 View 的单元测试。对于 UI 控件,我们将其收集用户点击和按键的责任留给 View,但与用户交互的逻辑进入一个单独的、简单的、可测试的类,该类模拟 View,因此称为 ViewModel。
有很多 UI 模式可以做到这一点。MVVM 的不同之处在于它使用了 WPF/Silverlight 提供的特定数据绑定机制。由于这些机制不是抽象,而是实现,因此可以说 MVVM 并非真正的架构模式,而是技术模式。而且您可能会说得对。至少它的作者 John Gossman 称其为 WPF 的 MVC(我喜欢缩写!它们听起来如此专业。)。
这里就存在一些问题,或者更确切地说,一些麻烦。
MVVM 的麻烦
由于 MVVM 依赖于特定的技术才能工作,因此它必须以该技术,即 WPF 数据绑定能够理解的方式来实现。这些是:INotifyPropertyChanged
接口实现(简称 INPC);DependencyProperty
;为每个属性提供一个名为 XXX 的 XXXChanged 事件。您还可以实现 ICustomTypeDescriptor
或使用自定义 TypeDescriptorProvider
来处理您的 ViewModel。
在这些方法中,最流行的选择是实现 INotifyPropertyChanged
。尽管与使用其他方法相比,有很多理由选择它,但在我看来,它最大的优势之一是它介于“太啰嗦难以阅读”(DependencyProperty
,每个属性都有 XXXChanged 事件)和“太复杂难以编写”(Type Descriptors)之间。
但它也不是理想的,并且已经付出了很多努力来克服它的缺点。其中一个缺点是通知事件使用字符串与 View 进行通信。在通知方面,这可以很容易地解决,通过使用“静态反射”技术,该技术允许以强类型的方式引发通知。但这对消费者端帮助不大,因为 WPF 数据绑定也不是强类型的。(请注意,这是真正的问题,因为维护 View 的人可能根本不知道您重命名了属性)。为了缓解这个问题,您可以创建一个单元测试来比较 XAML 绑定和 ViewModel 属性。这并不难,但超出了本文主题的范围。
本文讨论的是 INPC 的另一个缺点。它本身并不是一个缺点,而是一个麻烦。实现 INPC 会给您的 ViewModel 带来大量与逻辑本身无关的基础设施代码。写起来很烦人,读起来很分散注意力。虽然许多人认为这不是什么大问题(我也倾向于同意他们),但删除或简化 INPC 实现是 WPF/Silverlight 未来改进中最受欢迎的要求之一。
已经使用了许多方法来减弱这种烦恼。面向方面编程;使用特殊的类来表示属性(该类实现 INPC);代码生成——等等。所有这些方法都起到类似的作用——它们清除了 ViewModel 中的基础设施代码,并由某种外部机制来实现这些基础设施代码。但是,您应该始终记住,通过这样做,您实际上就失去了细粒度通知的能力。我稍后会回到这一点,但现在,让我们假设我们真的很讨厌实现 INPC 并想以某种方式自动化这个过程。现在,废话不多说,这里是另一种让您心爱的 ViewModels 摆脱 INPC 基础设施的方法。
最好的 ViewModel?
典型的“纯粹”ViewModel 会是什么样子?可能是这样的:
public class FindAnswersModel
// No INotifyPorpertyChanged implementation
{
// 1. Calculations
// keeps state for argument 1
public int Argument1 { get; set; }
// keeps state for argument 2
public int Argument2 { get; set; }
// reports calculated value
public int Total { get { return Argument1 + Argument2; } }
// 2. Commands (no ICommand infrastructure)
// command itself - just public method
public void FindAnswer() // command
{
CurrentlyFindingTheAnswer = true;
try
{
FindingAnswer();
_log.Add(DateTime.Now);
SelectedLog = Log.LastOrDefault();
}
finally
{
CurrentlyFindingTheAnswer = false;
}
}
// private method that does real job which
// probably just calls for external service
private void FindingAnswer()
{
// finding the answer could take 7.5 million years
Thread.Sleep(5000);
Argument1 = 42;
Argument2 = 54;
}
// reports if command can be invoked
public bool CanFindAnswer { get { return !CurrentlyFindingTheAnswer; } }
// reports if view model is busy now finding the answer
public bool CurrentlyFindingTheAnswer { get; private set; }
// 3. Collections
// reports how view should see collection of log entries (ordered)
public IEnumerable<DateTime> Log { get { return _log.OrderBy(x => x); } }
// keeps pointer of selected log entry (for selectors)
public DateTime SelectedLog { get; set; }
private IList<DateTime> _log = new List<DateTime>();
}
我将那个类命名为...Model
,但其实不重要。我只是想强调它不实现 INotifyPropertyChanged
或任何其他绑定机制的事实。尽管如此,它在某种意义上是一个 ViewModel,因为它模拟了该特定 View 的功能。它不是领域模型。但它是纯 POCO,没有附加通知。那么,我们如何将这个模型连接到 View 并确保它们开始互相通信呢?本文提出的答案使用了 C# 4.0 的主要功能——动态扩展。
进入 DynamicObject
让我们尝试一种略有不同的方法。如果我们创建一个包装器来包装我们的 Model,该包装器将实现 INPC,并将绑定到 View 而不是我们的 Model 类呢?它可以将属性通过反射更新 Model 类,可以代表底层 Model 引发 PropertyChanged
事件。然而,到目前为止,这都不是一个好方法,因为您无法在运行时模仿底层类的属性结构。(当然,除了通过 AOP。但说实话,C# 对 AOP 范例并不友好。您需要依赖外部库,您的属性应该是虚拟的(这感觉很奇怪),您需要用属性来装饰它们……这实在太多基础设施来消除基础设施了!)
但现在,由于 C# 4.0 中引入了动态扩展,我们可以很容易地做到这一点。我们可以将 DynamicObject
绑定到我们的 View,现在绑定开始通过 TryGetMember
请求这些属性值(按名称),并将值传回动态对象的 TrySetMemeber
。在那里,我们可以拦截它们并将它们传递给我们的实际 Model。事实证明这非常简单。以下是如何实现它。
首先,我们让我们的包装器继承自 DynamicObject
并实现 INotifyPropertyChanged
接口(我称这个包装器为 ViewModel)。我们将我们的 Model 对象传递给这个包装器,并将其保存在其中,以便为实际属性赋值。
public class ViewModel : DynamicObject, INotifyPropertyChanged
{
protected object TheObject { get; set; }
public event PropertyChangedEventHandler PropertyChanged = delegate { };
}
然后我们覆盖两个方法
public override bool TryGetMember(GetMemberBinder binder, out object result)
{
ICommand cmd;
// this is utility method - see the source
PropertyInfo prop = GetProperty(binder.Name);
result = prop.GetValue(TheObject, index: null);
return true;
}
public override bool TrySetMember(SetMemberBinder binder, object value)
{
// this is utility method - see the source
PropertyInfo prop = GetProperty(binder.Name);
// if you use correct ValueConverter for binding in View
// this automatic type change and try catch is not needed
try
{
prop.SetValue(TheObject, Convert.ChangeType(value, prop.PropertyType),
index: null);
}
catch
{
prop.SetValue(TheObject, value: null, index: null);
}
NotifyUI();
return true;
}
正如您所见,Set 方法在任何更改后都会自动调用 UI 的通知,因为任何更改都可能改变其他属性。我在这里停一下。这个 NotifyUI()
方法可以实现某种智能策略来通知更改。例如,我们可能不应该通知属性 prop
已更改。我们可以存储属性的原始值,并且只为真正已更改的属性发送通知。但在大多数情况下,只为所有属性发送通知就足够了。它在 WinForms 中有效,而 WPF 在处理真正更改方面更智能。性能如何?嗯,在大多数情况下足够了。在本文提供的示例中,您可以看到 100 个属性每 100 毫秒更新一次而没有任何延迟。当然,如果您有 10000 个属性,您可能不应该这样做。但可能,您也应该首先重新考虑您在做什么。
我们真正应该考虑的是避免循环通知。由于通知是自动发送的,这确实可能发生。这就是为什么我通过维护一个当前被通知属性的列表来做一个简单的保护。这是代码
HashSet<PropertyInfo> beingNotified = new HashSet<PropertyInfo>();
protected void NotifyUI()
{
foreach (var prop in TheObject.GetProperties().Where(
x => false == beingNotified.Contains(x)))
{
beingNotified.Add(prop);
PropertyChanged(this, new PropertyChangedEventArgs(prop.Name));
}
beingNotified.Clear();
}
当然,这不是真实代码。真实代码会进行一些反射缓存,以避免查找 PropertyInfo
的巨大开销。您可以在附加的示例中看到这一点。
但是,如果我们想从 Model 内部通知更改呢?从我之前所说的,您可以看到所有通知都是由用户的操作引起的。虽然对于 MVVM 来说,这几乎是所有情况的 90%,但有时您确实需要以不同的方式进行。例如,通过更改时间属性来向用户显示她在 View 中花费了多少时间。显而易见的答案是,您可以将通知发送给这个包装器。为了实现这一点,ViewModel 类维护一个对其所有已创建实例的弱引用列表。因此,您可以从 Model 内部请求通知,如下所示:
DispatcherTimer timer = new DispatcherTimer();
timer.Interval = TimeSpan.FromSeconds(1);
timer.Tick += (s, e) =>
{
Seconds++;
ViewModel.UpdateFor(this);
};
timer.Start();
这可能就是这样,并且效果很好。但是等等,命令呢?让我稍微跑题一下,告诉您,我个人认为整个 ICommand
的故事与 MVVM 并不太相符。命令是在 MVVM 之前发明的,这就是为什么您在使用 MVVM 时要做的第一件事就是实现自己的 Command 类。或者,您将 DelegateCommand
实现引入您的代码。但是让我们问问自己;点击按钮或菜单是调用 ViewModel 中方法的唯一允许方式吗?例如,在某些区域的画布上右键单击怎么办?或者在文本框中按 Enter 键?正确的方法是将控件事件映射到 ViewModel 中的方法。幸运的是,它也可以在当前框架内完成(感谢 John Gossman 的另一个术语——附加行为),但这现在超出了本文的范围。
现在,让我们使用命令。我们可以问自己,绑定到命令意味着什么?实际上,它不比绑定到任何其他属性更意味着什么。换句话说,当 Button
将其 Command
属性绑定到 Path="FindAnswer"
时,它只是要求我们的动态类的相同 TryGetMember
来提供类型为 ICommand
、名称为 FindAnswer
的属性。因此,我们可以为底层模型的每个公共方法创建命令,并在请求时返回正确的命令。这样 Button
就会非常满意。这是对 TryGetMember
实现的修改,以支持命令:
public override bool TryGetMember(GetMemberBinder binder, out object result)
{
ICommand cmd;
if (_commands.TryGetValue(binder.Name, out cmd))
{
result = cmd;
return true;
}
PropertyInfo prop = GetProperty(binder.Name);
result = prop.GetValue(TheObject, index: null);
return true;
}
请查看示例中的实际代码,了解这个 _commands
字典是如何创建和填充的。
在结束之前,我想再提一件事。在设计时,我们可以继续使用原始 Model 作为数据上下文,以查看我们必须绑定哪些属性(但不支持命令,抱歉,它们在原始类中根本不存在)。但在运行时,我们将数据上下文替换为 DynamicObject
包装器,View 将看不到任何区别。唯一的区别是,它将开始愉快地接收通知。
现在,让我稍微回顾一下,问您是否有一些犹豫。您可能有。您可能对失去对发送通知的时机和原因的控制感到有些不安。事实上,在实现完这一切之后,我也感到同样的感受。这把我带到了文章的最后一部分。
我们应该自动化通知吗?
让我们问一个简单的问题——我们首先需要这些通知做什么?例如,您有一个 Description
属性。您可能会将其绑定到 TextBox
或 TextBlock
。您可能在将 ViewModel 附加到 View 的数据上下文之前加载值。(我不确定您是否这样做,但通常一次性替换整个数据上下文比逐个发送属性通知更经济高效)。因此,TextBlock
可能会被覆盖,因为它在分配数据上下文而无需任何通知时从 Description
加载值。但即使您对 ViewModel 的使用方式不同,也无关紧要(稍后会详细介绍)。
重要的是,更新其源的 TextBox
也被覆盖了。TextBox
知道它绑定到了什么。在没有任何 INotifyPropertyChanged
的情况下更新 ViewModel 属性应该完全没问题。您猜怎么着?事实正是如此。此外,WPF 还采取了额外的步骤,更新了也绑定到该属性的其他控件。这是一个鲜为人知的事实,但您不需要实现 INotifyPropertyChanged
即可进行简单的双向绑定(关于 Binding 的另一个鲜为人知的事实是,它也能很好地处理跨线程引发 PorpertyChanged
事件)。总之,要进行测试,请创建一个简单的类,其中包含一个字符串属性,将其绑定到一个 TextBox
,然后在其旁边绑定到一个 TextBlock
。您会发现,即使没有实现 INotifyPropertyChanged
,绑定也能正常工作。TextBox
中的更改会反馈到属性值,并在 TextBlock
中得到反映。
那么我们为什么要实现它呢?顾名思义,我们需要它在 View 控件外部发生更改时向 UI 报告。数据从数据库加载。计算出的属性更改其值。计时器更新花费时间属性。我们启动了一个过程,并希望在它完成时将结果通知 UI。您可能已经猜到我接下来要问什么问题了……
为什么我们在属性本身的 setter 中引发通知?
我知道答案——我们倾向于将行为封装在 setter 中。通知值已更改是一个很好的封装对象。除了在 ViewModel 类的情况下,这毫无意义。ViewModel 的目的是绑定到控件。控件会更改这些 setter。它们在没有通知的情况下就知道这一点。从我们看到的情况来看,这两个类将像 ViewModel 一样完成绝对相同的工作。
public class CalculatorViewModel : INotifyPorpertyChanged
{
int description;
public int Description
{
get { return description; }
set
{
description = value;
RaiseNotifyPropertyChanged("Description");
}
}
.....
}
public class CalculatorViewModel
{
public int Description { get; set; }
}
让我再说一遍并解释得更清楚一点。ViewModel 的整个目的是要绑定到 View。此类中的公共 setter 仅用于接受来自 View 的更改(公共 getter 则不是)。这些更改由控件执行。您无需再次通知 View 您刚刚所做的更改。
换句话说,ViewModel 中的许多属性如果使用自动属性来实现,将完全没问题。
只有影响计算的属性才需要通知 UI。而不是关于其自身更改的通知。它们应该通知 UI 计算出的属性的结果已更改。方法也一样。如果我们有一个方法 Load
,它通过从数据库加载所有属性来更改它们的值——这是通知 UI 这些属性已更改的绝佳时机。而不是在属性 setter 本身。
理解了这一切之后,我们可以这样编写我们的 ViewModel:
public class ProductViewModel : INotifyPorpertyChanged
{
public string Name { get; set; }
public string Description { get; set; }
public DateTime Expired { get; set; }
decimal price;
public decimal Price
{
get { return price; }
set
{
if (value != price)
{
price = value;
NotifyUI("Tax");
NotifyUI("Total");
}
}
}
public decimal Tax { get { return 0.07m * price; } }
public decimal Total { get { return price + tax; } }
public void Clear() // public verb, command
{
Name = ""; NotifyUI("Name");
Description = ""; NotifyUI("Desription");
Expired = DateTime.MaxValue; NotifyUI("Expired");
Price = 0.0m;
}
}
请注意,借助静态反射,设置新值和引发通知可以合并到一个辅助方法中,该方法看起来像这样:SetAndNotify(() => this.Name, "");
。它为我们提供了改进的可读性和强类型通知。
现在,如果您查看上面的 ViewModel,它的通知看起来不再是杂乱无章的了,对吧?事实上,它们看起来非常重要的逻辑,因为它们精确地向 UI 报告了什么已更改以及何时更改。
但不仅仅是这些。
- 它们告诉我们 UI 何时应该更新。
- 它们告诉我们应该更新哪个属性。
- 它们显示了属性之间存在的依赖关系。
- 它们提示我们应该测试什么,而不仅仅是所有 setter 和 getter(我们应该只测试
price set
和Load
;并且不仅要测试值,还要测试引发的通知)。
ViewModel 的每个部分现在都变得清晰,并占据了正确的位置。而且,顺便说一句,这种清晰度是我们通过任何自动实现所失去的,因为自动实现必须遵循某些隐式规则。通常是——无论如何都要通知 View。从某种意义上说,通过自动化,我们只是掩盖了属性更改在自身 setter 中通知的初始方法。
关于 ICommand
与方法的选择仍然存在疑问,正如我上面所写的,我宁愿根本不使用 Commands。但目前,使用 Commands 是完全可以的,它们比在每个 setter 上引发 PropertyChanged
要简洁得多。
public class ProductViewModel : INotifyPorpertyChanged
{
public ICommand ClearCommad { get; private set; }
public ProductViewModel()
{
ClearCommand = new SomeSortOfDelegateCommand(() => Clear());
}
...
}
这就是我想结束的地方。
结论
可能最后一部分目前有点争议。或者看起来微不足道。它确实很小。我将这种技术命名为“Clean INPC”,但另一方面,也许给它命名太多了。
它不是一个框架。它甚至不是一个辅助类。它只是一个建议以略有不同的方式编写 ViewModel。
但是,在我看来,这种方法反映了 MVVM 的本质和目的,并且它明确而清晰地做到了这一点。最终,它创建了更好、更清洁的 ViewModel。
但是,如果您仍然讨厌实现 INotifyPropertyChanged
,请尝试“Dynamic INPC”示例。在 C# 4.0 中通过 dynamic
使用反射的能力确实很酷。
非常感谢。
历史
- 2011/03/26:初稿。
- 2011/03/27:添加项目以说明更新 100 个属性。