PropertyPathObserver - 一种创建绑定的新方式 - 可移植






4.93/5 (29投票s)
本文介绍了 PropertyPathObserver 类,它允许更快的绑定等等。
引言
过去,我曾介绍过 MathBinding 标记扩展。我创建它是因为我觉得 WPF 的 Binding
在我需要在属性之上进行一些简单的数学运算时,过于复杂了。
然后,我尝试为通用应用创建相同的 MathBinding
,但我失败了。我不知道是我没有找到正确的文档,还是在开发通用应用时根本无法创建标记扩展。因此,我开始尝试不同的绑定方法。特别是,我尝试在 C# 中进行绑定,而不是在 XAML 中进行。老实说,我真的认为大多数绑定应该放在代码隐藏中。了解要访问哪些属性,尤其是在有任何计算、数据类型转换等时,应该在代码中进行。
好了,结果比我最初预期的要好得多。我创建的 PropertyPathObserver
不仅限于绑定或 WPF。你可以在控制台应用程序中使用它来观察属性变化(在深度路径中)并执行任何你想要的代码,例如 Console.WriteLine()
或更新其他属性(在这种情况下,它就变成了绑定)。更好的是,在我的测试中,它的速度比 WPF 的 Binding 快 4 到 5 倍,并且如果配置得当,它还可以用于以其他方式通知属性更改的数据源(例如,每个属性都有一个事件)。
看看这个示例结果
它展示了在 WPF 的 Binding
和 PropertyPathObserver
之间进行 10 万次更新的性能比较,使用 INotifyPropertyChanged
和使用每个属性都有一个事件的对象。正如你所见,WPF 的 Binding
花了将近 4 秒才完成它的工作。PropertyPathObserver
花了将近 0.7 秒处理完全相同类型的对象,而使用优化对象(WPF 的 Binding
根本无法处理)则花了 0.34 秒。
Using the Code
PropertyPathObserver.Observe
(
() => localVariableOrInstanceField.FirstProperty.SecondProperty.Text,
(value) => textBlock.Text = value
);
PropertyPathObserver.Observe
方法完成了所有的魔法。它接收以下参数
- propertyPathExpression:这是一个描述我们想要访问的属性的表达式。重要的是要注意,它必须遵循一个非常严格的规则。它必须以局部变量或实例字段开头,然后必须至少访问一个属性。当然,你可以继续深度访问另一个属性,然后是另一个属性,依此类推;
- valueChanged:这是一个
Action<TValue>
。TValue
是上一个参数中我们访问的最后一个属性的类型。这里我们可以使用与表达式相同的 lambda 符号,但这实际上是代码。这里没有解析任何东西。它是将直接执行的代码。如果你愿意,你可以在这里写Console.WriteLine(value);
,你可以调用Trim()
,甚至在将值设置到另一个属性之前进行任何类型的预解析; - defaultValue:这是一个可选参数。它对值类型更有用。它只是告诉当属性路径中的任何地方是
null
时使用什么值。
正如你所见,这看起来并不难。
也许对于直接绑定来说,它看起来太冗长了,但是一旦你需要进行任何类型的数据操作,你就会发现它变得好多了。无需处理转换器类、静态资源等。
此外,我们必须记住它更快。如果我们处理的对象经常变化,使用它已经是一个优势。
重载
Observe
方法是重载的。我认为我刚才介绍的重载是最有利于重构的,因为表达式会通过重构工具进行更改,而大多数字符串则不会。然而,其他两个重载是基于字符串的。一个由点分隔的单个字符串,或一个字符串数组,每个字符串包含一个属性名称。
我们可以通过以下任何一种方式达到与上一个代码块相同效果
PropertyPathObserver.Observe<string>
(
localVariableOrInstanceField, "FirstProperty.SecondProperty.Text",
(value) => textBlock.Text = value
);
PropertyPathObserver.Observe<string>
(
localVariableOrInstanceField,
new string[] {"FirstProperty", "SecondProperty", "Text"},
(value) => textBlock.Text = value
);
对我来说,最令人讨厌的部分是需要将属性类型作为泛型参数(那个 <string>
)。
实际上,你可以使用基类作为泛型参数(在这种情况下,只有 object
是有效的),但你不能提供不同的类型。不会进行任何转换。如果给出了错误的类型,它将在运行时抛出异常。
优点
考虑到实际创建绑定的时间,最后一种选择是最快的,但我真的不认为你会注意到差异。
正如我所说,我认为使用 lambda 表达式的版本更利于重构(如果任何属性名称错误,你会在编译时收到错误)。然而,接收路径作为字符串的版本在某种程度上更具动态性。
例如,如果 localVariableOrInstanceField
(我的意思是,源对象)被强制转换为 object
,你就无法使用 lambda 表达式。但是,它与接收 string
路径的重载配合得很好。在进行绑定时,将使用实际的运行时对象类型。
但这只是支持的“动态性”。由于第一个对象的类型被发现,整个路径将基于属性的静态类型。也就是说,如果任何属性返回未键入的 object
,那么即使实际属性值是可观察类型并且有很多属性,绑定也看不到其中的任何属性。
也许我将来会创建一个更动态的版本,但如果我这样做,它肯定会是另一个重载,因为真正动态地发现会影响性能。
停止观察
这可能看起来不寻常,但我们没有 StopObserving
或类似的方法。原因是匹配参数会很困难,因为两个相同的 lambda 表达式可能会生成不同的委托,因此 remove/unsubscribe 将不起作用。
所以,为了简化事情,Observe
方法返回一个委托。如果你不打算停止观察对象(如果对象被收集,它将自然停止观察),你可以自由地忽略它,但如果你想停止观察变化,只需调用该委托即可。
我曾考虑让它返回一个 Action
,但我决定创建一个新的委托类型,名为 UnsubscribeObserverAction
,只是为了让事情更清楚。
双向绑定?
由于这实际上不是一个绑定,所以默认情况下没有双向绑定。但是,你可以观察对象 a.Text
并更新对象 b.Text
,也可以观察 b.Text
并更新 a.Text
。考虑到对象不会一直改变它们接收到的值,当 PropertyPathObserver
看到值相同时,它不会生成通知(并且属性本身的良好实现也不会这样做)。
然而,默认情况下,PropertyPathObserver
无法观察 DependencyProperties
的变化。它只查找 INotifyPropertyChanged
。在示例应用程序中,我为 TextBox.Text
提供了一个解决方案,但目前仅此而已。我真的很希望 WPF 的依赖属性能够有一种非常简单的方式来获取更改通知(就像通用应用那样)。但那不可用,并且让它工作的解决方法目前不是我的重点(我个人待办事项列表上的另一件事)。
异常
PropertyPathObserver
完全不处理异常。因此,如果发生异常,应用程序很可能会崩溃。
你必须确保只使用不抛出异常的属性,并且在执行属性更改时的操作时,要么捕获异常,要么也保证不会抛出异常。
作为额外细节,如果路径中的某个对象无法观察,它也会抛出异常。如果处于相同情况,WPF 绑定只会读取一次值,而永远不会获得更改通知。如果你想要该行为,你需要注册一个不做任何事情但“谎称”它已完成订阅的 SubscribingHandler(或者你可以直接跳过观察属性并直接读取其值)。
PropertyObserver.RegisterSubscribingHandler
所以,你应该使用这个方法来注册你自己的处理程序来处理替代属性通知。
实际上,PropertyObserver
是 PropertyPathObserver
用于获取路径中每个单独属性通知的类。对于 INotifyPropertyChanged
实例,它会管理只注册一个事件处理程序到 PropertyChanged
事件,无论你是只想观察一个属性,还是想观察多个属性(甚至对同一属性创建多个观察者)。
每次调用 PropertyObserver.Observe
方法时,都会调用 SubscribingHandlers
。它们应该通过分析实例和属性来验证它们是否可以进行订阅。如果它们不能,它们不应抛出异常,它们应该只返回 null
。如果处理程序可以进行订阅,那么它应该这样做并返回一个 UnsubscribeObserverAction
。
例如,用于支持 Text
属性的 SubscribingHandler
是这个
public UnsubscribeObserverAction _HandlerForTextBox_Text
(object instance, PropertyInfo property, Action action)
{
var textBox = instance as TextBoxBase;
if (textBox == null)
return null;
if (property.Name != "Text")
return null;
TextChangedEventHandler handler = (sender, args) => action();
textBox.TextChanged += handler;
return () => textBox.TextChanged -= handler;
}
正如你所见,我立即通过执行 textBox.TextChanged += handler;
注册到 TextChanged
事件,并返回一个在调用时移除处理程序的动作。记住 () =>
意味着我正在创建一个 lambda 表达式,而不是立即执行代码。
模拟 WPF 对不可观察对象的行为
如果你想模拟 WPF 的行为,即只获取一次属性值然后不再获取,而不是为不可观察对象抛出异常,你可以使用以下代码
public UnsubscribeObserverAction _HandlerForTextBox_Text
(object instance, PropertyInfo property, Action action)
{
if (instance is INotifyPropertyChanged)
return null;
// We didn't do anything, but this result will "lie" that we did.
return () => {};
}
线程安全
并行观察不同对象是线程安全的。但是当向同一个对象注册许多观察者时,嗯,它不是线程安全的。我并不真正期望它会被多个线程完成,但如果你这样做了,你必须确保线程安全。
注册 SubscribingHandler 也不是线程安全的。这真的只应在初始化应用程序时发生,所以也不应该成为问题。(我知道,我正在用这段代码违背我的一些原则……也许将来我会改变它)。
从值类型到可空类型
想象一下,我正在访问这个完整的路径:sourceObject.Other.Value
。
那个 Value
属性是一个值类型(例如,double
)。
默认情况下,如果你这样做
PropertyPathObserver.Observe
(
() => sourceObject.Other.Value,
(value) => Console.WriteLine(value)
);
Observe
方法将访问一个非可空 double
。如果 source.Other
的结果是 null
,valueChanged
操作将被调用,并使用默认值,因为没有提供默认值,所以它是零。它不会是 null
。
然而,人们自然会认为,当路径中的任何项是 null
时,结果应该是 null
。所以,在这种情况下,我们需要一个可空 double(类型 double?
或 Nullable<double>
)。
那么,这可能吗?
嗯……当使用接收路径作为 string
的重载时,我们总是指定结果的类型。幸运的是,即使属性是 double
类型,你也可以说你希望结果是 double?
类型,它就能正常工作。这不被认为是类型转换,因为 double?
可以自然地分配 double
类型的对象。
对于接收 Expression
的重载,在代码/文章的第一个版本中是不可能的。所以,你可能需要重新下载代码。现在有两种方法可以实现从非可空到可空的转换。
-
在
Observe
调用中将泛型参数指定为属性类型的可空对应项。也就是说,像这样调用PropertyPathObserver.Observe<double?> ( () => sourceObject.Other.Value, (value) => Console.WriteLine(value) );
即使我们不手动执行,这实际上也会将表达式更改为
() => (double?)sourceObject.Other.Value
这就是最初不起作用的原因。解析器不支持任何类型的强制类型转换/转换。现在它接受这种类型的强制类型转换或转换为
object
,但不要尝试任何其他类型的强制类型转换,因为它将不起作用。实际上并不执行强制类型转换。PropertyPathObserver
实际上不运行强制类型转换,它只分析表达式。 - 调用
ObserveAsNullable
方法而不是调用Observe
。这是一个我新创建的方法,其唯一目的是使从非可空到可空的支持更容易。这个方法比前一个方法的优点是你不需要指定目标类型,它由编译器推断出来。所以,你不会写错类型的机会,如果属性从一种类型更改为另一种类型,你的代码将不会尝试强制执行无效的转换。
工作原理
那么,PropertyPathObserver
是如何工作的?
我会尽量简要地解释,但不会深入其具体实现。原因很简单:代码使用了大量的编译表达式来实现良好的性能。然而,即使是简单的任务,表达式也非常令人困惑且难以理解。
所以,让我们假设我们有一个 baseObject
,我们想观察属性“A.B.C.Text”。
观察者将创建一个辅助对象,其中包括用于通知更改的委托、最后读取的值以及包含路径中每个步骤信息的数组。数组中的每个项包括诸如源对象、一个用于快速读取适当属性的委托以及一个用于取消更改通知的委托之类的内容。
所以,对于 A.B.C.Text
,我们将有一个包含四个项目的数组。
数组第一个元素的源将是 baseObject
。我们将立即在 baseObject
上注册对其 A
属性的更改,并将取消订阅委托存储在数组的第一个项目中。
然后,我们将进行第一次读取。如果在读取 A
时得到一个非空结果,我们将该结果存储在数组的第二个项目中,并且还将注册对其 B
属性的更改通知(并将取消订阅委托存储在数组的第二个项目中)。我们实际上会继续到 B
和 C
和 Text
。
在从 Text
读取值之后,嗯,我们将拥有第一个观察所需的值,并将其存储在辅助对象中。如果路径中间的任何值是 null
,只意味着我们需要用 null
调用通知委托。
请注意,数组中的对象是源对象,来自 A、B 和 C 的结果。Text
属性不存储在数组中,而是辅助对象的一部分。
第一次读取完成后,我们将正确填充数组。例如,如果我们更改 baseObject.A.B
的值,通知将以正确的索引(1)发送。所以,我们不会浪费时间读取 base 对象来获取 A
。我们将直接在已存储的 A
的结果之上读取 B
属性。如果结果值相同(有些对象可能会通知一个实际上没有发生的更改),我们只是返回。如果确实发生了更改,那么我们将执行 B 的取消订阅委托(它将取消订阅 B 的前一个值),如果新值不为 null
,我们将订阅其更改通知。如果发生这种情况,我们还将读取 C 的值。如果不是,我们仍然保持 null
并继续进行下一步。这意味着如果我们更改了 C 或 C 变成 null
,我们将最终也取消对 C 的通知注册。
如果我们遇到了 B 改变但新 B 的 C 值与旧 C 值相同的奇怪情况,那么我们只会取消注册之前的 B / 注册新的 B。C 将没有任何操作,因此方法将返回而不生成更改通知。
嗯……我想这就是全部。它并不真正简单,也不真正困难,但代码实际上让它看起来比实际的要难得多。
好奇心
在进行 WPF 的性能测试时,我发现了一个非常有趣的事情
红色矩形表示运行 WPF 绑定测试时。黄色表示 PropertyPathObserver,使用 INotifyPropertyChanged,绿色表示优化对象。
令我惊讶的是处理 WPF 绑定时发生的垃圾回收次数。我知道我更改属性值的频率很高,这不是一个常见场景,但我从未想到它会导致如此多的垃圾回收。
我多次运行测试,PropertyPathObserver 从未引起过此类问题,而 WPF 却一直在这样做。
所以,我相信性能上的差异是因为 WPF 产生了过多的垃圾。我只是不知道它为什么会这样做。
版本历史
- 第二次更新(版本 3):2016 年 11 月 8 日。在 Observe 的字符串重载中,将 DeclaringType 替换为 PropertyType。在示例中一切正常,因为两种类型相同。此外,优化了委托缓存,使其仅关注声明类型,避免内存中重复的委托缓存,从而在反射类型与声明类型不同时(对委托无关)节省了速度和内存;
- 第一次更新:2016 年 11 月 5 日。添加了“从值类型到可空类型”主题;
- 初始版本:2016 年 11 月 3 日。