Flutter状态管理

发布于 2019-11-30  约 27 分钟

Flutter状态管理

flutter的状态管理方案很多,这里对相关知识做一下梳理。

传递State

Flutter将组件分为StatefulWidget,StatelessWidget,有状态的组件通常继承自StatefulWidget,通过State来管理自身状态。

当业务比较简单时,基于StatefulWidget就可以管理好整个应用的状态了。

但是当业务复杂起来时,会存在一些问题:

Build-reactive-mobile-apps-with-Flutter--Google-I-2FO--18--0001

如图,有两个根节点需要共享某个状态,那么就需要把这个状态存到它们共同的父节点,然后逐级传递下来,当状态改变时,共同父节点之下的整个子树都会rebuild,而其中大部分组件的rebuild都是多余的,导致性能变差。

另外,这种方式可能会使父组件的state变得臃肿,有些数据可能并不适合放到它的state里但是为了共享只能放那儿。

因此,我们需要更进一步的手段进行状态管理。

多说两句,有部分客户端开发在接触Flutter时并不是很接受类React的这种范式,会想着把状态丢在某个管理器单例中,创建页面/Widget时去取用,如果状态改变时需要页面刷新,就抛个通知或者类似的方式让组件去更新。

在Flutter的世界里,把某个/某些全局状态搞成单例不是不可以,但通常仍倾向于保留响应式的优点(Flutter本身不算完全的响应式,因为setState是显式的,但至少有部分响应式的特点),组件在使用状态时维护监听关系,状态改变时通知订阅者。这种通知方式是像setState一样自然甚至是没有这种显式地发出通知步骤的,组件在读状态时就产生了订阅关系,而不是显式地订阅通知。总而言之,前面提到的想法太命令式了。

InheritedWidget

官方提供的共享数据基本方案。

InheritedWidget是一种特殊的功能性组件,它提供了一种将状态从上向下传递的方式。

比如Material组件中Theme的管理就使用了这种方式,在一个MaterialApp中,我们可以在build任意widget时使用ThemeData data = Theme.of(context)来获取当前主题数据,同时也会使当前Widget依赖了Theme(准确地说是_InheritedTheme这一组件),当主题变化时,所有依赖它的Widget都会被更新。这就解决了直接传递State导致多余的组件更新的问题。

看个demo,定义一个ShareDataWidget继承自InheritedWidget:

class ShareDataWidget extends InheritedWidget {
  ShareDataWidget({
    @required this.data,
    Widget child
  }) :super(child: child);

  final int data;
  static ShareDataWidget of(BuildContext context) {
    return context.inheritFromWidgetOfExactType(ShareDataWidget);
  }

  @override
  bool updateShouldNotify(ShareDataWidget old) {
    return old.data != data;
  }
}

在父组件中:

class _InheritedWidgetTestRouteState extends State<InheritedWidgetTestRoute> {
  int count = 0;

  @override
  Widget build(BuildContext context) {
    return  Center(
      child: ShareDataWidget( //使用ShareDataWidget
        data: count,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Padding(
              padding: const EdgeInsets.only(bottom: 20.0),
              child: _TestWidget(),//子widget中依赖ShareDataWidget
            ),
            RaisedButton(
              child: Text("Increment"),
              onPressed: () => setState(() => ++count),
            )
          ],
        ),
      ),
    );
  }
}

子组件获取InheritedWidget中的数据:

class __TestWidgetState extends State<_TestWidget> {
  @override
  Widget build(BuildContext context) {
    return Text(ShareDataWidget
        .of(context)
        .data
        .toString());
  }

可以看到,数据还是父组件的state管理的,InheritedWidget只是对数据包装了一下,提供了一个通道供子组件取数据。

这里比较关键的一点是inheritFromWidgetOfExactType的实现,这决定了子组件寻找共享的Model的性能,看一下实现:

