[翻譯] 響應式程式設計(Reactive Programming) - 流(Streams) - BLoC - 實際應用案例

芙蓉秋江發表於2019-03-14

非常感謝 Didier Boelens 同意我將它的一些文章翻譯為中文發表,這是其中一篇。

本文應用多個例項詳細講解了 BLoC 設計模式的原理和使用方法,非常值得學習。

原文 連結

特別提醒:本文很長,閱讀完需要較長時間

 

BLoC 設計模式、響應式程式設計、流、應用案例、和有用的模式。

難度:中等

簡介

前一段時間,我介紹了 BLoC響應式程式設計(Reactive Programming )流(Streams) 的概念後,我想給大家分享一些我經常使用並且非常有用的(至少對我而言)模式應該是一件有趣的事。

我本文要講的主題有這些:

本文完整的程式碼在 GitHub 上可以獲取到。

1. BLoC Provider 和 InheritedWidget

我藉此文章的機會介紹我另一個版本的 BlocProvider,它現在依賴一個 InheritedWidget

使用 InheritedWidget 的好處是我們可以提高 APP 的 效能

請容我細細道來……

1.1. 之前的實現方式

我之前版本的 BlocProvider 實現為一個常規 StatefulWidget,如下所示:

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;
  }
}
複製程式碼

我使用 StatefulWidget 利用其 dispose() 方法以確保在不再需要時釋放 BLoC 分配的資源。

這很好用,但從效能角度來看並不是最佳的。

context.ancestorWidgetOfExactType() 是一個 O(n) 複雜度的方法。為了獲取需要的某種特定型別的祖先,它從上下文開始向上遍歷樹,一次遞迴地向上移動一個父節點,直到完成。如果從當前上下文到祖先的距離很小,則呼叫此函式還是可以接受的,否則應該避免呼叫此函式。 這是這個函式的程式碼。

@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. 新的實現方式

新的實現方式依賴於 StatefulWidget,並結合了 InheritedWidget

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,現在可以呼叫 context.ancestorInheritedElementForWidgetOfExactType() 方法,它是一個 O(1) 複雜度的方法,這意味著獲取祖先節點是非常快的,如其原始碼所示:

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

這也表明所有 InheritedWidgets 都由 Framework 儲存。

為什麼要使用 ancestorInheritedElementForWidgetOfExactType 呢 ?

你應該已經注意到了我用 ancestorInheritedElementForWidgetOfExactType 代替了通常使用的 inheritFromWidgetOfExactType 方法。

原因是我不希望呼叫 BlocProvider 的上下文被註冊為 InheritedWidget 的依賴項,因為我不需要它。

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

要回答這個問題,您需要弄清楚其使用範圍。

2.1. 應用程式中任何地方都可用

假如您必須處理一些與使用者身份驗證或使用者簡介、使用者首選項、購物車相關的一些業務邏輯…… 任何需要從應用程式的任何可能地方(例如,從不同頁面)都可以 獲取到 BLoC 的業務邏輯,有兩種方式 可以使這個 BLoC 在任何地方都可以訪問。

2.1.1. 使用全域性單例

此解決方案依賴於使用全域性物件,(為所有使用的地方)例項化一次,不是任何 Widget 樹 的一部分。

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 'global_bloc.dart';

class MyWidget extends StatelessWidget {
    @override
    Widget build(BuildContext context){
        globalBloc.push('building MyWidget');
        return Container();
    }
}
複製程式碼

如果您需要一個 唯一BLoC 並需要從應用程式內部的任何位置訪問,這是一個可接受的解決方案。

  • 這是很容易使用的
  • 它不依賴任何 BuildContext
  • 不需要通過 BlocProvider 查詢 BLoC,並且
  • 為了釋放它的資源,只需確保將應用程式實現為 StatefulWidget,並在 StatefulWidget 重寫的 dispose() 方法中呼叫 globalBloc.dispose() 即可。

許多純粹主義者反對這種解決方案。 我不知道為什麼,但是…所以讓我們看看另一個實現方式吧 ......

