MobX流程分析與最佳實踐

大力智慧技術發表於2021-05-13

背景

大力輔導專案在首頁多個tab,課程詳情、社群、語文字詞和個人資訊頁等多個業務場景深度使用Flutter進行開發,而在Flutter開發過程中狀態管理是繞不開的話題。

在進行Native開發時,我們命令式地來表述UI構建和更新邏輯,通過類似setText、setImageUrl的程式碼對介面UI進行構建與更新。和Native開發不同,在進行Flutter開發時,UI的構建是宣告式的,這種框架結構直接影響了我們對更新邏輯的表達形式。

Flutter中觸發狀態更新的API即我們最熟悉的setState方法,但是專案中往往會碰到狀態需要跨層級或者在兄弟元件之間共享,僅僅使用setState一般不足以覆蓋複雜狀態管理的場景。因此我們需要狀態管理框架來幫助我們規範更新邏輯,同時也能更好地貼合Flutter framework的工作機制。

框架選型

我們在初期調研了多個開源的狀態管理框架包括BLoC、Redux以及MobX等。

BLoC 使用流共享資料,並且Dart語言本身對流的親和度很高,參考其它平臺的 ReactiveX 的解決方案,開發者可以快速地使用這種模式進行開發。BLoC的最大問題是,其它的 ReactiveX 每個資料來源都是獨立的 Stream,但是BLoC則是統一的單Stream。單 Stream 表達整個頁面的所有業務邏輯不具有普適性,其抽象層級過高,部分場景需要配合其他的方案。

就Js領域最流行的Redux框架而言,由於 Redux 本身的一些特點,Redux的主打功能是應用狀態可預知、可回溯,同時它也有使用上的成本,比如要求reducer是純函式,store之間的交流需要最佳實踐指導,樣板程式碼較多,可能需要開發同學有一定的FP開發背景。Redux是諸多框架中編碼最為繁瑣,樣板程式碼較多的一個。不過大量的模板程式碼也規範了程式碼風格,大型專案中,Redux更規範易操作擴充套件和維護。

MobX 的資料響應對開發者幾乎完全透明,開發者可以更加自由地組織自己的應用程式的狀態,擁有比較高的易用性和擴充套件性,也易於學習,更加符合OOP的思想,也可以更快地支援業務迭代。使用MobX,使得我們更加可以關注狀態本身和狀態引起的變化,不需要關心那麼多複雜元件是如何組合連線起來的,所有的事情都被簡單優雅的API抽象掉了。不過,MobX自身過於自由的特性也帶來了一些麻煩,由於編碼沒有模板約束,過於自由,容易導致團隊程式碼風格不統一,不同的頁面不同的風格,程式碼難以管理維護,對此往往需要制定統一的團隊編碼規範。

基於我們專案目前的規模,以及迭代速度,同一個頁面相鄰版本的兩次迭代很有可能發生了很大的變化,MobX的簡單易用性最有利於我們的專案進行高強度快速迭代。最終我們在諸多框架中,選擇了使用MobX作為我們專案的狀態管理框架,本文著重分析MobX資料繫結和更新的主流程,以及最佳實踐。

原理分析

使用方法不再詳述,參見MobX.dart官網,我們著重分析一下MobX驅動頁面更新的主流程,包含兩部分:資料繫結與資料更新。分析的程式碼基於MobX的1.1.0版本。

為了更直觀的分析,我們直接使用官網經典的MobX Counter這個demo進行示例,通過debug的堆疊幫助我們去探究MobX中資料的繫結和更新的主流程。

資料繫結流程

Observer和@observable物件一定通過某種方式建立了繫結關係,我們先來研究一下資料的繫結流程。

從reportRead()入手

Atom.reportRead()

在程式碼中獲取顯示的數字counter.value處打一個斷點,從demo app開啟開始,第一次頁面build時,程式碼會執行到生成的.g.dart中去。我們來看value相關的get方法和Atom物件:

final _$valueAtom = Atom(name: '_Counter.value');
@override
int get value {
  _$valueAtom.reportRead();
  return super.value;
}
複製程式碼

