阅读 972

Flutter状态管理 -- BLoC、ScopedModel和Provider的对比

Flutter的运行也是基于状态的变化触发绘制的。所以,Flutter开发一般是离不开这个主题的。

最常见的就是使用StatefulWidgetsetState。但是,这样的用法无法满足日渐增长的页面数量和隐藏在这些页面里的越来越复杂的业务逻辑。于是,各路大神开发除了与之配套的模式和响应的库来简化App的状态管理。其中最显著的几个模式分别是BLoC、ScopedModel和Provider。下面我们就一一的分析和对比他们的异同。以此来帮助开发者选择合适的模式和库。

示例

本文中所使用的示例是Flutter Sample的Provider shopper, 这里可以看到。运行效果是这样的:

运行的效果是完全一样的,只是在Provider的部分还是少许做了一点修改。本例使用的代码是为了表明Provider的一些基础用法。同一套代码适配到不同的模式下,才更有对比的价值。实现同一个功能,在不同的模式下该如何操作,不同点、共同点都特别明显。

笔者也是初学者,对各种模式的理解难免有不到位的地方。欢迎各位读者指出错误,或者一起探讨。

BLoC

这是一个模式,也有对应库。它最显著的特点就是有“流”。所以,要使用BLoC就要学会

说道流就会有很多的读者想到响应式编程。没错这确实是响应式编程的概念,不过Dart有自己的一套流的实现。我们来具体关注一下Dart的实现。这里补充一点,如果你想用ReactiveX的一套实现也是没有问题的。

使用流控制器处理数据

Dart提供了一个叫做StreamController的类来管理流(Stream)。流控制器(StreamController)会放出一个两个成员来共开发者使用,分别可以读取流里面的值,或者向流添加数据。开发者可以通过StreamController#Stream实例来读取数据,通过StreamController#Sink`实例来添加数据。

在一个ViewModel里如何使用流控制器:

/// 这里去掉了不必要的代码
class CartBloc extends BaseBloc {
  // 实例化流控制器
  final _controller = StreamController<Item>.broadcast();
  // Stream直接作为public属性暴露出去
  Stream<Item> get stream => _controller.stream;

  void addItem(Item item) {
    // 使用Sink添加数据
    _controller.sink.add(item);
  }

  @override
  void dispose() {
    // 关闭流控制器,释放资源
    _controller.close();
  }
}
复制代码

在这个类里面,首先示例话了一个流控制器:final _controller = StreamController<Item>.broadcast();。声明了一个 使用了一个stream getter:Stream<Item> get stream => _controller.stream;把流暴露给外面使用。同时有一个方法addItem用来接收新添加的数据,并在其内部实现里使用_controller.sink.add(item)添加数据。

在示例化流控制器的时候,是这样做的:StreamController<Item>.broadcast()。使用到了broadcast()。这里也可以是stream()。但是stream仅支持一个监听者,如果存在多个监听者的时候就会抛异常了。所以,一般都是使用stream()获得流控制器实例,如果有多个监听者的时候再使用broadcast()。简单说,就是一直用stream()直到出现多个监听者报错的时候换boradcast()

streamsink基本上可以理解为一个管子的两头。使用sink给这个管子假数据,数据流过这个管子之后可以通过stream拿到数据。

使用StreamBuilder显示流数据

流控制器处理好数据之后,就要在界面上把数据展现出来。

Flutter提供了StreamBuilder来展示流的数据。代码如下:

  Widget build(BuildContext context) {
    return Scaffold(
        // StreamBuilder,需要一个stream,和一个builder
        body: StreamBuilder<CatalogModel>(
            stream: BlocProvider.of<CatalogBloc>(context).stream,
            builder: (context, snapshot) {
              // 数据可以从snapshot.data拿到
              CatalogModel catalog = snapshot.data;

              return CustomScrollView(
                // 此处省略
              );
            }));
  }
复制代码

使用StreamBuilder只需要给它一个Stream和一个Builder方法即可。在获取每个传入给StreamBuilder的Stream的时候还有更加简化的方法。

