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

使用表达式树简化(视图模型-)数据绑定

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.88/5 (14投票s)

2010 年 9 月 19 日

Ms-PL

8分钟阅读

viewsIcon

35541

downloadIcon

412

想要摆脱混乱的“PropertyChanged”订阅和过多的 OnPropertyChanged("...") 调用来管理值之间的关系吗?那么 DataBinder 是您一直在寻找的!

引言

在现代应用程序中,一切都应该是动态的。一旦用户更改了文本框,他就会希望立即看到结果。得益于 MVVM 模式,我们可以使用数据绑定来解决这个棘手的需求。但是,我们仍然需要做很多工作来保持我们的视图模型中的数据与屏幕上的数据同步。特别是在处理复合值或与其他视图模型的关联时,我们的视图模型需要处理订阅其他视图模型的 PropertyChanged 处理程序。真的吗?

基本数据绑定(第一个示例)

示例 - “旧”方法

让我们从一个多多少少真实的例子开始:用户可以更改一个人的名字和姓氏,而他的全名应该自动组合名字和姓氏。

说明这个问题的简单程序可能看起来像截图中的那样

FirstScreenshot.PNG

相应的视图模型可能看起来怎么样?嗯,可能,和那个非常相似

public sealed class PersonViewModel
    : ViewModel
{
    private string _firstName;
    private string _lastName;

    public string LastName
    {
        get
        {
            return this._lastName;
        }
        set
        {
            if (this.LastName == value)
                return;

            this._lastName = value;
            this.OnPropertyChanged("LastName");
        }
    }
    public string FirstName
    {
        get
        {
            return this._firstName;
        }
        set
        {
            if (this.FirstName == value)
                return;

            this._firstName = value;
            this.OnPropertyChanged("FirstName");
        }
    }
    public string FullName
    {
        get
        {
            return String.Format("{1}, {0}", this.FirstName, this.LastName);
        }
    }
}

刷新 FullName

但是,实现仍然不能按我们想要的方式工作:FullName 只在用户现在更改 FirstNameLastName 时才被实际更新一次,它不会被刷新。作为解决方案,您可以修改 FirstNameLastName,通过添加对 OnPropertyChanged("FullName") 的调用。setter 现在看起来会像这样

if (this.LastName == value)
    return;

this._lastName = value;
this.OnPropertyChanged("LastName");
this.OnPropertyChanged("FullName");

//and:

if (this.FirstName == value)
    return;

this._firstName = value;
this.OnPropertyChanged("FirstName");
this.OnPropertyChanged("FullName");

添加更多属性

但是,这开始变得复杂:想象一下,您想通过添加一个头衔来扩展 FullName,并另外提供一个返回没有头衔的 FullNameShortName 属性。

这意味着在 LastNameFirstName 的 setter 中额外调用一次 OnPropertyChanged("ShortName"),在 Title 的 setter 中调用一次 OnPropertyChanged("FullName")

SecondScreenhot.PNG

缺点

但是,简单地在 setter 中插入 OnPropertyChanged 调用的方法有两个缺点

  • 当更改依赖属性(如 FullNameShortName)时,您很容易忘记添加/删除 OnPropertyChanged("...") 调用。
  • 由于大量的 OnPropertyChanged("...") 调用,您的 setter 开始变得混乱。

简而言之,您的代码的可维护性开始下降。

解决方案 - “新”方法

现在,如果我们能简单地将 LastNameFirstName 的实现保持在最初的实现方式,而只需更改 FullName,您会怎么说?很简单 - 只需按照以下两个步骤操作

用依赖于后备字段的只读属性替换依赖属性

我将让代码说明这一步 ;-)

private string _fullName; 
private string _shortName;

public string FullName
{
    get
    {
        return this._fullName;
    }
    private set
    {
        if (this.FullName == value)
            return;

        this._fullName = value;
        this.OnPropertyChanged("FullName");
    }
}

public string ShortName
{
    get
    {
        return this._shortName;
    }
    private set
    {
        if (this.ShortName == value)
            return;

        this._shortName = value;
        this.OnPropertyChanged("ShortName");
    }
}

添加魔法 - 引入 DataBinder

现在,将以下两行添加到构造函数中

DataBinder.Bind(s => this.FullName = s, () => String.Format("{0} {1} {2}", 
                this.Title, this.FirstName, this.LastName));