生成的.g.dart檔案中有一個Atom物件,其中覆寫了counter.value的get方法,我們每次使用 @observable 標記一個欄位,在 .g.dart中就會生成該欄位的getter 跟 setter 及對應的 Atom物件。Atom物件是對原欄位的一個封裝,當我們讀取couter.value欄位時,會在該 Atom 上呼叫 reportRead():

extension AtomSpyReporter on Atom {
  void reportRead() {
    ...
    reportObserved();
  }
    ...
}

void reportObserved() {
  _context._reportObserved(this);
}
複製程式碼

ReactiveContext._reportObserved()

這個_context,追溯一下可以看到是一個全域性的ReactiveContext單例,註釋寫的比較明白了:

它負責處理 Atom 跟 Reaction(下文會講到) 的依賴關係, 及進行資料方法繫結、分發、解綁等邏輯。

最終走到了context中的_reportObserved方法,這個Atom物件被新增到了一個derivation的_newObservables欄位中,該_newObservables型別為Set:

void _reportObserved(Atom atom) {
  final derivation = _state.trackingDerivation;

  if (derivation != null) {
    derivation._newObservables.add(atom);
    if (!atom._isBeingObserved) {
      atom
        .._isBeingObserved = true
        .._notifyOnBecomeObserved();
    }
  }
}
複製程式碼

Atom物件被型別為Derivation的變數derivation持有在一個_newObservables的Set裡面,我們回到之前打斷點的堆疊,來看一下這裡的derivation到底是什麼。

回到起點

堆疊資訊

下圖為整個頁面從main.dart的runApp開始到MyHomePage這個Widget的build的過程,從debug的堆疊資訊入手:

Observer相關的Widget和Element

首先我們簡單看一下Observer以及相關的Widget和Element的概念。我們通常使用的 Observer這個Widget,它實際上是一個StatelessObserverWidget(繼承自StatelessWidget),其 build方法中的Widget就是builder中返回的widget,該StatelessObserverWidget還mixin了ObserverWidgetMixin,StatelessObserverWidget的Element為StatelessObserverElement,該Element也mixin了ObserverElementMixin,他們之間的關係如圖所示:

ObserverElementMixin.mount()

從該部分開始看起:

@override
void mount(Element parent, dynamic newSlot) {
  _reaction = _widget.createReaction(invalidate, onError: (e, _) {
       ... ));
  }) as ReactionImpl;
  ...
}
複製程式碼

ObserverElementMixin的mount方法給_reaction賦了值,再追溯一下,ObserverWidgetMixin的createReaction方法傳入了上文提到的核心的ReactiveContext單例,建立了Reaction,而Reaction實現了ReactionImpl類,ReactionImpl又實現自Derivation,在Derivation類中我們看到了上一部分提到的_newobservables這個Set:

此時可以有一個初步的猜想:由Observer這個Widget持有了ReactionImpl,ReactionImpl中持有了_newobservables這個Set,在@observable變數被讀取的時候通過對應Atom物件的reportRead方法將該Atom物件新增入了這個Set,這樣就Observer這個Widget通過其中的ReactionImpl間接的持有了@observable物件。

繼續往下看我們來驗證一下。

ObserverElementMixin.build()

按著堆疊資訊走下去。來到ObserverElementMixin的build()方法,呼叫了mount中建立的ReactionImpl的track()方法:

Widget build() {
...
  reaction.track(() {
    built = super.build();
  });
...
}
複製程式碼

ReactionImpl.track() -> ReactiveContext.trackDerivation()

此處剔除掉了大部分和主流程無關的程式碼,如下:

void track(void Function() fn) {
...
  _context.trackDerivation(this, fn);
...
}

//主流程關注這兩句
T trackDerivation<T>(Derivation d, T Function() fn) {
  final prevDerivation = _startTracking(d);
    ...
  result = fn();
    ...
  _endTracking(d, prevDerivation);
    ...
}
複製程式碼

ReactiveContext的trackDerivation()方法接收Derivation引數,這裡傳入自身,來到下面,在_startTracking和_endTracking之間調了fn,這裡的fn就是ObserverElementMixin的build方法中傳入的super.build():

ReactiveContext._startTracking()