本文使用了Flutter - BLoC模式入门所介绍的方法来实现Stream和StreamBuilder的衔接。或者可以说使用了上文所述的方法简化了在Widget里获取流的方法。而没有使用BLoC库来简化。当然,有兴趣的话你可以试着用bloc库重新实现一次上面的例子。

是先BLoC的整体流程

在前面的描述中,只是充电介绍了和BLoC直接相关的内容:流和StreamBuilder。如果要真正的开发一个App一般遵循的是MVVM的模式。

在定义ViewModel的时候需要控制粒度。因为,你不想一个简单的数据变化让整个页面都进入绘制周期,粒度控制一般是只让有关联的最小组件树重新绘制。一般是一个页面一个ViewModel,当然可以更小到如果网络请求,loading,数据展示都在一个按钮的的话,那么这个ViewModel也可以只在这个按钮上使用。

首先,要有实体类。这样可以结构化的把数据展示出来。

class CartModel {
  /// The private field backing [catalog].
  CatalogModel _catalog;

  /// Internal, private state of the cart. Stores the ids of each item.
  final List<int> _itemIds = [];

  /// The current catalog. Used to construct items from numeric ids.
  CatalogModel get catalog => _catalog;

  set catalog(CatalogModel newCatalog) {
    assert(newCatalog != null);
    assert(_itemIds.every((id) => newCatalog.getById(id) != null),
        'The catalog $newCatalog does not have one of $_itemIds in it.');
    _catalog = newCatalog;
  }

  /// List of items in the cart.
  List<Item> get items => _itemIds.map((id) => _catalog.getById(id)).toList();

  /// The current total price of all items.
  int get totalPrice =>
      items.fold(0, (total, current) => total + current.price);

  void add(Item item) {
    _itemIds.add(item.id);
  }
}
复制代码

定义ViewModel,并使用StreamBuilder展示数据

简洁版的方式在上文中已经有提到过了。在ViewModel中定义也无逻辑相关的部分,以及:

  • 暴露流给Widget使用
  • 在更新数据的方法中使用Sink添加数据
  • 释放资源
使用BlocProvider方便获得ViewModel

在Widget树种,StreamBuilder经常出现在接近叶子节点的部分,也就是在Widget树比较深的部分。最直接的表现就是它会出现在非常分散的文件中。每个StreamBuilder都需要ViewModel提供的流来展示数据。那么流的声明也要随着StreamBuilder出现在这些分散的文件中。更让代码难以维护的是,ViewModel实例将会从Widget树的根部一直传递到每个StreamBuilder。

BlockProvider正式来解决这个问题的,它就是胶水,让ViewModel里的流和StreamBuilder更好的结合在一起。在Widget中使用StreamBuilder如何能够让子Widget树方便的获得已经实例化好的ViewModel呢?

先来看看这个胶水怎么起作用的。在main.dart里:

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    // In this app, catalog will never change.
    // But if catalog changes, the new catalog would pass through `snapshot`.
    return BlocProvider<CatalogBloc>(
      bloc: CatalogBloc(),
      child: BlocProvider<CartBloc>(
        bloc: CartBloc(),
        child: MaterialApp(
          title: 'Provider Demo',
          theme: appTheme,
          initialRoute: '/',
          routes: {
            '/': (context) => MyLogin(),
            '/catalog': (context) => MyCatalog(),
            '/cart': (context) => MyCart(),
          },
        ),
      ),
    );
  }
}
复制代码

每个BlocProvider初始化的时候需要一个ViewModel和一个child,子组件。多个BlocProvider可以嵌套使用。在需要用到ViewModel实例的流的时候只需要一个静态方法就可以完成。

body: StreamBuilder<CatalogModel>(
    stream: BlocProvider.of<CatalogBloc>(context).stream,
    builder: (context, snapshot) {
        return CustomScrollView(
        );
}));
复制代码

只需要BlocProvider.of<CatalogBloc>(context)就可以获得ViewModel实例,同时就可以直接拿到stream了。

最后,为什么BlocProvider用到StatefulWidget呢?在本例中是为了可以使用这个类的dispose方法。

class _BlocProviderState extends State<BlocProvider> {
  @override
  Widget build(BuildContext context) => widget.child;

