[译]Flutter 响应式编程:Steams 和 BLoC 实践范例(5) - Part Of 模式

原文:Reactive Programming - Streams - BLoC - Practical Use Cases 是作者 Didier BoelensReactive Programming - Streams - BLoC 写的后续

阅读本文前建议先阅读前篇,前篇中文翻译有两个版本:

  1. [译]Flutter响应式编程:Streams和BLoC by JarvanMo
    忠于原作的版本

  2. Flutter中如何利用StreamBuilder和BLoC来控制Widget状态 by 吉原拉面
    省略了一些初级概念,补充了一些个人解读

前言

在了解 BLoC, Reactive ProgrammingStreams 概念后,我又花了些时间继续研究,现在非常高兴能够与你们分享一些我经常使用并且个人觉得很有用的模式(至少我是这么认为的)。这些模式为我节约了大量的开发时间,并且让代码更加易读和调试。

目录

(由于原文较长,翻译发布时进行了分割)

  1. BlocProvider 性能优化
    结合 StatefulWidgetInheritedWidget 两者优势构建 BlocProvider

  2. BLoC 的范围和初始化
    根据 BLoC 的使用范围初始化 BLoC

  3. 事件与状态管理
    基于事件(Event) 的状态 (State) 变更响应

  4. 表单验证
    根据表单项验证来控制表单行为 (范例中包含了表单中常用的密码和重复密码比对)

  5. Part Of 模式
    允许组件根据所处环境(是否在某个列表/集合/组件中)调整自身的行为

文中涉及的完整代码可在 GitHub 查看。

5. Part Of 模式

有时候,需要组件根据所处环境(是否是属于某个列表/集合/组件等)来驱动自身的行为,作为本文的最后一个范例,我们将考虑如下场景:

  • App 提供与显示多个商品(item)
  • 用户可以将选择的商品放入购物篮
  • 每件商品仅能放入购物篮一次
  • 购物篮中的商品可以被移除
  • 被移除的商品可以重新被用户放入购物篮

在例子中,每个商品都会显示一个按钮,这个按钮根据商品是否是在购物篮中决定其行为:

  • 如果是在购物篮中,则允许用户点击后将商品从购物篮中移除
  • 如果没在购物篮中,则用户点击后对应商品将添加到购物篮中

为了更好地说明 Part of 模式,我采用了以下的代码架构:

  • 实现一个 Shopping Page,用来显示所有可能的商品列表
  • Shopping Page 中的每个商品都会有个按钮,这个按钮可将商品添加到购物篮中或从购物篮中移除,取决于商品是否已经在购物篮中
  • 如果 Shopping Page 中的一件商品被添加到购物篮中,那么按钮将自动更新,允许用户再次点击后将商品从购物篮中移除(反过来也一样);这个过程不需要重构 Shopping Page
  • 构建另一个页面 Shopping Basket,用来显示全部已经添加到购物篮的商品
  • 可从 Shopping Basket 页面中移除任何已添加到购物篮的商品

注意

Part Of 模式」 这个名字是我自己取的,并不是官方名称。

5.1. ShoppingBloc

你可能已经想到了,我们需要考虑让 BLoC 来处理所有商品的列表,以及 Shopping Basket 页面中的(已添加到购物篮中的)商品列表

这个 BLoC 代码如下:

bloc_shopping_bloc.dart

class ShoppingBloc implements BlocBase {
  // List of all items, part of the shopping basket
  Set<ShoppingItem> _shoppingBasket = Set<ShoppingItem>();


  // Stream to list of all possible items
  BehaviorSubject<List<ShoppingItem>> _itemsController = BehaviorSubject<List<ShoppingItem>>();
  Stream<List<ShoppingItem>> get items => _itemsController;


  // Stream to list the items part of the shopping basket
  BehaviorSubject<List<ShoppingItem>> _shoppingBasketController = BehaviorSubject<List<ShoppingItem>>(seedValue: <ShoppingItem>[]);
  Stream<List<ShoppingItem>> get shoppingBasket => _shoppingBasketController;


  @override
  void dispose() {
    _itemsController?.close();
    _shoppingBasketController?.close();
  }


  // Constructor
  ShoppingBloc() {
    _loadShoppingItems();
  }


  void addToShoppingBasket(ShoppingItem item){
    _shoppingBasket.add(item);
    _postActionOnBasket();
  }


  void removeFromShoppingBasket(ShoppingItem item){
    _shoppingBasket.remove(item);
    _postActionOnBasket();
  }


  void _postActionOnBasket(){
    // Feed the shopping basket stream with the new content
    _shoppingBasketController.sink.add(_shoppingBasket.toList());
    
    // any additional processing such as
    // computation of the total price of the basket
    // number of items, part of the basket...
  }