_startTracking()中做的是對狀態的更新。其中的_state是個_ReactiveState,就是一個對ReactiveContext單例當前狀態的封裝的類,這裡我們關注trackingDerivation,是當前正在被記錄的一個Derivation。

_startTracking()中最重要的一步是把_state中記錄的trackingDerivation賦值為當前的Derivation(即上方傳入的ReactionImpl),這一步很關鍵,直到_endTracking執行之前,這個state.trackingDerivation都是當前設定的值,並返回一個prevDerivation(上一個記錄的trackingDerivation):

Derivation _startTracking(Derivation derivation) {
  final prevDerivation = _state.trackingDerivation;
  _state.trackingDerivation = derivation;

  _resetDerivationState(derivation);
  derivation._newObservables = {};

  return prevDerivation;
}
複製程式碼

Observer.builder呼叫的位置

在_startTracking和_endTracking之間呼叫了fn,即ObserverElementMixin中傳入的super.build(),熟悉dart mixin語法規則的,也很快清楚這裡呼叫鏈最終會走到Observer這個Widget的build方法,也即我們使用時傳入的builder方法裡面去:

class Observer extends StatelessObserverWidget implements Builder {
    ...
    @override
    Widget build(BuildContext context) => builder(context);
    ...
}
複製程式碼

這時候就回到了一開頭的部分,builder中讀取了counter.value,也即呼叫它的get方法,通過reportRead,最終再通過state.trackingDerivation得到當前正在記錄的derivation物件,並給他的_newObservables的這個Set裡面新增了counter.value對應的封裝的Atom物件。

解決一開始我們提出的問題——持有_newObservables的derivation是什麼?

derivation就是_startTracking()方法中賦值給_state.trackingDerivation的當前ObserverElementMixin中持有的ReactionImpl物件。Observer通過該物件間接持有了我們的@observable物件,也驗證了我們上文的猜想。

回顧下,經過上面_startTracking中將當前的derivation賦值給context.state.trackingDerivation ,以及Observer的builder方法(fn)的呼叫,builder方法中任何對 @observable 物件的 get 方法,都將經過 reportRead,也就是 reportObserved,所以該 @observable 物件就會被新增到當前的 derivation 的 _newObservables 集合上,表示該 derivation 和 @observable 物件的依賴關係,注意此時這樣的繫結關係是單向的,目的是為了收集依賴。真正的資料繫結過程在_endTracking()中。

ReactiveContext._endTracking()

最後再看_endTracking,核心的建立繫結關係的方法是_bindDependencies:

void _endTracking(Derivation currentDerivation, Derivation prevDerivation) {
  _state.trackingDerivation = prevDerivation;//這裡又會把trackingDerivation恢復回去
  _bindDependencies(currentDerivation);
}

void _bindDependencies(Derivation derivation) {
    //derivation裡面實際上有兩個set _observables和_newObservables,分別裝的是之前舊的atom和reportRead裡面新加的atom
    //搞了兩次difference, 把新的和舊的@observable變數分開。舊的清空資料,新的繫結觀察者
  final staleObservables =
      derivation._observables.difference(derivation._newObservables);
  final newObservables =
      derivation._newObservables.difference(derivation._observables);
  var lowestNewDerivationState = DerivationState.upToDate;

  // Add newly found observables
  for (final observable in newObservables) {
    observable._addObserver(derivation);//繫結觀察者
    // Computed = Observable + Derivation
    if (observable is Computed) {
      if (observable._dependenciesState.index >
          lowestNewDerivationState.index) {
        lowestNewDerivationState = observable._dependenciesState;
      }
    }
  }

  // Remove previous observables
  for (final ob in staleObservables) {
    ob._removeObserver(derivation);//解除繫結
  }

  if (lowestNewDerivationState != DerivationState.upToDate) {
    derivation
      .._dependenciesState = lowestNewDerivationState
      .._onBecomeStale();
  }

  derivation
    .._observables = derivation._newObservables
    .._newObservables = {}; // No need for newObservables beyond this point
}

