[譯]Flutter 響應式編程:Steams 和 BLoC 實踐範例

原文:Reactive Programming - Streams - BLoC - Practical Use Cases 是做者 Didier BoelensReactive Programming - Streams - BLoC 寫的後續html

閱讀本文前建議先閱讀前篇,前篇中文翻譯有兩個版本:react

  1. [譯]Flutter響應式編程:Streams和BLoC by JarvanMogit

    較忠於原做的版本github

  2. Flutter中如何利用StreamBuilder和BLoC來控制Widget狀態 by 吉原拉麪正則表達式

    省略了一些初級概念,補充了一些我的解讀編程

前言

在瞭解 BLoC, Reactive ProgrammingStreams 概念後,我又花了些時間繼續研究,如今很是高興可以與你們分享一些我常用而且頗有用的模式(至少我是這麼認爲的)。這些模式爲我節約了大量的開發時間,而且讓代碼更加易讀和調試。後端

在這篇文章中我要分享的有:緩存

  1. BlocProvider 性能優化性能優化

    結合 StatefulWidget 和 InheritedWidget 二者優點構建 BlocProvider服務器

  2. BLoC 的範圍和初始化

    根據 BLoC 的使用範圍初始化 BLoC

  3. 事件與狀態管理

    基於事件(Event) 的狀態 (State) 變動響應

  4. 表單驗證

    根據表單項驗證來控制表單行爲 (範例中包含了表單中經常使用的密碼和重複密碼比對)

  5. Part Of 模式

    容許組件根據所處環境(是否在某個列表/集合/組件中)調整自身的行爲

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

1. BlocProvider 性能優化

我想先給你們介紹下我結合 InheritedWidget 實現 BlocProvider 的新方案,這種方式相比原來基於 StatefulWidget 實現的方式有性能優點。

1.1. 舊的 BlocProvider 實現方案

以前我是基於一個常規的 StatefulWidget 來實現 BlocProvider 的,代碼以下:

bloc_provider_previous.dart

abstract class BlocBase {
  void dispose();
}

// Generic 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;
  }
}

複製代碼

這種方案的優勢是:StatefulWidgetdispose() 方法能夠確保在 BLoC 初始化時分配的內存資源在不須要時能夠釋放掉。

譯者注

這個優勢是單獨基於 InheritedWidget 很難實現的,由於 InheritedWidget 沒有提供 dispose 方法,而 Dart 語言又沒有自帶的析構函數

雖然這種方案運行起來沒啥問題,但從性能角度卻不是最優解。

這是由於 context.ancestorWidgetOfExactType() 是一個時間複雜度爲 O(n) 的方法,爲了獲取符合指定類型的 ancestor ,它會沿着視圖樹從當前 context 開始逐步往上遞歸查找其 parent 是否符合指定類型。若是當前 context 和目標 ancestor 相距不遠的話這種方式還能夠接受,不然應該儘可能避免使用。

下面是 Flutter 中定義這個方法的源碼:

@override
Widget ancestorWidgetOfExactType(Type targetType) {
    assert(_debugCheckStateIsActiveForAncestorLookup());
    Element ancestor = _parent;
    while (ancestor != null && ancestor.widget.runtimeType != targetType)
        ancestor = ancestor._parent;
    return ancestor?.widget;
}
複製代碼

1.2. 新的 BlocProvider 實現方案

新方案雖然整體也是基於 StatefulWidget 實現的,可是組合了一個 InheritedWidget

譯者注

即在原來 StatefulWidgetchild 外面再包了一個 InheritedWidget

下面是實現的代碼:

bloc_provider_new.dart

Type _typeOf<T>() => T;

abstract class BlocBase {
  void dispose();
}

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

  final Widget child;
  final T bloc;

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

  static T of<T extends BlocBase>(BuildContext context){
    final type = _typeOf<_BlocProviderInherited<T>>();
    _BlocProviderInherited<T> provider = 
            context.ancestorInheritedElementForWidgetOfExactType(type)?.widget;
    return provider?.bloc;
  }
}

class _BlocProviderState<T extends BlocBase> extends State<BlocProvider<T>>{
  @override
  void dispose(){
    widget.bloc?.dispose();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context){
    return new _BlocProviderInherited<T>(
      bloc: widget.bloc,
      child: widget.child,
    );
  }
}

class _BlocProviderInherited<T> extends InheritedWidget {
  _BlocProviderInherited({
    Key key,
    @required Widget child,
    @required this.bloc,
  }) : super(key: key, child: child);

  final T bloc;

  @override
  bool updateShouldNotify(_BlocProviderInherited oldWidget) => false;
}
複製代碼

新方案毫無疑問是具備性能優點的,由於用了 InheritedWidget,在查找符合指定類型的 ancestor 時,咱們就能夠調用 InheritedWidget 的實例方法 context.ancestorInheritedElementForWidgetOfExactType(),而這個方法的時間複雜度是 O(1),意味着幾乎能夠當即查找到知足條件的 ancestor

Flutter 中該方法的定義源碼體現了這一點:

@override
InheritedElement ancestorInheritedElementForWidgetOfExactType(Type targetType) {
    assert(_debugCheckStateIsActiveForAncestorLookup());
    final InheritedElement ancestor = _inheritedWidgets == null 
                                    ? null 
                                    : _inheritedWidgets[targetType];
    return ancestor;
}
複製代碼

固然這也是源於 Fluter Framework 緩存了全部 InheritedWidgets 才得以實現。

爲何要用 ancestorInheritedElementForWidgetOfExactType 而不用 inheritFromWidgetOfExactType ?

由於 inheritFromWidgetOfExactType 不只查找獲取符合指定類型的Widget,還將context 註冊到該Widget,以便Widget發生變更後,context能夠獲取到新值;

這並非咱們想要的,咱們想要的僅僅就是符合指定類型的Widget(也就是 BlocProvider)而已。

1.3. 如何使用新的 BlocProvider 方案?

1.3.1. 注入 BLoC

Widget build(BuildContext context){
    return BlocProvider<MyBloc>{
        bloc: myBloc,
        child: ...
    }
}
複製代碼

1.3.2. 獲取 BLoC

Widget build(BuildContext context){
    MyBloc myBloc = BlocProvider.of<MyBloc>(context);
    ...
}
複製代碼