2.1.2. 把它放在所有的 Widget 之上

在 Flutter 中,所有頁面的祖先本身必須是 MaterialApp 的父級 。這是因為一個頁面(或路徑)是被包裝在 一個 OverlayEntry 中的,是所有頁面 的一個子項。

換句話說,每個頁面都有一個 獨立於任何其他頁面Buildcontext。 這就解釋了為什麼在不使用任何技巧的情況下,兩個頁面(或路由)不可能有任何共同的地方。

因此,如果您需要在應用程式中的任何位置使用 BLoC,則必須將其作為 MaterialApp 的父級,如下所示:

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

作為一個例子,我們可以想象一個討論相關的模組,它的 BLoC 將會用於:

  • 與伺服器互動以 獲取、新增、修改帖子。
  • 在某一個頁面中列出所有討論的話題。
  • ……

在這個例子中,你不需要使這個 BLoC 在整個應用的任何地方都可用,只需要在一些 Widget 中可用(樹的一部分)。

第一種解決方案可能是將 BLoC 注入到 Widget 樹 的根節點,如下所示:

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

邊注

如上所示的解決方案並不是最佳的,因為它將在每次重新構建(rebuild)時例項化BLoC。

後果:

  • 您將丟失 BLoC 的任何現有的內容
  • 它會耗費 CPU 時間,因為它需要在每次構建時例項化它。

在這種情況下,更好的方法是使用 StatefulWidget 從其持久狀態中受益,如下所示:

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” Widget ,則不必重新例項化 BLoC 並直接重用現有例項。

2.3. 僅適用於一個小部件

這涉及到 一個 BLoC 僅由一個 Widget 使用的情況。 在這種情況下,可以在 Widget 中例項化 BLoC

3. 事件(Event) 、 狀態(State)

有時,處理一系列可能是順序或並行,長或短,同步或非同步以及可能導致各種結果的操作可能變得非常難以程式設計。您可能還需要根據狀態的改變或進度更新顯示。

此第一個例子旨在使這種情況更容易處理。

該解決方案基於以下原則:

  • 一個事件被髮出;
  • 這個事件觸發一些導致一個或多個狀態的動作;
  • 這些狀態中的每一個都可以反過來發出其他事件或導致另一個狀態;
  • 然後,這些事件將根據活動狀態觸發其他操作;
  • 等等…

為了說明這個概念,我們來看兩個常見的例子:

  • 應用初始化

    假設您需要執行一系列操作來初始化一個應用程式。 這些操作可能有與伺服器的互動(例如,載入一些資料)。 在此初始化過程中,您可能需要顯示進度條和一系列影象以使使用者等待。

  • 使用者認證 在啟動時,應用程式可能要求使用者進行身份驗證或註冊。 使用者通過身份驗證後,將重定向到應用程式的主頁面。 然後,如果使用者登出,則將其重定向到認證頁面。

為了能夠處理所有可能的情況,事件序列,並且如果我們認為可以在應用程式中的任何地方觸發這些事件,這可能變得非常難以管理。

這就是 BlocEventStateBlocEventStateBuilder 相結合可以有很大幫助的地方……

3.1. BlocEventState

BlocEventState 背後的思想是定義這樣一個 BLoC

  • 接受 事件(Event) 作為輸入;
  • 在發出新事件時呼叫 eventHandler;
  • eventHandler 負責根據 事件 採取適當的操作併發出 狀態 作為迴應。

下圖顯示了這個思想:

[翻譯] 響應式程式設計(Reactive Programming) - 流(Streams) - BLoC - 實際應用案例

這是這個類的原始碼。 解釋在程式碼後面:

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) 來推送一個 事件;
  • 一個 Stream (state) 來監聽傳送的 狀態

在初始化時(請參閱建構函式):

  • 需要提供一個 initialState;
  • 它建立一個 StreamSubscription 來監聽傳入的 事件
    • 將它們傳送到 eventHandler
    • 發出結果 狀態

3.2. 專門的 BlocEventState

用於實現此 BlocEventState 的泛型類在下面給出。 之後,我們將實現一個真實的類。

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