  @override
  void dispose() {
    widget.bloc.dispose();
    super.dispose();
  }
}
复制代码

原理和本文的关系不是很大,有兴趣的同学可以移步blocs/bloc_provider.dart

ScopedModel

在开始ScopedModel之前先做一下回顾。流在BLoC模式中的作用就是使用Sink接受数据的变化,再通过Stream结合StreamBuilder展现在界面上,从而达到状态管理的效果。ScopedModel也有类似的机制。只是更加简单,没有用到流,那么对于初学者来说也就不需要花时间去另外学习流的知识。

通用的开发模式也是MVVM。在我们定义好与网络请求、本地存储对应的实体类之后就可以定义VM了。

在ScopedModel里我们用了scoped_model库。在每个VM里继承Model之后就拥有了出发状态变更的能力。

import 'package:scoped_model/scoped_model.dart';

class BaseModel extends Model {}


import 'base_model.dart';
// 略掉了其他的impoort

class CartModel extends BaseModel {
  // 略掉部分成员定义

  set catalog(CatalogModel newCatalog) {
    // 通知状态变更
    notifyListeners();
  }

  void addItem(Item item) {
    assert(_cartInfo != null);

    // 通知状态变更
    notifyListeners();
  }
}
复制代码

上面的例子中,首先定义了一个BaseModel,每个对应的VM继承BaseModel之后可以在数据发生变更的时候使用notifyListeners方法来通知状态发生了变化。

看起来在View Model的定义上简化了很多。那么状态的变化如何体现在界面上呢?我们来看一下scoped_model_tutorial/lib/main.dart

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return ScopedModel<CatalogModel>(
      model: CatalogModel(),
      child: ScopedModel<CartModel>(
        model: CartModel(),
        child: MaterialApp(
            // 略
        ),
      ),
    );
  }
}
复制代码

提供View Model对象的方式基本一样,而且都存在嵌套的问题,至少是写法上。

代替StreamBuilder组件的就是ScopedModelDescendant组件了。

class MyCatalog extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(body: ScopedModelDescendant<CatalogModel>(builder: (context, child, model) {
      CatalogModel catalog = model;
     
      return CustomScrollView(
        // 略
      );
    }));
  }
}
复制代码

ScopedModelDescendant接受一个类型参数和一个builder方法,在这个方法的三个参数中,第三个就是类型参数的model实例。

如果不是在组成界面的时候需要用到model的实例要如何处理呢?看代码:

final cartBloc = ScopedModel.of<CartModel>(context);
复制代码

只需要ScopedModel.of<CartModel>()方法即可。

ScopedModel使用notifyListeners()方法简化掉了BLoC模式中需要用到的流。只是在为界面提供ViewModel实例的时候依然没有摆脱嵌套的写法。下面来看下Provider模式能为开发者带来什么。

Provider

Provider模式里发起状态变更的依然是ViewModel里的notifyListeners方法。我们来看一下具体的实现步骤:

首先,我们要考虑引入Provider库了。具体步骤可以参考这里的文档

接着来实现ViewModel。比如有一个CartModel,可以写成:

import 'catalog.dart';

class CartModel extends ChangeNotifier {
  CatalogModel _catalog;
  final List<int> _itemIds = [];
  CatalogModel get catalog => _catalog;

  set catalog(CatalogModel newCatalog) {
    _catalog = newCatalog;
    notifyListeners();
  }

  List<Item> get items => _itemIds.map((id) => _catalog.getById(id)).toList();

  int get totalPrice =>
      items.fold(0, (total, current) => total + current.price);

  void add(Item item) {
    _itemIds.add(item.id);
    notifyListeners();
  }
}
复制代码

这里的ViewModel的实现非常之简单,只需要继承ChangeNotifier就可以得到notifyListeners方法。在需要改变状态的地方调用这个方法即可。