2. BLoC 的範圍和初始化

要回答「要在哪初始化 BLoC?」這個問題,須要先搞清楚 BLoC 的可用範圍 (scope)

2.1. 應用中任何地方可用

在實際應用中,經常須要處理如用戶鑑權、用戶檔案、用戶設置項、購物籃等等須要在 App 中任何組件均可訪問的數據或狀態,這裏總結了適用這種狀況的兩種 BLoC 方案:

2.1.1. 全局單例 (Global Singleton)

這種方案使用了一個不在Widget視圖樹中的 Global 對象,實例化後可用供全部 Widget 使用。

bloc_singleton.dart

import 'package:rxdart/rxdart.dart';

class GlobalBloc {
  ///
  /// Streams related to this BLoC
  ///
  BehaviorSubject<String> _controller = BehaviorSubject<String>();
  Function(String) get push => _controller.sink.add;
  Stream<String> get stream => _controller;

  ///
  /// Singleton factory
  ///
  static final GlobalBloc _bloc = new GlobalBloc._internal();
  factory GlobalBloc(){
    return _bloc;
  }
  GlobalBloc._internal();
  
  ///
  /// Resource disposal
  ///
  void dispose(){
    _controller?.close();
}

GlobalBloc globalBloc = GlobalBloc();
複製代碼

要使用全局單例 BLoC,只須要 import 後調用定義好的方法便可:

import 'global_bloc.dart';

class MyWidget extends StatelessWidget {
    @override
    Widget build(BuildContext context){
        globalBloc.push('building MyWidget'); //調用 push 方法添加數據 
        return Container();
    }
}
複製代碼

若是你想要一個惟一的、可從應用中任何組件訪問的 BLoC 的話,這個方案仍是不錯的,由於:

  • 簡單易用
  • 不依賴任何 BuildContext
  • 固然也不須要經過 context 查找 BlocProvider 的方式來獲取 BLoC
  • 釋放資源也很簡單,只需將 application Widget 基於 StatefulWidget 實現,而後重寫其 dispose() 方法,在 dispose() 中調用 globalBloc.dispose() 便可

我也不知道具體是爲啥,不少較真的人反對全局單例方案,因此…咱們再來看另外一種實現方案吧…

2.1.2. 注入到視圖樹頂層

在 Flutter 中,包含全部頁面的ancestor自己必須是 MaterialApp 的父級。 這是由於頁面(或者說Route)實際上是做爲全部頁面共用的 Stack 中的一項,被包含在 OverlayEntry 中的。

換句話說,每一個頁面都有本身獨立於任何其它頁面Buildcontext。這也解釋了爲啥不用任何技巧是沒辦法實現兩個頁面(或路由)之間數據共享的。

所以,必須將 BlocProvider 做爲 MaterialApp 的父級才能實如今應用中任何位置均可使用 BLoC,以下所示:

bloc_on_top.dart

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

class Application extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocProvider<AuthenticationBloc>(
      bloc: AuthenticationBloc(),
      child: MaterialApp(
        title: 'BLoC Samples',
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: InitializationPage(),
      ),
    );
  }
}
複製代碼

2.2. 在子視圖樹(多個頁面或組件)中可用

大多數時候,咱們只須要在應用的部分頁面/組件樹中使用 BLoC。舉個例子,在一個 App 中有相似論壇的功能模塊,在這個功能模塊中咱們須要用到 BLoC 來實現:

  • 與後端服務器交互,獲取、添加、更新帖子
  • 在特定的頁面列出須要顯示的數據

顯然咱們不須要將論壇的 BLoC 實現成全局可用,只需在涉及論壇的視圖樹中可用就好了。

那麼可採用經過 BlocProviderBLoC 做爲模塊子樹的根(父級)注入的方式,以下所示:

bloc_init_root.dart

class MyTree extends StatelessWidget {
  @override
  Widget build(BuildContext context){
    return BlocProvider<MyBloc>(
      bloc: MyBloc(),
      child: Column(
        children: <Widget>[
          MyChildWidget(),
        ],
      ),
    );
  }
}

class MyChildWidget extends StatelessWidget {
  @override 
  Widget build(BuildContext context){
    MyBloc = BlocProvider.of<MyBloc>(context);
    return Container();
  }
}
複製代碼

這樣,該模塊下全部 Widget 均可以經過調用 BlocProvider.of 來獲取 BLoC.

注意

上面給出的並非最佳方案,由於每次 MyTree 重構(rebuild)時都會從新初始化 BLoC ,帶來的結果是:

  • 丟失 BLoC 中已經存在的數據內容
  • 從新初始化BLoC 要佔用 CPU 時間

在這個例子中更好的方式是使用 StatefulWidget ,利用其持久化 State 的特性解決上述問題,代碼以下:

bloc_init_root_2.dart

class MyTree extends StatefulWidget {
 @override
  _MyTreeState createState() => _MyTreeState();
}
class _MyTreeState extends State<MyTree>{
  MyBloc bloc;
  
  @override
  void initState(){
    super.initState();
    bloc = MyBloc();
  }
  
  @override
  void dispose(){
    bloc?.dispose();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context){
    return BlocProvider<MyBloc>(
      bloc: bloc,
      child: Column(
        children: <Widget>[
          MyChildWidget(),
        ],
      ),
    );
  }
}
複製代碼

這樣實現的話,即便 MyTree 組件重構,也不會從新初始化 BLoC,而是直接使用以前的BLoC實例。

2.3. 單一組件中可用

若是隻在某一個組件 (Widget) 中使用 BLoC,只須要在該組件內構建 BLoC 實例便可。

3. 事件與狀態管理(Event - State)

有時侯須要咱們編碼實現一些棘手的業務流程,這些流程可能會由串行或並行、耗時長短不1、同步或異步的子流程構成的,極可能每一個子流程的處理結果也是變幻無窮的,並且還可能須要根據其處理進度或狀態進行視圖更新。

而本文中「事件與狀態管理」解決方案的目的就是讓處理這種複雜的業務流程變得更簡單。

