各位對於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;
}];
}];
}
}
複製程式碼