  //
  // Generates a series of Shopping Items
  // Normally this should come from a call to the server
  // but for this sample, we simply simulate
  //
  void _loadShoppingItems() {
    _itemsController.sink.add(List<ShoppingItem>.generate(50, (int index) {
      return ShoppingItem(
        id: index,
        title: "Item $index",
        price: ((Random().nextDouble() * 40.0 + 10.0) * 100.0).roundToDouble() /
            100.0,
        color: Color((Random().nextDouble() * 0xFFFFFF).toInt() << 0)
            .withOpacity(1.0),
      );
    }));
  }
}

可能唯一需要解释说明的就是 _postActionOnBasket() 方法:每次我们将商品添加到购物篮或移除时,都需要「刷新」 _shoppingBasketController 控制的 stream 内容,监听该 stream 的组件就会收到变更通知,以便组件自身进行刷新或重建(refresh/rebuild)

5.2. ShoppingPage

这个页面很简单,就是显示所有商品而已:

bloc_shopping_page.dart

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


    return SafeArea(
        child: Scaffold(
      appBar: AppBar(
        title: Text('Shopping Page'),
        actions: <Widget>[
          ShoppingBasket(),
        ],
      ),
      body: Container(
        child: StreamBuilder<List<ShoppingItem>>(
          stream: bloc.items,
          builder: (BuildContext context,
              AsyncSnapshot<List<ShoppingItem>> snapshot) {
            if (!snapshot.hasData) {
              return Container();
            }
            return GridView.builder(
              gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: 3,
                childAspectRatio: 1.0,
              ),
              itemCount: snapshot.data.length,
              itemBuilder: (BuildContext context, int index) {
                return ShoppingItemWidget(
                  shoppingItem: snapshot.data[index],
                );
              },
            );
          },
        ),
      ),
    ));
  }
}

说明:

  • AppBar 会显示一个按钮,用来:
    • 显示购物篮中商品的数量
    • 当点击时,跳转到 ShoppingBasket 页面
  • 商品列表使用了 GridView 布局,这个 GridView 是包含在一个 StreamBuilder<List<ShoppingItem>>中的
  • 每个商品对应一个 ShoppingItemWidget

5.3. ShoppingBasketPage

This page is very similar to the ShoppingPage except that the StreamBuilder is now listening to variations of the _shoppingBasket stream, exposed by the ShoppingBloc.

这个页面和 ShoppingPage 非常相似,只是其 StreamBuilder 监听对象是 ShoppingBloc 提供的 _shoppingBasket stream 的变更结果


5.4. ShoppingItemWidget 和 ShoppingItemBloc

Part Of 模式依赖于ShoppingItemWidgetShoppingItemBloc两个元素的组合应用:

  • ShoppingItemWidget 负责显示:
    • 商品信息
    • 添加到购物车或移除的按钮
  • ShoppingItemBloc 负责告诉 ShoppingItemWidget 它「是否在购物篮中」状态

我们来看看它们是怎么一起运作的…

5.4.1. ShoppingItemBloc

ShoppingItemBloc 由每个 ShoppingItemWidget 来实例化,并向其提供了自身的商品 ID(identity)

BLoC 将监听 ShoppingBasket stream 的变更结果,并检查具有特定 ID 的商品是否已在购物篮中;

如果已在购物篮中,BLoC 将抛出一个布尔值(=true),对应 ID 的 ShoppingItemWidget 将捕获这个布尔值,从而得知自己已经在购物篮中了。

以下就是 BLoC 的代码:

bloc_shopping_item_bloc.dart

class ShoppingItemBloc implements BlocBase {
  // Stream to notify if the ShoppingItemWidget is part of the shopping basket
  BehaviorSubject<bool> _isInShoppingBasketController = BehaviorSubject<bool>();
  Stream<bool> get isInShoppingBasket => _isInShoppingBasketController;


  // Stream that receives the list of all items, part of the shopping basket
  PublishSubject<List<ShoppingItem>> _shoppingBasketController = PublishSubject<List<ShoppingItem>>();
  Function(List<ShoppingItem>) get shoppingBasket => _shoppingBasketController.sink.add;


  // Constructor with the 'identity' of the shoppingItem
  ShoppingItemBloc(ShoppingItem shoppingItem){
    // Each time a variation of the content of the shopping basket
    _shoppingBasketController.stream
                          // we check if this shoppingItem is part of the shopping basket
                         .map((list) => list.any((ShoppingItem item) => item.id == shoppingItem.id))
                          // if it is part
                         .listen((isInShoppingBasket)
                              // we notify the ShoppingItemWidget 
                            => _isInShoppingBasketController.add(isInShoppingBasket));
  }