方案是基於如下流程和規則的:

  • 發出某個事件
  • 該事件觸發一些動做 (action) ,這些動做會致使一個或多個狀態產生/變動
  • 這些狀態又觸發其它事件,或者產生/變動爲其它狀態
  • 而後這些事件又根據狀態的變動狀況,觸發其它動做
  • 等等…

爲了更好的展現這些概念,我還舉了兩個具體的例子:

  • 應用初始化 (Application initialization)

    不少時候咱們都須要運行一系列動做來初始化 App, 這些動做多是與服務器的交互相關聯的 (例如:獲取並加載一些數據)。並且在初始化過程當中,可能還須要顯示進度條及載入動畫讓用戶能耐心等待。

  • 用戶身份驗證 (Authentication)

    在 App 啓動後須要用戶登陸或註冊,用戶成功登陸後,將跳轉(重定向)到 App 的主頁面; 而用戶註銷則將跳轉(重定向)到驗證頁面。

爲了應對全部的可能,咱們將管理一系列的事件,而這些事件多是在 App 中任何地方觸發的,這使得事件和狀態的管理異常複雜,所幸咱們能夠藉助結合了 BlocEventStateBuiderBlocEventState 類大大下降事件和狀態管理的難度。

3.1. BlocEventState 抽象類

BlocEventState 背後的邏輯是將 BLoC 定義成這樣一套機制:

  • 接收事件 (event) 做爲輸入
  • 當新的事件觸發(輸入)時,調用一個對應的事件處理器 eventHandler
  • 事件處理器 (eventHandler) 負責根據事件 (event) 採用適當的處理 (actions) 後,拋出一個或多個狀態 (State) 做爲響應

以下圖所示:

BlocEventState

定義 BlocEventState 的代碼和說明以下:

bloc_event_state.dart

import 'package:blocs/bloc_helpers/bloc_provider.dart';
import 'package:meta/meta.dart';
import 'package:rxdart/rxdart.dart';

abstract class BlocEvent extends Object {}
abstract class BlocState extends Object {}

abstract class BlocEventStateBase<BlocEvent, BlocState> implements BlocBase {
  PublishSubject<BlocEvent> _eventController = PublishSubject<BlocEvent>();
  BehaviorSubject<BlocState> _stateController = BehaviorSubject<BlocState>();

  ///
  /// To be invoked to emit an event
  ///
  Function(BlocEvent) get emitEvent => _eventController.sink.add;

  ///
  /// Current/New state
  ///
  Stream<BlocState> get state => _stateController.stream;

  ///
  /// External processing of the event
  ///
  Stream<BlocState> eventHandler(BlocEvent event, BlocState currentState);

  ///
  /// initialState
  ///
  final BlocState initialState;

  //
  // Constructor
  //
  BlocEventStateBase({
    @required this.initialState,
  }){
    //
    // For each received event, we invoke the [eventHandler] and
    // emit any resulting newState
    //
    _eventController.listen((BlocEvent event){
      BlocState currentState = _stateController.value ?? initialState;
      eventHandler(event, currentState).forEach((BlocState newState){
        _stateController.sink.add(newState);
      });
    });
  }

  @override
  void dispose() {
    _eventController.close();
    _stateController.close();
  }
}
複製代碼

如代碼所示,咱們定義的實際上是一個抽象類,是須要擴展實現的,實現的重點就是定義 eventHandler 這個方法的具體行爲。

固然咱們還能夠看到:

  • Sink (代碼中的 emitEvent) 做爲事件 Event 的輸入入口
  • Stream (代碼中的 state) 監聽已發出的狀態 State(s) 做爲狀態的輸出出口

在這個類初始化時 (參考代碼中 Constructor 部分)

  • 須要提供初始狀態 initialState
  • 建立了一個 StreamSubscription 用來監聽輸入的事件 (Events) 並:
    • 將事件分配給事件處理器 eventHandler
    • 拋出結果 state(s)

3.2. BlocEventState 的擴展實現

下方的模板代碼就是基於擴展 BlocEventStateBase 抽象類實現了一個具體的 BlocEventState 類:

bloc_event_state_template.dart

class TemplateEventStateBloc extends BlocEventStateBase<BlocEvent, BlocState> {
  TemplateEventStateBloc()
      : super(
          initialState: BlocState.notInitialized(),
        );

  @override
  Stream<BlocState> eventHandler( BlocEvent event, BlocState currentState) async* {
     yield BlocState.notInitialized();
  }
}
複製代碼

模板代碼會報錯,請不要擔憂,這是正常的…由於咱們尚未定義 BlocState.notInitialized()…後面會給出的。

這個模板只是在初始化時簡單地給出了一個初始狀態 initialState,並覆寫了 eventHandler 方法。

還須要注意的是,咱們使用了 異步生成器 (asynchronous generator) 語法:async*yield

使用 async* 修飾符可將某個方法標記爲一個 異步生成器(asynchronous generator) 方法,好比上面的代碼中每次調用 eventHandler 方法內 yield 語句時,它都會把 yield 後面的表達式結果添加到輸出 Stream 中。

若是咱們須要經過一系列動做觸發一系列 States (後面會在範例中看到),這一點特別有用。

有關 異步生成器 的其餘詳細信息,可參考 這篇文章

3.3. BlocEvent 和 BlocState

你可能注意到了,咱們還定義了 BlocEventBlocState 兩個抽象類,這兩個抽象類都是要根據實際狀況,也就是在實際業務場景中根據你想要觸發的事件和拋出的狀態來具體 擴展實現 的。

3.4. BlocEventStateBuilder 組件

這個模式的最後一部分就是 BlocEventStateBuilder 組件了,這個組件能夠根據 BlocEventState 拋出的 State(s) 做出視圖層面的響應。

代碼以下:

bloc_event_state_builder.dart

typedef Widget AsyncBlocEventStateBuilder<BlocState>(BuildContext context, BlocState state);

class BlocEventStateBuilder<BlocEvent,BlocState> extends StatelessWidget {
  const BlocEventStateBuilder({
    Key key,
    @required this.builder,
    @required this.bloc,
  }): assert(builder != null),
      assert(bloc != null),
      super(key: key);

  final BlocEventStateBase<BlocEvent,BlocState> bloc;
  final AsyncBlocEventStateBuilder<BlocState> builder;

