flutter中Provider详解

为什么我们需要状态管理

如果应用足够简单,Flutter 作为一个声明式框架,你或许只需要将 数据 映射成 视图 就可以了。你可能并不需要状态管理,就像下面这样。

image.png

但是随着功能的增加,你的应用程序将会有几十个甚至上百个状态。这个时候你的应用应该会是这样。


image.png

这实在是太复杂了!而且还会有多个页面共享同一个状态,需要同步状态。

Flutter 提供了一种状态管理方式,那就是 StatefulWidget。在 State 属于某一个特定的 Widget,在多个 Widget 之间进行交流的时候,虽然你可以使用 callback 解决,但是当嵌套足够深的话,我们增加非常多可怕的垃圾代码。

状态管理Provider

Provider 从名字上就很容易理解,它就是用于提供数据,无论是在单个页面还是在整个 app 都有它自己的解决方案,我们可以很方便的管理状态。可以说,Provider 的目标就是完全替代 StatefulWidget。

可以看看官方的例子

Consumer

我们先看 floatingActionButton,使用了一个 Consumer 的情况。

Consumer 使用了 Builder模式,收到更新通知就会通过 builder 重新构建。Consumer<T> 代表了它要获取哪一个祖先中的 Model。

Consumer 的 builder 实际上就是一个 Function,它接收三个参数 (BuildContext context, T model, Widget child)

  • context: context 就是 build 方法传进来的 BuildContext 。
  • T:T也很简单,就是获取到的最近一个祖先节点中的数据模型。
  • child:它用来构建那些与 Model 无关的部分,在多次运行 builder 中,child 不会进行重建。

然后它会返回一个通过这三个参数映射的 Widget 用于构建自身。

在这个浮动按钮的例子中,我们通过 Consumer 获取到了顶层的 CounterModel 实例。并在浮动按钮 onTap 的 callback 中调用其 increment 方法。

而且我们成功抽离出 Consumer 中不变的部分,也就是浮动按钮中心的 Icon 并将其作为 child 参数传入 builder 方法中。

还有 Consumer2, Consumer3,Consumer4,……

区别

我们来看 Consumer 的内部实现。

@override
  Widget build(BuildContext context) {
    return builder(
      context,
      Provider.of<T>(context),
      child,
    );
  }

可以发现,Consumer 就是通过 Provider.of<T>(context) 来实现的。但是从实现来讲 Provider.of<T>(context)Consumer 简单好用太多,为啥我要搞得那么复杂捏。

实际上 Consumer 非常有用,它的经典之处在于能够在复杂项目中,极大地缩小你的控件刷新范围Provider.of<T>(context) 将会把调用了该方法的 context 作为听众,并在 notifyListeners 的时候通知其刷新。

Provides 的构造方法

在上面这个例子中👆,我们选择了使用 XProvider<T>.value 的构造方法来创建祖先节点中的 提供者。除了这种方式,我们还可以使用默认构造方法。

Provider({
    Key key,
    @required ValueBuilder<T> builder,
    Disposer<T> dispose,
    Widget child,
  }) : this._(
          key: key,
          delegate: BuilderStateDelegate<T>(builder, dispose: dispose),
          updateShouldNotify: null,
          child: child,
        );

ValueBuilder

相比起 .value 构造方式中直接传入一个 value 就 ok,这里的 builder 要求我们传入一个 ValueBuilder。
typedef ValueBuilder<T> = T Function(BuildContext context);
其实很简单,就是传入一个 Function 返回一个数据而已。在上面这个例子中,你可以替换成这样。

Provider(
    builder: (context) => textSize,
    ...
)

由于是 Builder 模式,这里默认需要传入 context,实际上我们的 Model(textSize)与 context 并没有关系,所以你完全可以这样写。

Provider(
    builder: (_) => textSize,
    ...
)

Disposer

现在我们知道了 builder,那这个 dispose 方法又用来做什么的呢。实际上这才是 Provider 的点睛之笔。

typedef Disposer<T> = void Function(BuildContext context, T value);

dispose 属性需要一个 Disposer<T>,而这个其实也是一个回调。

当 Provider 所在节点被移除的时候,它就会启动 Disposer<T>,然后我们便可以在这里释放资源。

在需要释放资源,但是又不想使用 StatefulWidget的时候,我们就可以在页面顶层套上这个 Provider。

