Flutter 中状态管理是一个经久不衰的话题,当下市面上也有诸如 provider 、get 、fish_redux 等框架。自接触 flutter 开发以来,我大致经历了无状态管理 、简单的状态抽象,再到目前使用的是公司内部一个类似 provider 的解决方案。加上最近看到 张风捷特烈 的 对状态管理的看法与理解 和 小呆呆666 的 Flutter 对状态管理的认知与思考 。 我也结合过往经验和大家分享下我对于状态管理的看法和主流框架(如 provider、get )的思路分析,以及我在实践过程中踩过的一些坑。


一、为什么需要状态管理:解决响应式开发带来的问题

首先,为什么 flutter 开发中需要状态管理?在我看来,本质是因为 Flutter 响应式 的构建带来的一系列问题。传统原生开发采用 控制式 的构建,这是两种完全不同的思路,所以我们没有在原生开发中听到过状态管理一说。

1、「响应式」 VS 「控制式」分析

那么怎么理解「响应式」和「控制式」?这里我们还是用最简单的计数器例子分析:

Flutter 主流状态管理框架 provider get 分析与思考

如图,点击右下角按钮,显示的文本数字加一。 这个非常简单的功能在控制式的构建模式下,应该是这么思考。

Flutter 主流状态管理框架 provider get 分析与思考

当右下角按钮点中时,拿到中间 TextView 的对象,手动设置其展示的文本。代码如下:

/// 展示的数量
private int mCount = 0;
/// 中间展示数字的 TextView
private TextView mTvContent;
/// 右下角按钮调用的方案
private void increase() {
  mCount++;
  mTvContent.setText(mCount);
}

而在 flutter 中,我们只需要 _counter++ ,之后调用 setState((){})即可。setState 会刷新整个页面,使的中间展示的值不断变化。

Flutter 主流状态管理框架 provider get 分析与思考

这是两种完全不同的开发思路,控制式的思路下,开发者需要拿到每一个 View 的实例处理显示。 而响应式的思路下,我们只需要处理状态(数据)以及状态对应的展示(Widget)即可,剩余的都交给了 setState()。所以有这么一种说法

UI = f(state)

上面的例子中,state 就是 _counter 的值,调用 setState 驱动 f (build 方法)生成新的 UI。

那么「响应式」开发有哪些 优点 以及 问题 呢?

2、响应式开发的优点:让开发者摆脱组件的繁琐控制,聚焦于状态处理

响应式开发最大的优点我认为是 让开发者摆脱组件的繁琐控制,聚焦于状态处理。 在习惯 flutter 开发之后,我切回原生最大的感受是,对于 View 的控制太麻烦了,尤其是多个组件之间如果有相互关联的时候,你需要考处理的东西非常爆炸。而在 flutter 中我们只需要处理好状态即可(复杂度在于状态 -> UI 的映射,也就是 Widget 的构建)。

举个例子,假如你现在是一家公司的 CEO,你制定了公司员工的工作计划。控制式的开发下,你需要推动每一个员工(View)完成他们的任务。

Flutter 主流状态管理框架 provider get 分析与思考
如果你的员工越来越多,或者员工之间的任务有关联,可想而知你的工作量会有多大。

这种情况下 不是你当上了老板,而是你在为所有的员工(View)打工。

一张图来说,控制式的开发就是

Flutter 主流状态管理框架 provider get 分析与思考

这时候你琢磨,你都已经当上 CEO,干嘛还要处理这种细枝末节的小事,所以响应式开发来了。

响应式开发下,你只需要处理好每个员工的计划(状态),只等你一声令下(setState),每个员工(Widget)便会自己按照计划展示(build),让你着实体会到了 CEO 的乐趣。

Flutter 主流状态管理框架 provider get 分析与思考

一张图来说,响应式开发就是

Flutter 主流状态管理框架 provider get 分析与思考

如 jetpack compose,swift 等技术的最新发展,也是朝着「响应式」的方向前进,恋猫de小郭 也聊过。学会 flutter 之后离 compose 也不远了。

响应式开发那么优秀,它会存在哪些问题呢?

3、响应式开发存在的问题:状态管理解决的目标

我一开始接触 flutter 的时候,并没有接触状态管理,而是使用最原始的「响应式」开发。过程中遇到了很多问题,总结下来主要的有三个

逻辑和页面 UI 耦合,导致无法复用/单元测试,修改混乱等

一开始所有代码都是直接写到 widget 中,这就导致 widget 文件臃肿,并且一些通用逻辑,例如网络请求与页面状态、分页等,不同页面重复的写(CV)。这个问题在原生上同样存在,所以后面也衍生了诸如 MVP 之类的思路去解决。