  @override
  Widget build(BuildContext context){
    return StreamBuilder<BlocState>(
      stream: bloc.state,
      initialData: bloc.initialState,
      builder: (BuildContext context, AsyncSnapshot<BlocState> snapshot){
        return builder(context, snapshot.data);
      },
    );
  }
}
複製代碼

其實這個組件除了一個 StreamBuilder 外沒啥特別的,這個 StreamBuilder 的做用就是每當有新的 BlocState 拋出後,將其做爲新的參數值調用 builder 方法。


好了,這些就是這個模式的所有構成,接下來咱們看看能夠用它們來作些啥…

3.5. 事件與狀態管理例1: 應用初始化 (Application Initialization)

第一個例子演示了 App 在啓動時執行某些任務的狀況。

一個常見的場景就是遊戲的啓動畫面,也稱 Splash 界面(無論是否是動畫的),在顯示真正的遊戲主界面前,遊戲應用會從服務器獲取一些文件、檢查是否須要更新、嘗試與系統的「遊戲中心」通信等等;並且在完成初始化前,爲了避免讓用戶以爲應用啥都沒作,可能還會顯示進度條、定時切換顯示一些圖片等。

我給出的實現是很是簡單的,只顯示了完成百分比的,你能夠根據本身的須要很是容易地進行擴展。

首先要作的就是定義事件和狀態…

3.5.1. 定義事件: ApplicationInitializationEvent

做爲例子,這裏我只考慮了 2 個事件:

  • start:觸發初始化處理過程
  • stop:用於強制中止初始化過程

它們的定義以下:

app_init_event.dar

class ApplicationInitializationEvent extends BlocEvent {
  
  final ApplicationInitializationEventType type;

  ApplicationInitializationEvent({
    this.type: ApplicationInitializationEventType.start,
  }) : assert(type != null);
}

enum ApplicationInitializationEventType {
  start,
  stop,
}
複製代碼

3.5.2. 定義狀態: ApplicationInitializationState

ApplicationInitializationState 類將提供與初始化過程相關的信息。

一樣做爲例子,這裏我只考慮了:

  • 2 個 flag:
    • isInitialized 用來標識初始化是否完成
    • isInitializing 用來知曉咱們是否處於初始化過程當中
  • 進度完成率 prograss

代碼以下:

app_init_state.dart

class ApplicationInitializationState extends BlocState {
  ApplicationInitializationState({
    @required this.isInitialized,
    this.isInitializing: false,
    this.progress: 0,
  });

  final bool isInitialized;
  final bool isInitializing;
  final int progress;

  factory ApplicationInitializationState.notInitialized() {
    return ApplicationInitializationState(
      isInitialized: false,
    );
  }

  factory ApplicationInitializationState.progressing(int progress) {
    return ApplicationInitializationState(
      isInitialized: progress == 100,
      isInitializing: true,
      progress: progress,
    );
  }

  factory ApplicationInitializationState.initialized() {
    return ApplicationInitializationState(
      isInitialized: true,
      progress: 100,
    );
  }
}
複製代碼

3.5.3. 實現 BLoC: ApplicationInitializationBloc

BLoC 將基於事件類型來處理具體的初始化過程。

代碼以下:

bloc_init_bloc.dart

class ApplicationInitializationBloc extends BlocEventStateBase<ApplicationInitializationEvent, ApplicationInitializationState> {
  ApplicationInitializationBloc()
      : super(
          initialState: ApplicationInitializationState.notInitialized(),
        );

  @override
  Stream<ApplicationInitializationState> eventHandler(
      ApplicationInitializationEvent event, ApplicationInitializationState currentState) async* {
    
    if (!currentState.isInitialized){
      yield ApplicationInitializationState.notInitialized();
    }

    if (event.type == ApplicationInitializationEventType.start) {
      for (int progress = 0; progress < 101; progress += 10){
        await Future.delayed(const Duration(milliseconds: 300));
        yield ApplicationInitializationState.progressing(progress);
      }
    }

    if (event.type == ApplicationInitializationEventType.stop){
      yield ApplicationInitializationState.initialized();
    }
  }
}
複製代碼

說明:

  • 當接收到 ApplicationInitializationEventType.start 事件時,進度完成率 prograss 將從 0100 開始計數(每次步進 10),並且未到 100 時每次都將經過 yield 拋出一個新狀態 (state) 告知初始化正在進行 (isInitializing = true) 及完成進度 prograss 具體的值
  • 當接收到 ApplicationInitializationEventType.stop 事件時,會認爲初始化已經完成。
  • 如你所見,我在循環過程當中加了些延遲 (delay) ,目的是演示 Future的適用場景(如從服務器獲取數據)

3.5.4. 組合使用

如今,剩下的事情就是把表明進度完成率的計數器顯示到假的 Splash 界面上:

bloc_init_page.dart

class InitializationPage extends StatefulWidget {
  @override
  _InitializationPageState createState() => _InitializationPageState();
}

class _InitializationPageState extends State<InitializationPage> {
  ApplicationInitializationBloc bloc;

  @override
  void initState(){
    super.initState();
    bloc = ApplicationInitializationBloc();
    bloc.emitEvent(ApplicationInitializationEvent());
  }

  @override
  void dispose(){
    bloc?.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext pageContext) {
    return SafeArea(
      child: Scaffold(
        body: Container(
          child: Center(
            child: BlocEventStateBuilder<ApplicationInitializationEvent, ApplicationInitializationState>(
              bloc: bloc,
              builder: (BuildContext context, ApplicationInitializationState state){
                if (state.isInitialized){
                  //
                  // Once the initialization is complete, let's move to another page
                  //
                  WidgetsBinding.instance.addPostFrameCallback((_){
                    Navigator.of(context).pushReplacementNamed('/home');
                  });
                }
                return Text('Initialization in progress... ${state.progress}%');
              },
            ),
          ),
        ),
      ),
    );
  }
}
複製代碼

說明:

  • 在 App 中,ApplicationInitializationBloc 並非任何組件都須要用到,因此只在一個 StatefulWidget 中初始化(實例化)了該 BLoC
  • 直接發出 ApplicationInitializationEventType.start 事件來觸發 eventHandler
  • 每次 ApplicationInitializationState 被拋出,都會更新文字內容
  • 初始化過程完成後,跳轉(重定向)到了 Home 界面