Provider(
    builder:(_) => ValidatorBLoC(),
    dispose:(_, ValidatorBLoC bloc) => bloc.dispose(),
    }
)

这样就完美解决了数据释放的问题!
我应该选择哪种构造方法呢。

简单模型就选择 Provider<T>.value,好处是可以精确控制刷新时机。
需要对资源进行释放处理等复杂模型的时候,Provider() 默认构造方式绝对是你的最佳选择。

Provider分类

你也可以在 main 方法中通过下面这行代码来禁用此提示。 Provider.debugCheckInvalidValueType = null;

这是由于 Provider 只能提供恒定的数据,不能通知依赖它的子部件刷新。提示也说的很清楚了,假如你想使用一个会发生 change 的 Provider,请使用下面的 Provider。

  • ListenableProvider
  • ChangeNotifierProvider
  • ValueListenableProvider
  • StreamProvider

这几个 Provider 有什么异同。

先关注 ListenableProvider / ChangeNotifierProvider 这两个类。

ListenableProvider 提供(provide)的对象是继承了 Listenable 抽象类的子类。由于无法混入,所以通过继承来获得 Listenable 的能力,同时必须实现其 addListener / removeListener 方法,手动管理收听者。显然,这样太过复杂,我们通常都不需要这样做。

class ChangeNotifier implements Listenable

而混入了 ChangeNotifier 的类自动帮我们实现了听众管理,所以 ListenableProvider 同样也可以接收混入了 ChangeNotifier 的类。

ChangeNotifierProvider 则更为简单,它能够对子节点提供一个 继承 / 混入 / 实现 了 ChangeNotifier 的类。通常我们只需要在 Model 中 with ChangeNotifier ,然后在需要刷新状态的时候调用 notifyListeners 即可。

那么 ChangeNotifierProviderListenableProvider 究竟区别在哪呢,ChangeNotifierProvider 会在你需要的时候,自动调用其 _disposer 方法。

static void _disposer(BuildContext context, ChangeNotifier notifier) => notifier?.dispose();

我们可以在 Model 中重写 ChangeNotifier 的 dispose 方法,来释放其资源。

ValueListenableProvider。

ValueListenableProvider 用于提供实现了 继承 / 混入 / 实现 了 ValueListenable 的 Model。它实际上是专门用于处理只有一个单一变化数据的 ChangeNotifier。

class ValueNotifier<T> extends ChangeNotifier implements ValueListenable<T>

通过 ValueListenable 处理的类不再需要数据更新的时候调用 notifyListeners

StreamProvider

StreamProvider 专门用作提供(provide)一条 Single Stream。我在这里仅对其核心属性进行讲解。

  • T initialData:你可以通过这个属性声明这条流的初始值。
  • ErrorBuilder<T> catchError:这个属性用来捕获流中的 error。在这条流 addError 了之后,你会能够通过 T Function(BuildContext context, Object error) 回调来处理这个异常数据。实际开发中它非常有用。
  • updateShouldNotify:和之前的回调一样,这里不再赘述。

除了这三个构造方法都有的属性以外,StreamProvider 还有三种不同的构造方法。

  • StreamProvider(...):默认构造方法用作创建一个 Stream 并收听它。
  • StreamProvider.controller(...):通过 builder 方式创建一个 StreamController<T>。并且在 StreamProvider 被移除时,自动释放 StreamController。
  • StreamProvider.value(...):监听一个已有的 Stream 并将其 value 提供给子孙节点。

FutureProvider,它提供了一个 Future 给其子孙节点,并在 Future 完成时,通知依赖的子孙节点进行刷新

优雅地处理多个 Provider ---- MultiProvider

void main() {
  final counter = CounterModel();
  final textSize = 48;
  runApp(
    MultiProvider(
      providers: [
        Provider.value(value: textSize),
        ChangeNotifierProvider.value(value: counter)
      ],
      child: MyApp(),
    ),
  );
}

Tips

保证 build 方法无副作用

build 无副作用也通常被人叫做,build 保持 pure,二者是一个意思。

通常为了获取顶层数据我们会在 build 方法中调用 XXX.of(context) 方法。 build 函数不应该产生任何副作用,包括新的对象(Widget 以外),请求网络,或作出一个映射视图以外的操作等。这是因为无法控制什么时候你的 build 函数将会被调用。

举一个例子。