這裡有一些非常有趣的事情需要注意。 我們使用了非同步生成器:**async *** 和 yield 語句。

使用 async* 修飾符標記函式,將函式標識為非同步生成器:

每次呼叫 yield 語句時,它都會將 yield 後面的表示式結果新增到輸出 Stream 中。

如果我們需要通過一系列操作發出一系列狀態(我們稍後會在實踐中看到),這將特別有用

有關非同步生成器的其他詳細資訊,請點選此連結

3.3. BlocEvent 和 BlocState

正如您所注意到的,我們已經定義了一個 BlocEventBlocState 抽象類。

這些類需要你使用想要發出的特定的事件和狀態去 繼承

3.4. BlocEventStateBuilder Widget

這個模式的最後一部分是 BlocEventStateBuilder Widget,它允許您響應 BlocEventState 發出的 State

這是它的原始碼:

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);
      },
    );
  }
}
複製程式碼

這個 Widget 只是一個專門的 StreamBuilder,它會在每次發出新的 BlocState 時呼叫傳入的 builder 引數。

OK,現在我們已經擁有了 EventStateBloc 設計模式 所有的部分了,現在是時候展示我們可以用它們做些什麼了......

3.5. 案例1:應用程式初始化

第一個示例說明了您需要應用程式在啟動時執行某些任務的情況。

通常的用途是,遊戲在顯示實際主螢幕之前,最初顯示啟動畫面(動畫與否),同時從伺服器獲取一些檔案,檢查新的更新是否可用,嘗試連線到任何遊戲中心……。為了不給使用者程式什麼都沒做的感覺,它可能會顯示一個進度條並定期顯示一些圖片,同時它會完成所有初始化過程。

我要展示的具體實現非常簡單。 它只會在螢幕上顯示一些完成百分比,但這可以根據你的需求很容易地擴充套件。

首先要做的是定義事件和狀態......

3.5.1. ApplicationInitializationEvent

在這個例子中,我只考慮2個事件:

  • start:此事件將觸發初始化過程;
  • stop:該事件可用於強制停止初始化程式。

這是定義:

class ApplicationInitializationEvent extends BlocEvent {
  
  final ApplicationInitializationEventType type;

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

enum ApplicationInitializationEventType {
  start,
  stop,
}
複製程式碼

3.5.2. ApplicationInitializationState

這個類將提供與初始化過程相關的資訊。

對於這個例子,我會考慮:

  • 2個標誌:

    • isInitialized 指示初始化是否完成
    • isInitializing 以瞭解我們是否處於初始化過程的中間
  • 進度完成率

這是程式碼:

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. ApplicationInitializationBloc

BLoC 負責處理基於事件的初始化過程。

這是程式碼:

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” 事件時,它從0到100開始計數(步驟10),並且對於每個值(0,10,20,……),它發出(通過yield)一個新狀態,以通知 BLoC 初始化正在進行中(isInitializing = true)及其進度值。

  • 當收到 “ApplicationInitializationEventType.stop” 事件時,它認為初始化已完成。

  • 正如你所看到的,我在計數器迴圈中設定了一些延遲。 這將向您展示如何使用任何 Future(例如,您需要聯絡伺服器的情況)

3.5.4. 將它們全部包裝在一起

現在,剩下的部分是顯示 計數器的假的啟動畫面 ......

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){
                  //
                  // 一旦初始化完成,跳轉到其他頁面
                  //
                  WidgetsBinding.instance.addPostFrameCallback((_){
                    Navigator.of(context).pushReplacementNamed('/home');
                  });
                }
                return Text('Initialization in progress... ${state.progress}%');
              },
            ),
          ),
        ),
      ),
    );
  }
}
複製程式碼

說明:

  • 由於 ApplicationInitializationBloc 不需要在應用程式的任何地方使用,我們可以在一個 StatefulWidget 中初始化它;

  • 我們直接發出 ApplicationInitializationEventType.start 事件來觸發 eventHandler

  • 每次發出 ApplicationInitializationState 時,我們都會更新文字

  • 初始化完成後,我們將使用者重定向到主頁。

