各位对于MVVM这种架构应该多多少少有一定的了解了,而提到MVVM那么数据绑定应该是绕不过去的一个话题。数据绑定是MVVM架构中的一个重要组成部分,可以做到View跟ViewModel之间的解耦,真正的做到UI、逻辑的分离。
在iOS上要是实现MVVM,那么一般使用RAC
或者RXSwift
来实现数据绑定的功能。而GIC
在单向
、双向
的数据绑定的实现是基于RAC
来实现的,只是GIC
在实现的过程中进一步的简化了数据绑定的方式,可以让开发者仅仅使用一个绑定表达式就能实现数据绑定。
在GIC
中,数据绑定
分三种模式,分别是:
- once:
一次性的绑定,绑定后不管数据源的有没有更新都不会再次触发绑定。默认就是这种模式。原因后面详细分析
- one way:
单向绑定。在once的基础上,增加了当数据源有更新后自动重新进行绑定的功能。
- two way:
双向绑定。在one way的基础上,增加了当目标value改变后反向更新数据源的功能。比如:input元素的text属性支持双向绑定,当输入内容有改变的话,会反向将输入内容更新到数据源。
原理剖析
GIC
的数据绑定在实际的实现过程中参考了WPF
、前端VUE
等。要实现数据绑定,那么必须要有数据源,在GIC
中叫做dataContext
。
这里
数据源
指的是任意NSObject,并不是特指ViewModel
,ViewModel
算是一种特殊的数据源,不仅提供view所需的数据,还提供view所需的方法、业务逻辑等等,通常将ViewModel
作为根元素的数据源。
当为某个元素设置数据源后,GIC
会根据先执行该元素上所有的数据绑定,然后遍历该元素的所有子孙元素,按照顺序依次执行子孙元素上的数据绑定。
相当于当为某个树的节点设置了数据源后,那么该节点的所有子孙节点都自动继承了这个数据源。
在GIC
中,为了能够在绑定的时候支持JS脚本计算,比如:一个lable的text属性需要绑定到数据源上的name
属性,并且在前面添加姓名:
的前缀,这时候你就可以直接以{{'姓名:'+name}}
这样的绑定表达式来表示,表达式可以是任意的一段JS代码,GIC
会自动将表达式的结果赋值给元素的对应属性上。
另外,在绑定的表达式中你可以对数据源的任意属性做计算,这也就是说需要一种方式,能够访问数据源的任意属性,而且确保表达式不会过于复杂,比如在一个表达式中访问多个属性,{{'姓名:'+name+',性别:'+(sex==1?'男':'女')}}
,对于这样的表达式计算,如果是直接在native中计算好那自然是没问题的,但是GIC
作为一个库来说,这样的计算只能由库来计算,而能够直接完成如此复杂的表达式的,只能是使用脚本类语言去动态计算,比如:JS。因此,GIC
在整个的执行数据绑定的流程中都是围绕JSValue
来实现的。(注:JSValue
是JavaScriptCore
提供的一种数据类型,用来作为native跟JS之间互相调用的中间人) ,如果您对什么是JSValue
不熟悉的话,可以google下。这样一来,由JS提供的动态特性就能实现对任意native的数据源做动态计算的能力。
once 绑定模式
这里先上一张执行数据绑定的流程图。
这张流程图显示的是once模式下的绑定流程。在这个模式下无需监听数据源的属性改变,因此也就无需RAC上场。
- 第一步。提取解析表达式,并且判断绑定模式。
- 第二步。将数据源转换成JSValue。
这一步至关重要。只有将数据源转换成
JSValue
才能在JS环境下访问该数据源,进一步能够执行绑定表达式得到想要的结果。 - 第三步。为JSValue的所有属性添加getter方法。
之所以有这一步,是为了JSValue能够访问非
NSDictionary
的数据类型,比如你自定义的Class。因为JSValue默认只能访问NSDictionary
中的数据,而对于其他的数据类型,不管是访问属性或者方法都需要你手动加入到JSValue
中,因此这一步就是手动将数据源的所有属性的keys,转换成JSValue中的getter方法,这样就能在JS中访问任意数据类型的任意属性了。 - 第四步。执行绑定表达式。
在这一步执行表达式后就能得到最终的结果了。但是
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;
}];
}];
}
}
复制代码