首发于vczh的日常
考不上三本也会实现数据绑定(三)

考不上三本也会实现数据绑定(三)

你现在所阅读的并不是第一篇文章,你可能想看目录和前言

本文默认你已经读过以下两篇文章:

考不上三本也会实现数据绑定(一)

考不上三本也会实现数据绑定(二)

@vczh 的《考不上三本也会实现数据绑定》系列断更已过去两年,恰好最近对这个问题比较感兴趣,并尝试用 C#实现了一个单项数据绑定的案例(见下方链接),故写文记录,算作为该系列补上一个非官方的结尾。
DingpingZhang/DataBindinggithub.com图标

该系列的第二篇已讲完了属性链的处理方法,本文在此基础上,补充以下三点内容:

  1. 复杂表达式中的依赖分析
  2. 条件表达式的处理
  3. 一个C#实现的简单案例

1. 复杂表达式的处理

这里所说的“复杂”表达式,是指由多个属性链通过特定的函数关系,组合而成的表达式树。为了方便说明,这里引入一个“依赖关系”(节点更新的传递关系)的助记符:

对于任意两个属性A和B,若A更新时导致B也需要被更新,则称B依赖于A,记作A->B。

显然,对于前文提到的属性链:a.b.c.d,其对应的“依赖关系链”为:a->b->c->d。而本节的目的,则是将这一“关系链(Linked List)”扩充为“关系图(Graph)”。

1.1 合并同父的属性链

我们先来考虑一类比较简单的情况:这些表达式都可以等效于一个函数,所有的属性链都是该函数的参数。

(1) A.B + A.B.C + A.B.D // <=> Sum(A.B, A.B.C, A.B.D)
(2) A[B.C]              // <=> A.get_Item(B.C) <=> static_get_Item(A, B.C)
(3) Function(A, B, C.D) // <=> Function(A, B, C.D)

显然,所有属性链都是平级的、互不影响的。与前文处理单个属性链一样,对于(1)式,你完全可以通过写出以下三个绑定式来达到对整个表达式的观测。

attach(bind(A.B).ValueChanged, callback);
attach(bind(A.B.C).ValueChanged, callback);
attach(bind(A.B.D).ValueChanged, callback);

但是这样不免有些粗暴,我们还可以做一个小小的优化:不难发现,以上三个属性链存在相同的父节点:A和B,它们在表达式中是完全相同的实例,没有必要为B生成三个BChanged事件,然后一次变化,触发三次事件。所以我们可以对其进行合并:当B变更时,同时刷新C和D。

A->B
A->B->C    ==> A->B->C
A->B->D           └>D

至此,1.1小节的内容已经结束。然而细心的朋友可能会提出质疑:之前分明说的是要将依赖关系扩充为“图(Graph)”,而若是按这么合并同类项,至多也就是“树(Tree)”结构而已。不急,接下来的1.2小节正是要解释这个问题。

1.2 虚拟节点

那么,什么样的表达式会出现“图(Graph)”的结构呢?让我们来观察下方的表达式:

(1) A.Function(B).C // <=> static_Function(A, B).C
(2) (A+B).C         // <=> Add(A, B).C

它们与1.1小节中所处理的表达式有着明显的不同:C节点的父级是一个函数的返回值。这个返回值在表达式中并没有一个明确的变量来“装”它,我们也无法通过直接对其赋值来变更它,但我们很清楚:如果A或B发生了变化,那么“它”也是需要被更新的(可能发生变更)。于是,我们便找到了一个拥有两个“上游”节点的“虚拟”节点。正是它,导致了“依赖关系”不再是“树(Tree)”而是“图(Graph)”(注:本文涉及的图肯定是有向无环的)。

表达式(1)和(2)的关系图一致,如下:

A
  ↘
     (Virtual)->C
  ↗
B

我用了一个虚拟节点(Virtual)来表示函数的返回值,之后的合并相同父节点操作与1.1小节一致,并无特殊。

2. 条件表达式的处理

其实到目前为止,按上述方法实现的数据绑定已经可以用了(又不是不能用.jpg)。但对于条件表达式,我们还可以单独进行一下优化:

A.BoolValue ? A.B : A.C.D

考虑以上表示式:当A.BoolValue = true时,C或D随便怎么变化,整个条件表达式的值都不会发生改变。也即是说,此时我们不应该理会C或D的Changed事件,更不应该触发整个表达式的重新计算。

条件节点(Test)的更新将会影响到分支节点(IfTrue/IfFalse)是否被观测。

这相当于对上文关系图中的依赖节点进行激活/失活操作。具体的做法是:

  1. 为所有的条件表达式构成条件树,树节点(Conditional Node)应当记录以下信息:
    1. Test Getter(Func<bool>):用于计算条件表达式的Test结果;
    2. Test中所有依赖节点的集合;
    3. IfTrue中所有依赖节点的集合,当然,IfTrue也可以是另一个Conditional Node;
    4. 同理,IfFalse也一样。
  2. 当Test中的节点发生更新时,通过Test Getter重新计算Test表达式:若为true,则解绑IfFalse中所有节点的Changed事件,且重新绑定并刷新IfTrue中的所有依赖节点(为何要刷新?因为上次Test=false时,IfTrue中的所有节点都处于“不被观测”状态,若此时其中有节点偷偷摸摸地被更新,你是不知道的);反之同理。当IfTrue/IfFalse为另一个Conditional Node时,则需激活/失活该Conditional Node中的所有节点(即Test、IfTrue、IfFalse成员)。