  @override
  InheritedWidget inheritFromWidgetOfExactType(Type targetType, { Object aspect }) {
    assert(_debugCheckStateIsActiveForAncestorLookup());
    final InheritedElement ancestor = _inheritedWidgets == null ? null : _inheritedWidgets[targetType];
    if (ancestor != null) {
      assert(ancestor is InheritedElement);
      return inheritFromElement(ancestor, aspect: aspect);
    }
    _hadUnsatisfiedDependencies = true;
    return null;
  }

_inheritedWidgets是个Map,dart中的Map默认是LinkedHashMap,这里的查找操作是O(1)的。因此性能是有保障的。

InheritedWidget缺点还是比较明显,一方面直接使用时代码上比较啰嗦,另一方面只提供了从上到下的数据传递,子组件想要改数据还需要使用Notification机制,就更重了。

由于InheritedWidget性能好但使用不便,社区在此基础上进行了很多封装。

ScopedModel

ScopedModel是早期Flutter社区封装的比较成功的状态管理组件。它把状态封装入Model里,修改数据的逻辑也在model里,通过跟InheritedWidget类似的方式把Model传给子组件,子组件可以从Model取数据,也可以直接调用Model的方法修改数据。

不过后来Flutter官方选择了更优秀且同样方便的状态管理框架provider进行推广,这里就不多介绍ScopedModel了。

provider

provider目前是官方钦定的应用状态管理组件。

Pragmatic State Management in Flutter (Google I/O'19)

官方文档:简单的应用状态管理

看个demo

class CounterModel with ChangeNotifier {
  int _count = 0;
  int get value => _count;

  void increment() {
    _count++;
    notifyListeners();
  }
}

void main() {
  final counter = CounterModel();

  runApp(
    ChangeNotifierProvider.value(
        value: counter,
        child: MaterialApp(
          home:FirstScreen()
        ),
      ),
  );
}
class FirstScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final _counter = Provider.of<CounterModel>(context);
    final textSize = Provider.of<int>(context).toDouble();

    return Scaffold(
      appBar: AppBar(
        title: Text('FirstPage'),
      ),
      body: Center(
        child: Text(
          'Value: ${_counter.value}',
          style: TextStyle(fontSize: textSize),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _counter.increment,
        child: Icon(Icons.navigate_next),
      ),
    );
  }
}

定义一个CounterModel,通过Provider组件注入组件树,子组件在build时可以通过Provider.of<CounterModel>(context)获取model,可以从model中获取数据,也可以直接调用model的方法(CounterModel.increment)修改数据。

可以看到这样做比InheritedWidget方便太多了。

这里用到的是ChangeNotifierProvider,跟ScopedModel用起来是基本一样的,它也是我们最常用的Provider。但它比ScopedModel优秀的地方在于,使用ScopedModel时必须继承它的Model才能用,因此ScopedModel是有比较强的侵入性的,而Provider就好很多。

除了ChangeNotifierProvider之外,Provider还提供了另外几种方式进行数据管理:

  1. Provider

    • 单纯共享数据给子组件,但是数据更新时不会通知子组件。
  2. ListenableProvider

    • 比起ChangeNotifierProvider区别主要是不会自动调用ChangeNotifier.dispose释放资源。一般不用。
  3. ValueListenableProvider

    • 可以认为是ChangeNotifierProvider的特例,只监听一个数据的时候,使用ValueListenableProvider在修改数据时可以不用调用notifyListeners()
  4. StreamProvider