//下面是atom的_addObserver和_removeObserver方法 
//atom中有個observers變數 Set<Derivation>物件,記錄了觀察自己的Derivation。
void _addObserver(Derivation d) {
  _observers.add(d);
  if (_lowestObserverState.index > d._dependenciesState.index) {
    _lowestObserverState = d._dependenciesState;
  }
}
void _removeObserver(Derivation d) {
  _observers.remove(d);
  if (_observers.isEmpty) {
    _context._enqueueForUnobservation(this);
  }
}
複製程式碼

這個方法的邏輯,根據前後兩次build 時Set中收集Atom物件的依賴,分別執行 _addObserver 和 _removeObserver,這樣,每個 @observable 物件上的 observers集合都會是最新的了。

結論

Observer對應的Element——StatelessObserverElement,持有一個Derivation——即ReactionImpl物件reacton**,**而該物件持有一個Set型別的_observables,@observable物件在被讀取呼叫get方法的時候,對應的Atom被新增到了這個Set中去,該Set中的@observable物件對應的Atom在endTracking中呼叫了_addObserver方法,把觀察自己的ReactionImpl新增進observers這個Set中去。從而@obsevable物件對應的Atom持有了Observer這個Widget中的ReactionImpl,Observer就這樣和@observable物件建立了繫結關係。

資料更新流程

知道了Observer和@observable物件是怎樣建立聯絡之後,再來看一下當我們修改@observable物件時候,更新介面邏輯是怎麼觸發的。

reportWrite()

在.g.dart檔案中,覆寫了@observable變數的get方法,會在get時候呼叫對應Atom物件的reportRead(),並且這裡還覆寫了@observable變數的set方法,都會呼叫Atom物件的reportWrite()方法,這個方法做了兩件事情

  1. 更新資料

  2. 把與之繫結 的derivation (即 reaction) 加到更新佇列。

    @override set value(int value) { _$valueAtom.reportWrite(value, super.value, () { super.value = value; }); }

最終可以追溯到這裡:

void propagateChanged(Atom atom) {
  if (atom._lowestObserverState == DerivationState.stale) {
    return;
  }

  atom._lowestObserverState = DerivationState.stale;

 _observers就是上面資料繫結過程中涉及到的atom物件記錄觀察者的Set<Derivation>
  for (final observer in atom._observers) {
    if (observer._dependenciesState == DerivationState.upToDate) {
      observer._onBecomeStale();
    }
    observer._dependenciesState = DerivationState.stale;
  }
}
複製程式碼

ReactionImpl._onBecomStale()

@override
void _onBecomeStale() {
  schedule();
}

void schedule() {
   ...
  _context
    ..addPendingReaction(this)
    ..runReactions();
}
複製程式碼

ReactiveContext.addPendingReaction()

reaction 新增到佇列,reaction也就是上面傳入的ReactionImpl
void addPendingReaction(Reaction reaction) {
  _state.pendingReactions.add(reaction);
}
複製程式碼

ReactiveContext.runReactions

void runReactions() {
   ...
    for (final reaction in remainingReactions) {
      reaction._run();
    }
  
  _state
    ..pendingReactions = []
    ..isRunningReactions = false;
}
複製程式碼

ReactionImpl.run()

@override
void _run() {
    ...
    _onInvalidate();//這裡實際上就是觸發更新的地方
    ...
}
複製程式碼

這邊的_onInvalidate()就是在ObserverElementMixin.mount()裡面createReaction時候傳進去的

@override
void mount(Element parent, dynamic newSlot) {
  _reaction = _widget.createReaction(invalidate, onError: (e, _) {
       ... ));
  }) as ReactionImpl;
  ...
}
複製程式碼

看看invalidate是什麼:

void invalidate() => markNeedsBuild();
複製程式碼

也就是markNeedsBuild標髒操作,這樣Flutter Framework的 buildOwner 會在下一幀重新呼叫 build 方法,就完成了資料更新操作。

結論

至此資料更新的流程也搞明白了,在更改@observable變數的時候,呼叫到Atom物件的reportWrite方法,首先更新了資料,然後把與之繫結的ReactionImpl物件 derivation加到佇列pendingReactions,最終佇列裡面的ReactionImpl呼叫run方法,觸發markNeedsBuild,完成了介面更新。

錯誤示範與最佳實踐舉例