技巧

由於我們無法直接在構建器內部重定向到主頁,我們使用 WidgetsBinding.instance.addPostFrameCallback() 方法請求 Flutter 在渲染完成後立即執行方法

3.6 案例2:應用程式身份驗證和退出

對於此示例,我將考慮以下用例:

  • 在啟動時,如果使用者未經過身份驗證,則會自動顯示“身份驗證/註冊”頁面;

  • 在使用者認證期間,顯示 CircularProgressIndicator;

  • 經過身份驗證後,使用者將被重定向到主頁;

  • 在應用程式的任何地方,使用者都可以登出;

  • 當使用者登出時,使用者將自動重定向到“身份驗證”頁面。

當然,很有可能以程式設計方式處理所有這些,但將所有這些委託給 BLoC 要容易得多。

下圖解釋了我要講解的解決方案:

[翻譯] 響應式程式設計(Reactive Programming) - 流(Streams) - BLoC - 實際應用案例

名為 “DecisionPage” 的中間頁面將負責將使用者自動重定向到“身份驗證”頁面或主頁,具體取決於使用者身份驗證的狀態。 當然,此 DecisionPage 從不顯示,也不應被視為頁面。

首先要做的是定義事件和狀態......

3.6.1. AuthenticationEvent

在這個例子中,我只考慮2個事件:

  • login:當使用者正確認證時發出此事件;
  • logout:使用者登出時發出的事件。

這是定義:

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

該類將提供與身份驗證過程相關的資訊。

對於這個例子,我將考慮:

  • 3個標誌:

    • isAuthenticated 指示身份驗證是否完整
    • isAuthenticating 以瞭解我們是否處於身份驗證過程的中間
    • hasFailed 表示身份驗證失敗
  • 經過身份驗證的使用者名稱

這是它的原始碼:

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. AuthenticationBloc

BLoC 負責根據事件處理身份驗證過程。

這是程式碼:

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 )發出一個新狀態,告知身份驗證正在執行(isAuthenticating = true)。
  • 然後它執行身份驗證,一旦完成,就會發出另一個狀態,告知身份驗證已完成。
  • 當收到 “AuthenticationEventLogout” 事件時,它將發出一個新狀態,告訴程式使用者已退出認證。

3.6.4. AuthenticationPage

正如您將要看到的那樣,為了便於解釋,此頁面非常基本且不會做太多內容。

這是程式碼。 解釋稍後給出:

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
    • 如果身份驗證正在進行中,它會顯示一個 CircularProgressIndicator ,告訴使用者正在進行某些操作並阻止使用者操作此頁面(第25-27行)
    • 如果驗證成功,我們不需要顯示任何內容(第29-31行)。
    • 如果使用者未經過身份驗證,則會顯示2個按鈕以模擬成功的身份驗證和失敗。
    • 當我們點選其中一個按鈕時,我們發出一個 AuthenticationEventLoginevent,以及一些引數(通常由認證過程使用)
    • 如果驗證失敗,我們會顯示錯誤訊息(第60-64行)

就是這樣! 沒有別的事情需要做了……很簡單,不是嗎?

提示:

您可能已經注意到,我將頁面包裝在 WillPopScope 中。 理由是我不希望使用者能夠使用 Android '後退' 按鈕,如此示例中所示,身份驗證是一個必須的步驟,它阻止使用者訪問任何其他部分,除非經過正確的身份驗證。

3.6.5. DecisionPage

如前所述,我希望應用程式根據身份驗證狀態自動重定向到 AuthenticationPageHomePage

以下是此 DecisionPage 的程式碼,說明在程式碼後面:

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,它建立一個 Overlay

這個 Overlay 是一個 OverlayEntry 堆疊,每個都包含一個 Page。

當我們通過 Navigator.of(context) 壓入,彈出,替換頁面時,後者更新其 重新構建(rebuild)Overlay (此堆疊)。

重新構建堆疊時,每個 OverlayEntry(包括其內容) 也會 重新構建