小技巧

因爲沒法直接跳轉到 Home 界面,在 builder 方法中,使用了WidgetsBinding.instance.addPostFrameCallback() 方法來請求 Flutter 在完成渲染後執行跳轉。參考 addPostFrameCallback()


3.6. 事件與狀態管理例2: 用戶身份驗證(登陸與註銷)

在這個例子中,我考慮了以下場景:

  • 若是用戶沒有登陸,則自動顯示 登陸/註冊(Authentication/Registration) 界面
  • 用戶提交登陸信息後,顯示一個表明正在處理的循環進度指示器(轉圈圈)
  • 一旦用戶登陸成功,將跳轉到 Home 界面
  • 在 App 任何地方,用戶均可能註銷
  • 若是用戶註銷,將自動跳轉到 登陸(Authentication) 界面

固然以其它編程方式也能夠實現這些功能,但以 BLoC 的方式來實現可能更簡單。

下圖解釋了將要實現的方案流程:

BlocAuthentication

中間跳轉頁面 DecisionPage 將負責 自動 將用戶重定向到 Authentication 界面或 Home 界面,具體到哪一個界面取決於用戶的登陸狀態。固然 DecisionPage 不會顯示給用戶,也不該該將其視爲一個真正的頁面。

一樣首先要作的是定義一些事件和狀態…

3.6.1. 定義事件: AuthenticationEvent

做爲例子,我只考慮了2個事件:

  • login:用戶成功登陸時會發出該事件
  • logout:用戶註銷時會發出該事件

它們的定義以下:

bloc_auth_event.dart

abstract class AuthenticationEvent extends BlocEvent {
  final String name;

  AuthenticationEvent({
    this.name: '',
  });
}

class AuthenticationEventLogin extends AuthenticationEvent {
  AuthenticationEventLogin({
    String name,
  }) : super(
          name: name,
        );
}

class AuthenticationEventLogout extends AuthenticationEvent {}
複製代碼

3.6.2. 定義狀態: AuthenticationState

AuthenticationState 類將提供與驗證過程相關的信息。

一樣做爲例子,我只考慮了:

  • 3 個 flag:
    • isAuthenticated 用來標識驗證是否完成
    • isAuthenticating 用來知曉是否處於驗證過程當中
    • hasFailed 用來表示身份是否驗證失敗
  • 通過身份驗證後的用戶名:name

代碼以下:

bloc_auth_state.dart

class AuthenticationState extends BlocState {
  AuthenticationState({
    @required this.isAuthenticated,
    this.isAuthenticating: false,
    this.hasFailed: false,
    this.name: '',
  });

  final bool isAuthenticated;
  final bool isAuthenticating;
  final bool hasFailed;

  final String name;
  
  factory AuthenticationState.notAuthenticated() {
    return AuthenticationState(
      isAuthenticated: false,
    );
  }

  factory AuthenticationState.authenticated(String name) {
    return AuthenticationState(
      isAuthenticated: true,
      name: name,
    );
  }

  factory AuthenticationState.authenticating() {
    return AuthenticationState(
      isAuthenticated: false,
      isAuthenticating: true,
    );
  }

  factory AuthenticationState.failure() {
    return AuthenticationState(
      isAuthenticated: false,
      hasFailed: true,
    );
  }
}
複製代碼

3.6.3. 實現 BLoC: AuthenticationBloc

BLoC 將基於事件類型來處理具體的身份驗證過程。

代碼以下:

bloc_auth_bloc.dart

class AuthenticationBloc extends BlocEventStateBase<AuthenticationEvent, AuthenticationState> {
  AuthenticationBloc()
      : super(
          initialState: AuthenticationState.notAuthenticated(),
        );

  @override
  Stream<AuthenticationState> eventHandler(
      AuthenticationEvent event, AuthenticationState currentState) async* {

    if (event is AuthenticationEventLogin) {
      // Inform that we are proceeding with the authentication
      yield AuthenticationState.authenticating();

      // Simulate a call to the authentication server
      await Future.delayed(const Duration(seconds: 2));

      // Inform that we have successfuly authenticated, or not
      if (event.name == "failure"){
        yield AuthenticationState.failure();
      } else {
        yield AuthenticationState.authenticated(event.name);
      }
    }

    if (event is AuthenticationEventLogout){
      yield AuthenticationState.notAuthenticated();
    }
  }
}
複製代碼

說明:

  • 當接收到 AuthenticationEventLogin事件時,會經過 yield 拋出一個新狀態 (state) 告知身份驗證正在進行 (isAuthenticating = true)
  • 當身份驗證一旦完成,會拋出另外一個新的狀態 (state) 告知已經完成了
  • 當接收到 AuthenticationEventLogout 事件時,會拋出一個新狀態 (state) 告知用戶已經不在是已驗證狀態

3.6.4. 登陸頁面: AuthenticationPage

如你所見,爲了便於說明,這個頁面並無作的很複雜。

代碼及說明以下:

bloc_auth_page.dart

class AuthenticationPage extends StatelessWidget {
  ///
  /// Prevents the use of the "back" button
  ///
  Future<bool> _onWillPopScope() async {
    return false;
  }

  @override
  Widget build(BuildContext context) {
    AuthenticationBloc bloc = BlocProvider.of<AuthenticationBloc>(context);
    return WillPopScope(
      onWillPop: _onWillPopScope,
      child: SafeArea(
        child: Scaffold(
          appBar: AppBar(
            title: Text('Authentication Page'),
            leading: Container(),
          ),
          body: Container(
            child:
                BlocEventStateBuilder<AuthenticationEvent, AuthenticationState>(
              bloc: bloc,
              builder: (BuildContext context, AuthenticationState state) {
                if (state.isAuthenticating) {
                  return PendingAction();
                }

                if (state.isAuthenticated){
                  return Container();
                }
                
                List<Widget> children = <Widget>[];

                // Button to fake the authentication (success)
                children.add(
                  ListTile(
                      title: RaisedButton(
                        child: Text('Log in (success)'),
                        onPressed: () {
                            bloc.emitEvent(AuthenticationEventLogin(name: 'Didier'));
                        },
                      ),
                    ),
                );

                // Button to fake the authentication (failure)
                children.add(
                  ListTile(
                      title: RaisedButton(
                        child: Text('Log in (failure)'),
                        onPressed: () {
                            bloc.emitEvent(AuthenticationEventLogin(name: 'failure'));
                        },
                      ),
                    ),
                );

                // Display a text if the authentication failed
                if (state.hasFailed){
                  children.add(
                    Text('Authentication failure!'),
                  );
                }

                return Column(
                  children: children,
                );    
              },
            ),
          ),
        ),
      ),
    );
  }
}
複製代碼

