1

flutter state management

 3 years ago
source link: https://xuyisheng.top/flutter_state_management6/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

FlutterDojo设计之道—状态管理之路(六)

经过前面这么多文章的学习,Flutter的状态管理之路终于要接近尾声了。

其实前面讲了这么多,最后的结论依然是——Provider真香。这毕竟是官方推荐的状态管理方案,就目前而言,绝大部分的场景都可以使用Provider来进行状态管理,同时也基本上是最佳方案。

Google的风格还真是这样,先不给出任何指定方案,大家百花齐放,最后选一个好一点的改改,这就成了官方方案!

但是我们为什么还要讲这么多其它的状态管理方案呢?实际上并不多,大家再去翻阅下前面的文章就可以发现,我讲的都是Flutter中的原生方案,关于第三方的Redux、scope_model等方案,其实我也没有涉及,其原因就是希望读者能够从根本原理上来了解「什么是状态管理」、「怎么进行状态管理」以及「状态管理各种方案的优缺点」,只有了解了这些,再使用Provider进行状态管理,就不仅仅是调用API这么简单了,你会对其根源有所了解,这才是本系列文章的核心所在。

Provider是Flutter官方提供的状态管理解决方案,其基本原理是InheritedWidget,Pub地址如下所示。

https://github.com/rrousselGit/provider

Provider的迭代很快,目前最新版本是4.x,在pubspec.yaml中添加Provider的依赖,代码如下所示。

dependencies:
  flutter:
    sdk: flutter

  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^0.1.2
  provider: ^4.3.2+1 

执行pub get之后,即可更新Provider库。

Provider的核心实际上就是InheritedWidget,它实际上是对InheritedWidget的封装,让InheritedWidget在数据管理上能够更加方便的被开发者所使用。

所以,如果你的InheritedWidget比较熟悉,那么在使用Provider的时候,你一定会有一种似曾相识的感觉。

创建DataModel

在使用Provider之前,首先需要对Model进行下处理,通过mixin,为Model提供notifyListeners的能力。

class TestModel with ChangeNotifier {
  int modelValue;

  int get value => modelValue;

  TestModel({this.modelValue = 0});

  void add() {
    modelValue++;
    notifyListeners();
  }
}

在这个Model中,管理了需要共享的数据,同时,提供了修改数据的方法,唯一不一样的是,在修改数据后,需要通过ChangeNotifier提供的notifyListeners()来刷新数据。

ChangeNotifierProvider

使用ChangeNotifierProvider,维护需要管理的数据,代码如下。

class ProviderState1Widget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (_) => TestModel(modelValue: 1),
      child: Padding(
        padding: const EdgeInsets.all(8.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            ChildWidget1(),
            SizedBox(height: 24),
            ChildWidget2(),
          ],
        ),
      ),
    );
  }
}

通过ChangeNotifierProvider的create函数,创建初始化的Model。同时创建其Child,这个风格和InheritedWidget是不是有异曲同工之妙。

Provider提供了很多不同类型的Provider,这里先只用了解ChangeNotifierProvider

管理数据之Provider.of

通过Provider管理的数据,可以通过Provider.of<TestModel>(context);来读取数据,代码如下所示。

var style = TextStyle(color: Colors.white);

class ChildWidget1 extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    debugPrint('ChildWidget1 build');
    var model = Provider.of<TestModel>(context);
    return Container(
      color: Colors.redAccent,
      height: 48,
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
        children: <Widget>[
          Text('Child1', style: style),
          Text('Model data: ${model.value}', style: style),
          RaisedButton(
            onPressed: () => model.add(),
            child: Text('add'),
          ),
        ],
      ),
    );
  }
}

还可以通过model来获取操作数据的方法add()。

效果如图所示。

这样就完成了一个最简单的Provider使用方法。

但是通过日志可以发现,每次调用Provider.of<TestModel>(context);后,都会导致Context所处的Widget执行Rebuild。

I/flutter (18490): ChildWidget2 build
I/flutter (18490): ChildWidget1 build

是不是又似曾相识?是的,这就是前面文章中所提到的dependOnInheritedWidgetOfExactType的问题,它会对调用者进行记录,在数据更新时,对数据进行rebuild操作。

另外,上面的例子中,实际上还隐藏了一个很容易被初学者忽视的问题,我们来看下这段代码。

RaisedButton(
  onPressed: () => model.add(),
  child: Text('add'),
),

在button的点击事件中,我们并没有直接使用每次调用Provider.of<TestModel>(context).add(),而是将每次调用Provider.of<TestModel>(context)抽取了出来,为什么要多此一举呢?

其实大家可以尝试下这样调用,点击后,会报错,如下所示。