难以跨组件(跨页面)访问数据

跨组件通信可以分为两种,「1、父组件访问子组件」和「2、子组件访问父组件」。第一种可以借助 Notification 机制实现,而第二种在没有接触到 element 树的时候,我使用 callback。如果遇到 widget 嵌套两层左右,就能体会到是何等的酸爽。

这个问题也同样体现在访问数据上,比如有两个页面,他们中的筛选项数据是共享,并没有一个很优雅的机制去解决这种跨页面的数据访问。

无法轻松的控制刷新范围(页面 setState 的变化会导致全局页面的变化)

最后一个问题也是上面提到的优点,很多场景我们只是部分状态的修改,例如按钮的颜色。但是整个页面的 setState 会使的其他不需要变化的地方也进行重建(build),我之前也总结过 原来我一直在错误的使用 setState()?

在我看来,Flutter 中状态管理框架的核心在于这三个问题的解决思路。下面一起看看一些主流的框架比如 provider、get 是如何解决?


二、provider、get 状态管理框架设计分析:如何解决上面三个问题?

1、逻辑和页面 UI 耦合

传统的原生开发同样存在这个问题,Activity 也存在爆炸的可能,所以有 MVP 框架进行解耦。简单来说就是将 View 中的逻辑代码抽离到 Presenter 层,View 只负责视图的构建。

Flutter 主流状态管理框架 provider get 分析与思考

这也是 flutter 中几乎所有状态管理框架的解决思路,上面的 Presenter 你可以认为是 get 中的 GetxController、provider 中的 ChangeNotifier,bloc 中的 Bloc。值得一提的是,具体做法上 flutter 和原生 MVP 框架有所不同。

我们知道在传统 MVP 模型中,逻辑处理收敛到 Presenter 中,View 专注 UI 构建,一般 View 和 Presenter 以接口定义自身行为(action),相互持有接口进行调用 (也有省事儿直接持有对象的)。

Flutter 主流状态管理框架 provider get 分析与思考

但 Flutter 中不太适合这么做,从 Presenter → View 关系上View 在 Flutter 中对应 Widget,而 Widget 的生命周期外部是无感知的,直接拿 Widget 实例并不是好的做法。原生中有 View.setBackground 的方法,但是 flutter 中你不会去定义和调用 Widget.xxx。这一点在 flutte 中我们一般结合着局部刷新组件做 Presenter(例如 ValueListenable) -> View(ValueListenableBuilder) 的控制。

而在从 View → Presenter 的关系上,Widget 可以确实可以直接持有 Presenter,但是这样又会带来难以数据通信的问题。这一点不同状态管理框架的解决思路不一样,从实现上他们可以分为两大类,一类是 provider,bloc 这种,基于 Flutter 树机制,另一类是 get 这种通过 依赖注入 实现。下面具体看看:

A、Provider、Bloc 依赖树机制的思路

首先需要简单了解一下 Flutter 树机制是怎么回事。

我们在 flutter 中通过嵌套各种 widget,构成了一个 Widget 树。如果这时有一个节点 WidgetB 想要获取 WidgetA 中 定义的 name 属性,该怎么做?

Flutter 主流状态管理框架 provider get 分析与思考

Flutter 在 BuildContext 类中为我们提供了方法进行向上和向下的查找

abstract class BuildContext {
///查找父节点中的T类型的State 
T findAncestorStateOfType<T extends State>();
///遍历子元素的element对象 
void visitChildElements(ElementVisitor visitor);
///查找父节点中的T类型的 InheritedWidget 例如 MediaQuery 等 
T dependOnInheritedWidgetOfExactType<T extends InheritedWidget>({ Object aspect })
...... }

这个 BuildContext 对应我们在每个 Widget 的 build(context) 方法中的 context。你可以把 context 当做树中的一个实体节点。借助 findAncestorStateOfType 方法,我们可以一层一层向上的访问到 WidgetA,获取到 name 属性。

Flutter 主流状态管理框架 provider get 分析与思考

调用的 findAncestorStateOfType() 方法,会一级一级父节点的向上查找,很显然,查找快慢取决于树的深度,时间复杂度为 O(logn)。而数据共享的场景在 Flutter 中非常常见,比如主题,比如用户信息等,为了更快的访问速度,Flutter 中提供了 dependOnInheritedWidgetOfExactType() 方法,它会将 InheritedWidget 存储到 Map 中,这样子节点的查找的时间复杂变成了 O(1)。不过这两种方法本质上都是通过树机制实现,他们都需要借助 「context」