    • 用于监听一个Stream
  5. FutureProvider

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

Redux(flutter_redux)

前面几个库,大体上来讲,只是提供了基本的数据管理能力,也就是,专心做好自己数据通道的职责,并未对具体实现做过多的限制的话。

相比之下,Redux就重得多了,它不只是做了个数据通道,还对整个数据层的操作进行了比较强的约束,相当于提供了比较完整的数据层设计模式。场景比较简单的话,这会显得代码很累赘;但项目比较大场景比较复杂的话,这些约束可以有效阻止代码的腐烂。

我们来看一下Redux的核心概念:

img

Redux有一个Store用于存储数据,我们的Model放在Store.state这个属性中,对数据的get操作,跟前面的框架并没有太大区别,当Store中数据变化时也会触发View的刷新;而对数据的set操作,Redux提出了很高的要求。

Provider框架下的Model默认其实是个比较重的model(当然你也可以进一步拆分),数据从model里出,view需要修改数据时调用model的方法,甚至可以直接修改model的属性然后抛个notifyListeners出来。

这在Redux的模式下是不允许的。Redux的理由是:在这种不受约束的情况下,可能处处都会去改model,当业务越来越复杂的时候,这里状态的变化很可能会变得难以追溯:state在什么时候、出于什么原因、如何发生变化变得不受控制。Redux的所有设计的出发点就在这里:让state的变化变得可预测

因此Redux对state的set进行了高度封装,为了所有state的修改有据可查,Redux引入了Action的概念,这个有点像客户端常用的Notification,每个通知有自己的标识。

具体使用上,Action可以是任意的类型,不需要携带数据的话可以用enum,给每种Action定义一个枚举;也可以给每个Action定义一个class,如:

class SearchLoadingAction {}
class SearchErrorAction {}
class SearchResultAction {
  final SearchResult result;
  SearchResultAction(this.result);
}

总之,Action应当能够区分类型,并且可以携带数据,通常只会带些比较简单的参数。

view层接收到用户输入时,产生一个Action通过Store进行分发store.dispatch(Actions.Increment)

因此,我们需要一个地方处理Action并更新state,这又引入了一个Reducer的概念。其实就是个处理Action更新State的函数。

当然它有一点特别的地方,你可以把State想成一个字典,每次Reducer改动State时,会对State做一次浅拷贝并修改其中的某个值,Store会存储新的State,而旧的State中的所有内容都是不变的,这称为不可变对象。很直接的好处是,这样修改的State,只要对比前后两个State的指针是否一致就知道State是否被修改过了(State中的属性可以嵌套很多层,道理是一样的,逐层比较指针即可)。此外,这种方式约束了数据的修改,提高了数据处理的安全性。并且,可以很方便地记录State变化的序列以及触发其变化的Action,方便调试、追溯数据变化的过程。

总结一下Redux的三大原则,其实上面已经提到了一部分了。

第一大原则是单一数据源。Redux建议整个应用的State被存储在一棵object tree中,并且这个object tree只存在于唯一一个store中。单Store和多Store一直以来有诸多争议,Redux认为集中式的Store更方便管理,也容易调试,并且避免了多个Store间同步数据的问题;而多Store则方便组件化、模块化的拆分,不同业务负责自己的Store也比较符合一般开发习惯。

第二大原则是state是只读的。前面已经讲得比较清楚了,为了让State的变化可预测,因此Redux中state是只读的,想要修改必现通过Action、Reducer进行修改。

第三大原则是用纯函数执行修改。也就是newState = oldState + Action这种方式,前面Reducer部分也讲得很清楚了。

fish-redux

刚刚,阿里宣布开源Flutter应用框架Fish Redux!

flutter_redux是基本完全遵循Redux规范的flutter实现。而闲鱼在实践过程中,在flutter-redux基础上进行了进一步的封装。

纯flutter应用使用flutter-redux是没什么问题的,但是很多应用是像闲鱼这样,在Native应用的基础上集成Flutter进行混合开发的,这种开发往往是以页面为单位的。因此对业务逻辑的分治、可插拔的组件化有比较强的需求,在这种背景下Redux应当如何实践,fish-redux给出了一个完整的框架。

看一下fish-redux的demo项目

fish-redux提出了Component的概念,可以理解为“Redux组件”,Component本身有完整的Redux能力(state/action/reducer),也可以方便地嵌入全局的Redux体系中。这套设计Fish-Redux自称可插拔的组件体系、既保留了Redux的原则又提供了业务代码分治的能力。

fish-redux涉及的新概念非常多,如果要用的话,对Redux应当有比较丰富的经验,并且,不建议简单应用使用这么重的框架。

BloC

基于流的响应式状态管理,由Google在2018的 DartConf首次提出。

毫无疑问,Stream或者更进一步的ReactiveX是纯粹的响应式编程,但其实Redux也通过Action、Reducer这样的方式实现了响应式(或许某种程度上不是特别纯粹,对异步的处理可能比起Stream的方式略有不足),因此实际使用时BloC和Redux其实会有一定的相似性。

image.png

BLoC代表业务逻辑组件(Business Logic Component),它的思想其实很朴素,基本上就是两点,第一是把业务逻辑抽离出来封装成独立的BLoC组件,这个其实每个数据流框架都是要这么做的;第二是使用流的方式构造响应式的数据流。

如上图所示,BLoC封装了所有的业务逻辑,通过Stream输出给Widget,而Widget产生的事件会抛给BLoC,BLoC处理后把数据塞进流的Sink中触发订阅者的更新。

BLoC本身只是个设计模式,并没有限定具体的实现,目前各方的实现也略有区别。可以通过InheritedWidget将BLoC注入组件树由子组件获取,也可以把BLoC做成一个单例去获取;流的部分可以用原生的Stream或RxDart。在此基础上还有一些更进一步的封装,能够减少一些重复代码。

这里我们看一下在flutter_bloc这个库基础上的BLoC实现:

class App extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
        title: 'Flutter Demo',
        home: BlocProvider(
            create: (context) => CounterBloc(),
            child: CounterPage(),
        )
    );
  }
}

class CounterPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Counter')),
      body: BlocBuilder<CounterBloc, int>(
        builder: (context, count) {
          return Center(
            child: Text(
              '$count',
              style: TextStyle(fontSize: 24.0),
            ),
          );
        },
      ),
      floatingActionButton: Column(
        crossAxisAlignment: CrossAxisAlignment.end,
        mainAxisAlignment: MainAxisAlignment.end,
        children: <Widget>[
          Padding(
            padding: EdgeInsets.symmetric(vertical: 5.0),
            child: FloatingActionButton(
              child: Icon(Icons.add),
              onPressed: () => BlocProvider.of<CounterBloc>(context)
                  .add(CounterEvent.increment),
            ),
          ),
          Padding(
            padding: EdgeInsets.symmetric(vertical: 5.0),
            child: FloatingActionButton(
              child: Icon(Icons.remove),
              onPressed: () => BlocProvider.of<CounterBloc>(context)
                  .add(CounterEvent.decrement),
            ),
          )
        ],
      ),
    );
  }
}

enum CounterEvent { increment, decrement }

class CounterBloc extends Bloc<CounterEvent, int> {
  @override
  int get initialState => 0;

  @override
  Stream<int> mapEventToState(CounterEvent event) async* {
    switch (event) {
      case CounterEvent.decrement:
        yield state - 1;
        break;
      case CounterEvent.increment:
        yield state + 1;
        break;
    }
  }
}

子组件通过BlocProvider.of拿到BLoC实例,通过Action的方式分发事件给BLoC,组件通过BlocBuilder获取数据进行构建。

横向对比

框架选型

Flutter目前状态管理这块呈现出一个百花齐放的状态,简单总结一下。

仅使用state本身,在很小的应用上还能搞搞,中型应用是扛不住的,性能上缺乏有效的优化手段,数据层架构上也很难维护。

InheritedWidget本身用起来太麻烦,更多地是作为一种基础能力给上层框架,而不是直接用。

ScopedModel和Provider差不多是同类产品,能力上Provider更丰富一些并且有官方背书,一般的中小型应用都倾向于Provider一些。

Redux算是更复杂一点的数据层方案了,跟BLoC可以比较一下,这两者在响应式的路上比原始的State更进一步(或许BLoC更纯粹一些),Redux的优点是状态的变化比较可控,Action/Reducer的设计让状态的变化有理有据。BLoC的优点是对异步的处理更好一些。

最后说一下fish-redux,这其实是以Redux为核心定制的应用框架,并且很多理念是为混合应用而不是纯Flutter应用考虑的,虽然github上star很多...但是感觉实际上适合用它的应用并不多,中小型的用不上,大型的,还是混合应用,人家往往也有自己定制化的考量,或许更乐意从redux的基础上进行定制而不是拿fish-redux去改。


性能优化

横向对比大概就是这些,然后说说性能优化。

除了BLoC外,无论哪种方式将状态注入widget tree,想要获取状态,大体上都是两种方式

,一种是在build方法中使用Provider.of<Model>(context)获取Model,通常这会使得当前组件订阅Model,当Model发生变化时,引起当前组件rebuild;另一种是使用一个Consumer类的组件获取状态并传递给子组件,如:

Foo(
  child: Consumer<A>(
    builder: (_, a, child) {
      return Bar(a: a, child: child);
    },
    child: Baz(),
  ),
)

通常来讲,Comsumer类组件因为可以用builder方法来生成子组件,能做的优化会多很多。比如这个来自于Provider的例子,可以看到嵌套层级是Foo -> Bar -> Baz,使用Consumer封装后,其实只有Bar刷新了,上层和下层的Foo和Baz都没有rebuild,而如果使用`Provider.of方式,子组件是必然会全部rebuild的。

此外,Provider还提供了一种Selector订阅的方式:

Selector<List, int>(
  selector: (_, list) => list.length,
  builder: (_, length, __) {
    return Text('$length');
  }
);

此时只有selector的值产生变化时才会触发刷新,在这里也就是list.length。

flutter-redux也提供了类似方案:

StoreConnector<AppState, AppViewModel>(
         distinct: true,
         builder: (context, vm) {
           return App(vm.isLogin, vm.key);
         },
         converter: (store) => AppViewModel.fromStore(store))

使用StoreConnector注入状态,不过要显式指定distinct: true才能依靠vm进行状态变化的过滤,一个使用Redux的中型应用,这应当是必须做的优化,毕竟,Redux秉承单一Store原则,牵一发而动全身。目前看起来框架作者甚至没有在readme里提这回事儿,感觉很多人用flutter-redux会有严重的性能问题。

Provider之流本身是推荐多个Model的,大部分widget只会依赖自己相关的model,潜在的性能风险要小很多,更何况Provider提供的性能优化能力还更加完善。

结语

综上,我推荐中小型应用使用Provider,功能丰富,使用简单,性能优化能力也提供得比较完整。

阅读 911发布于 2019-11-30

推荐阅读
二师兄的博客
用户专栏

个人博客,希望这次能多写点

3 人关注
41 篇文章
专栏主页
目录