把ViewModel粘到Widget树里。这部分需要关注一下lib/main.dart

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // Using MultiProvider is convenient when providing multiple objects.
    return MultiProvider(
      providers: [
        Provider(create: (context) => CatalogModel()),
        ChangeNotifierProxyProvider<CatalogModel, CartModel>(
          create: (context) => CartModel(),
          update: (context, catalog, cart) {
            cart.catalog = catalog;
            return cart;
          },
        ),
      ],
      child: MaterialApp(
        title: 'Provider Demo',
          // 略
      ),
    );
  }
}
复制代码

在界面中显示数据。有两种方法, 一种是使用Consumer,另一种是使用Provider.of()方法:

使用Consumer的方式

Consumer<CartModel>(
    builder: (context, cart, child) =>
        Text('\?{cart.totalPrice}', style: hugeStyle)
)
复制代码

Consumer会把ViewModel的实例传入到它的builder方法里。也就是上例中builder方法的第二个参数。这个时候再ViewModel发生变化的时候Consumer和它下面的子树就回重绘。

使用Provider.of()的方式: 在Consumer内部实现也是用的这个方式。代码如下:

class Consumer<T> extends SingleChildStatelessWidget {

  @override
  Widget buildWithChild(BuildContext context, Widget child) {
    return builder(
      context,
      // 这里使用了`Provider.of`方法
      Provider.of<T>(context),
      child,
    );
  }
}
复制代码

在使用这个方式的时候需要注意一点,在传递参数的时候考虑到只是需要获取这个view model实例,那么就需要屏蔽掉默认的注册行为,所以是这么用的:

var cart = Provider.of<CartModel>(context, listen: false);
复制代码

listen: false就是用来屏蔽注册组件这个默认行为的。我们要屏蔽的功能就是Consumer所拥有的的,在状态变化之后重绘的功能。

这里有一个默认的,或者说是约定的做法。如果需要Provider下的某个子树在状态变化之后重绘,那么将这个子树放在Consumer组件下。如果只是把view model实例的数据读出来,或者触发状态变更,那么就用Provider.of<T>(context, listen: false)。直接在调用的时候屏蔽默认行为。

另外

Provider库还定义了另外一种更加简洁的方式。provider库用extension给Context添加了一些方法可以快速的读取view model实例,或者读取的时候并注册组件响应状态更新。

  • context.watch<T>():注册组件响应状态变更
  • context.read<T>():只读取view model实例
  • context.select<T, R>(R cb(T value)):允许组件至相应view model的一个子集的变更

更多可以参考文档

不同的Provider

最常用的Provider都已经出现在上面的例子中了。

每个App里正常不会只有一个Provider,为了解决这个问题就有了MultiProvider。在providers数组里塞满app用到的provider即可。

    MultiProvider(
      providers: [
        Provider(create: (context) => CatalogModel()),
        ChangeNotifierProxyProvider<CatalogModel, CartModel>(
          create: // 略,
          update: // 略,
        ),
      ]
    )
复制代码

它的内部还是嵌套的,只不过在写法上是一个数组。数组里的provider,从头到尾分别嵌套的从深到浅。

Provider只能提供一个ViewModel实例,没法响应状态的变化。在本例中这么用只是表明CartCatalog有依赖。

ChangeNotifierProvider

这是最常用的一个provider类型,它的作用就是让view model的变化可以反映在界面上。只要在view model类里继承ChangeNotifier(作为mixin使用亦可),并在修改数据的方法里使用notifyListeners()方法。

ProxyProvider

当两个view model之间存在依赖关系的时候使用这个类型的provider。

ChangeNotifierProxyProvider

前两个类型的和就是ChangeNotifierProxyProvider。也是我们在上面的代码里使用的provider类型。本类型和ProxyProvider的不同之处在,本类型会发送更新到ChangeNotifierProviderProxyProvider会把更新发送给Provider

最重要的是,ProxyProvider不会监听任何的变化,而ChangeNtofierProxyProvider可以。

StreamProvider

