MobX流程分析与最佳实践
作者:大力智能技术团队-客户端 旺酱
背景
大力家长APP在首页多个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,看二者是否相等,不相等的时候再进行后续流程。
有兴趣的读者可以自行阅读源码探索更多的内容,在此不作详细分析了。
转载自:https://juejin.cn/post/6961668123405582349