因此,當我們通過 Navigator.of(context) 進行操作時,所有剩餘的頁面都會重新構建

  • 那麼,為什麼我將它實現為 StatefulWidget

    為了能夠響應 AuthenticationState 的任何更改,此 “頁面” 需要在應用程式的整個生命週期中保持存在。

    這意味著,根據上面的提醒,每次 Navigator.of(context) 完成操作時,都會重新構建此頁面

    因此,它的 BlocEventStateBuilder 也將重建,呼叫自己的 builder 方法。

    因為此 builder 負責將使用者重定向到與 AuthenticationState 對應的頁面,所以如果我們每次重新構建頁面時重定向使用者,它將繼續重定向,因為不斷地重新構建。

    為了防止這種情況發生,我們只需要記住我們採取操作的最後一個 AuthenticationState,並且只在收到另一個 AuthenticationState 時採取另一個動作。

  • 這是如何起作用的?

    如上所述,每次發出AuthenticationState 時,BlocEventStateBuilder 都會呼叫其 builder

    基於狀態標誌(isAuthenticated),我們知道我們需要向哪個頁面重定向使用者。

技巧

由於我們無法直接從構建器重定向到另一個頁面,因此我們使用WidgetsBinding.instance.addPostFrameCallback() 方法在呈現完成後請求 Flutter 執行方法

此外,由於我們需要在重定向使用者之前刪除任何現有頁面,除了需要保留在所有情況下的此 DecisionPage 之外,我們使用 Navigator.of(context).pushAndRemoveUntil(…) 來實現此目的。

3.6.8. Log out

為了讓使用者退出,您現在可以建立一個 “LogOutButton” 並將其放在應用程式的任何位置。

此按鈕只需要發出 AuthenticationEventLogout() 事件,這將導致以下自動操作鏈:

  1. 它將由AuthenticationBloc處理
  2. 反過來會發出一個AuthentiationState(isAuthenticated = false)
  3. 這將由 DecisionPage 通過 BlocEventStateBuilder 處理
  4. 這會將使用者重定向到AuthenticationPage

這是此按鈕的程式碼:

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.9. AuthenticationBloc

由於 AuthenticationBloc 需要在此應用程式的任何頁面可用,我們還是將其作為MaterialApp的父級注入,如下所示:

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 相關的輸入;
  • 根據規則顯示驗證錯誤訊息;
  • 根據業務規則自動化 Widget 的可訪問性。

我現在要做的一個例子是 RegistrationForm,它由3個TextFields(電子郵件,密碼,確認密碼)和1個RaisedButton組成,以啟動註冊過程。

我想要實現的業務規則是:

  • 電子郵件需要是有效的電子郵件地址。 如果不是,則需要顯示錯誤提示訊息。
  • 密碼必須有效(必須包含至少8個字元,1個大寫,1個小寫,1個數字和1個特殊字元)。 如果無效,則需要顯示錯誤提示訊息。
  • 重新輸入密碼需要符合相同的驗證規則並且與密碼相同。 如果不相同,則需要顯示錯誤訊息。
  • 註冊按鈕只有在所有規則有效時才有效。

4.1. The RegistrationFormBloc

此 BLoC 負責處理驗證業務規則,如前所述。

這是它的原始碼:

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 來處理表單的每個 TextFieldStreams
  • 我們公開了3個Function(String),它將用於接受來自 TextFields 的輸入。
  • 我們公開了3個 Stream <String>TextField 將使用它來顯示由它們各自的驗證產生的潛在錯誤訊息
  • 我們公開了1個 Stream <bool>,它將被 RaisedButton 使用,以根據整個驗證結果啟用/禁用它。

OK,現在是時候深入瞭解更多細節......

您可能已經注意到,此類的簽名有點特殊。 我們來回顧一下吧。

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

With 關鍵字表示此類正在使用 MIXINS(=“在另一個類中重用某些類程式碼的方法”),並且為了能夠使用 with 關鍵字,該類需要繼承 Object 類。 這些 mixin 分別包含驗證電子郵件和密碼的程式碼。

