You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

303 lines
15 KiB
Markdown

2 years ago
# 30 | 为什么需要做状态管理,怎么做?
你好,我是陈航。
在上一篇文章中我与你分享了如何在原生混编Flutter工程中管理混合导航栈应对跨渲染引擎的页面跳转即解决原生页面如何切换到Flutter页面以及Flutter页面如何切换到原生页面的问题。
如果说跨渲染引擎页面切换的关键在于如何确保页面跳转的渲染体验一致性那么跨组件页面之间保持数据共享的关键就在于如何清晰地维护组件共用的数据状态了。在第20篇文章“[关于跨组件传递数据,你只需要记住这三招](https://time.geekbang.org/column/article/116382)”中我已经与你介绍了InheritedWidget、Notification和EventBus这3种数据传递机制通过它们可以实现组件间的单向数据传递。
如果我们的应用足够简单数据流动的方向和顺序是清晰的我们只需要将数据映射成视图就可以了。作为声明式的框架Flutter可以自动处理数据到渲染的全过程通常并不需要状态管理。
但,随着产品需求迭代节奏加快,项目逐渐变得庞大时,我们往往就需要管理不同组件、不同页面之间共享的数据关系。当需要共享的数据关系达到几十上百个的时候,我们就很难保持清晰的数据流动方向和顺序了,导致应用内各种数据传递嵌套和回调满天飞。在这个时候,我们迫切需要一个解决方案,来帮助我们理清楚这些共享数据的关系,于是状态管理框架便应运而生。
Flutter在设计声明式UI上借鉴了不少React的设计思想因此涌现了诸如flutter\_redux、flutter\_mobx 、fish\_redux等基于前端设计理念的状态管理框架。但这些框架大都比较复杂且需要对框架设计概念有一定理解学习门槛相对较高。
而源自Flutter官方的状态管理框架Provider则相对简单得多不仅容易理解而且框架的入侵性小还可以方便地组合和控制UI刷新粒度。因此在Google I/O 2019大会一经面世Provider就成为了官方推荐的状态管理方式之一。
那么今天我们就来聊聊Provider到底怎么用吧。
## Provider
从名字就可以看出Provider是一个用来提供数据的框架。它是InheritedWidget的语法糖提供了依赖注入的功能允许在Widget树中更加灵活地处理和传递数据。
那么,什么是依赖注入呢?通俗地说,依赖注入是一种可以让我们在需要时提取到所需资源的机制,即:预先将某种“资源”放到程序中某个我们都可以访问的位置,当需要使用这种“资源”时,直接去这个位置拿即可,而无需关心“资源”是谁放进去的。
所以为了使用Provider我们需要解决以下3个问题
* 资源(即数据状态)如何封装?
* 资源放在哪儿,才都能访问得到?
* 具体使用时,如何取出资源?
接下来我通过一个例子来与你演示如何使用Provider。
在下面的示例中我们有两个独立的页面FirstPage和SecondPage它们会共享计数器的状态其中FirstPage负责读SecondPage负责读和写。
在使用Provider之前我们**首先需要在pubspec.yaml文件中添加Provider的依赖**
```
dependencies:
flutter:
sdk: flutter
provider: 3.0.0+1 #provider依赖
```
添加好Provider的依赖后我们就可以进行数据状态的封装了。这里我们只有一个状态需要共享即count。由于第二个页面还需要修改状态因此我们还需要在数据状态的封装上包含更改数据的方法
```
//定义需要共享的数据模型通过混入ChangeNotifier管理听众
class CounterModel with ChangeNotifier {
int _count = 0;
//读方法
int get counter => _count;
//写方法
void increment() {
_count++;
notifyListeners();//通知听众刷新
}
}
```
可以看到我们在资源封装类中使用mixin混入了ChangeNotifier。这个类能够帮助我们管理所有依赖资源封装类的听众。当资源封装类调用notifyListeners时它会通知所有听众进行刷新。
**资源已经封装完毕,接下来我们就需要考虑把它放到哪儿了。**
因为Provider实际上是InheritedWidget的语法糖所以通过Provider传递的数据从数据流动方向来看是由父到子或者反过来。这时我们就明白了原来需要把资源放到FirstPage和SecondPage的父Widget也就是应用程序的实例MyApp中当然把资源放到更高的层级也是可以的比如放到main函数中
```
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
//通过Provider组件封装数据资源
return ChangeNotifierProvider.value(
value: CounterModel(),//需要共享的数据资源
child: MaterialApp(
home: FirstPage(),
)
);
}
}
```
可以看到既然Provider是InheritedWidget的语法糖因此它也是一个Widget。所以我们直接在MaterialApp的外层使用Provider进行包装就可以把数据资源依赖注入到应用中。
这里需要注意的是由于封装的数据资源不仅需要为子Widget提供读的能力还要提供写的能力因此我们需要使用Provider的升级版ChangeNotifierProvider。而如果只需要为子Widget提供读能力直接使用Provider即可。
**最后在注入数据资源完成之后我们就可以在FirstPage和SecondPage这两个子Widget完成数据的读写操作了。**
关于读数据与InheritedWidget一样我们可以通过Provider.of方法来获取资源数据。而如果我们想写数据则需要通过获取到的资源数据调用其暴露的更新数据方法本例中对应的是increment代码如下所示
```
//第一个页面,负责读数据
class FirstPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
//取出资源
final _counter = Provider.of<CounterModel>(context);
return Scaffold(
//展示资源中的数据
body: Text('Counter: ${_counter.counter}'),
//跳转到SecondPage
floatingActionButton: FloatingActionButton(
onPressed: () => Navigator.of(context).push(MaterialPageRoute(builder: (context) => SecondPage()))
));
}
}
//第二个页面,负责读写数据
class SecondPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
//取出资源
final _counter = Provider.of<CounterModel>(context);
return Scaffold(
//展示资源中的数据
body: Text('Counter: ${_counter.counter}'),
//用资源更新方法来设置按钮点击回调
floatingActionButton:FloatingActionButton(
onPressed: _counter.increment,
child: Icon(Icons.add),
));
}
}
```
运行代码,试着多点击几次第二个界面的“+”按钮,关闭第二个界面,可以看到第一个界面也同步到了按钮的点击数。
![](https://static001.geekbang.org/resource/image/8e/45/8e13ca8e62920a403b00122136a46245.gif)
图1 Provider使用示例
## Consumer
通过上面的示例可以看到使用Provider.of获取资源可以得到资源暴露的数据的读写接口在实现数据的共享和同步上还是比较简单的。但是**滥用Provider.of方法也有副作用那就是当数据更新时页面中其他的子Widget也会跟着一起刷新。**
为验证这一点我们以第二个界面右下角FloatingActionButton中的子Widget “+”Icon为例做个测试。
首先为了打印出Icon控件每一次刷新的情况我们需要自定义一个控件TestIcon并在其build方法中返回Icon实例的同时打印一句话
```
//用于打印build方法执行情况的自定义控件
class TestIcon extends StatelessWidget {
@override
Widget build(BuildContext context) {
print("TestIcon build");
return Icon(Icons.add);//返回Icon实例
}
}
```
然后我们用TestIcon控件替换掉SecondPage中FloatingActionButton的Icon子Widget
```
class SecondPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
//取出共享的数据资源
final _counter = Provider.of<CounterModel>(context);
return Scaffold(
...
floatingActionButton:FloatingActionButton(
onPressed: _counter.increment,
child: TestIcon(),//替换掉原有的Icon(Icons.add)
));
}
```
运行这段实例,然后在第二个页面多次点击“+”按钮,观察控制台输出:
```
I/flutter (21595): TestIcon build
I/flutter (21595): TestIcon build
I/flutter (21595): TestIcon build
I/flutter (21595): TestIcon build
I/flutter (21595): TestIcon build
```
可以看到TestIcon控件本来是一个不需要刷新的StatelessWidget但却因为其父Widget FloatingActionButton所依赖的数据资源counter发生了变化导致它也要跟着刷新。
那么,**有没有办法能够在数据资源发生变化时只刷新对资源存在依赖关系的Widget而其他Widget保持不变呢**
答案当然是可以的。
在本次分享一开始时我曾说Provider可以精确地控制UI刷新粒度而这一切是基于Consumer实现的。Consumer使用了Builder模式创建UI收到更新通知就会通过builder重新构建Widget。
接下来,我们就看看**如何使用Consumer来改造SecondPage**吧。
在下面的例子中我们在SecondPage中去掉了Provider.of方法来获取counter的语句在其真正需要这个数据资源的两个子Widget即Text和FloatingActionButton中使用Consumer来对它们进行了一层包装
```
class SecondPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
//使用Consumer来封装counter的读取
body: Consumer<CounterModel>(
//builder函数可以直接获取到counter参数
builder: (context, CounterModel counter, _) => Text('Value: ${counter.counter}')),
//使用Consumer来封装increment的读取
floatingActionButton: Consumer<CounterModel>(
//builder函数可以直接获取到increment参数
builder: (context, CounterModel counter, child) => FloatingActionButton(
onPressed: counter.increment,
child: child,
),
child: TestIcon(),
),
);
}
}
```
可以看到Consumer中的builder实际上就是真正刷新UI的函数它接收3个参数即context、model和child。其中context是Widget的build方法传进来的BuildContextmodel是我们需要的数据资源而child则用来构建那些与数据资源无关的部分。在数据资源发生变更时builder会多次执行但child不会重建。
运行这段代码,可以发现,不管我们点击了多少次“+”按钮TestIcon控件始终没有发生销毁重建。
## 多状态的资源封装
通过上面的例子我们学习了Provider是如何共享一个数据状态的。那么如果有多个数据状态需要共享我们又该如何处理呢
其实也不难。接下来,我就**按照封装、注入和读写这3个步骤与你介绍多个数据状态的共享**。
在处理多个数据状态共享之前我们需要先扩展一下上面计数器状态共享的例子让两个页面之间展示计数器数据的Text能够共享App传递的字体大小。
**首先,我们来看看如何封装**。
多个数据状态与单个数据的封装并无不同,如果需要支持数据的读写,我们需要一个接一个地为每一个数据状态都封装一个单独的资源封装类;而如果数据是只读的,则可以直接传入原始的数据对象,从而省去资源封装的过程。
**接下来,我们再看看如何实现注入。**
在单状态的案例中我们通过Provider的升级版ChangeNotifierProvider实现了可读写资源的注入而如果我们想注入多个资源则可以使用Provider的另一个升级版MultiProvider来实现多个Provider的组合注入。
在下面的例子中我们通过MultiProvider往App实例内注入了double和CounterModel这两个资源Provider
```
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MultiProvider(providers: [
Provider.value(value: 30.0),//注入字体大小
ChangeNotifierProvider.value(value: CounterModel())//注入计数器实例
],
child: MaterialApp(
home: FirstPage(),
));
}
}
```
在完成了多个资源的注入后,最后我们来看看**如何获取这些资源**。
这里我们还是使用Provider.of方式来获取资源。相较于单状态资源的获取来说获取多个资源时我们只需要依次读取每一个资源即可
```
final _counter = Provider.of<CounterModel>(context);//获取计时器实例
final textSize = Provider.of<double>(context);//获取字体大小
```
而如果以Consumer的方式来获取资源的话我们只要使用Consumer2<A,B>对象(这个对象提供了读取两个数据资源的能力),就可以一次性地获取字体大小与计数器实例这两个数据资源:
```
//使用Consumer2获取两个数据资源
Consumer2<CounterModel,double>(
//builder函数以参数的形式提供了数据资源
builder: (context, CounterModel counter, double textSize, _) => Text(
'Value: ${counter.counter}',
style: TextStyle(fontSize: textSize))
)
```
可以看到Consumer2与Consumer的使用方式基本一致只不过是在builder方法中多了一个数据资源参数。事实上如果你希望在子Widget中共享更多的数据我们最多可以使用到Consumer6即共享6个数据资源。
## 总结
好了,今天的分享就到这里,我们总结一下今天的主要内容吧。
我与你介绍了在Flutter中通过Provider进行状态管理的方法Provider以InheritedWidget语法糖的方式通过数据资源封装、数据注入和数据读写这3个步骤为我们实现了跨组件跨页面之间的数据共享。
我们既可以用Provider来实现静态的数据读传递也可以使用ChangeNotifierProvider来实现动态的数据读写传递还可以通过MultiProvider来实现多个数据资源的共享。
在具体使用数据时Provider.of和Consumer都可以实现数据的读取并且Consumer还可以控制UI刷新的粒度避免与数据无关的组件的无谓刷新。
可以看到通过Provider来实现数据传递无论在单个页面内还是在整个App之间我们都可以很方便地实现状态管理搞定那些通过StatefulWidget无法实现的场景进而开发出简单、层次清晰、可扩展性高的应用。事实上当我们使用Provider后我们就再也不需要使用StatefulWidget了。
我把今天分享所涉及到的知识点打包到了[GitHub](https://github.com/cyndibaby905/30_provider_demo)中,你可以下载下来,反复运行几次,加深理解与记忆。
## 思考题
最后,我给你留一道思考题吧。
使用Provider可以实现2个同样类型的对象共享你知道应该如何实现吗
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。