看到这里相信你应该差不多明白了,bloc、provider 正是借助这种树机制,完成了 View -> Presenter 的获取。所以每次用 Provider 的时候你都会调用 Provider.of<T>(context)

Flutter 主流状态管理框架 provider get 分析与思考

这么做有啥好处么?显然,所有 Provider 以下的 Widget 节点,都可以通过自身的 context 访问到 Provider 中的 Presenter,这很好的解决了跨组件的通信问题,但依赖 context 我们在实践中也遇到了一些问题,我会在下一篇文章介绍。更多 View 与 Presenter 之间交互的规范设计,我非常推荐 Flutter 对状态管理的认知与思考。

多提一嘴,看到这种 .of(context) 的做法你有没有很眼熟?没错,Flutter 中路由也是基于这个机制,有关路由你可看看我之前写过的 如何理解 Flutter 路由源码设计,Flutter 树机制可以看看 Widget、Element、Render是如何形成树结构? 这一系列。

B、get 通过依赖注入的方式

树机制很不错,但他依赖于 context,这一点有时很让人抓狂。get 通过依赖注入的方式,实现了对 Presenter 层的获取。简单来说,就是将 Presenter 存到一个单例的 Map 中,这样在任何地方都能随时访问。

Flutter 主流状态管理框架 provider get 分析与思考

全局单例存储一定要考虑到 Presenter 的回收,不然很有可能引起内存泄漏。使用 get 要么你手动在页面 dispose 的时候做 delete 操作,要么你使用 GetBuilder ,其实它里面也是在 dispose 去做了释放。

@override
void dispose() {
  super.dispose();
    if (widget.autoRemove && GetInstance().isRegistered<T>(tag: widget.tag)) {
      // 移除 Presenter 实例
      GetInstance().delete<T>(tag: widget.tag);
    }
}

你可能在想,为什么使用 Provider 的时候不需要考虑这个问题?

这是因为一般页面级别的 Provider 总是跟随 PageRoute。随着页面的退出,整树中的节点都被会回收,所以可以理解为系统机制为我们解决了这个问题。

Flutter 主流状态管理框架 provider get 分析与思考

当然如果你的 Provider 层级特别高,比如在 MaterialApp 一级,这时你存储的 Presenter 也往往是一些全局的逻辑,它们的生命周期往往跟随整个 App。

2、难以跨组件(跨页面)访问数据

两类状态管理方案都能支持跨组件访问数据,在 provider 中我们通过 context 。

而跨页面访问数就像上图所说,一般 Provider 的存储节点是跟随页面,要想实现跨页面访问那么 Provider 的存储节点需要放在一个更高的位置,但同样需要注意回收的处理。而 get 因为是全局单例,无论是跨页面或者跨组件,都没有任何依赖。

3、无法轻松的控制刷新范围

这一点解法其实很多,比如系统提供的 StreamChangeNotifierValueListenable 等等。他们本质上都是通过建立 View 与数据的绑定机制,当数据发生变化的时候,响应的组件随着变化,避免额外的构建。

/// 声明可能变化的数据
ValueNotifier<int> _statusNotifier;
ValueListenableBuilder<int>(
    // 建立与 _statusNotifier 的绑定关系
    valueListenable: _statusNotifier,
    builder: (c, data, _) {
      return Text('$data');
 })
/// 数据变化驱动 ValueListenableBuilder 局部刷新
_statusNotifier.value += 1;

这里提一点,一开始在看 get 的 Obx 组件使用时真的惊艳到了我。

class Home extends StatelessWidget {
  var count = 0.obs;
  @override
  Widget build(context) => Scaffold(
      body: Center(
        child: Obx(() => Text("$count")),
      ),
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.add),
        onPressed: () => count ++,
      ));
}

关键代码就三行

/// 申明变量
var count = 0.obs;
/// 响应组件
Obx(() => Text("$count"))
/// 变量修改,同步 Obx 变化
count ++

What?这么简单? Obx 怎么知道他应该观察哪个变量呢?

这个机制的实现,总结下来有两点:1、变量置为变成可观察态 2、响应组件与变量建立联系。我们简单看看:

1、变量可观察态

观察他的变量申明方式,你会发现 count 不是普通的 int 类型,而是 0.obsobs 这个扩展方法会返回一个 RxInt 类型的对象,这种对象核心在于他的 getset 方法。

T get value {
  if (RxInterface.proxy != null) {
    RxInterface.proxy!.addListener(subject);
  }
  return _value;
}
set value(T val) {
   // ** 省略非关键代码 **
  _value = val;
  subject.add(_value);
}