有關 Mixins 的更多詳細資訊,我建議您閱讀 Romain Rastel 的這篇精彩文章。

4.1.1. Validator Mixins

我只會解釋EmailValidator,因為PasswordValidator非常相似。

首先,程式碼:

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)

StreamTransformer 從 Stream 通過 transformmethod 引用它的輸入。 然後處理此輸入,並將轉換後的輸入重新注入初始 Stream。

在此程式碼中,輸入的處理包括根據正規表示式進行檢查。 如果輸入與正規表示式匹配,我們只需將輸入重新注入流中,否則,我們會向流中注入錯誤訊息。

4.1.2. 為什麼使用stream.transform()?

如前所述,如果驗證成功,StreamTransformer 會將輸入重新注入 Stream。 為什麼這樣做是有用的?

以下是與 Observable.combineLatest3() 相關的解釋…此方法在它引用的所有 Streams 至少發出一個值之前不會發出任何值。

讓我們看看下面的圖片來說明我們想要實現的目標。

[翻譯] 響應式程式設計(Reactive Programming) - 流(Streams) - BLoC - 實際應用案例

  • 如果使用者輸入電子郵件並且後者經過驗證,它將由電子郵件流發出,它將是 Observable.combineLatest3() 的一個輸入;

  • 如果電子郵件無效,則會向流中新增錯誤(並且流中沒有值);

  • 這同樣適用於密碼和重新輸入密碼;

  • 當所有這三個驗證都成功時(意味著所有這三個流都會發出一個值),Observable.combineLatest3() 將由 “(e,p,c)=> true”,發出一個true(見 第35行)。

4.1.2. 兩個密碼的驗證

我在網際網路上看到了很多與這種比較有關的問題。 存在幾種解決方案,讓我解釋其中的兩種。

4.1.2.1. 基本解決方案 - 沒有錯誤訊息

第一個解決方案可能是下面這樣的:

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

這種解決方案簡單地比較這兩個密碼,當它們驗證通過且相互匹配,發出一個值(= true)。

我們很快就會看到,Register 按鈕的可訪問性將取決於registerValid 流。

如果兩個密碼不匹配,那個 Stream 不會發出任何值,並且 Register 按鈕保持不活動狀態,但使用者不會收到任何錯誤訊息以幫助他了解原因。

4.1.2.2. 帶錯誤訊息的解決方案

另一種解決方案包括擴充套件 confirmPassword 流的處理,如下所示:

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");
      }
    });
複製程式碼

一旦重新輸入密碼驗證通過,它就會被 Stream 發出,並且,通過使用 doOnData,我們可以直接獲取此發出的值並將其與 password 流的值進行比較。 如果兩者不匹配,我們現在可以傳送錯誤訊息。

4.2. RegistrationForm

現在讓我們在解釋前先看一下 RegistrationForm 的程式碼 :

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 傳送輸入到 BLoC 進行驗證:_registrationFormBloc.onEmailChanged(電子郵件輸入的情況)

  • 對於RegisterButton,也包含在 StreamBuilder <bool> 中。

    • 如果 _registrationFormBloc.registerValid 發出一個值,onPressed 方法將執行某些操作
    • 如果未發出任何值,onPressed 方法將被指定為 null,這將撤銷該按鈕的啟用狀態。

就這樣! 表單中沒有任何業務規則,這意味著可以更改規則而無需對錶單進行任何修改,這樣非常好!

5. Part Of (部分模式)

有些時候,對於一個 Widget,根據它是否存在於某一個集合中來驅動其行為是一件有趣的事。

對於本文的最後一個例子,我將考慮以下場景:

  • 應用程式處理商品;

  • 使用者可以選擇放入購物車的商品;

  • 一件商品只能放入購物車一次;

  • 存放在購物車中的商品可以從購物車中移除;

  • 一旦被移除,就可以將其再次新增到購物車中。

