iOS混合開發庫(GICXMLLayout)六、資料繫結原理

搬磚的碼農發表於2019-01-04

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

相關文章