Flutter中如何利用StreamBuilder和BLoC来控制Widget状态

5字数 2668阅读 14361

参考文章:Reactive Programming - Streams - BLoC
为了便于阅读,略去了原文中的一些跟StreamBuilder和Bloc无关的拓展概念,比如RxDart、Demo的解释等,想要进一步了解的可以移步原文。

先粗略讲点关于Stream的东西

  Stream其实类似于Rx大家族,也是一种对于数据流的订阅管理。Stream可以接受任何类型的数据,值、事件、对象、集合、映射、错误、甚至是另一个Stream,通过StreamController中的sink作为入口,往Stream中插入数据,然后通过你的自定义监听StreamSubscription对象,接受数据变化的通知。如果你需要对输出数据进行处理,可以使用StreamTransformer,它可以对输出数据进行过滤、重组、修改、将数据注入其他流等等任何类型的数据操作。
  Stream有两种类型:单订阅Stream和广播Stream。单订阅Stream只允许在该Stream的整个生命周期内使用单个监听器,即使第一个subscription被取消了,你也没法在这个流上监听到第二次事件;而广播Stream允许任意个数的subscription,你可以随时随地给它添加subscription,只要新的监听开始工作流,它就能收到新的事件。
  下面是一个单订阅的例子:

import 'dart:async';

void main() {
  // 初始化一个单订阅的Stream controller
  final StreamController ctrl = StreamController();
  
  // 初始化一个监听
  final StreamSubscription subscription = ctrl.stream.listen((data) => print('$data'));

  // 往Stream中添加数据
  ctrl.sink.add('my name');
  ctrl.sink.add(1234);
  ctrl.sink.add({'a': 'element A', 'b': 'element B'});
  ctrl.sink.add(123.45);
  
  // StreamController用完后需要释放
  ctrl.close();
}

  下面是添加了StreamTransformer的例子:

import 'dart:async';

void main() {
  // 初始化一个int类型的广播Stream controller
  final StreamController<int> ctrl = StreamController<int>.broadcast();
  
  // 初始化一个监听,同时通过transform对数据进行简单处理
  final StreamSubscription subscription = ctrl.stream
                          .where((value) => (value % 2 == 0))
                          .listen((value) => print('$value'));

  // 往Stream中添加数据
  for(int i=1; i<11; i++){
    ctrl.sink.add(i);
  }
  
  // StreamController用完后需要释放
  ctrl.close();
}
关于RxDart

  之前已经说了,Stream是一种订阅者模式,所以跟Rx大家族很类似。Rx官方已经提供了对Dart语言的官方支持——RxDart。两者的对应关系可以看下下表:

Dart RxDart
Stream Observable
StreamController Subject

  对于RxDart的用法这里不多做讨论。

Flutter中Widget的状态管理和响应式编程的概念

  我们都知道,Flutter中Widget的状态控制了UI的更新,比如最常见的StatefulWidget,通过调用setState({})方法来刷新控件。那么其他类型的控件,比如StatelessWidget就不能更新状态来吗?答案当然是肯定可以的。比如Flutter的Redux插件,就是一种在非StatefulWidget中刷新控件的机制。
  我们上面已经说了,Stream的特性就是当数据源发生变化的时候,会通知订阅者,那么我们是不是可以延展一下,实现当数据源发生变化时,改变控件状态,通知控件刷新的效果呢?Flutter为我们提供了StreamBuilder
  所以,StreamBuilder是Stream在UI方面的一种使用场景,通过它我们可以在非StatefulWidget中保存状态,同时在状态改变时及时地刷新UI。
(Flutter还有其他的一些管理状态的方法跟插件,鄙人暂时没有研究过其他,就不多说了。)
  其实这种数据源改变,UI也跟着改变的方式就是一种响应式编程(Reactive Programming)。响应式编程就是使用异步数据流来编程的方式,换句话说,任何事件(比如点击事件)、变量、消息、请求等等的改变,都会触发数据流的传递。

  如果使用响应式编程,那么App将:

  • 变为异步的;
  • 围绕Streams和listeners的概念来构建;
  • 当任意事件、变量等等发生变化时,会向Stream发送一个通知;
  • Stream的监听者无论位于app中的哪个位置,都会收到这个通知。

什么是StreamBuilder

  StreamBuilder其实是一个StatefulWidget,它通过监听Stream,发现有数据输出时,自动重建,调用builder方法。

