背景
大力輔導專案在首頁多個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()方法,這個方法做了兩件事情
-
更新資料
-
把與之繫結 的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,看二者是否相等,不相等的時候再進行後續流程。
有興趣的讀者可以自行閱讀原始碼探索更多的內容,在此不作詳細分析了。