Flutter
14 Apr 2023
6 min read
tags: FlutterBloc PatternBlocStream

[Note] BLoC Pattern

前言

BLoC(Business Logic Component)Pattern 是一種用於 Flutter 開發的設計模式,它將應用程序分成三個主要部分:界面、資料層和業務邏輯,並利用 Streams 管理資料流,以實現組件之間的解耦和資料共享。

用原生 widgets 就可以實作,也有基於 BLoC Pattern 而開發的套件 - flutter_bloc,將方法封裝更容易維護和開發。

首先要先了解 Stream 概念,因為 BLoC Pattern 核心是 Streams。

Stream

Flutter 用來管理非同步的處理事件序列的概念,可想像 Stream 是一條河流或是通道,Sender 經由 Stream 傳遞任何東西給 ReceiverReceiver 不會知道 Sender 送的東西什麼時候會到和送什麼東西,只有能被動等東西到達。

  • Shipped Data:可以是任何形式 data/event
  • Sender:傳送資料(傳遞的源頭)
  • Receiver:接收資料(傳遞的終點),無法預期何時接收到資料
  • 非同步行為,因為傳遞會需要消耗時間
  • 有順序性,先進先出

用 Stream 專有名詞表示:

  • 這條河流 ➡️ StreamController
  • Sender ➡️ Sink
    • StreamController 的入口
    • 使用 add 方法,將要傳送東西傳送出去
  • Receiver ➡️ Stream
    • StreamController 的出口
    • 使用 listen 方法,監聽是否接收到東西

BLoC Pattern 優點

  1. 解耦:界面、業務邏輯和資料層分開,使它們可以獨立開發、測試和維護
  2. 可測試性:業務邏輯分離到單獨的組件中,可以方便地進行 Unit Test
  3. code 共用性:將業務邏輯分離到單獨的組件中,可以在多個 View 中重用
  4. 易維護:有良好的結構和清晰的職責分工,易於維護和擴展功能

BLoC pattern 實作

範例原始碼:Repo 網址

目標

  • 實作簡單的 Counter
  • 支援 加一減一Reset,這三個功能
  • 由兩個 StreamController 組成 (State StreamController & Event StreamController)。

首先專注做 State StreamController加一 功能。

Step1. 新增 Bloc 檔案,建立 State StreamController

  • 定義 StreamController,因為預期 in/out 皆為數字, Type 為 int
  • _XXXStreamController.sink 可取得 Input proterty
  • _XXXStreamController.stream 可取得 Output proterty
// counter_bloc.dart
class CounterBloc {
  // pipe
  final _stateStreamController = StreamController<int>();

  // input
  StreamSink<int> get counterSink => _stateStreamController.sink;
  // output
  Stream<int> get counterStream => _stateStreamController.stream;
}

Step2. 按鈕點擊時,利用 counterSinkadd 方法,傳遞 _counter

counterBloc.counterSink.add(_counter);

此時點擊 “+”,數字不會增加,即使 onPressed 會觸發 _counter++,讓 _counter 值增加,但因為沒有使用如 setState 等方法,去強迫 rebuild wiget,所以 Text 的內容永遠都會初次 mounted 的值,即為 0。

// main.dart
class Counter extends StatefulWidget { ... }

class _CounterState extends State<Counter> {
  int _counter = 0;

  // 1. 宣告 counterBloc
  final counterBloc = CounterBloc();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      ...,
      body: Center(
        child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
          Text('$_counter'),
          const SizedBox(
            height: 16,
          ),
          IconButton(
            onPressed: () {
              _counter++;
              // 2. add state
              counterBloc.counterSink.add(_counter);
            },
            icon: const Icon(Icons.add_circle),
            iconSize: 36,
          )
        ]),
      ),
    );
  }
}

Step3. 使用 StreamBuilder,監聽 _counter

StreamBuilder 中的參數:

  • stream:傳入(欲 listen) target stream,只要監聽的資料改變,會 rebuild widget
  • builder:回傳 widget

當在點擊 “+” 按鈕時,顯示的數字已經會如預期往上加了

// main.dart
class Counter extends StatefulWidget { ... }

class _CounterState extends State<Counter> {
  int _counter = 0;