Tried to listen to a value exposed with provider, from outside of the widget tree.

This is likely caused by an event handler (like a button's onPressed) that called
Provider.of without passing `listen: false`.

To fix, write:
Provider.of<TestModel>(context, listen: false);

It is unsupported because may pointlessly rebuild the widget associated to the
event handler, when the widget tree doesn't care about the value.

简单的说,就是在button的event handler中,触发了Provider.of,但是这个时候,传入的Context并不在Widget中,导致notifyListeners出错。

解决方法有两个,一个就是将Provider.of抽取出来,用Widget的Context来获取Model,另一个呢,就是通过Provider.of的另一个参数来去掉监听的注册。

RaisedButton(
  onPressed: () => Provider.of<TestModel>(context, listen: false).add(),
  child: Text('add'),
),

通过listen: false,去掉默认注册的监听。

Provider.of的默认实现中,listen = true,至于为什么,大家可以看这里的讨论。
https://github.com/rrousselGit/provider/issues/188#issuecomment-526259839
https://github.com/rrousselGit/provider/issues/313#issuecomment-576156922

因此,我们总结了两条Provider的使用规则。

  • Provider.of<T>(context):用于需要根据数据的变化而自动刷新的场景
  • Provider.of<T>(context, listen: false):用于只需要触发Model中的操作而不关心刷新的场景

因此对应的,在新版本的Provider中,作者还提供了两个Context的拓展函数,来进一步简化调用。

  • T watch<T>()
  • T read<T>()

他们就分别对应了上面的两个使用场景,所以在上面的示例中,Text获取数据的方式,和在Button中点击的方式还可以写成下面这张形式。

Text('watch: ${context.watch<TestModel>().value}', style: style)

RaisedButton(
  onPressed: () => context.read<TestModel>().add(),
  child: Text('add'),
),

代码地址 Flutter Dojo-Backend-ProviderState1Widget

管理数据之Consumer

获取Provider管理的数据Model,有两种方式,一种是通过Provider.of<T>(context)来获取,另一种,是通过Consumer来获取,在设计Consumer时,作者给它赋予了两个功能。

  • 当传入的BuildContext中,不存在指定的Provider时,Consumer允许我们从Provider中的获取数据(其原因就是Provider使用的是InheritedWidget,所以只能遍历父Widget,当指定的Context对应的Widget与Provider处于同一个Context时,就无法找到指定的InheritedWidget了)
  • 提供更加精细的数据刷新范围,避免无谓的刷新

创建新的Context环境

首先,我们来看下第一个功能。

@override
Widget build(BuildContext context) {
  return ChangeNotifierProvider(
    create: (_) => TestModel(modelValue: 1),
    child: Center(
      child: Padding(
        padding: const EdgeInsets.all(8.0),
        child: Container(
          color: Colors.redAccent,
          height: 48,
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: <Widget>[
              Text('Child1', style: style),
              Text('Model data: ${Provider.of<TestModel>(context).value}', style: style),
              RaisedButton(
                onPressed: () => Provider.of<TestModel>(context, listen: false).add(),
                child: Text('add'),
              ),
            ],
          ),
        ),
      ),
    ),
  );
}

在上面的这个例子中,ChangeNotifierProvider和使用Provider的Widget,使用的是同一个Context,所以肯定是无法找到对应的InheritedWidget的,所以会报错。

The following ProviderNotFoundException was thrown building ProviderState2Widget(dirty):
Error: Could not find the correct Provider<TestModel> above this ProviderState2Widget Widget

This likely happens because you used a `BuildContext` that does not include the provider
of your choice. There are a few common scenarios:

- The provider you are trying to read is in a different route.

  Providers are "scoped". So if you insert of provider inside a route, then
  other routes will not be able to access that provider.

- You used a `BuildContext` that is an ancestor of the provider you are trying to read.

  Make sure that ProviderState2Widget is under your MultiProvider/Provider<TestModel>.
  This usually happen when you are creating a provider and trying to read it immediately.

解决方法也很简单,一个是将需要使用Provider的Widget抽取出来,放入一个新的Widget中,这样在这个Widget中,就有了属于自己的Context,另一种,就是通过Consumer,来创建一个新的Context环境,代码如下所示。

@override
Widget build(BuildContext context) {
  return ChangeNotifierProvider(
    create: (_) => TestModel(modelValue: 1),
    child: Center(
      child: Padding(
        padding: const EdgeInsets.all(8.0),
        child: Container(
          color: Colors.redAccent,
          height: 48,
          child: Consumer<TestModel>(
            builder: (BuildContext context, value, Widget child) {
              return Row(
                mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                children: <Widget>[
                  Text('Child1', style: style),
                  Text('Model data: ${value.value}', style: style),
                  RaisedButton(
                    onPressed: () => Provider.of<TestModel>(context, listen: false).add(),
                    child: Text('add'),
                  ),
                ],
              );
            },
          ),
        ),
      ),
    ),
  );
}

