65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.11/5 (4投票s)

2016年1月28日

CPOL

14分钟阅读

viewsIcon

16200

downloadIcon

227

尝试使用 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 参数必须以字符串形式指定,这很容易被程序员输错。至少有几种不同的解决方法:

  • 在构建时使用代码生成工具来自动生成“通知型”属性。(示例)
  • 使用 lambda 表达式和扩展方法来减少所需的代码量。(示例)
  • 使用 [CallerMemberName] 特性。(示例)

尽管这些都是处理这个问题的不错方法,我还是决定尝试另一种途径,定义一个泛型 `Property` 类,它可以自动提供变更通知,并且使用简单。代码中的实现与WPF的数据绑定机制不兼容。因为这只是一个实验,我并不介意走这条路,但我预计许多读者可能会对此感到惊讶。整个方法可以被实现为与 `INotifyPropertyChanged` 协同工作的方式,但这会稍微复杂一些。如果需要这种方法,就留给读者作为练习了。否则,`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` 类型直接用于赋值表达式。例如,假设 `model.Text` 是一个 `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`,它支持我想要支持的所有操作的通用语义。我在思考各种 LINQ 运算符的形式以及它们涉及类型 T 上的函数这一事实时,会这样做。最后,我尽量确保运算符的英文含义在上下文中至少接近合理!不过,有时这不太可能,一些滥用也是可以容忍的。就表达式树操作而言,我确定了两个基本操作,它们在我能想到的许多不同特定转换中是共通的:

  • 自顶向下访问
  • 自底向上访问

两者唯一的区别在于访问树节点的顺序,即分别是父节点先于子节点,反之亦然。自顶向下访问的好处是,如果某些大型子树与转换无关,可以跳过它们。在极端情况下,如果根节点有某种特性意味着不需要检查其他节点,你就可以节省大量的处理时间。另一方面,对于自底向上的处理,要跳过某个特定子树就太晚了,因为一旦你到达它,你已经访问了它的所有子节点。然而,对于某些类型的转换,自底向上访问也有其优势。如果你用一个包含原始节点作为子节点的新节点替换父节点,你很容易创建出无限循环的代码,因为在扩展树之后你会重新访问原始父节点。当以自底向上的顺序访问节点时,可以完全避免这种情况。要理解这一点,可以看一个非常简单的例子:从这个名为“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` 接口非常契合,因此将一组自定义运算符与 `IEnumerable` 的语义进行比较通常很有用,这有助于形象化和理解每个运算符在新上下文中可能提供的功能。在我们的例子中,我们考虑的是*访问 (visitation)*过程,而不是*枚举 (enumeration)*。它们有些相似,实际上,想象一个可以枚举树节点的 `IEnumerable` 类型是很自然的。然而,我们希望保留树的结构,因为这对于转换可能很重要(除非只是用单个节点替换单个节点)。换句话说,许多不同的树结构可以通过以特定顺序遍历节点来产生相同的 `IEnumerable`。

然而,以 `IEnumerable` 模型为起点,让我们想象一组 LINQ 查询运算符,它们对树中的节点应用函数。我们不想使用可枚举接口,而是想遵循通常的访问者模式。也就是说,对于每个节点,我们希望提供一个可以“接受”树中可能存在的每种节点类型的类型。通常,这是一个实现了特定于上下文的接口的类,该接口为每种节点类型定义了一个方法。敏锐的读者此时可能想知道这如何与 LINQ 运算符集成。毕竟,LINQ 运算符是对某个类型 T 应用*函数*,而不是任意的接口实现。然而,通过利用 `Expression` 树中(几乎)所有节点都有一个共同的基类 `Expression` 这一事实,这两个概念可以结合在一起。由于这个巧合,我们可以将一个包含多个“AcceptExpressionTypeXyz”方法的接口简化为单个对 `Expression` 类型进行操作的函数。

LINQ 运算符集

我差不多准备好介绍代码中实现的、用于转换表达式树的运算符集了。在列出运算符本身之前,我们必须定义捕获访问语义的通用接口。我们称之为 `IVisitable` 接口,其中 T 是 `Expression` 或其派生类型(在某些有限情况下,甚至可以是*任何*类型,只要查询表达式中最后的“select”运算符返回的是可赋给 `Expression` 的类型)。`IVisitable` 的定义如下:

    public interface IVisitable<T>
    {
        Expression Visit(Func<T, Expression> accept);
    }

也就是说,`IVisitable` 有一个方法,它应用一个从 T 到 `Expression` 的函数,该函数返回一个转换后的表达式。这意味着 `IVisitable` 的任何实现都包含一个对某个要转换的“源”表达式的引用。

接下来,我们需要一些函数,将 `Expression` 对象转换为实现 `IVisitable`(首先是 `IVisitable<Expression>`)的类型。在这里,我们可以捕捉遍历方向的概念:

    public static IVisitable<Expression> TopDown(this Expression e);
    public static IVisitable<Expression> BottomUp(this Expression e);

这两个函数都返回一个 `IVisitable`,它会按相应的顺序访问所提供的 `Expression`。需要注意的一个重要点是,一旦选定了方向,所有应用于返回的 IVisitable 的 LINQ 运算符都将绑定到同一方向。也就是说,在单个 LINQ 查询表达式中,只能有一个方向(除非使用一个独立的嵌套查询)。现在来看运算符:

    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`,它会跳过所有谓词返回 false 的节点(但不会跳过它们的子节点)。

    IVisitable<T> OfType<T>(this IVisitable<Expression> source)

返回一个新的 `IVisitable`,它会跳过类型不为 T 的节点(但不会跳过它们的子节点),并且在访问期间,在将节点传递给 `Func` 委托之前,将其强制转换为 T 类型。

    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`,绑定会更新以使用新的模型。

就算没有其他作用,我希望本文能激发读者对于使用表达式树可以实现什么的创造力。祝元编程愉快!

© . All rights reserved.