不像 Redux 在 React 中独占鳌头,Flutter 的数据流管理方案层出不穷,本文旨在介绍在2021年值得使用的 Flutter 数据流管理方案,除了大家都比较熟悉的 InheritedWidget 和 provider, 还有 Remi Rousselet 新推出的、令人十分期待的 Riverpod。希望读者对Flutter 已经有一定的基础,并且了解声明式UI。下面就一起开始吧
1. 什么状态才需要使用数据流管理方案?
对于声明式的 UI 而言,UI = f(state),f 是 build 方法,方案的设计首先应该考虑的是能够使得状态的消费者可以获取到对应的数据,在状态更新时被通知到,并可以减少不必要的刷新。
首先,不是所有的状态都需要我们来关心,只有需要当状态变更需要对应的 UI 更新的这部分才是我们关心的。其次,在设计状态结构的时候,需要先考虑到状态分为 Ephemeral State (瞬时状态,也称为本地状态) 和 App State。
- Ephemeral State 是由单一的 widget 所使用的,譬如复杂动画中的运行进度;
- App State 是指保留在 APP 各处、被各个组件共享的,比如用户的登录状态。
一般情况下,可以通过状态被哪些组件使用来组分状态类型。
对于 Ephemeral State, 可以用 StatefulWidget 进行状态管理。
对于 App State, 有以下几种方式可以考虑状态传递与刷新:
- InheritedWidget: Flutter 提供的功能性组件,用来与子孙节点共享数据
- Event Bus:一个全局的单例,相当于是借助全局的静态变量,不是本文的重点,便不多加以介绍
- 数据流框架:Flutter 社区提供了丰富的数据流管理方案选择,比如 下文会提到的 provider / riverpod
2. InheritedWidget
InheritedWidget 是 Flutter 提供的一种非常重要的功能型组件,它的作用是:把祖先节点的状态传递到子孙节点,而不需要通过层层传递参数。InheritedWidget 的属性是 final 的,这也意味着只要刷新其属性就会触发 UI 重建。
2.1 使用方法
具体的使用方法比较简单,就不过多介绍,简单的说一下使用步骤:
- 先通过继承 InheritedWidget 实现一个保存状态与状态更改方法的 widget
- 将这个 widget 放在需要使用该状态的最小子树的顶层
- 在需要用到状态的子树中使用 of 方法获取状态
下面来看看 InheritedWidget 是如何工作的。
2.2 如何建立节点间的数据依赖关系?
在子组件调用 of 方法的时候,会继续调用到 BuildContext.getElementForInheritedWidgetOfExactType, 建立两个节点之间的依赖关系。
每个 Element 都维护了这两个数据:
- _inheritedWidgets 建立所有祖先遗传节点, 类型为 Map<Type, InheritedElement>
- _dependencies 只记录自己依赖的祖先遗传节点
而 InheritedElement 则维护了依赖于自己的后代节点的列表 dependents,通过 InheritFromWidgetOfExactType,如果找到相应类型的最近的遗传节点,则将该遗传结点添加到自己的 dependencies 中,同时会调用祖先遗传结点的 InheritedElement.updateInheritance 方法,将自己注册到InheritedElement 的 _dependents 表中。
2.3 为什么需要通过 BuildContext 获取数据?
子组件是通过 XXDataWidget.of(context).data 来获取数据的,为什么这里会需要传入一个 context 呢?因为 context 其实是 widget 所对应的 element,通过 of 方法,调用到子组件自己对应的 element 实例上的 getElementForInheritedWidgetOfExactType,通过该方法,取得实例上的祖先遗传节点(_inheritedWidgets)。如下源码所示:
代码语言:javascript复制@override
InheritedElement getElementForInheritedWidgetOfExactType<T extends InheritedWidget>() {
assert(_debugCheckStateIsActiveForAncestorLookup());
final InheritedElement ancestor = _inheritedWidgets == null ? null : _inheritedWidgets[T];
return ancestor;
}
但是请注意:_inheritedWidgets 以类型为索引,保存了所有类型的祖先节点,由于是用 Map 存储,所以永远只能找到最近的同类型祖先节点,这也是一个无可规避的缺点。
2.4 更新机制
当需要更新时,会调用一下 InheritedWidget.updateShouldNotify(通常这个会由业务方覆写) 确认一下是否需要通知,如果确实需要通知,则会遍历 _denpendents(依赖于自己的后代节点的列表) 并调用Element.didChangeDependencies。
3. provider
尽管 InheritedWidget 已经提供了一套数据共享的方案,但是显然它所带来的模板代码太多了,需要写很多重复的代码,比如覆写注册依赖和通知函数,对于复杂一点的应用,需要一套更加方便的数据管理方案,减少不必要的模板代码、参数传递依赖。
provider 是当前官方推荐的状态管理方式之一,也已经迭代到了4.3.3,是现阶段数据管理方案的一个比较稳妥的选择。
3.1 使用方法
Provider 的使用方式颇多,下文已经介绍的非常详细,可供大家参考
Flutter | 状态管理指南篇--Provider https://juejin.cn/post/6844903864852807694
3.2 简易版实现
Provider 也是基于 InheritedWidget 共享数据的思想实现的,事实上我们自己也可以对 InheritedWidget 做一个简单的封装,实现一个 mini_provider,建议读者可以通过阅读 Flutter实战的 provider 章节并动手实现一个简单的 provider 以便加深理解?
跨组件状态共享(Provider) https://book.flutterchina.club/chapter7/provider.html
3.3 provider 与 MVVM
在业务开发的过程中,刚开始的时候不太熟悉 provider,真的会写出一堆性能不是很好的代码,主要有以下这两个问题:
- 尽管 provider 已经帮我们做了很多优化,包括懒加载等,但如果没有用好 Selector 增强或者 Consumer,再有 ViewModel 层有不必要的重建之类,还是会导致页面不必要的刷新
- 不同页面数据有依赖关系或者包含关系时,不好做数据依赖刷新。
针对第二个问题,需要我们做好项目的架构设计,Flutter 本身并没有局限于哪种模式,使用者完全可以根据自己的喜好,使用 MVC / MVVM 或者其他任何自己喜欢的架构。引入 provider 之后,我们可以很方便的将软件架构设计为 MVVM。
使用 MVVM 架构,首先来区分定义好以下这几个概念:
- View:用户所看到的界面、响应用户交互
- Model: 持有数据
- ViewModel:持有需要处理过后的数据,作为 View 层和 Model 层的接口;并存放一些其他的函数,帮助维护界面状态
- Repository:实现 Model 层,从 database 或者 api 接口获取数据
- Bean: 实体类,定义数据单项
为了减少不必要刷新带来的影响,应当要划分清楚 ViewModel 和 Model 层的界限,使得 ViewModel 不持有 Model 层,这样可以规避 VM 重建时 Model 层重建,结构如下所示:
3.4 封装通用的页面容器
在 业务场景中,绝大多数页面都是需要通过 api 请求获取数据,根据返回结果页面显示:加载中、正常页面、空状态、网络错误、其他错误这么几种情况。因此,可以抽象把这个过程抽离出一个通用的容器,注意的是 Flutter 的 UI 型组件的设计倾向于组合而不是继承,而对于功能型组件则多使用继承和 mixin。
封装中用到的几个类如下:
- ChangeNotifier:是 Flutter 实现的一个监听-订阅类
- NormalPageState:页面状态枚举值
- NormalPageController:负责页面状态变化,并继承 ChangeNotifier,可以把变化通知给订阅者,在页面的 VM 层来 with 混入 NormalPageController, VM 层便具备了可以改变页面当前状态的能力
- ContainerStateManger:根据 NormalPageState 的不同页面状态展示不同的内容,传入 VM 的泛型,在内部通过 provider 订阅状态变化。
3.png
3.5 缺点
尽管 provider 是现在最受欢迎的数据管理方案之一了,但其实 provider 并不完美,它仍然存在以下几个问题:
- provider 是依赖于 Flutter 的,依赖注入会与 UI 代码耦合
- 由于 provider 是基于 InheritedWidget 实现的,永远只能找到距离最近的同类型状态
- 需要在运行时才能发现是否可获取状态
除此之外,还有其他的 issues 由于在 provider 过于底层且 provider 使用人数过多,provider 的作者 Remi Rousselet 认为几乎是不可能改的,因此他启动了 riverpod,虽然 riverpod 目前尚未达到一个稳定版本,但它不仅继承了 provider 的使用宗旨,还解决了以上的三个问题,使其与 flutter 独立,是2021年最值得期待的数据管理方案了。
4. Riverpod
Riverpod 的口号是:provider but different。可以先在官网大致了解下它的设计初衷与使用。
4.1 使用
4.1.1 state 存放在哪里?
一般情况下,在整个 widget 树的最外层包上一个 ProviderScope,state 存放于此处,当然如果想覆盖上一层的state 的话,可以使用多个 ProviderScope
代码语言:javascript复制 void main() {
runApp(
ProviderScope(
child: MyApp(),
),
);
}
4.1.2 如何监听 provider 的变化并触发 UI 重建?
方法1:flutter_riverpod 提供了一个 ConsumerWidget,它会在 build 函数中多提供了一个 ScopedReader 函数来从 provider 中获取值并使 state 变更触发 UI 重建
typedef ScopedReader = T Function<T>(ProviderBase<Object, T> provider);
final helloProvider = Provider((_) => 'Hello World');
class HomePage extends ConsumerWidget {
@override
Widget build(BuildContext context, ScopedReader watch) {
final String value = watch(helloProvider);
return Scaffold(
appBar: AppBar(title: Text('Example')),
body: Center(
child: Text(value),
)
);
}
}
方法2:我们还是可以使用 Consumer Widget, Consumer 提供了一个 ScopedReader 参数
代码语言:javascript复制 class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Example')),
body: Center(
child: Consumer(
builder: (context, watch, child) {
final String value = watch(helloProvider);
return Text(value);
},
),
)
);
}
}
4.1.3 如何取到 provider?
若非在 build 函数中,可以使用 context.read 获得 provider
代码语言:javascript复制class IncrementNotifier extends ChangeNotifier {
int _value = 0;
int get value => _value;
void increment() {
_value = 1;
notifyListeners();
}
}
final incrementProvider = ChangeNotifierProvider((ref) => IncrementNotifier());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Riverpod Tutorial',
home: Scaffold(
body: Center(
child: Consumer(
builder: (context, watch, child) {
final incrementNotifier = watch(incrementProvider);
return Text(incrementNotifier.value.toString());
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
context.read(incrementProvider).increment();
},
child: Icon(Icons.add),
),
),
);
}
}
4.2 优点
- Riverpod 可以向上获取同类型的数据了
final firstStringProvider = Provider((ref) => 'First String Data');
final secondStringProvider = Provider((ref) => 'Second String Data');
// Somewhere inside a ConsumerWidget, we can distinguish two String
final first = watch(firstStringProvider);
final second = watch(secondStringProvider);
- 依赖更加简单了
class FakeHttpClient {
Future<String> get(String url) async {
await Future.delayed(const Duration(seconds: 1));
return 'Response from $url';
}
}
final fakeHttpClientProvider = Provider((ref) => FakeHttpClient());
final responseProvider = FutureProvider<String>((ref) async {
final httpClient = ref.read(fakeHttpClientProvider);
return httpClient.get('https://baidu.com');
});
五. 总结
最后,把以上的三种数据流管理方案做一个小结供大家选择时对比下
方案 | 优点 | 缺点 |
---|---|---|
InheritedWidget | 1. Flutter 自带的数据流管理方案 | 1. 太多模板代码2. 只能获取最近的同类型状态 |
provier | 1. 非常全面的数据流管理方案,方便数据共享内部做了很多控制刷新的优化,使用者心智负担不高2. 使用人数多,比较稳定 | 1. provider 是依赖于 Flutter 的,依赖注入会与 UI 代码耦合2. 由于 provider 是基于 InheritedWidget 实现的,永远只能找到距离最近的同类型状态3. 需要在运行时才能发现是否可获取状态 |
Riverpod | 1. provider 原作者开发,解决了 provider 的三个缺点2. 不和 Flutter 耦合,并且提供了另外一个包支持 flutter_hooks | 1. 目前还属于 beta 版本 |
Riverpod 相当于是另外一个版本的 provider,但又集成了其他优点,是2021年最值得期待的数据管理方案了,如果你正在开始一个新项目的话,建议不妨试下 Riverpod,另外,flutter_hooks 同样也值得上手,可以取代 StatefulWidget?