說明:

  • 第 11 行:在頁面中獲取 AuthenticationBloc
  • 第 24 ~ 70 行:監聽被拋出的 AuthenticationState
    • 若是正在驗證過程當中,會顯示循環進度指示器(轉圈圈),告知用戶正在處理中,並阻止用戶訪問到其它頁面(第25 ~ 27 行)
    • 若是驗證成功,顯示一個空的 Container,即不顯示任何內容 (第 29 ~ 31 行)
    • 若是用戶尚未登陸,顯示2個按鈕,可模擬登陸成功和失敗的狀況
    • 當點擊其中一個按鈕時,會發出 AuthenticationEventLogin 事件以及一些參數(一般會被用於驗證處理)
    • 若是身份驗證失敗,顯示一條錯誤消息(第 60 ~ 64 行)

好了,沒啥別的事了,很簡單對不?

小技巧

你確定注意到了,我把頁面包在了 WillPopScope 裏面,這是由於身份驗證是必須的步驟,除非成功登陸(驗證經過),我不但願用戶使用 Android 設備提供的 Back 鍵來跳過驗證訪問到其它頁面。

3.6.5. 中間跳轉頁面: DecisionPage

如前所述,我但願 App 根據用戶登陸狀態自動跳轉到 AuthenticationPageHomePage

代碼及說明以下:

bloc_decision_page.dart

class DecisionPage extends StatefulWidget {
  @override
  DecisionPageState createState() {
    return new DecisionPageState();
  }
}

class DecisionPageState extends State<DecisionPage> {
  AuthenticationState oldAuthenticationState;

  @override
  Widget build(BuildContext context) {
    AuthenticationBloc bloc = BlocProvider.of<AuthenticationBloc>(context);
    return BlocEventStateBuilder<AuthenticationEvent, AuthenticationState>(
      bloc: bloc,
      builder: (BuildContext context, AuthenticationState state) {
        if (state != oldAuthenticationState){
          oldAuthenticationState = state;

          if (state.isAuthenticated){
            _redirectToPage(context, HomePage());
          } else if (state.isAuthenticating || state.hasFailed){
  //do nothing
          } else {
            _redirectToPage(context, AuthenticationPage());
          }
        }
        // This page does not need to display anything since it will
        // always remind behind any active page (and thus 'hidden').
        return Container();
      }
    );
  }

  void _redirectToPage(BuildContext context, Widget page){
    WidgetsBinding.instance.addPostFrameCallback((_){
      MaterialPageRoute newRoute = MaterialPageRoute(
          builder: (BuildContext context) => page
        );

      Navigator.of(context).pushAndRemoveUntil(newRoute, ModalRoute.withName('/decision'));
    });
  }
}
複製代碼

提示

爲了詳細解釋下面的問題,咱們先回溯下 Flutter 處理 Pages(也就是 路由Route)的方式,即便用 Navigator 對象來管理 Routes,而 Navigator 對象建立了一個 Overlay 對象;這個 Overlay 實際上是包含多個 OverlayEntryStack 對象,而每一個 OverlayEntry 都包含了一個 Page

當咱們經過 Navigator.of(context) 操做路由堆棧進行壓入、彈出或替換時,也會更新 Overlay 對象(也就是Stack 對象),換句話說,這些操做會致使 Stack 對象的重構;而 Stack 重構時,OverlayEntry (包括其內容 Page)也會跟着重構;

結果就是:

當咱們經過 Navigator.of(context) 進行路由操做後,全部其它頁面都會重構!

  • 那麼,爲啥我要把它實現爲 StatefulWidget ?

    爲了可以響應 AuthenticationState 任何變動,這個 page 須要在 App 整個生命週期內保留;

    而根據上面的提示,每次調用 Navigator.of(context) 後,這個頁面都會被重構,所以也會重構 BlocEventStateBuilder ,毫無疑問 BlocEventStateBuilder 裏面的 builder 方法也會被調用;

    由於這個 builder 方法是負責將用戶重定向到與 AuthenticationState 對應的頁面,重定向又要經過 Navigator.of(context) 來實現…明顯死循環了

    因此爲了防止這種狀況發生,咱們須要將「最後一個」 AuthenticationState 存起來,只有當新的 AuthenticationState 與已存的不同時,咱們才進行重定向處理;

    而實現存儲就是利用 StatefulWidget 的特性,將「最後一個」 AuthenticationState 放到了 StateoldAuthenticationState 屬性中。

  • 究竟是怎麼運做的?

    如上所訴,每當 AuthenticationState 被拋出時,BlocEventStateBuilder 會調用 builder 方法,根據 isAuthenticated 標識,咱們就知道具體將用戶重定向到哪一個頁面。

小技巧

因爲在 builder 中沒法直接跳轉到其它界面,咱們使用了WidgetsBinding.instance.addPostFrameCallback() 方法來請求 Flutter 在完成渲染後執行跳轉。

此外,除了 DecisionPage 須要在整個應用生命週期保留以外,咱們須要移除路由堆棧中重定向前全部其它已存在的頁面,因此咱們使用了 Navigator.of(context).pushAndRemoveUntil(…) 來實現這一目的。參考 pushAndRemoveUntil()


3.6.6. 用戶註銷

爲了讓用戶可以註銷,能夠建立一個 LogOutButton,放到 App 中任何地方。

這個按鈕只須要點擊後發出 AuthenticationEventLogout() 事件,這個事件會觸發以下的自動處理動做:

  1. 事件由 AuthenticationBloc 進行處理
  2. 處理後拋出一個 AuthentiationState(isAuthenticated = false)
  3. 拋出的狀態將由DecisionPage 經過 BlocEventStateBuilder 進行處理
  4. 最後將用戶重定向到 AuthenticationPage

