iOS混合开发库(GICXMLLayout)六、数据绑定原理

795 阅读10分钟

各位对于MVVM这种架构应该多多少少有一定的了解了,而提到MVVM那么数据绑定应该是绕不过去的一个话题。数据绑定是MVVM架构中的一个重要组成部分,可以做到View跟ViewModel之间的解耦,真正的做到UI、逻辑的分离。

在iOS上要是实现MVVM,那么一般使用RAC或者RXSwift来实现数据绑定的功能。而GIC单向双向的数据绑定的实现是基于RAC来实现的,只是GIC在实现的过程中进一步的简化了数据绑定的方式,可以让开发者仅仅使用一个绑定表达式就能实现数据绑定。

GIC中,数据绑定三种模式,分别是:

  1. once:

    一次性的绑定,绑定后不管数据源的有没有更新都不会再次触发绑定。默认就是这种模式。原因后面详细分析

  2. one way:

    单向绑定。在once的基础上,增加了当数据源有更新后自动重新进行绑定的功能。

  3. two way:

    双向绑定。在one way的基础上,增加了当目标value改变后反向更新数据源的功能。比如:input元素的text属性支持双向绑定,当输入内容有改变的话,会反向将输入内容更新到数据源。

原理剖析

GIC的数据绑定在实际的实现过程中参考了WPF前端VUE等。要实现数据绑定,那么必须要有数据源,在GIC中叫做dataContext

这里数据源指的是任意NSObject,并不是特指ViewModelViewModel算是一种特殊的数据源,不仅提供view所需的数据,还提供view所需的方法、业务逻辑等等,通常将ViewModel作为根元素的数据源。

当为某个元素设置数据源后,GIC会根据先执行该元素上所有的数据绑定,然后遍历该元素的所有子孙元素,按照顺序依次执行子孙元素上的数据绑定。

相当于当为某个树的节点设置了数据源后,那么该节点的所有子孙节点都自动继承了这个数据源。

GIC中,为了能够在绑定的时候支持JS脚本计算,比如:一个lable的text属性需要绑定到数据源上的name属性,并且在前面添加姓名:的前缀,这时候你就可以直接以{{'姓名:'+name}}这样的绑定表达式来表示,表达式可以是任意的一段JS代码,GIC会自动将表达式的结果赋值给元素的对应属性上。

另外,在绑定的表达式中你可以对数据源的任意属性做计算,这也就是说需要一种方式,能够访问数据源的任意属性,而且确保表达式不会过于复杂,比如在一个表达式中访问多个属性,{{'姓名:'+name+',性别:'+(sex==1?'男':'女')}},对于这样的表达式计算,如果是直接在native中计算好那自然是没问题的,但是GIC作为一个库来说,这样的计算只能由库来计算,而能够直接完成如此复杂的表达式的,只能是使用脚本类语言去动态计算,比如:JS。因此,GIC在整个的执行数据绑定的流程中都是围绕JSValue来实现的。(注:JSValueJavaScriptCore提供的一种数据类型,用来作为native跟JS之间互相调用的中间人) ,如果您对什么是JSValue不熟悉的话,可以google下。这样一来,由JS提供的动态特性就能实现对任意native的数据源做动态计算的能力。

once 绑定模式

这里先上一张执行数据绑定的流程图。

数据绑定流程