StreamProvider可以简单的理解为是对StreamBulder的一层封装。如:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return StreamProvider<MyModel>( //                       <--- StreamProvider
      initialData: MyModel(someValue: 'default value'),
      create: (context) => getStreamOfMyModel(),
      child: MaterialApp(
        home: Scaffold(
          appBar: AppBar(title: Text('My App')),
          body: Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[

              Container(
                padding: const EdgeInsets.all(20),
                color: Colors.green[200],
                child: Consumer<MyModel>( //                    <--- Consumer
                  builder: (context, myModel, child) {
                    return RaisedButton(
                      child: Text('Do something'),
                      onPressed: (){
                        myModel.doSomething();
                      },
                    );
                  },
                )
              ),

              Container(
                padding: const EdgeInsets.all(35),
                color: Colors.blue[200],
                child: Consumer<MyModel>( //                    <--- Consumer
                  builder: (context, myModel, child) {
                    return Text(myModel.someValue);
                  },
                ),
              ),

            ],
          ),
        ),
      ),
    );
    
  }
}

Stream<MyModel> getStreamOfMyModel() { //                        <--- Stream
  return Stream<MyModel>.periodic(Duration(seconds: 1),
          (x) => MyModel(someValue: '$x'))
      .take(10);
}

class MyModel { //                                               <--- MyModel
  MyModel({this.someValue});
  String someValue = 'Hello';
  void doSomething() {
    someValue = 'Goodbye';
    print(someValue);
  }
}
复制代码

FutureProvider

FutureProvider也是对FutureBuilder的一层封装。如:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return FutureProvider<MyModel>( //                      <--- FutureProvider
      initialData: MyModel(someValue: 'default value'),
      create: (context) => someAsyncFunctionToGetMyModel(),
      child: MaterialApp(
        home: Scaffold(
          appBar: AppBar(title: Text('My App')),
          body: Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[

              Container(
                padding: const EdgeInsets.all(20),
                color: Colors.green[200],
                child: Consumer<MyModel>( //                    <--- Consumer
                  builder: (context, myModel, child) {
                    return RaisedButton(
                      child: Text('Do something'),
                      onPressed: (){
                        myModel.doSomething();
                      },
                    );
                  },
                )
              ),

              Container(
                padding: const EdgeInsets.all(35),
                color: Colors.blue[200],
                child: Consumer<MyModel>( //                    <--- Consumer
                  builder: (context, myModel, child) {
                    return Text(myModel.someValue);
                  },
                ),
              ),

            ],
          ),
        ),
      ),
    );
    
  }
}

Future<MyModel> someAsyncFunctionToGetMyModel() async { //  <--- async function
  await Future.delayed(Duration(seconds: 3));
  return MyModel(someValue: 'new data');
}

class MyModel { //                                               <--- MyModel
  MyModel({this.someValue});
  String someValue = 'Hello';
  Future<void> doSomething() async {
    await Future.delayed(Duration(seconds: 2));
    someValue = 'Goodbye';
    print(someValue);
  }
}
复制代码

StreamProviderFutureProvider都是对于某些特殊情况的定制的Provider,在平时使用Provider模式的时候对于返回数据的Future和Stream情况做专门的处理,可以让开发者少些很多自定义代码。

总结

BLoC模式在使用前需要对或者更大的一点说,需要对响应式编程有一定的理解。我们这里给出的例子还在非常基础的阶段,虽然在尽量接近产品级别,但是还是有差距。所以看起来非常简单。如果你想用这个模式,那么最好能多花时间研究一下响应式编程。

ScopedModel已经成为历史。各位也看到,它和Provider的写法很接近。那是因为后者就是从ScopedModel进化来的。ScopedModel已经完成了它的历史使命。

Provider可以说是最简洁的一种模式了。虽然每次都给最小变化子树上加了另外的一个组件。但是结合Flutter号称可以达到亚线性复杂度的构建算法,其实对性能的影响很小。最关键的是,它是加载最小变化子树上的。在某些情况下,如果使用组件之外的一个巨大的状态树,开发者稍有不慎,那么就是很大范围的重绘。这样对开发者驾驭巨大状态树的能力有很高的要求。个人观点是使用Provider也比较省心。

当然笔者水平有限,对Flutter很多深度只是也还在探索中。欢迎拍砖!

参考

github.com/flutter/sam… www.raywenderlich.com/4074597-get… medium.com/flutter-com…

文章分类
前端
文章标签