在使用MobX進行狀態管理的過程中,我們也踩了一些坑,總結了最佳實踐,對開發過程中時常遇到的更改資料頁面未被更新的情況做了總結。

因為 MobX 的資料繫結是執行時的,所以需要注意繫結不要寫在控制流語句中,同時也要注意繫結的層級。在此看三個bad case,同時引出最佳實踐。相信在瞭解了框架資料繫結和更新的原理之後,也很容易理解這些bad case出現的原因。

Bad Case 1:

Widget build(BuildContext context) {
  return Observer(builder:(context){
      Widget child;
    if (store.showImage) {
        child = Image.network(
          store.imageURL
        );
      } else {
    // ...
  });

}
複製程式碼

這個例子裡面store.imageURL是一個被@observable標註的欄位。如果在第一次build的過程中,即資料繫結的過程中,store.showImage為false,程式碼走else分支,這樣store.imageURL就沒能和Observer建立繫結關係,後續store.imageURL發生改變,就無法驅動介面更新。

Bad Case 2:

Widget build(BuildContext context) {
  return Observer(builder:(context){
      Widget child;
    if (store.a && store.b && store.c) {
        child = Image.network(
          store.imageURL
        );
      } else {
    // ...
  });

}
複製程式碼

這個例子裡面store.a、store.b還有store.c都是@observable標註的bool變數,遵循大部分語言的邏輯表示式判斷規則,if語句中多個並列的與的條件,如果排列靠前的條件為false,那麼後續的條件不會再被判斷,直接走入else分支。

那麼問題也顯而易見了,如果本意是希望store.a、store.b還有store.c都和Observer繫結關係,如果在第一次build時,store.a為false,那麼b和c均沒有和Observer建立聯絡,這樣b和c的變化就無法驅動該Widget更新。

Bad Case 3:

針對我們開發過程中一個常見的錯誤舉出這個Case:

class WidgetA extends StatelessWidget{
    Widget build(BuildContext context) {
       ...
      Observer(builder:(context){
           return TestWidget();
      });
      ...
    }
}

class WidgetB extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return  GestureDetector(
      child: Text(
        '${counter.value}',
        style: Theme.of(context).textTheme.headline4,
      ),
      onTap: () {
        counter.increment();
      },
    );
  }
}
複製程式碼

這個例子改編自MobX官網經典的counter Demo,counter.value是@observable標註的欄位。編寫者的本意是用Observer包裹了WidgetB,希望GestureDetector的點選事件使得counter.value自增,驅動Observer的Widget的更新,不過我們點選按鈕發現頁面並沒有更新。

根據上述原理分析,資料繫結的過程是在_startTracking 和_endTracking 之間的Observer.build方法的呼叫過程中完成的。而這裡Observer.builder中只是return了TestWidget,也即呼叫了WidgetB的構造方法,WidgetB 的build方法,也即讀取counter.value的方法是在下一層widget構建的過程中,才會被呼叫,因此counter.value未能和它上一層的Observer建立繫結關係,自然也不能夠驅動頁面更新了。

Good

我們針對Bad Case 2提出最佳實踐:

Widget build(BuildContext context) {
  return Observer(builder:(context){
      Widget child;
      bool a = store.a;
      bool b= store.b;
      bool c = store.c;
    if (a && b && c) {
        child = Image.network(
          store.imageURL
        );
      } else {
    // ...
  });
}
複製程式碼

對於 @observable 物件的依賴依次羅列在最開始,而不是寫在if判斷括號中,就可以保證所有變數均和Observer建立了繫結關係。

其它細節與優化點

MobX還有許多其他的細節,比如,context 上的 startBatch 相關,這是因為 action 中可以呼叫其他的action,為了減少不必要的更新通知呼叫,通過batch機制合併 pendingReaction 的呼叫。同理,在 reaction 內部也可以對 @observable物件進行更新,因此也需要 batch 機制合併更改。

MobX也有一些優化點,比如,上述資料更新的reportWrite方法,我們可以diff一下oldValue和value,看二者是否相等,不相等的時候再進行後續流程。

有興趣的讀者可以自行閱讀原始碼探索更多的內容,在此不作詳細分析了。

相關文章