这张流程图显示的是once模式下的绑定流程。在这个模式下无需监听数据源的属性改变,因此也就无需RAC上场。

  1. 第一步。提取解析表达式,并且判断绑定模式。
  2. 第二步。将数据源转换成JSValue。

    这一步至关重要。只有将数据源转换成JSValue才能在JS环境下访问该数据源,进一步能够执行绑定表达式得到想要的结果。

  3. 第三步。为JSValue的所有属性添加getter方法。

    之所以有这一步,是为了JSValue能够访问非NSDictionary的数据类型,比如你自定义的Class。因为JSValue默认只能访问NSDictionary中的数据,而对于其他的数据类型,不管是访问属性或者方法都需要你手动加入到JSValue中,因此这一步就是手动将数据源的所有属性的keys,转换成JSValue中的getter方法,这样就能在JS中访问任意数据类型的任意属性了。

  4. 第四步。执行绑定表达式。

    在这一步执行表达式后就能得到最终的结果了。但是GIC在这一步上其实也做了其他的处理。如果您写过前端代码,那么一定对JS里面的点语法有了解,在JS中要想访问某个对象的属性的话那必须要通过点语法来访问的,比如:obj.name。然而GIC为了简化绑定表达式,允许你不用通过点语法来访问属性,而是就像访问变量一样来直接访问属性。这样一来在执行表达式之前就必须做一个转换,将数据源的所有的属性keys变成JS中的var

这里贴一下第四步中将数据源的属性keys转换成var,然后执行表达式的js代码。

/**
 * @param props 数据源的属性keys
 * @param expStr 绑定表达式
 * @returns {*}
 */
Object.prototype.executeBindExpression2 = function (props, expStr) {
  let jsStr = '';
  props.forEach((key) => {
    jsStr += `var ${key}=this.${key};`;
  });
  jsStr += expStr;
  return (new Function(jsStr)).call(this);
};

one way 模式

在单向绑定的模式中,就需要监听数据源的属性改变了,GIC在这一块是使用RAC来实现的。但是问题是,如何确定到底要监听哪个属性?或者哪些属性?因为绑定表达式中有可能访问了多个属性。

GIC的在这方面的处理直接采用的方式,就是遍历数据源的属性keys,然后看看这个key是否在绑定表达式中,如果存在,那么就说明需要对这个属性做监听,也就是需要使用RAC。RAC监听到属性更改的时候,重新执行绑定流程从而得到新的结果。

for(NSString *key in allKeys){
    if([self.expression rangeOfString:key].location != NSNotFound){
        @weakify(self)
        [[self.dataSource rac_valuesAndChangesForKeyPath:key options:NSKeyValueObservingOptionNew observer:nil] subscribeNext:^(RACTwoTuple<id,NSDictionary *> * _Nullable x) {
            @strongify(self)
            [self refreshExpression];
        }];
    }
}

各位看官可能也发现了,采用的方式有可能会发生误判,但是在没有想到更好的解决方案之前,这样的方式显然简单又高效的。

two way 模式

双向绑定模式,就是在单向的基础上增加了反向更新数据源的功能。GIC实现的双向绑定流程目前来说其实并不完美,这个也是无奈之举。

既然是需要反向更新数据源的能力,那么就得建立一套 View -> 数据源 的机制。也就是建立一套当元素的某个属性改变的时候能够反向通知GIC的机制。考虑到并不是所有的元素都支持双向绑定的,比如image元素没什么属性需要提供双向绑定,而input元素的text属性却有必要提供双向绑定的能力,因此在综合考虑下,GIC将这个反向反馈的机制通过protocol交由元素自己实现,由元素返回一个RACSignal,然后GIC的数据绑定订阅这个Signal,当这个Signal产生信号的时候,GIC就将新的value反向更新到数据源。

实现代码如下:

// 处理双向绑定
if(self.bingdingMode == GICBingdingMode_TowWay){
    if([self.target respondsToSelector:@selector(gic_createTowWayBindingWithAttributeName:withSignalBlock:)]){
        @weakify(self)
        [self.target gic_createTowWayBindingWithAttributeName:self.attributeName withSignalBlock:^(RACSignal *signal) {
            [[signal takeUntil:[self rac_willDeallocSignal]] subscribeNext:^(id  _Nullable newValue) {
                @strongify(self)
                // 判断原值和新值是否一致,只有在不一致的时候才会触发更新
                if(![newValue isEqual:[self.dataSource valueForKey:self.expression]]){
                    // 将新值更新到数据源
                    [self.dataSource setValue:newValue forKey:self.expression];
                }
            }];
        }];
    }
}