DataBinder.Bind(s => this.ShortName = s, () => this.LastName + ", " + this.FirstName);

就是这样。您只需指定 FullNameShortName 应该包含什么。DataBinder 将为您处理整个刷新过程。

它的能力

语法

语法非常易于理解

DataBinder.Bind([callback], [expression to "bind" to the callback]);

调用静态方法 DataBinder.Bind,并传递一个回调,当第二个参数的值可能已更改时,应调用该回调。最常见的情况是,该回调将简单地将值存储在属性中。作为第二个参数,您应该传递一个返回您想要“绑定”到回调的值的表达式。请注意,只有从该表达式内部访问的对象才可能被观察到。本地变量中使用缓存结果可能会阻止您始终看到表达式的当前值。

支持的场景

属性访问

您已经在第一个示例中看到了这一点。您可以绑定到任何类型的任何属性。

DataBinder.Bind(v => this.Property = v, () => this.OtherProperty);

如果提供该属性的类型实现了 INotifyPropertyChanged,则值将保持最新。

链式属性访问

您不仅可以绑定到对象的直接属性,还可以绑定到属性的属性(依此类推)。

DataBinder.Bind(v => this.Property = v, () => this.Parent.OtherProperty);

同样,如果提供最终属性的类型(在此例中,为 Parent 的类型)实现了 INotifyPropertyChanged 并且属性值发生更改,新结果将通过回调报告。此外,如果提供第一个属性的类型(在此示例中,为 this)通知了属性(在此示例中为 Parent)的更改,新结果也将通过回调报告。此外,DataBinder 将取消订阅属性旧值的 PropertyChanged,并订阅新值的属性。这使得 DataBinder 非常方便!

调用静态方法

随时在绑定的表达式中调用任何静态方法

DataBinder.Bind(v => this.Property = v, () => String.Format("{0}", this.OtherProperty));

当参数的值更改(并通过 PropertyChanged 报告更改)或参数本身的属性更改时,会发生重新评估。

调用实例方法

实例方法可以用作静态方法

DataBinder.Bind(v => this.Property = v, () => this.Foo());

与静态方法一样,参数更改会导致重新评估。此外,目标对象的任何属性更改也会导致重新评估(当然,前提是它实现了 INotifyPropertyChanged)。

使用运算符

运算符也可以使用

DataBinder.Bind(v => this.Property = v, () => (this.Age + 42).ToString() + " years old");

一旦操作数之一的访问属性发生更改,表达式就会被重新评估。

条件

也可以利用三元运算符或空合并运算符

DataBinder.Bind(v => this.Property = v, () => this.FirstName == null ? 
                                               "<unknown>" : this.FirstName);
DataBinder.Bind(v => this.Property = v, () => this.FirstName ?? "<unknown>");

除了值路径中涉及的对象属性更改外,条件部分中使用的属性的更改也会导致表达式重新评估。

它们的任何组合

通过组合这些可能性,您应该能够构成几乎任何您需要的表达式。如果不行,请通过评论告诉我。

释放全部潜力(第二个示例)

添加关联

我们将为我们旧的示例添加一些新功能:用户现在可以从列表中选择一个人和一个工作。结果是,为选定的工作和人员组合显示工资单。但是,用户仍然应该能够编辑工作和人员的详细信息。应用程序的外观如下(它是附加到本文的那个)

FinalScreenshot.PNG

视图模型类如下所示

ClassDiagram.png

想象一下,如果您要实现工资单(如果您想将其作为字符串返回)

  • SelectedPersonSelectedJob 的 setter 添加代码,以便在其中一个更改时刷新工资单
  • 订阅 SelectedPersonPropertyChanged 并相应地取消订阅
  • 订阅 SelectedJobPropertyChanged 并相应地取消订阅
  • 提供一个 RefreshPayroll 方法来重新创建工资单

对于如此简单的任务,听起来工作量不小,不是吗?使用 DataBinder,您可以再次轻松地将这个负担从您的责任中解脱出来

DataBinder.Bind(p => this.Payroll = p, () => String.Format("Payroll for {0}:\r\n", 
                this.SelectedPerson.FullName) + String.Format("{0}: Month: {1} - Year: {2}", 
                this.SelectedJob.Description, this.SelectedJob.MonthlySalary, 
                this.SelectedJob.MonthlySalary * 12));