我们可以把这个 RxInt 类型的对象想象成一个大小姐,里面的 subject大小姐的丫鬟,每个大小姐只有一个丫鬟RxInterface.proxy 是一个静态变量,还没出现过咱们暂时把他当做小黑就行。

Flutter 主流状态管理框架 provider get 分析与思考

get value 方法中我们可以看到,每次调用 RxInt 的 get 方法时,小黑都会去关注我们的丫鬟动态。

set value 时,大小姐都会通知丫鬟。

所以小黑到底是谁?

2、响应组件与变量建立联系

真相只有一个,小黑就是我们的 Obx 组件,查看 Obx 内部代码可以看到:

@override
Widget build(BuildContext context) => notifyChilds;
Widget get notifyChilds {
  // 先暂时把 RxInterface.proxy 的值存起来,build 完恢复
  final observer = RxInterface.proxy;
  // 每个 Obx 都有一个 _observer 对象
  RxInterface.proxy = _observer;
  final result = widget.build();
  RxInterface.proxy = observer;
  return result;
}

Obx 在调用 build 方法时,会返回 notifyChilds,这个 get 方法中将 _observer 赋给了 RxInterface.proxy_observer 和 Obx 我们认为他是一个 渣男 就行。

有了上面的认知,现在我们捋一遍整个过程

      body: Center(
        child: Obx(() => Text("$count")),
      ),

首先,在页面的 build 方法中返回了 Obx 组件,这个时候,也就是我们的渣男登场了,现在他就是小黑

Flutter 主流状态管理框架 provider get 分析与思考

在 Obx 组件内调返回了 Text("$count")),其中 $count 其实翻译为 count.toString(),这个方法被 RxInt 重写 ,他会调用 value.toString()

@override
String toString() => value.toString();

所以 $count 等价于 count.value.toString()。还记得我们上面说过 get 方法调用的时候,小黑会去关注丫鬟么,所以现在变成了

Flutter 主流状态管理框架 provider get 分析与思考

这一天,大小姐心情大好,直接 count++,仔细一看,原来 count++ 也被重写了,调用了 value =

RxInt operator +(int other) {
  value = value + other;
  return this;
}

上面咱提过大小姐的 set 方法时,她会通知丫鬟。而渣男时刻注意着丫鬟,一看到丫鬟发生了变化,渣男不得快速响应,马上就来谄媚了。

Flutter 主流状态管理框架 provider get 分析与思考

整个流程可以按照上面方式理解,好,为什么我们说 Obx 是个渣男呢。因为只要是在他 build 阶段,所有调用过 get 方式的 Rx 变量他都可以观察。也就是说只要其中任意一个变量调用 set value 都会触发他的重建。

正经版可以看看 Flutter GetX深度剖析 | 我们终将走出自己的路(万字图文) 。

总的来说,这个设计确实还蛮巧妙的。Obx 在 build 阶段会间接观察所有里面调用过 get value 方法的 Rx 类型变量。但这会带来一个问题,必须在 build 阶段显式调用 get value,否则无法建立绑定关系。

但像 LsitView 一类的组件,子节点 build 是在在 layout 过程中进行,如果你没有提前调用 get value 这时就会产生错误。例如下方代码

Center(
  child: Obx(() => ListView.builder(
        itemBuilder: (i, c) => Text('${count}'),
        itemCount: 10,
      )),
),

Flutter 主流状态管理框架 provider get 分析与思考

当然,get 中还提供了 GetBuilder 处理局部刷新,其他的问题我们留着下一期进行分析。


三、总结

每个人心中可能都有一些对于状态管理的看法,以上就是我的思考,欢迎在评论区留下你的看法。总的来看,本质都是为了解决提到的三个问题,不过不同的框架可能会有不同的做法。你也可以参考这个思路去分析你当前使用的框架是如设计,这样才会接触到到框架的精髓,而非仅局限于框架的使用。下一期,我会结合框架的设计与日常使用,分享我在开发过程中使用 provider 和 get 遇到的问题,以及我认为不合理的设计。

如果你有任何疑问可以通过公众号与联系我,如果文章对你有所启发,希望能得到你的点赞、关注和收藏,这是我持续写作的最大动力。Thanks~

公众号:进击的Flutter或者 runflutter 里面整理收集了最详细的Flutter进阶与优化指南,欢迎关注。

往期精彩内容:

Flutter 进阶优化

Flutter核心渲染机制

Flutter路由设计与源码解析