从代码中可以看到,这个协议提供的RACSignal是由一个block提供的,之所以采用block的回调方式,那是因为GIC支持异步解析+布局+渲染,而在创建双向绑定的过程中有可能需要在UI线程访问元素,因此这里面使用block的方式,由元素本身决定到底怎么如何访问。当然这里面也可以使用线程wait方式来实现,但是这样一来就有可能导致解析效率低下。

另外也可以看到,GIC是直接使用绑定表达式作为key来反向设置数据源的属性的,这也就意味着对于双向绑定的表达式只能是属性名,不能是脚本表达式。这个方案也是无奈的方案,因为GIC可以知道具体是元素的哪个属性产生了Signal,但是无法确定到底是反向更新到数据源的哪个属性,因此这里面就使用了一个妥协的方案。好在,在实际的开发过程中,对于双向绑定的绑定表达式都是比较简单的。

在实际的开发过程中,大多数的绑定需求只需要once模式就行了,再结合RAC在实现KVO的过程中会造成额外的内存开销,因此综合考虑下来,GIC的默认绑定模式为once

JavaScript对象作为数据源的绑定实现原理。

上面介绍的绑定流程中的数据源都是针对Native的NSObject来实现的,而自从GIC支持直接使用JavaScript来写业务逻辑后,上面的那套流程就部分不适用了。因为数据源有可能已经直接是JSValue了。

其实对于once模式来说,在数据源本身就是JSValue的情况下,执行绑定表达式是已经非常简单的过程,直至参考上面的第四步就行了。

对于one way模式来说,就不一样了。你已经不能通过RAC来实现对JSValue属性的监听了。JS本身就可以通过对属性的setter方法进行重写从而获得属性改变的通知。而GIC在实现的过程中参考了VUE的源码,其实严格来说是直接照搬了VUE的相关源码,因为vue已经实现了相关的属性value变更监控的一套机制了。因此GIC在这方面的实现上相对来说是比较轻松的。下面贴一下对于属性的监听代码。

/**
 * 添加元素数据绑定
 * @param obj
 * @param bindExp 绑定表达式
 * @param cbName
 * @returns {Watcher}
 */
Object.prototype.addElementBind = function (obj, bindExp, cbName) {
  observe(this);
  // 主要是用来判断哪些属性需要做监听
  Object.keys(this).forEach((key) => {
    if (bindExp.indexOf(key) >= 0) {
      let watchers = obj.__watchers__;
      if (!watchers) {
        watchers = [];
        obj.__watchers__ = watchers;
      }

      let hasW = false;
      watchers.forEach((w) => {
        if (w.expOrFn === key) {
          hasW = true;
        }
      });

      if (!hasW) {
        const watcher = new Watcher(this, key, () => {
          obj[cbName](this);
        });
        watchers.push(watcher);
      }

      // check path
      const value = this[key];
      if (isObject(value)) {
        value.addElementBind(obj, bindExp, cbName);
      }
    }
  });
};

最后对于two way的实现上,相对于Native的数据源实现来说区别不大。唯一的区别就是反向更新的数据源对象变成了JSValue

// 实现双向绑定
if(self.bingdingMode == GICBingdingMode_TowWay){
    if([self.target respondsToSelector:@selector(gic_createTowWayBindingWithAttributeName:withSignalBlock:)]){
        @weakify(self)
        [self.target gic_createTowWayBindingWithAttributeName:self.attributeName withSignalBlock:^(RACSignal *signal) {
            [[signal takeUntil:[self rac_willDeallocSignal]] subscribeNext:^(id  _Nullable newValue) {
                // 判断原值和新值是否一致,只有在不一致的时候才会触发更新
                @strongify(self)
                jsValue.value[self.expression] = newValue;
            }];
        }];
    }
}