  @override
  void dispose() {
    _isInShoppingBasketController?.close();
    _shoppingBasketController?.close();
  }
}

5.4.2. ShoppingItemWidget

这个组件负责:

  • 创建一个 ShoppingItemBloc 实例,并将组件自身的 ID 传递给这个 BLoC 实例
  • 监听任何 ShoppingBasket 内容的变化,并将变化情况传递给 BLoC
  • l监听 ShoppingItemBloc 获知自身「是否已在购物篮中」状态
  • 根据自身是否在购物篮中,显示相应的按钮(添加/移除)
  • 用户点击按钮后给出响应:
    • 当用户点击「添加」按钮时,将自身放入到购物篮中
    • 当用户点击「移除」按钮时,将自身从购物篮中移除

来看看具体的实现代码和说明:

bloc_shopping_item.dart

class ShoppingItemWidget extends StatefulWidget {
  ShoppingItemWidget({
    Key key,
    @required this.shoppingItem,
  }) : super(key: key);


  final ShoppingItem shoppingItem;


  @override
  _ShoppingItemWidgetState createState() => _ShoppingItemWidgetState();
}


class _ShoppingItemWidgetState extends State<ShoppingItemWidget> {
  StreamSubscription _subscription;
  ShoppingItemBloc _bloc;
  ShoppingBloc _shoppingBloc;


  @override
  void didChangeDependencies() {
    super.didChangeDependencies();


    // As the context should not be used in the "initState()" method,
    // prefer using the "didChangeDependencies()" when you need
    // to refer to the context at initialization time
    _initBloc();
  }


  @override
  void didUpdateWidget(ShoppingItemWidget oldWidget) {
    super.didUpdateWidget(oldWidget);


    // as Flutter might decide to reorganize the Widgets tree
    // it is preferable to recreate the links
    _disposeBloc();
    _initBloc();
  }


  @override
  void dispose() {
    _disposeBloc();
    super.dispose();
  }


  // This routine is reponsible for creating the links
  void _initBloc() {
    // Create an instance of the ShoppingItemBloc
    _bloc = ShoppingItemBloc(widget.shoppingItem);


    // Retrieve the BLoC that handles the Shopping Basket content 
    _shoppingBloc = BlocProvider.of<ShoppingBloc>(context);


    // Simple pipe that transfers the content of the shopping
    // basket to the ShoppingItemBloc
    _subscription = _shoppingBloc.shoppingBasket.listen(_bloc.shoppingBasket);
  }


  void _disposeBloc() {
    _subscription?.cancel();
    _bloc?.dispose();
  }


  Widget _buildButton() {
    return StreamBuilder<bool>(
      stream: _bloc.isInShoppingBasket,
      initialData: false,
      builder: (BuildContext context, AsyncSnapshot<bool> snapshot) {
        return snapshot.data
            ? _buildRemoveFromShoppingBasket()
            : _buildAddToShoppingBasket();
      },
    );
  }


  Widget _buildAddToShoppingBasket(){
    return RaisedButton(
      child: Text('Add...'),
      onPressed: (){
        _shoppingBloc.addToShoppingBasket(widget.shoppingItem);
      },
    );
  }


  Widget _buildRemoveFromShoppingBasket(){
    return RaisedButton(
      child: Text('Remove...'),
      onPressed: (){
        _shoppingBloc.removeFromShoppingBasket(widget.shoppingItem);
      },
    );
  }


  @override
  Widget build(BuildContext context) {
    return Card(
      child: GridTile(
        header: Center(
          child: Text(widget.shoppingItem.title),
        ),
        footer: Center(
          child: Text('${widget.shoppingItem.price} €'),
        ),
        child: Container(
          color: widget.shoppingItem.color,
          child: Center(
            child: _buildButton(),
          ),
        ),
      ),
    );
  }
}

5.5. 这是到底是怎么运作的?

具体每部份的运作方式可参考下图

Part_Of

后记

又一篇长文,我倒是希望能够少写点,但是我觉得很多东西要解释清楚。

正如我在前言中说的,就我个人来说这些「模式」我已经中在开发中经常使用了,它们帮我节省了大量的时间和精力,而且产出的代码更加易读和调试;此外还有助于业务和视图的解耦分离。

肯定有大量其它方式也可以做到,甚至是更好的方式,但是本文中的模式对我来说确实很实用,这就是为啥我想与你分享的原因。

请继续关注新的文章,同时祝您编程愉快。

--全文完--

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

推荐阅读更多精彩内容