StreamBuilder<T>(
    key: ...可选...
    stream: ...需要监听的stream...
    initialData: ...初始数据,否则为空...
    builder: (BuildContext context, AsyncSnapshot<T> snapshot){
        if (snapshot.hasData){
            return ...基于snapshot.hasData返回的控件
        }
        return ...没有数据的时候返回的控件
    },
)

  下面是一个模仿官方自带demo“计数器”的一个例子,使用了StreamBuilder,而不需要任何setState:


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

class CounterPage extends StatefulWidget {
  @override
  _CounterPageState createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  int _counter = 0;
  final StreamController<int> _streamController = StreamController<int>();

  @override
  void dispose(){
    _streamController.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Stream version of the Counter App')),
      body: Center(
        child: StreamBuilder<int>(  // 监听Stream,每次值改变的时候,更新Text中的内容
          stream: _streamController.stream,
          initialData: _counter,
          builder: (BuildContext context, AsyncSnapshot<int> snapshot){
            return Text('You hit me: ${snapshot.data} times');
          }
        ),
      ),
      floatingActionButton: FloatingActionButton(
        child: const Icon(Icons.add),
        onPressed: (){
          // 每次点击按钮,更加_counter的值,同时通过Sink将它发送给Stream;
          // 每注入一个值,都会引起StreamBuilder的监听,StreamBuilder重建并刷新counter
          _streamController.sink.add(++_counter);
        },
      ),
    );
  }
}

  这种实现方式比起setState是一个很大的改进,因为我们不需要强行重建整个控件和它的子控件,只需要重建我们希望重建的StreamBuilder(当然它的子控件也会被重建)。我们之所以依然使用StatefulWidget的唯一原因就是:StreamController需要在控件dispose()的时候被释放

能不能完全抛弃StatefulWidget?BLoC了解下

  BLoC模式由来自Google的Paolo Soares和Cong Hui设计,并在2018年DartConf期间(2018年1月23日至24日)首次演示。 你可以在YouTube上观看此视频。
  BLoC是Business Logic Component(业务逻辑组建)的缩写,就是将UI与业务逻辑分离,有点MVC的味道。

  简而言之,BLoC的使用注意点和好处是:

  • 将业务逻辑(Business Logic)转移到一个或者多个BLoC中去;
  • 尽可能地与表现层(Presentation Layer)分离,换句话说就是UI组建只需要关心UI,而不需要关心业务逻辑;
  • input(Sink)和output(Stream)唯一依赖于Streams的使用;
  • 保持了平台独立性;
  • 保持了环境独立性。

  下图是BLoC与Widget交互的简单示意图:


image.png
  • Widget通过Sinks发送事件给BLoC;
  • BLoC通过Streams通知Widget;
  • 不需要关心BLoC实现的业务逻辑。

  这么做的好处显而易见:

多亏了业务逻辑和UI的分离,使得:
 1、我们可以随时更改业务逻辑,最小化对App的影响;
 2、我们可以在完全不影响业务逻辑的前提下更改UI;
 3、业务逻辑测试会更方便。

  通过将StreamBuilder和BLoC结合,我们就可以完全放弃StatefulWidget了:

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
        title: 'Streams Demo',
        theme: new ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: BlocProvider<IncrementBloc>(
          bloc: IncrementBloc(),
          child: CounterPage(),
        ),
    );
  }
}

class CounterPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final IncrementBloc bloc = BlocProvider.of<IncrementBloc>(context);

    return Scaffold(
      appBar: AppBar(title: Text('Stream version of the Counter App')),
      body: Center(
        child: StreamBuilder<int>(
        // StreamBuilder控件中没有任何处理业务逻辑的代码
          stream: bloc.outCounter,
          initialData: 0,
          builder: (BuildContext context, AsyncSnapshot<int> snapshot){
            return Text('You hit me: ${snapshot.data} times');
          }
        ),
      ),
      floatingActionButton: FloatingActionButton(
        child: const Icon(Icons.add),
        onPressed: (){
          bloc.incrementCounter.add(null);
        },
      ),
    );
  }
}

class IncrementBloc implements BlocBase {
  int _counter;

  // 处理counter的stream
  StreamController<int> _counterController = StreamController<int>();
  StreamSink<int> get _inAdd => _counterController.sink;
  Stream<int> get outCounter => _counterController.stream;