控制更加精细的刷新范围

来看下下面这个例子。

class ProviderState2Widget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (_) => TestModel(modelValue: 1),
      child: NewWidget(),
    );
  }
}

class NewWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Center(
      child: Padding(
        padding: const EdgeInsets.all(8.0),
        child: Container(
          color: Colors.redAccent,
          height: 48,
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: <Widget>[
              Text('Child1', style: style),
              Text('Model data: ${Provider.of<TestModel>(context).value}', style: style),
              RaisedButton(
                onPressed: () => Provider.of<TestModel>(context, listen: false).add(),
                child: Text('add'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

在调用Provider.of的时候,会造成Context范围内的Widget执行Rebuild,这个在前面的例子中,已经看过了。那么要解决这个问题,也很简单,只需要将需要刷新的Widget,用Consumer包裹即可,这样在收到notifyListeners时,就只有Consumer范围内的Widget会进行刷新了,其它范围的地方,就不会被迫刷新了。在Consumer的builder中,可以获取指定泛型的数据对象,代码如下所示。

@override
Widget build(BuildContext context) {
  return Center(
    child: Padding(
      padding: const EdgeInsets.all(8.0),
      child: Container(
        color: Colors.redAccent,
        height: 48,
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
          children: <Widget>[
            Text('Child1', style: style),
            Consumer<TestModel>(
              builder: (BuildContext context, value, Widget child) {
                return Text(
                  'Model data: ${value.value}',
                  style: style,
                );
              },
            ),
            RaisedButton(
              onPressed: () => Provider.of<TestModel>(context, listen: false).add(),
              child: Text('add'),
            ),
          ],
        ),
      ),
    ),
  );
}

代码地址 Flutter Dojo-Backend-ProviderState2Widget

那么Consumer究竟是为什么可以实现更加精细的刷新控制呢?其实原理很简单,前面甚至已经提到了,那就是「在调用Provider.of的时候,会造成Context范围内的Widget执行Rebuild」,所以,只需要将调用的范围尽可能的缩小,那么执行Rebuild的范围就会越小,看下Consumer的源码。

可以发现,Consumer就是通过一个Builder,来进行了一层封装,最终还是调用的Provider.of<T>(context),
看了源码之后,相信大家应该能理解Consumer的这两个功能了。

所以Consumer的处理实际上和下面两种方式是相同的。1、将刷新的Widget封装成一个独立的Widget,拥有独立的Context。2、通过Builder创建一个新的Context。

more Consumer

Consumer中存在多个类型的变种,它代表着使用多个数据模型的数据获取方式,如图所示。

其实说简单点,就是在一个Consumer的builder中,同时获取多个不同类型的数据模型,是一种简单的写法,是一种将嵌套的过程打平的过程。源码中只写到Consumer6,即支持同时最多6个数据类型,如果要支持更多,则需要自己实现了。

管理数据之Selector

Selector同样是获取数据的一种方式,从理论上来说,Selector等于Consumer等于Provider.of,但是它们对数据的控制粒度,才是它们之间根本的区别。

获取数据的方式,从Provider.of,到Consumer,再到Selector,实际上经历了这样一种进化。

  • Provider.of:Context内容进行Rebuild
  • Consumer:Model内容变化进行Rebuild
  • Selector:Model中的指定内容变化进行Rebuild

可以发现,虽然都是获取数据,但是其控制的精细程度确是递增的。

下面就通过一个例子,来演示下Selector的使用场景。

首先,我们定义一个数据模型,代码如下所示。

class TestModel with ChangeNotifier {
  int modelValueA;
  int modelValueB;

  int get valueA => modelValueA;

  int get valueB => modelValueB;

  TestModel({this.modelValueA = 0, this.modelValueB = 0});

  void addA() {
    modelValueA++;
    notifyListeners();
  }

  void addB() {
    modelValueB++;
    notifyListeners();
  }
}

在这个数据模型中,管理了两个类型的数据,modelValueA和modelValueB。

下面是展示界面。

class ProviderState3Widget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (_) => TestModel(modelValueA: 1, modelValueB: 1),
      child: Padding(
        padding: const EdgeInsets.all(8.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            ChildWidgetA(),
            SizedBox(height: 24),
            ChildWidgetB(),
          ],
        ),
      ),
    );
  }
}

var style = TextStyle(color: Colors.white);

class ChildWidgetA extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    debugPrint('ChildWidgetA build');
    var model = Provider.of<TestModel>(context);
    return Container(
      color: Colors.redAccent,
      height: 48,
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
        children: <Widget>[
          Text('ChildA', style: style),
          Text('Model data: ${model.valueA}', style: style),
          RaisedButton(
            onPressed: () => model.addA(),
            child: Text('add'),
          ),
        ],
      ),
    );
  }
}