按鈕代碼以下:

bloc_log_out_button.dart

class LogOutButton extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    AuthenticationBloc bloc = BlocProvider.of<AuthenticationBloc>(context);
    return IconButton(
      icon: Icon(Icons.exit_to_app),
      onPressed: () {
        bloc.emitEvent(AuthenticationEventLogout());
      },
    );
  }
}
複製代碼

3.6.7. 注入 AuthenticationBloc

因爲須要 AuthenticationBloc 在應用中任何頁面均可用,因此咱們將其注入爲 MaterialApp 的父級,以下所示:

bloc_auth_app.dart

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

class Application extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocProvider<AuthenticationBloc>(
      bloc: AuthenticationBloc(),
      child: MaterialApp(
        title: 'BLoC Samples',
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: DecisionPage(),
      ),
    );
  }
}
複製代碼

4. 表單驗證

BLoC 另外一個有意思的應用場景就是表單的驗證,好比:

  • 驗證某個 TextField 表單項是否知足一些業務規則
  • 業務規則驗證錯誤時顯示提示信息
  • 根據業務規則自動處理表單組件是否可用

下面的例子中,我用了一個名叫 RegistrationForm 的表單,這個表單包含3個 TextField (分別爲電子郵箱email、密碼password和重複密碼 confirmPassword)以及一個按鈕 RaisedButton 用來發起註冊處理

想要實現的業務規則有:

  • email 須要是有效的電子郵箱地址,若是不是的話顯示錯誤提示信息
  • password 也必須需有效,即包括至少1個大寫字母、1個小寫字母、1個數字和1個特殊字符在內,且很多於8位字符,若是不是的話也須要顯示錯誤提示信息
  • 重複密碼 retype password 除了須要和 password 同樣的驗證規則外,還須要和 password 徹底同樣,若是不是的話,顯示錯誤提示信息
  • register 按鈕只有在以上全部規則都驗證經過後才能使用

4.1. RegistrationFormBloc

如前所述,這個 BLoC 負責業務規則驗證的處理,實現的代碼以下:

bloc_reg_form_bloc.dart

class RegistrationFormBloc extends Object with EmailValidator, PasswordValidator implements BlocBase {

  final BehaviorSubject<String> _emailController = BehaviorSubject<String>();
  final BehaviorSubject<String> _passwordController = BehaviorSubject<String>();
  final BehaviorSubject<String> _passwordConfirmController = BehaviorSubject<String>();

  //
  // Inputs
  //
  Function(String) get onEmailChanged => _emailController.sink.add;
  Function(String) get onPasswordChanged => _passwordController.sink.add;
  Function(String) get onRetypePasswordChanged => _passwordConfirmController.sink.add;

  //
  // Validators
  //
  Stream<String> get email => _emailController.stream.transform(validateEmail);
  Stream<String> get password => _passwordController.stream.transform(validatePassword);
  Stream<String> get confirmPassword => _passwordConfirmController.stream.transform(validatePassword)
    .doOnData((String c){
      // If the password is accepted (after validation of the rules)
      // we need to ensure both password and retyped password match
      if (0 != _passwordController.value.compareTo(c)){
        // If they do not match, add an error
        _passwordConfirmController.addError("No Match");
      }
    });

  //
  // Registration button
  Stream<bool> get registerValid => Observable.combineLatest3(
                                      email, 
                                      password, 
                                      confirmPassword, 
                                      (e, p, c) => true
                                    );

  @override
  void dispose() {
    _emailController?.close();
    _passwordController?.close();
    _passwordConfirmController?.close();
  }
}
複製代碼

說明:

  • 這個類中咱們最早初始化了 3 個 BehaviorSubject,用來處理表單中 3 個 TextFieldStream
  • 這個類提供了 3 個 Function(String) ,用來接收來自 TextField 的輸入
  • 這個類提供了 3 個 Stream<String> ,在 TextField 驗證失敗時,顯示各自的錯誤信息
  • 同時還提供了 1 個 Stream<bool>,做用是根據所有表單項的驗證結果,控制 RaisedButton 是否可用 (enable/disabe)

好了,咱們來深刻了解更多的細節…

你可能注意到了,這個 BLoC 類的代碼有點特殊,是這樣的:

class RegistrationFormBloc extends Object with EmailValidator, PasswordValidator implements BlocBase {
  ...
}
複製代碼

使用了 with 關鍵字代表這個類用到了 MIXINS (一種在另外一個類中重用類代碼的方法),並且爲了使用 with,這個類還須要基於 Object 類進行擴展。這些 mixins 包含了 email 和 password 各自的驗證方式。

關於 Mixins 更多信息建議閱讀 Romain Rastel 的這篇文章

4.1.1. 表單驗證用到的 Mixins

我這裏只對 EmailValidator 進行說明,由於 PasswordValidator 也是相似的。

首先,代碼以下:

bloc_email_validator.dart

const String _kEmailRule = r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$";


class EmailValidator {
  final StreamTransformer<String,String> validateEmail = 
      StreamTransformer<String,String>.fromHandlers(handleData: (email, sink){
        final RegExp emailExp = new RegExp(_kEmailRule);


        if (!emailExp.hasMatch(email) || email.isEmpty){
          sink.addError('Entre a valid email');
        } else {
          sink.add(email);
        }
      });
}
複製代碼

這個類提供了一個 final 方法 (validateEmail),這個方法其實返回的是一個 StreamTransformer 實例

提示

StreamTransformer 的調用方式爲:stream.transform(StreamTransformer)

StreamTransformerStream 獲取輸入,而後引用 Streamtransform 方法進行輸入的處理,並將處理後的數據從新注入到初始的 Stream 中。

在上面的代碼中,處理流程包括根據一個 正則表達式 檢查輸入的內容,若是匹配則將輸入的內容從新注入到 stream 中;若是不匹配,則將錯誤信息注入給 stream

4.1.2. 爲何要用 stream.transform()?

如前所述,若是驗證成功,StreamTransformer 會把輸入的內容從新注入回 Stream,具體是怎麼運做的呢?

咱們先看看 Observable.combineLatest3() 這個方法,它在每一個 Stream 全都拋出至少一個值以前,並不會給出任何值