  // 处理业务逻辑的stream
  StreamController _actionController = StreamController();
  StreamSink get incrementCounter => _actionController.sink;

  // 构造器
  IncrementBloc(){
    _counter = 0;
    _actionController.stream
                     .listen(_handleLogic);
  }

  void dispose(){
    _actionController.close();
    _counterController.close();
  }

  void _handleLogic(data){
    _counter = _counter + 1;
    _inAdd.add(_counter);
  }
}
  • 责任分离:StreamBuilder控件中没有任何处理业务逻辑的代码,所有的业务逻辑处理都在单独的IncrementBloc类中进行。如果你要修改业务逻辑,只需要修改 _handleLogic()方法就行了,无论处理过程多么复杂,CounterPage都不需要知道,不需要关心。
  • 可测试性:只需要测试IncrementBloc类即可。
  • 自由组织布局:有了Streams,你就可以完全独立于业务逻辑地去组织你的布局了。可以在App中任何位置触发操作,只需要通过.incrementCounter sink来传入即可;也可以在任何页面的任何位置来展示counter,只需要监听.outCounter stream
  • 减少了build的数量:使用StreamBuilder放弃setState()大大减少了build的数量,因为只需要build需要刷新的控件,从性能角度来讲是一个重大的提升。

关于Bloc的可访问性

  以上所有功能的实现,都依赖于一点,那就是Bloc必须是可访问的。
  有很多方法都可以保证Bloc的可访问性:

  • 全局单例(global Singleton):这种方式很简单,但是不推荐,因为Dart中对类没有析构函数(destructor)的概念,因此资源永远无法释放。
  • 局部变量(local instance):你可以创建一个Bloc局部实例,在某些情况下可以完美解决问题。但是美中不足的是,你需要在StatefulWidget中初始化,并记得在dispose()中释放它。
  • 由祖先(ancestor)来提供:这也是最常见的一种方法,通过一个实现了StatefulWidget的父控件来获取访问权。
      下面的这个例子展示了一个通用的BlocProvider的实现:
// 所有 BLoCs 的通用接口
abstract class BlocBase {
  void dispose();
}

// 通用 BLoC provider
class BlocProvider<T extends BlocBase> extends StatefulWidget {
  BlocProvider({
    Key key,
    @required this.child,
    @required this.bloc,
  }): super(key: key);

  final T bloc;
  final Widget child;

  @override
  _BlocProviderState<T> createState() => _BlocProviderState<T>();

  static T of<T extends BlocBase>(BuildContext context){
    final type = _typeOf<BlocProvider<T>>();
    BlocProvider<T> provider = context.ancestorWidgetOfExactType(type);
    return provider.bloc;
  }

  static Type _typeOf<T>() => T;
}

class _BlocProviderState<T> extends State<BlocProvider<BlocBase>>{
  @override
  void dispose(){
    widget.bloc.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context){
    return widget.child;
  }
}

  怎么使用这个BlocProvider呢?

 home: BlocProvider<IncrementBloc>(
          bloc: IncrementBloc(),
          child: CounterPage(),
        ),

  这段代码实例化了一个新的BlocProvider,用于处理IncrementBloc,然后见CounterPage渲染为一个子控件。那么,BlocProvider下的任意一个子树(sub-tree)都可以访问IncrementBloc,访问方式为:IncrementBloc bloc = BlocProvider.of<IncrementBloc>(context);

多个Bloc的使用

  • 每一个有业务逻辑的页面的顶层都应该有自己的BLoC;
  • 每一个“足够复杂的组建(complex enough component)”都应该有相应的BLoC;
  • 可以使用一个ApplicationBloc来处理整个App的状态。
      下面的例子展示了在整个App的顶层使用ApplicationBloc,在CounterPage的顶层使用IncrementBloc
void main() => runApp(
  BlocProvider<ApplicationBloc>(
    bloc: ApplicationBloc(),
    child: MyApp(),
  )
);

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context){
    return MaterialApp(
      title: 'Streams Demo',
      home: BlocProvider<IncrementBloc>(
        bloc: IncrementBloc(),
        child: CounterPage(),
      ),
    );
  }
}

class CounterPage extends StatelessWidget {
  @override
  Widget build(BuildContext context){
    final IncrementBloc counterBloc = BlocProvider.of<IncrementBloc>(context);
    final ApplicationBloc appBloc = BlocProvider.of<ApplicationBloc>(context);
    
    ...
  }
}

