通过表达式树自动数据绑定






4.11/5 (4投票s)
尝试使用 C# 表达式树从代码创建数据绑定的用户界面。
引言
我在尝试有效使用 Window Presentation Foundation (WPF) 框架和 Model-View-ViewModel (MVVM) 原则时,得出了本文的内容。在此过程中,我发现WPF框架的许多方面都是通过运行时绑定实现的,这牺牲了本可以实现的编译时安全性和静态检查。一个普遍的例子就是数据绑定。也就是说,像下面这样的 XAML 结构
<TextBlock Text="{Binding Path=SomeTextProperty}"/>
这种结构的好处是,它提供了一种相对简洁的方式来表达某个控件的属性应该绑定到程序员的模型(或“视图模型”)中的另一个属性。然而,其缺点是,“ModelProperty”这个字符串直到运行时才会被验证是否与目标模型类匹配。所以,如果我把 ModelProperty 错打成了“SchmodelProperty”,我只有在操作包含该绑定的特定UI部分时才会发现这个问题。我一向热衷于最大化利用计算机告诉我我犯了人为错误(即愚蠢的错误)的能力,于是我开始尝试各种方法,以在不牺牲静态检查的情况下实现同样的表达能力。本文的其余部分将描述一种可能的方法,并最终能够实现如下声明:
new TextBlock() { Text = model.SomeTextProperty }
从表面上看,这只是一个表达式,它返回一个新创建的 TextBlock 对象,其 Text 属性被静态设置为模型定义的某个属性的值。然而,通过使用 C# 的一个名为表达式树 (Expression Trees) 的特性,可以将这个表达式转换成另一个表达式,从而提供与上述 XAML 代码段类似的功能。
背景
本文的大部分内容讨论的是转换表达式树,但也会涉及一些语言集成查询 (LINQ)。这两个主题都相对深入,可能需要读者对它们有一定的了解。当然,对 WPF 中的 MVVM 有一个粗略的了解也会有所帮助,但并非必需。在整篇文章中,术语“模型 (model)”和“视图模型 (view model)”会交替使用,因为在这个上下文中,它们的区别并不重要。
更改通知
在 WPF 中,将模型的变化传播到 UI 的标准方法是使用 IPropertyNotifyChanged 接口。该接口要求在模型的任何属性发生变化时触发一个事件,并且事件数据中包含属性的名称(一个字符串)。这本身没什么问题,但它也带来了另一个问题,即编译时已知的信息被丢弃,留待运行时验证。一种常见的模式是开发一个基类,该基类实现了一个 `OnPropertyChanged(string propertyName)` 方法,派生类可以调用该方法来引发事件。不幸的是,propertyName 参数必须以字符串形式指定,这很容易被程序员输错。至少有几种不同的解决方法:
尽管这些都是处理这个问题的不错方法,我还是决定尝试另一种途径,定义一个泛型 `Property
public class Property<T>
{
public event EventHandler<ValueChangedEventArgs<T>> ValueChanged;
public static implicit operator T(Property<T> p)
{
return p.val;
}
public Property(T value)
{
val = value;
}
public Property()
{
val = default(T);
}
private T val;
public T Value
{
get { return val; }
set
{
if (!EqualityComparer<T>.Default.Equals(value, val))
{
val = value;
ValueChanged.Raise(this, new ValueChangedEventArgs<T>(val));
}
}
}
}
隐式运算符 T 的重载是可选的,但它允许 `Property
textBox.Text = model.Text
在模型类中使用 `Property
public class Model
{
// Assign an initial value to the Property<string>
public Property<string> Text = new Property<string>("initial value");
public void Method()
{
// Assign the Property<string>'s value, which causes the ValueChanged
// event to be fired.
Text.Value = text;
}
}
使用表达式树
在直接深入探讨用于数据绑定的特定表达式树转换之前,我想先讨论一些代码中使用的抽象构建块。这些结构可以更广泛地应用于不同类型的查询或表达式树转换。为了构建它们,我使用了一些标准的 LINQ 运算符,这样我就可以利用 C# 中可用的查询语法。代码的这些部分主要围绕 `System.Linq.Expressions` 命名空间中的 `ExpressionVisitor` 类构建。这个类提供了遍历和转换表达式树所需的所有逻辑,甚至在不了解或不理解可能存在的各种表达式节点类型的情况下也能工作。不幸的是,直接使用 `ExpressionVisitor` 需要一个派生类的特殊实现。本文介绍的 LINQ 运算符允许在没有自定义类实现的情况下,对表达式树执行多种不同类型的操作。
用于树形结构的 LINQ 运算符
我编写 LINQ 运算符时通常采取的方法是,尝试确定一个泛型接口 `ISomeInterface
- 自顶向下访问
- 自底向上访问
两者唯一的区别在于访问树节点的顺序,即分别是父节点先于子节点,反之亦然。自顶向下访问的好处是,如果某些大型子树与转换无关,可以跳过它们。在极端情况下,如果根节点有某种特性意味着不需要检查其他节点,你就可以节省大量的处理时间。另一方面,对于自底向上的处理,要跳过某个特定子树就太晚了,因为一旦你到达它,你已经访问了它的所有子节点。然而,对于某些类型的转换,自底向上访问也有其优势。如果你用一个包含原始节点作为子节点的新节点替换父节点,你很容易创建出无限循环的代码,因为在扩展树之后你会重新访问原始父节点。当以自底向上的顺序访问节点时,可以完全避免这种情况。要理解这一点,可以看一个非常简单的例子:从这个名为“XZY”的树开始
X / \ Y Z
然后从上到下访问,用另一个“XZY”树替换Z。在对“Z”节点进行最初几次访问后,会产生以下树:
X X X / \ / \ / \ Y X Y X Y X / \ / \ / \ Y Z Y X Y X / \ / \ Y Z Y X / \ Y Z
你可以看到这个模式会无限地持续下去。如果我们改为以自底向上的方式进行,我们只进行一次替换就停在了这棵树上:
X / \ Y X / \ Y Z
上面,我们使用了“二叉”树(节点只有两个子节点),但请注意,C# 中的表达式树具有多种不同类型的节点。一些节点有固定数量的子节点(例如,二元表达式),而另一些则有可变数量的节点(例如,方法调用表达式中的参数)。
与 IEnumerable 的比较
由于内置的 LINQ 运算符与 `IEnumerable
LINQ 运算符集
我差不多准备好介绍代码中实现的、用于转换表达式树的运算符集了。在列出运算符本身之前,我们必须定义捕获访问语义的通用接口。我们称之为 `IVisitable
public interface IVisitable<T>
{
Expression Visit(Func<T, Expression> accept);
}
也就是说,`IVisitable
接下来,我们需要一些函数,将 `Expression` 对象转换为实现 `IVisitable
public static IVisitable<Expression> TopDown(this Expression e);
public static IVisitable<Expression> BottomUp(this Expression e);
这两个函数都返回一个 `IVisitable
IVisitable<TResult> Select<T, TResult>(this IVisitable<T> source, Func<T, TResult> selector)
返回一个新的 `IVisitable
IVisitable<T> Where<T>(this IVisitable<T> source, Func<T, bool> predicate)
返回一个新的 `IVisitable
IVisitable<T> OfType<T>(this IVisitable<Expression> source)
返回一个新的 `IVisitable
IVisitable<Expression> TakeWhile(this Expression source, Func<Expression, bool> predicate)
与“where”运算符非常相似,但这个运算符*确实*会跳过不匹配谓词的节点的后代。如果你想跳过整个子树,这个运算符很有用。
Expression AsExpression<T>(this IVisitable<T> visitable)
返回底层的 Expression 对象,可能已被转换。
List<T> ToList<T>(this IVisitable<T> visitable)
通过访问底层的表达式树,并按访问顺序将访问过的节点放入列表中,返回一个 T 类型的对象列表。
数据绑定转换
我们现在准备开始转换表达式树了!我认为在这一点上有很多不同的方向和设计选择,所以鼓励读者在消化本节内容时进行批判性思考。我正在使用 WPF 控件,所以我基于这一点做了一些假设。正如引言中提到的,转换的基本思想是获取这种形式的表达式:
new SomeWpfControl() {
// ... member initialization expressions ...
}
然后将其转换为这种形式的新表达式(伪代码):
{
var control = new SomeWpfControl() { ... };
foreach (memberInitExpression in newExpression)
{
foreach (modelProperty in memberInitExpression)
{
modelProperty.ValueChanged += (sender, args) => evaluate(memberInitExpression)
}
}
return control;
}
也就是说,为作为控件属性初始化器右侧部分的每个模型属性安装一个 ValueChanged 事件处理器。每当这些模型属性中的任何一个更新时,初始化器表达式就会被重新求值,以更新控件的属性。在我们看代码中最终的转换之前,我将在这里引入几个复杂点。它们都是有价值的特性,但确实给实现增加了一些复杂性。
有限的重求值范围
这是一种旨在更好地处理成员初始化表达式本身包含 WPF 控件的情况的优化。一个例子是使用自定义按钮时,如下所示:
new Button() {
Enabled = model.Enabled,
Content = new TextBlock() {
Text = model.Text
}
}
这里的想法是,当 model.Text 改变时,没有必要重新创建 TextBlock,尽管它最终是用于赋给 Button.Content 属性的表达式的一部分。这对于任意表达式来说不应该普遍成立,但对于 WPF 控件,或许可以合理地假设,当子控件更新时所需的任何变更处理已经由控件自己处理了。这样做的最终结果是,在赋值表达式中寻找模型依赖时,任何用于 WPF 控件的新表达式都会被跳过。
在源代码中,有一个方法:
Dependencies<TSkip>(MemberBinding binding, Type dependType)
实现了这个想法。它通过利用 `TakeWhile` 运算符来跳过 `TSkip` 类型的嵌入式 new 表达式。在我们的例子中,`TSkip` 是 `UIElement`,而 `dependType` 是 `typeof(Property<>)`。
弱事件模式
当事件订阅者的生命周期远短于其关联的发布者时,通常会使用这种设计模式。如果在事件处理器已注册的情况下重新创建 WPF 控件,可能会发生一种内存泄漏。因为处理器持有对控件的引用,并且(在使用标准事件注册机制,即“+=”运算符时)模型持有对处理器的引用,所以在模型的生命周期内,控件无法被垃圾回收。当使用弱事件模式时(如 WPF 所做的那样),一个代理类会向发布者注册处理器,并维护自己的一张对订阅者处理器的弱引用表,这样就不会阻止订阅者被垃圾回收。
.NET 4.5 类库提供了一个 `WeakEventManager(TSource, TEventArgs)` 类供我们使用。然而,因为我们使用 lambda 闭包作为处理器,我们必须维护自己的一张对事件处理器的强引用表。否则,处理器将立即有资格被垃圾回收。为了实现这一点,我们创建了一个名为 `BoundContent` 的类,它继承自 `System.Windows.Controls.ContentControl`。这个特殊的内容控件会转换一个表达式,编译它,并保留对编译后代码和事件处理器表的引用。
转换过程
下面展示了用于插入事件处理器的表达式转换的大部分代码。虽然有点长,但我认为它展示了上面定义的 LINQ 运算符的实用性。
public static BoundExpression<TResult> AddBindings<TBase, TResult>(this Expression<Func<TResult>> expr)
{
// Generate a visitable that visits only MemberInitExpressions
// for type T, i.e., expressions of the form "new T(){ ... }".
// It is very important to use BottomUp() so that nodes can be
// replaced without visiting the new nodes.
var visitable =
from e in expr.BottomUp()
where e.IsMemberInit<TBase>()
select e as MemberInitExpression;
// This list is used to store references to the handlers that are
// created when the expression is evaluated.
var table = new BindingTable();
int handlerIndex = 0;
// Transform each MemberInitExpression into a new expression that,
// when evaulated, adds ValueChanged handlers which update the
// object created by the new expression.
var modifiedExpression = visitable.Visit(init =>
{
// Create a variable that will store the constructed object.
var newObjectVar = Expression.Variable(init.NewExpression.Type);
// For each member binding (e.g., assignment expression
// within the braces), enumerate all the sub-expressions that
// are of a Property<T> type. For each of the Property<T>
// expressions, create a lambda expression that will serve as
// a ValueChanged event handler.
var handlerRegistrations =
from binding in init.Bindings
from depend in binding.Dependencies<TBase>(typeof(Property<>))
// (object sender, ValueChangedEventArgs<T>) formal parameters
let sourceParameter = Expression.Parameter(typeof(object))
let valueType = typeof(ValueChangedEventArgs<>)
.MakeGenericType(depend.Type.GenericTypeArguments[0])
let valueParameter = Expression.Parameter(valueType)
// (sender, args) => ObjectProperty = expression;
let lambda = Expression.Lambda(
typeof(EventHandler<>).MakeGenericType(valueType),
Expression.Block(
Expression.Assign(
Expression.Property(newObjectVar, binding.Member.Name),
// TODO: This only works for MemberAssignment bindings
((MemberAssignment)binding).Expression)
),
sourceParameter, valueParameter)
// MethodInfo for WeakEventManager<...>.AddHandler method
let addHandler = typeof(System.Windows.WeakEventManager<,>)
.MakeGenericType(depend.Type, valueType)
.GetMethod("AddHandler")
// Create a block expression that
// 1. stores the handler in a variable
// 2. adds it to the handlers list
// 3. registers it using the WeakEventManager
let lambdaVar = Expression.Variable(lambda.Type)
select Expression.Block(
new ParameterExpression[] { lambdaVar },
Expression.Assign(lambdaVar, lambda),
Expression.Call(
Expression.Constant(table),
typeof(BindingTable).GetMethod("AddHandler"),
Expression.Constant(handlerIndex++),
lambdaVar),
Expression.Call(
addHandler,
depend,
Expression.Constant("ValueChanged"),
lambdaVar));
// Finally, convert the original expression into a block expression that:
// 1. Evaluates the original new T(){...} expression and stores the result in a variable.
// 2. Registers handlers on the Property<T> objects
// 3. Returns the new object (just as the original expression did)
var statements = new List<Expression>();
statements.Add(Expression.Assign(newObjectVar, init));
statements.AddRange(handlerRegistrations);
statements.Add(newObjectVar);
return Expression.Block(
new ParameterExpression[] { newObjectVar },
statements);
});
return new BoundExpression<TResult>(
(Expression<Func<TResult>>)modifiedExpression, table);
}
激励示例
为了说明我们取得了什么成果,我使用 BoundControl 控件创建了一个有点傻的 WPF 小窗口。运行时,UI 看起来是这样的:
这个窗口有一些交互功能,用来说明一些基本的数据绑定。就 UI 而言,这相当乏味,但全部都是用 C# 以声明式、类型安全的方式完成的。用于声明 UI(通常在 XAML 中完成)的表达式如下所示:
public MainWindow()
{
InitializeComponent();
var viewModel = new ViewModel()
{
Editable = { Value = false },
Text = { Value = "asdf" }
};
grid.Children.Add(new BoundContent(() =>
new DockPanel().Containing(
new Button() {
Content = viewModel.Editable ?
(object)new Border() {
BorderThickness = new Thickness(5),
BorderBrush = Brushes.Blue,
Child = new TextBlock() {
Text = "Disable edit",
FontStyle = FontStyles.Italic
}
}
:
new TextBlock() {
Text = "Enable edit"
}
}
.DoWith(c => DockPanel.SetDock(c, Dock.Top))
.OnClick(c => viewModel.Toggle()),
new Button() {
Content = "Reset text"
}
.DoWith(c => DockPanel.SetDock(c, Dock.Top))
.OnClick(c => viewModel.ResetText()),
new TextBlock() {
Text = viewModel.Text.Value.Length + " characters"
}
.DoWith(c => DockPanel.SetDock(c, Dock.Top)),
new TextBox() {
Text = viewModel.Text,
IsReadOnly = !viewModel.Editable,
Background = viewModel.Editable ?
Brushes.LightBlue :
Brushes.LightGreen
}
.OnTextChanged((s, args) => viewModel.Text.Set(s.Text))
)));
}
这里用到了一点小技巧,以解决当前 C# 版本不支持在从 lambda 函数生成的 `Expression` 对象中使用赋值或语句块的问题。尽管 C# 在普通 lambda 中支持它们,并且 `Expression` 类本身也支持底层数据结构。也许我们会在未来版本中得到这个功能。这种技巧的一个例子是 `DoWith` 扩展方法。这个方法所做的只是将对象传递给一个 `Action` 委托,然后返回同一个对象。这样就避免了为了调用 `DockPanel.SetDock` 方法而存储一个临时变量。
注意一些属性是如何使用表达式设置的,例如:
Background = viewModel.Editable ?
Brushes.LightBlue :
Brushes.LightGreen
为了在 XAML 中实现同样的效果,你需要类似这样的东西:
<Style TargetType="TextBox">
<Setter Property="Background" Value="LightGreen"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Editable}" Value="True">
<Setter Property="Background" Value="LightBlue"/>
</DataTrigger>
</Style.Triggers>
</Style>
简单多了,不是吗?
结论
这个实验实际上只是浅尝辄止地触及了可能性以及可能遇到的问题的表面。我能看到的这种方法的主要好处是:
- 绑定表达式和属性变更通知是类型安全的。
- 绑定表达式可以写成具有任意复杂度的普通 C# 表达式。
与标准 WPF 绑定相比,我能看到的一些限制如下(我确定还有更多):
- 不支持双向绑定。在本文中,我们假设从 UI 到模型的数据流是通过订阅控件的事件来完成的。另一种可能性是尝试在表达式中编码“绑定方向”,并在表达式转换中适当地处理它。
- 不支持集合变更(即 `INotifyCollectionChanged`)。这是一个很大的问题!在这种情况下应该如何实现呢?
- 控件事件处理器(例如 `Button.Click`)的注册更为冗长。
- 模型或视图模型必须在 UI 创建之前存在,并且不能更改。在普通的 WPF 代码中,你可以在任何时候设置控件的 `DataContext`,绑定会更新以使用新的模型。
就算没有其他作用,我希望本文能激发读者对于使用表达式树可以实现什么的创造力。祝元编程愉快!