以下圖所示:

Observable.combineLatest3

  • 若是用戶輸入的 email 是有效的,emailstream 會拋出用戶輸入的內容,同時再做爲 Observable.combineLatest3() 的一個輸入
  • 若是用戶輸入的 email 是無效的,emailstream 中會被添加一條錯誤信息(並且 stream 不會拋出數據)
  • passwordretype password 也是相似的機制
  • 當它們3個都驗證經過時(也就是 3 個 stream 都拋出了數據),Observable.combineLatest3() 會藉助 (e, p, c) => true 方法拋出一個 true 值(見代碼第 35 行)

4.1.3. 密碼與重複密碼驗證

我在網上看到有不少關於密碼與重複密碼的驗證問題,解決方案確定是有不少的,這裏我針對其中兩種說明下。

4.1.3.1. 無錯誤提示的基礎方案

第一種解決方案的代碼以下:

bloc_password_valid_1.dart

Stream<bool> get registerValid => Observable.combineLatest3(
                                      email, 
                                      password, 
                                      confirmPassword, 
                                      (e, p, c) => (0 == p.compareTo(c))
                                    );
複製代碼

這個解決方案只是在驗證了兩個密碼以後,將它們進行比較,若是它們同樣,則會拋出一個 true 值。

等下咱們會看到,Register 按鈕是否可用是依賴於 registerValid stream 的,若是兩個密碼不同,registerValid stream 就不會拋出任何值,因此 Register 按鈕依然是不可用狀態。

可是,用戶不會接收到任何錯誤提示信息,因此也不明白髮生了什麼。

4.1.3.2. 具備錯誤提示的方案

另外一種方案是把 confirmPassword stream的處理方法進行了擴展,代碼以下:

bloc_password_valid_2.dart

Stream<String> get confirmPassword => _passwordConfirmController.stream.transform(validatePassword)
    .doOnData((String c){
      // If the password is accepted (after validation of the rules)
      // we need to ensure both password and retyped password match
      if (0 != _passwordController.value.compareTo(c)){
        // If they do not match, add an error
        _passwordConfirmController.addError("No Match");
      }
    });
複製代碼

一旦 retype password 業務規則驗證經過, 用戶輸入的內容會被 Stream 拋出,並調用 doOnData() 方法,在該方法中經過 _passwordController.value.compareTo() 獲取是否與 password stream 中的數據同樣,若是不同,咱們就可用添加錯誤提示了。


4.2. RegistrationForm 組件

在解釋說明前咱們先來看看 Form 組件的實現代碼:

bloc_reg_form.dart

class RegistrationForm extends StatefulWidget {
  @override
  _RegistrationFormState createState() => _RegistrationFormState();
}


class _RegistrationFormState extends State<RegistrationForm> {
  RegistrationFormBloc _registrationFormBloc;


  @override
  void initState() {
    super.initState();
    _registrationFormBloc = RegistrationFormBloc();
  }


  @override
  void dispose() {
    _registrationFormBloc?.dispose();
    super.dispose();
  }


  @override
  Widget build(BuildContext context) {
    return Form(
      child: Column(
        children: <Widget>[
          StreamBuilder<String>(
              stream: _registrationFormBloc.email,
              builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
                return TextField(
                  decoration: InputDecoration(
                    labelText: 'email',
                    errorText: snapshot.error,
                  ),
                  onChanged: _registrationFormBloc.onEmailChanged,
                  keyboardType: TextInputType.emailAddress,
                );
              }),
          StreamBuilder<String>(
              stream: _registrationFormBloc.password,
              builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
                return TextField(
                  decoration: InputDecoration(
                    labelText: 'password',
                    errorText: snapshot.error,
                  ),
                  obscureText: false,
                  onChanged: _registrationFormBloc.onPasswordChanged,
                );
              }),
          StreamBuilder<String>(
              stream: _registrationFormBloc.confirmPassword,
              builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
                return TextField(
                  decoration: InputDecoration(
                    labelText: 'retype password',
                    errorText: snapshot.error,
                  ),
                  obscureText: false,
                  onChanged: _registrationFormBloc.onRetypePasswordChanged,
                );
              }),
          StreamBuilder<bool>(
              stream: _registrationFormBloc.registerValid,
              builder: (BuildContext context, AsyncSnapshot<bool> snapshot) {
                return RaisedButton(
                  child: Text('Register'),
                  onPressed: (snapshot.hasData && snapshot.data == true)
                      ? () {
                          // launch the registration process
                        }
                      : null,
                );
              }),
        ],
      ),
    );
  }
}
複製代碼

說明:

  • 由於 RegisterFormBloc 只是用於表單的驗證處理,因此僅在表單組件中初始化(實例化)是合適的
  • 每一個 TextField 都包含在一個StreamBuilder<String> 中,以便可以響應驗證過程的任何結果(見代碼中的errorText:snapshot.error
  • 每次 TextField 中輸入的內容發生改變時,咱們都將已輸入的內容經過 onChanged:_registrationFormBloc.onEmailChanged (輸入email狀況下) 發送給 BLoC 進行驗證,
  • RegisterButton 一樣也包含在一個 StreamBuilder<bool>
    • 若是 _registrationFormBloc.registerValid 拋出了值,onPressed 將在用戶點擊時對拋出的值進行後續處理
    • 若是沒有值拋出,onPressed 方法被指定爲 null,按鈕會被置爲不可用狀態

好了!可用看到在表單組件中,是看不到任何和業務規則相關的代碼的,這意味着咱們能夠隨意修改業務規則,而不須要對錶單組件自己進行任何修改,簡直 excellent!


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
  • 監聽 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


後記

又一篇長文,我卻是但願可以少寫點,可是我以爲不少東西要解釋清楚。

正如我在前言中說的,就我我的來講這些「模式」我已經中在開發中常用了,它們幫我節省了大量的時間和精力,並且產出的代碼更加易讀和調試;此外還有助於業務和視圖的解耦分離。

確定有大量其它方式也能夠作到,甚至是更好的方式,可是本文中的模式對我來講確實很實用,這就是爲啥我想與你分享的緣由。

請繼續關注新的文章,同時祝您編程愉快。

--全文完--

相關文章
相關標籤/搜索