为什么不使用InheritedWidget?

  很多关于BLoC的文章,都将一个InheritedWidget实现为了Provider。
  当然,这么做是可行的,但是:

  • 一个InheritedWidget没有提供任何dispose()方法,但是,在不需要某个资源的时候及时释放它是一个好的编程实践。
  • 没有谁会拦着你在一个StatefulWidget中放一个InheritedWidget,但是请考虑一下,这么做会不会增加什么负担呢?
  • 如果控制的不好,InheritedWidget会产生副作用(下面会讲)。

Flutter无法实例化范型,所以我们需要将BLoC实例传递给BlocProvider。为了在每一个BLoC中执行dispose(),所有BLoCs都需要实现BlocBase接口。

  我们在使用InheritedWidget的context.inheritFromWidgetOfExactType(…)方法来获取制定类型的widget时,每当InheritedWidget的父级或者子布局发生变化,这个方法会自动将当前“context”(= BuildContext)注册到要重建的widget当中。关联至BuildContext的Widget类型(Stateful还是Stateless)并不重要。

BLoC的一些缺点

   BLoC模式起初是希望用在跨平台(如AngularDart)分享代码上的。BLoC没有getter/setter概念,只有sinks/streams,所以说:“rely on exclusive use of Streams for both input (Sink) and output (stream)”,BLoC只依赖于sinks和streams
   我们用两个例子来说一下BLoC的缺点:

  • 我们从BLoC中获取数据,将数据作为页面的输入源,依赖于Streams异步build页面,但是有时候这种方式并不是很优雅:
class FiltersPage extends StatefulWidget {
  @override
  FiltersPageState createState() => FiltersPageState();
}

class FiltersPageState extends State<FiltersPage> {
  MovieCatalogBloc _movieBloc;
  double _minReleaseDate;
  double _maxReleaseDate;
  MovieGenre _movieGenre;
  bool _isInit = false;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    // 在 initState() 的时候,我们是拿不到 context 的。
    // 如果还没有初始化,我们通过_getFilterParameters获取参数
    if (_isInit == false){
      _movieBloc = BlocProvider.of<MovieCatalogBloc>(context);
      _getFilterParameters();
    }
  }

  @override
  Widget build(BuildContext context) {
    return _isInit == false
      ? Container()
      : Scaffold(
    ...
    );
  }

  // 这么写是为了完全100%遵循 BLoC 的规则,我们所有的数据都必须是从Streams里面获取的。
  // 这很不优雅,这个例子看看就好,只是一个学习性的例子展示
  void _getFilterParameters() {
    StreamSubscription subscriptionFilters;

    subscriptionFilters = _movieBloc.outFilters.listen((MovieFilters filters) {
        _minReleaseDate = filters.minReleaseDate.toDouble();
        _maxReleaseDate = filters.maxReleaseDate.toDouble();

        // Simply to make sure the subscriptions are released
        subscriptionFilters.cancel();
        
        // Now that we have all parameters, we may build the actual page
        if (mounted){
          setState((){
            _isInit = true;
          });
        }
      });
    });
  }
}
  • 在BLoC级别,我们有时候还需要注入一些假数据,来触发stream提供你想要获得的数据:
class ApplicationBloc implements BlocBase {
 // 提供movie genres的同步Stream
  StreamController<List<MovieGenre>> _syncController = StreamController<List<MovieGenre>>.broadcast();
  Stream<List<MovieGenre>> get outMovieGenres => _syncController.stream;

  // 假的数据处理
  StreamController<List<MovieGenre>> _cmdController = StreamController<List<MovieGenre>>.broadcast();
  StreamSink get getMovieGenres => _cmdController.sink;

  ApplicationBloc() {
    // 如果我们通过这个sink接收到了任意数据,我们简单地提供一个MovieGenre列表作为输出流
    _cmdController.stream.listen((_){
      _syncController.sink.add(UnmodifiableListView<MovieGenre>(_genresList.genres));
    });
  }

  void dispose(){
    _syncController.close();
    _cmdController.close();
  }

  MovieGenresList _genresList;
}

// 外部调用的例子
BlocProvider.of<ApplicationBloc>(context).getMovieGenres.add(null);

一个实践Demo

  大佬构建了一个伪应用程序来展示如何使用所有这些概念。 完整的源代码可以在Github上找到。

推荐阅读更多精彩内容