  final counterBloc = CounterBloc();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      ...,
      body: Center(
        child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
          // 1. wrap with StreamBuilder
          StreamBuilder(
            // 2. use builder and return widget
            stream:
                counterBloc.counterStream,
            builder: (context, snapshot) => Text(
              '$_counter',
              style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
            ),
          ),
          const SizedBox(
            height: 16,
          ),
          IconButton(
            onPressed: () {
              _counter++;
              counterBloc.counterSink.add(_counter);
            },
            icon: const Icon(Icons.add_circle),
            iconSize: 36,
          )
        ]),
      ),
    );
  }
}


接下來是建立 Event StreamController,並實作 減一Reset 倆功能。

大致流程如下:

  1. 點擊 Button 會傳送 CounterAction (@ widget)
  2. eventStream 監聽 CounterAction (@ CounterBloc)
  3. counterSink 傳遞 counter(@ CounterBloc)
  4. widget 使用 snapshot.data,取得 counter(@ widget)

Event StreamController stream 接收到的資料會直接由 State StreamController sink 傳入,這段是在 CounterBloc 內實作,外面使用的 widget 都不需要知道實作細節。

Step4. CounterBloc 內建立 Event StreamController

  • 新增 action 的 enum

    action 和 event 這邊概念是共通的,也可取名叫 CounterAction

  • 宣告 counter 變數
  • eventStream 使用 listen 方法,監聽 CounterAction
  • counterSink 使用 add 方法,傳遞運算過後 counter
// counter_bloc.dart

// 定義出 counter actions
enum CounterAction { INCREMENT, DECREMENT, RESET }

class CounterBloc {
  late int counter;

  final _stateStreamController = StreamController<int>();
  StreamSink<int> get counterSink => _stateStreamController.sink;
  Stream<int> get counterStream => _stateStreamController.stream;

  final _eventStreamController = StreamController<CounterAction>();
  StreamSink<CounterAction> get eventSink => _eventStreamController.sink;
  Stream<CounterAction> get eventStream => _eventStreamController.stream;

  CounterBloc() {
    counter = 0;

    // listen change in the stream (CounterAction)
    eventStream.listen((event) {
      if (event == CounterAction.INCREMENT) {
        counter++;
      } else if (event == CounterAction.DECREMENT) {
        counter--;
      } else if (event == CounterAction.RESET) {
        counter = 0;
      }

      // 傳遞運算過後的 counter 值
      counterSink.add(counter);
    });
  }
}

Step5. 按鈕點擊時,利用 eventSinkadd 方法,傳遞 CounterAction

  • 移除 widget 內的 _counter,新增 initialData

    initialData:初始值,不設定的話,一開始會為 null

  • StreamBuildersnapshot.data 取得 counter
  • onPress 觸發的函式,改成由 eventSink 傳遞 CounterAction
// main.dart
class Counter extends StatefulWidget {...}

class _CounterState extends State<Counter> {
  final counterBloc = CounterBloc();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      ...,
      body: Center(
        child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
          StreamBuilder(
            stream: counterBloc.counterStream,
            // 不給 init data,一開始會拿到 null
            initialData: 0,
            // get value via snapshot.data
            builder: (context, snapshot) => Text(
              '${snapshot.data}',
              style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
            ),
          ),
          const SizedBox(
            height: 16,
          ),
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              IconButton(
                onPressed: () {
                  // INCREMENT 事件
                  counterBloc.eventSink.add(CounterAction.INCREMENT);
                },
                icon: const Icon(Icons.add_circle),
                iconSize: 36,
              ),
              IconButton(
                onPressed: () {
                  // DECREMENT 事件
                  counterBloc.eventSink.add(CounterAction.DECREMENT);
                },
                icon: const Icon(Icons.remove_circle),
                iconSize: 36,
              ),
              IconButton(
                onPressed: () {
                 // RESET 事件
                  counterBloc.eventSink.add(CounterAction.RESET);
                },
                icon: const Icon(Icons.loop_outlined),
                iconSize: 36,
              ),
            ],
          )
         ]
        ),
      ),
    );
  }
}

參考資料

FlutterBloc PatternBlocStream
Published on 14 Apr 2023
Updated on 14 Apr 2023