假如你有一个 ArticleModel 这个 Model 的作用是 通过网络 获取一页 List 数据,并用 ListView 显示在页面上。

@override
  Widget build(BuildContext context) {
      final articleModel = Provider.of<ArticleModel>(context);
      articleModel.getPage(); // requesting data 
      return XWidget(...);
  }

我们在 build 函数中获得了祖先节点中的 articleModel,随后调用了 getPage 方法。

这时候会发生什么事情呢,当我们请求成功获得了结果的时候,根据之前我们已经介绍过的,调用了 Provider.of<T>(context); 会重新运行其 build。这样 getPage 就又被执行了一次。就会不断的重绘。

由于 didChangeDependence 方法也会随着依赖改变而被调用,所以也需要保证它没有副作用。具体解释参见下面单页面数据初始化。

不要所有状态都放在全局

开发者为了图方便省事,经常把所有东西都放在顶层 MaterialApp 之上。严格区分你的全局数据与局部数据,资源不用了就要释放!否则将会严重影响你的应用 performance。

尽量在 Model 中使用私有变量“_”,减少耦合

控制你的刷新范围

在 Flutter 中,组合大于继承的特性随处可见。常见的 Widget 实际上都是由更小的 Widget 组合而成,直到基本组件为止。为了使我们的应用拥有更高的性能,控制 Widget 的刷新范围便显得至关重要。尽量使用 Consumer 来获取祖先 Model,以维持最小刷新范围。

Provider 是如何做到状态共享的

这个问题实际上得分两步。

获取顶层数据

实际上在祖先节点中共享数据这件事我们已经在之前的文章中接触过很多次了,都是通过系统的 InheritedWidget 进行实现的。Provider 也不例外,在所有 Provider 的 build 方法中,返回了一个 InheritedProvider。

class InheritedProvider<T> extends InheritedWidget

Flutter 通过在每个 Element 上维护一个 InheritedWidget 哈希表来向下传递 Element 树中的信息。通常情况下,多个 Element 引用相同的哈希表,并且该表仅在 Element 引入新的 InheritedWidget 时改变。时间复杂度为 O(1) 。

通知刷新

通知刷新这一步实际上就是使用了 Listener 模式。Model 中维护了一堆听众,然后 notifiedListener 通知刷新。

全局状态需要放在顶层 MaterialApp 之上,优先初始化,以便在 Navigator 以及 BuildContex控制全局状态

数据初始化

全局数据

当需要获取全局顶层数据,并需要做一些会产生额外结果的时候,main 函数是一个很好的选择。在 main 方法中创建 Model 并进行初始化的工作,这样就只会执行一次。

单页面

如果我们的数据只是在这个页面中需要使用,那么你有这两种方式可以选择。

StatefulWidget

在InitState()中使用 Provider.of<T>(context)是错误的。

  /// If [listen] is `true` (default), later value changes will trigger a new
  /// [State.build] to widgets, and [State.didChangeDependencies] for
  /// [StatefulWidget].

源码中的注释解释了,如果这个 Provider.of<T>(context) listen 了的话,那么当 notifyListeners 的时候,就会触发 context 所对应的 State 的 [State.build] 和 [State.didChangeDependencies] 方法。也就是说,如果你使用了非 Provider 提供的数据,例如 ChangeNotifierProvider 这样会改变依赖的类,并且获取数据时 Provider.of<T>(context, listen: true) 选择 listen (默认就为 listen)的话,数据刷新时会重新运行 didChangeDependencies 和 build 两个方法。这样一来对 didChangeDependencies 也会产生副作用。假如在这里请求了数据,当数据到来的时候,又回触发下一次请求,最终无限请求下去。

这里除了副作用以外还有一点,假如数据改变是一个同步行为,例如这里的 counter.increment 这样的方法,在 didChangeDependencies 中调用的话,就会造成下面这个错误。

The following assertion was thrown while dispatching notifications for CounterModel:
flutter: setState() or markNeedsBuild() called during build.
flutter: This ChangeNotifierProvider<CounterModel> widget cannot be marked as needing to build because the
flutter: framework is already in the process of building widgets. A widget can be marked as needing to be
flutter: built during the build phase only if one of its ancestors is currently building. This exception is
flutter: allowed because the framework builds parent widgets before children, which means a dirty descendant
flutter: will always be built. Otherwise, the framework might not visit this widget during this build phase.

