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

盛開發表於2019-01-11

原文: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 效能優化

    結合 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)

有時侯需要我們編碼實現一些棘手的業務流程,這些流程可能會由序列或並行、耗時長短不一、同步或非同步的子流程構成的,很可能每個子流程的處理結果也是千變萬化的,而且還可能需要根據其處理進度或狀態進行檢視更新。

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

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

  • 發出某個事件
  • 該事件觸發一些動作 (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


後記

又一篇長文,我倒是希望能夠少寫點,但是我覺得很多東西要解釋清楚。

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

肯定有大量其它方式也可以做到,甚至是更好的方式,但是本文中的模式對我來說確實很實用,這就是為啥我想與你分享的原因。

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

--全文完--

相關文章