對於此例子,每個商品將顯示為一個按鈕,該按鈕如何顯示將取決於該商品是否存在於購物車中。 如果該商品沒有新增到購物車中,按鈕將允許使用者將其新增到購物車中。 如果商品已經被新增到購物車中,該按鈕將允許使用者將其從購物車中刪除。

為了更好地說明 “Part of” 模式,我將考慮以下架構:

  • 購物頁面將顯示所有可能的商品的列表;

  • 購物頁面中的每個商品都會顯示一個按鈕,用於將商品新增到購物車或將其從購物車中刪除,具體取決於其是否存在於在購物車中;

  • 如果購物頁面中的商品被新增到購物車中,其按鈕將自動更新以允許使用者將其從購物車中刪除(反之亦然),而無需重新構建購物頁面

  • 另一個頁面,購物車頁,將列出購物車中的所有商品;

  • 可以從此頁面中刪除購物車中的任何商品。

邊注

Part Of 這個名字是我個人取的名字。 這不是一個官方名稱。

5.1. ShoppingBloc

正如您現在可以想象的那樣,我們需要考慮一個專門用於處理所有商品的列表,以及存在於購物車中的商品的 BLoC。

這個BLoC可能如下所示:

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 的內容,以便通知所有正在監聽此 StreamWidgets 並能夠更新或重新構建頁面。

5.2. ShoppingPage

此頁面非常簡單,只顯示所有的商品。

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 顯示一個按鈕:

    • 顯示出現在購物車中的商品的數量

    • 單擊時將使用者重定向到購物車頁面

  • 商品列表使用 GridView 構建,包含在 StreamBuilder <List <ShoppingItem >>

  • 每個商品對應一個 ShoppingItemWidget

5.3. 購物車頁面 (ShoppingBasketPage)

此頁面與商品列表(ShoppingPage)非常相似,只是 StreamBuilder 現在正在偵聽由 ShoppingBloc 公開的 _shoppingBasket 流的變化。

5.4. ShoppingItemWidget 和 ShoppingItemBloc

Part Of 模式依賴於這兩個元素的組合:

  • ShoppingItemWidget 負責:
    • 顯示商品和
    • 顯示用於將其新增到購物車中或從購物車中將其刪除的按鈕
  • ShoppingItemBloc 負責通知 ShoppingItemWidget 後者其是否存在於購物車中。

讓我們看看他們如何一起工作......

5.4.1. ShoppingItemBloc

ShoppingItemBloc 由每個 ShoppingItemWidget 例項化,賦予它 “身份”

BLoC 監聽 ShoppingBasket 流的所有變化,並檢測特定商品是否是存在於購物車中。

如果是,它會發出一個布林值(= true),此值將被 ShoppingItemWidget 捕獲,也就知道它是否存在於購物車中。

這是 BLoC 的程式碼:

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

此 Widget 負責:

  • 建立 一個 ShoppingItemBloc 例項並將自己的商品標識傳遞給BLoC

  • 監聽 ShoppingBasket 內容的任何變化並將其轉移到BLoC

  • 監聽 ShoppingItemBloc 以判斷它是否存在於購物車中

  • 顯示相應的按鈕(新增/刪除),具體取決於它是否存在於購物車中

  • 響應按鈕的使用者操作

    • 當使用者點選新增按鈕時,將自己新增到購物籃中
    • 當使用者點選刪除按鈕時,將自己從籃子中移除。

讓我們看看它是如何工作的(解釋在程式碼中給出)。

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. 這一切是如何運作的?

下圖顯示了所有部分如何協同工作。

[翻譯] 響應式程式設計(Reactive Programming) - 流(Streams) - BLoC - 實際應用案例

小結

這是又一篇長文章,我本來希望我能簡短一點,但我認為,為了闡述得更清楚,這麼長也是值得的。

正如我在簡介中所說的,我個人在我的開發中經常使用這些“模式”。 這讓我節省了大量的時間和精力; 我的程式碼更易讀,更容易除錯。

此外,它有助於將業務與檢視分離。

很有可能其他的方法也可以做到這些,甚至是更好的實現方式,但它對我是有用的,這就是我想與你分享的一切。

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

相關文章