3. 具体实现(C#)

前文已经说完了理论部分,接下来将用C#这门语言来做一个真正可用(玩?)的单向数据绑定功能。我们最终的目标是实现以下函数(对,就只有这么一个静态函数),并且这里默认了被观测的表达式中的属性,除叶节点外,均是实现了INotifyPropertyChanged接口的对象。

public class ExpressionObserver
{
    public static void Observes<T>(Expression<Func<T>> expression, Action<T, Exception> onChanged);
}

// How to use?
ExpressionObserver.Observes(() => /* Your expression */, (value, exception) => { /* Do something */ })

完整的代码可以在文章开头给出的Github仓库中查看,其实代码量很少且简单,完全可以直接阅读源码。但由于我感觉这次代码写的太丑,所以还是在这里做一个简单的导读,下文将提到具体实现过程中的两个要点。

3.1 利用访客模式遍历表达式

这里实现的关键是使用ExpressionVisitor对表达式进行遍历,一般一个抽象语法树的分析服务里都会带这么一个访客模式以方便大众,比如Roslyn里也是一样的。如果你作为年轻人血气方刚,硬是要自己写一套纯手工解析遍历表达式树的工具类,那其实也不算一件坏事。

首先,我们编写一个SingleLineLambdaVisitor类,其继承自ExpressionVisitor。对于Single Line Lambda Expression,我们只需要对以下五个虚方法进行重写,以在遍历过程中拦截这五种类型的表达式:

  1. VisitMember:我们将在这里得到所有属性链中的中继节点和叶节点,以及一种特殊的根节点——闭包类的字段;
  2. VisitConstant:得到普通的根节点;
  3. VisitBinary和VisitMethodCall:这两种虚节点处理方式一致,按上文所述,二元表达式和普通方法调用无并差别,一个前缀表示一个中缀表示而已;
  4. VisitConditional:生成Conditional Node,并在此处把ConditionalExpression拆分成Test、IfTrue和IfFalse三部分,在不同的context下分别遍历。
    public class SingleLineLambdaVisitor : ExpressionVisitor
    {
        // Property chain, and closure root field node.
        protected override Expression VisitMember(MemberExpression node)
        {
            // TODO: Record node and create context
            return VisitInContext(() => base.VisitMember(node), context);
        }

        // Root node.
        protected override Expression VisitConstant(ConstantExpression node)
        {
            // TODO: Record node
            return node;
        }

        // Virtual node
        protected override Expression VisitBinary(BinaryExpression node)
        {
            // TODO: Record node and create context
            return VisitInContext(() => base.VisitBinary(node), context);
        }

        // Virtual node
        protected override Expression VisitMethodCall(MethodCallExpression node)
        {
            // TODO: Record node and create context
            return VisitInContext(() => base.VisitMethodCall(node), context);
        }

        // Conditional node
        protected override Expression VisitConditional(ConditionalExpression node)
        {
            // TODO: Record test getter and node, and create context
            context.ConditionalNodeType = ConditionalNodeType.Test;
            var testExpression = VisitInContext(() => Visit(node.Test), context);

            context.ConditionalNodeType = ConditionalNodeType.IfTrue;
            var ifTrueExpression = VisitInContext(() => Visit(node.IfTrue), context);

            context.ConditionalNodeType = ConditionalNodeType.IfFalse;
            var ifFalseExpression = VisitInContext(() => Visit(node.IfFalse), context);

            return node.Update(testExpression, ifTrueExpression, ifFalseExpression);
        }
    }

3.2 通过Context在遍历过程中传递信息

接下来需要说明一下Context:Context是遍历时,在不同遍历层之间传递信息的一个全局变量。所以它通过一个栈(Stack)来储存:每递归前进一层,就push一个新的context实例供该层使用;而每回退一层,就pop一个context以恢复之前的上下文,VisitInContext的代码见下方(其实是用链表实现的)。

        private Expression VisitInContext(Func<Expression> visitCallback, Context context)
        {
            // Push
            context.Parent = _context;
            _context = context;

            var expression = visitCallback();

            // Pop
            _context = _context.Parent;

            return expression;
        }

Context主要用于传递两类信息:

  1. 为了组建依赖关系图,需要在遍历时将本层创建的节点传递到上一层,以告诉上层的爸爸(Upstream Node),它们的儿子(Downstream Node)是谁;当然,如果你反过来遍历,则需要把上游节点传递到下游;
  2. 为了组建条件节点树,需要告知当前节点是否是条件表达式的成员(None、Test、IfTrue、IfFalse),以及它将被哪一个条件节点所包含。

以上便是C#版本具体实现时的两个要点,剩下的内容便是if-else或switch-case的这种体力活,大家可以直接查看源码。

4. 扩展阅读

其实实现数据绑定的方法还有很多,这里稍微提一下Vue.js的实现方法:相比于本文的分析依赖法,Vue.js中计算属性(Computed Property)的实现则十分讨巧,其方法大致如下:

  1. 先利用JavaScript的Object.defineProperty()函数对每个属性注册getter和setter,以拦截get和set操作;
  2. 跑一遍表达式,那么,本次表达式中“被用到”的属性就会触发get操作,该信息在getter中被拦截到,该属性被添加到“重点关注对象列表”里;
  3. 若“重点关注对象列表”中的属性被赋值(通过其setter知晓),则重复步骤2:重新计算表达式,并得到一个新的“重点关注对象列表”。

该方法得益于JavaScript的Object.defineProperty()功能,若要在C#中实现,则需要用户手动地把每个属性写成以下形式:

public string Name
{
    get => GetProperty(_name);
    set => SetProperty(ref _name, value);
}

主要是增加了BindableBase.GetProperty()这个方法,当然你在VS中加一个snippet写起来也不麻烦,但老项目无法兼容,毕竟C#中通用的变更通知规则只要求在setter里触发PropertyChanged。

编辑于 2019-05-01

文章被以下专栏收录