……您就完成了。

注意:当然,您可以通过将该表达式分解为多个较小的表达式来重构它。我只是想向您展示 DataBinder 实际上有多强大。:-)

它(还)不能做什么 - 已知限制

  • 回调可能会被调用,并传递与前一个调用相同的值。
  • 对于单个属性更改,回调可能会被调用多次。
  • 对表达式部分没有缓存 - 每次都会评估整个表达式。
  • 没有递归检测 - 您的属性必须确保在应用与它们已持有的值相同的值时,它们不会调用 PropertyChanged

工作原理

您现在可能想知道 DataBinder 内部是什么使其能够实现其出色的功能。您会惊讶(我也是)需要多少代码来实现它。

签名

再次查看 Bind 方法的签名

public static void Bind<tresult>(Action<tresult> changedCallback, 
                   Expression<func><tresult>> valueExpression)

正如您所见,提供值的表达式实际上是 System.Linq.Expressions.Expression<TDelegate> 类型。这解释了为什么传递给 Bind 的参数可以被多次评估,以及为什么您将其定义为 lambda 表达式。

内部

表达式遍历

DataBinder 在内部使用 ExpressionVisitor 来检查传递的表达式。它重写了以下两个方法(这意味着它会特别处理成员访问和方法调用)。

请注意,ExpressionVisitor 已经负责递归地遍历整个表达式树。

protected override Expression VisitMember(MemberExpression node)
{
    if (typeof(INotifyPropertyChanged).IsAssignableFrom(node.Expression.Type))
    {
        Dependency dependency = this.AddDependencyToProperty(node.Expression, 
                                                             node.Member.Name);

        using (this.PushChildDependency(dependency))
            return base.VisitMember(node);
    }

    return base.VisitMember(node);
}

protected override Expression VisitMethodCall(MethodCallExpression node)
{
    foreach (Expression argument in node.Arguments)
    {
        if (typeof(INotifyPropertyChanged).IsAssignableFrom(argument.Type))
            this.AddDependencyToProperty(argument, "");
    }

    if (node.Object != null
        && typeof(INotifyPropertyChanged).IsAssignableFrom(node.Object.Type))
    {
        Dependency dependency = this.AddDependencyToProperty(node.Object, "");

        using (this.PushChildDependency(dependency))
            return base.VisitMethodCall(node);
    }

    return base.VisitMethodCall(node);
}

成员

无需查看类的其余部分,您就应该能够理解当访问器遇到成员时会发生什么:它检查该成员是否属于实现 INotifyPropertyChanged 的类型

if (typeof(INotifyPropertyChanged).IsAssignableFrom(argument.Type))

如果是,则会添加对该属性的依赖项

this.AddDependencyToProperty(node.Expression, node.Member.Name);

PushChildDependency 方法负责处理像 this.Parent.Parent.FirstName 这样的表达式。对 this.Parent 的依赖项将拥有对 Parent.FirstName 作为子项的依赖项,允许在 this.Parent 更改时进行处理程序(取消)订阅。

方法

方法与成员的处理方式非常相似。但是,它不是添加对特定属性的依赖项,而是添加对所有属性(空字符串)的依赖项。参数也得到特殊处理:如果它们是 INotifyPropertyChanged 类型,则任何属性更改都会导致整个表达式的重新评估。这是一个功能!:-)

杂项

在首次遍历表达式树之后,将在需要的地方添加对 PropertyChanged 的订阅。在执行此操作时,将多次部分评估表达式以提取实现 INotifyPropertyChanged 的对象。

附件中有什么?

附加到本文的是一个包含三个项目的解决方案

  • "DataBinder":DataBinder 组件本身
  • "DataBinderSample":本文使用的示例应用程序
  • "Tests":一些 DataBinder 的单元测试

注意:您需要 Code Contracts Rewriter 才能成功生成项目。或者,您可以删除项目设置中常量 "CONTRACTS_FULL" 的定义。

祝您使用 DataBinder 愉快!

如果您发现任何错误或有任何关于如何改进 DataBinder 的想法,请通过评论告知我。

历史

  1. 2010/09/19:初始发布。
  2. 2010/09/20:修正了包含两个项目附件的错误。
© . All rights reserved.