这里和 Flutter 的构建算法有关。简单来说,就是不能够在 State 的 build 期间调用 setState() 或者 markNeedsBuild(),在我们这里 didChangeDependence 的时候调用了此方法,导致出现这个错误。异步数据则会由于 event loop 的缘故不会立即执行。

首先 要保证初始化数据不能够产生副作用,我们需要找一个在 State 声明周期内一定只会运行一次的方法。initState 就是为此而生的。但是 initState 不是无法获取到 Inherit 吗。但是我们现在本身就在页面顶层啊,页面级别的 Model 就在顶层被创建,现在根本就不需要 Inherit。

class _HomeState extends State<Home> {
    final _myModel = MyModel();
      @override
  void initState() {
    super.initState();
    _myModel.init(); 
  }
}

页面级别的 Model 数据都在页面顶层 Widget 创建并初始化即可。

 void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((callback){
      Provider.of<CounterModel>(context).increment();
    });
  }

我们通过 addPostFrameCallback 回调中在第一帧 build 结束时调用 increment 方法,这样就不会出现构建错误了。

provider 作者 Remi 给出了另外一种方式

This code is relatively unsafe. There's more than one reason for didChangeDependencies to be called.
You probably want something similar to:

MyCounter counter;

@override
void didChangeDependencies() {
  final counter = Provider.of<MyCounter>(context);
  if (conter != this.counter) {
    this.counter = counter;
    counter.increment();
  }
}

This should trigger increment only once.

也就是说初始化数据之前判断一下这个数据是否已经存在。

cascade

你也可以在使用 dart 的级连语法 ..do() 直接在页面的 StatelessWidget 成员变量声明时进行初始化。

class FirstScreen extends StatelessWidget {
    CounterModel _counter = CounterModel()..increment();
    double _textSize = 48;
    ...
}

使用这种方式需要注意,当这个 StatelessWidget 重新运行 build 的时候,状态会丢失。这种情况在 TabBarView 中的子页面切换过程中就可能会出现。

性能问题

遵守其规范,做任何事情都考虑对性能的影响,要知道 Flutter 把更新算法可是优化到了 O(N)。
Provider 仅仅是对 InheritedWidget 的一个升级,不必担心引入 Provider 会对应用造成性能问题。

为什么选择 Provider

Provider 不仅做到了提供数据,而且它拥有着一套完整的解决方案,覆盖了你会遇到的绝大多数情况。
但是仅仅使用 Provider,Model 和 View 之间还是容易产生依赖。

只有通过手动将 Model 转化为 ViewModel 这样才能消除掉依赖关系,所以假如各位有组件化的需求,还需要另外处理。

不过对于大多数情况来说,Provider 足以优秀,它能够让你开发出简单高性能层次清晰 的应用。

源码浅析

这里在分享一点源码浅析(真的很浅😅)

Flutter 中的 Builder 模式

在 Provider 中,各种 Provider 的原始构造方法都有一个 builder 参数,这里一般就用 (_) => XXXModel() 就行了。感觉有点多次一举,为什么不能像 .value() 构造方法那样简洁呢。

实际上,Provider 为了帮我们管理 Model,使用到了 delegation pattern。

builder 声明的 ValueBuilder 最终被传入代理类 BuilderStateDelegate / SingleValueDelegate。 然后通过代理类才实现的 Model 生命周期管理。

class BuilderStateDelegate<T> extends ValueStateDelegate<T> {
  BuilderStateDelegate(this._builder, {Disposer<T> dispose})
      : assert(_builder != null),
        _dispose = dispose;

  final ValueBuilder<T> _builder;
  final Disposer<T> _dispose;

  T _value;
  @override
  T get value => _value;

  @override
  void initDelegate() {
    super.initDelegate();
    _value = _builder(context);
  }

  @override
  void didUpdateDelegate(BuilderStateDelegate<T> old) {
    super.didUpdateDelegate(old);
    _value = old.value;
  }

  @override
  void dispose() {
    _dispose?.call(context, value);
    super.dispose();
  }
}

附:
flutter 状态管理指南之 Provider
图解新的状态管理 Provider
全面理解State与Provider

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,298评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,701评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,078评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,687评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,018评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,410评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,729评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,412评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,124评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,379评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,903评论 1 257
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,268评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,894评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,014评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,770评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,435评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,312评论 2 260

推荐阅读更多精彩内容