class ChildWidgetB extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    debugPrint('ChildWidgetB build');
    var model = Provider.of<TestModel>(context);
    return Container(
      color: Colors.blueAccent,
      height: 48,
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
        children: <Widget>[
          Text('ChildB', style: style),
          Text('Model data: ${model.valueB}', style: style),
          RaisedButton(
            onPressed: () => model.addB(),
            child: Text('add'),
          ),
        ],
      ),
    );
  }
}

效果如图所示。

在上面的代码下,不论我们点击ChildA的Add,还是ChildB的Add,整个界面都会Rebuild。即使通过Consumer,也无法做到只刷新对应的数据,原因在于它们的数据模型是同一个,Consumer只能做到数据模型层面上的更新刷新,但是无法针对同一个数据模型中不同字段的变换而进行更新。

所以,Consumer解决方案就是需要将这个数据模式拆成两个,ModelA和ModelB,这样使用MultiProvider管理ChangeNotifierProvider(ModleA)和ChangeNotifierProvider(ModelB),再通过Consumer分别管理ModelA和ModelB,这样才能做到互补干扰的刷新。

那如果数据模型不能拆分呢?这个时候,就可以使用Selector了,先来看下Selector的构造函数。

  • A代表传入的数据源,例如前面的TestModel
  • S代表想要监听的A数据源中的的某个属性,比如TestModel的ModelA
  • selector的功能,就是从A数据源中筛选出需要监听的数据S,然后将S传递传给builder进行构造
  • shouldRebuild用来覆盖默认的对比算法,可以不设置

对比算法如下所示。

从源码可以发现,Selector判断的标准就是新旧数据Model是否「==」,如果是Collection类型,则通过DeepCollectionEquality来进行比较,官方建议使用https://pub.flutter-io.cn/packages/tuple 来进行简化判断

有了Selector之后,就可以在同一个数据模型中,根据条件,筛选出不同的刷新条件了,这样就可以避免数据模型中的某个属性变换而引起的整个数据模型刷新了。

通过Selector,将上面的代码进行下改造。

class ChildWidgetA extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    debugPrint('ChildWidgetA build');
    return Selector<TestModel, int>(
      selector: (context, value) => value.modelValueA,
      builder: (BuildContext context, value, Widget child) {
        return Container(
          color: Colors.redAccent,
          height: 48,
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: <Widget>[
              Text('ChildA', style: style),
              Text('Model data: $value', style: style),
              RaisedButton(
                onPressed: () => context.read<TestModel>().addA(),
                child: Text('add'),
              ),
            ],
          ),
        );
      },
    );
  }
}

这样通过Selector进行一次筛选,就可以避免同一个Model中不同的数据刷新导致整个Model Rebuild的问题,例如上面的Selector,指定了需要在TestModel中寻找int类型的数据,其过滤条件是TestModel中的modelValueA这样一个int类型的数据,根据ShouldRebuild(默认实现)的判断,返回这个情况下,ChildWidgetA是否需要Rebuild。

与Provider.of类似,在4.1之后,Provider提供了基于BuildContext的拓展函数来简化Selector的使用,例如上面的代码通过selector拓展函数来实现,代码如下所示。

class ChildWidgetB extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    debugPrint('ChildWidgetB build');
    return Container(
      color: Colors.blueAccent,
      height: 48,
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
        children: <Widget>[
          Text('ChildB', style: style),
          Builder(
            builder: (BuildContext context) {
              return Text(
                'Model data: ${context.select((TestModel value) => value.modelValueB)}',
                style: style,
              );
            },
          ),
          RaisedButton(
            onPressed: () => context.read<TestModel>().addB(),
            child: Text('add'),
          ),
        ],
      ),
    );
  }
}

不过需要注意的是,这里需要通过Builder来创建一个子类的Context,避免当前Context的刷新。

more Selector

与Consumer类似,Selector同样也有多种不同的实现。

其实很简单,就是实现多种不同的数据类型,在这些数据模型中,找到需要监听的那一种类型,这种情况比较常用于多个数据模型中具体共同参数的场景。

上面就是通过Provider来获取被管理的数据的三种方式:Provider.of,Consumer和Selector,它们的功能完全一致,区别仅仅在于刷新的控制粒度。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK