MENU

【Flutter】Bloc/Cubit の状態管理をまとめる bloc編

この記事を読むのに必要な時間は約 40 分です。

この記事ではBloc/Cubit の使い方についてBlocライブラリの内容を元にまとめています。

Blocライブラリのドキュメントはこちら。

目次

Bloc とは

Bloc はBusiness Logic Componentの略で、Google が推奨している、

アプリのロジック(business logic) とUI(presentation) を分ける状態管理のアーキテクチャ」です。

要はStatefulWidget で今まで状態管理してきたけど、もっと扱いやすくするためにコンポーネントとして分けようってことですね。

Blocアーキテクチャは次の5つのパッケージで構成されています。

bloc – Blocの中心となるライブラリ
flutter_bloc – 強力な Flutter ウィジェットで、高速で反応性の高いモバイルアプリ構築するため に bloc と連動するように作られています。
angular_bloc – 強力なAngularコンポーネントで、高速でリアクティブなウェブアプリを構築するためにblocと連動するように作られています。
hydrated_bloc – blocの状態を自動的に持続・復元するbloc状態管理ライブラリの拡張。
replay_bloc – bloc 状態管理ライブラリの拡張で、undoとredoのサポートが追加されています。

中でも、この記事ではモバイルアプリで使用する①bloc と②flutter_bloc を扱います。

bloc の中にはCubitクラスとBlocクラスがあります。

どちらも似たような状態管理が可能ですがそれぞれの利点があります。
Blocクラスの簡易版がCubitという感じ。そのあたりも後で触れていきますね。

flutter_bloc にはBlocBuilder やBlocListener のようなCubitクラスBlocクラスを扱うためのWidget が入っています。

記事が長くなってしまうためこちらは後編で触れたいと思います。

Bloc を使う理由

公式ドキュメントでも触れられています。

Bloc makes it easy to separate presentation from business logic, making your code fasteasy to test, and reusable.

UIとアプリ固有のロジック(Business logic)を切り離してコードを使いやすくしようと言うことですね。

Blocは以下のような開発者のニーズを満たすために設計されています。

・アプリの状態を常に把握したい。
・アプリの適切な応答を確認するために、簡単にテストを行いたい。
・データ駆動型の意思決定ができるように、ユーザーとのやりとりを記録したい。
・アプリ間でコンポーネントを再利用したい。
・同じパターン、ルールに従ってシームレスに作業したい。
・高速でレスポンスの良いアプリを開発したい。

とはいえ、これは実際使ってみて納得するのが一番だと思います。

以下の記事にカウンターアプリでのBloc/Cubit の使用例を解説していますので、ご参考下さい

StatefulWidget との違いを体感できると思います。

package: bloc

ここからはbloc パッケージの概要を説明していきます。

Streams

bloc ライブラリにはStreamを使った管理方法もあるのでStreamの基礎を理解している必要があります。

Streamsは非同期データの列のこと。知らない場合は、水の流れるパイプを想像して下さい。
パイプがStreamで水が非同期データです。

Streamsは本筋ではないので参考に、ふーんくらいのイメージを掴んで読み進めてもらえれば大丈夫です。

Cubit

Cubit は状態を更新するトリガーとなる関数をUIから受け取り、アプリの状態を表すstateを返します
UIコンポーネントはstate の変更通知を受け取り、再描画を行います
状態管理にはstate のみで行うということですね。

– Cubit の作成

例えばカウンターアプリで使用するCounterCubit を作るとこんな感じ。

class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);
}

Cubit を作成するときはstate の型 (ここではint ) と初期値(0)を指定します。

外部の値を初期値として使いたい場合は下記の形で引数(initialState)として受け取ることができます。

class CounterCubit extends Cubit<int> {
  CounterCubit(int initialState) : super(initialState);
}

こうすることで、Cubit のインスタンスを呼び出す際に異なる初期値を与えることができます。

final cubitA = CounterCubit(0); // state starts at 0
final cubitB = CounterCubit(10); // state starts at 10

– State の変更

Cubit はemit で状態を出力でき、Cubit の中でのみ使用できます。
emit されたstate は非同期データとしてstream に入ります。

class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);

  void increment() => emit(state + 1); // state に1 を足して状態を出力
}

– Cubit の使い方

Basic な使い方

CounterCubit のインスタンスを作成します。increment() を呼び出すと状態更新され state は 1 になります。close を呼び出すとstate が入っているstream を閉じることができます。

void main() {
  final cubit = CounterCubit();
  print(cubit.state); // 0
  cubit.increment();
  print(cubit.state); // 1
  cubit.close();
}

Stream を利用した使い方

Cubit はリアルタイムの状態更新の受け取りを許可するStreamを 出力できます。
listen によってのみ状態更新を受け取ることができます。

Future<void> main() async {
  final cubit = CounterCubit();
  final subscription = cubit.stream.listen(print); // 1
  cubit.increment();
  await Future.delayed(Duration.zero);
  await subscription.cancel();
  await cubit.close();
}

– Cubit の監視

Cubit が新しい状態をemit した際、状態変更が起こります。
onChange をoverride することで状態変更を監視することができます。

class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);

  void increment() => emit(state + 1);

  @override
  void onChange(Change<int> change) {
    print(change);
    super.onChangechange);
  }
}

これでCubit の全ての状態変更をコンソールで確認できます。mainで呼び出してみましょう。
ん〜とっても便利。

void main() {
  CounterCubit()
    ..increment()
    ..close();
}

出力例ではStateに何が入っているか確認できていますね。

Change { currentState: 0, nextState: 1 }

BlocObserver

大規模なアプリではアプリの状態を管理するCubitが多数存在するのが一般的です。
もし複数の状態変化を監視をしたいのであればBlocObserverを使います。

使い方はBlocObserverを継承し、onChangeメソッドをオーバーライドするだけでOK。

class SimpleBlocObserver extends BlocObserver {
  @override
  void onChange(BlocBase bloc, Change change) {
    super.onChange(bloc, change);
    print('${bloc.runtimeType} $change');
  }
}

SimpleBlocObserver を使うためにmain 関数で呼び出してみましょう。

void main() {
  BlocOverrides.runZoned(
    () {
      CounterCubit()
        ..increment()
        ..close();
    },
    blocObserver: SimpleBlocObserver(),
  );
}
Change { currentState: 0, nextState: 1 }
CounterCubit Change { currentState: 0, nextState: 1 }

– エラーの取り扱い

エラーが発生したことを示すにはaddErrorメソッドを使用します。

class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);

  void increment() {
    addError(Exception('increment error!'), StackTrace.current);
    emit(state + 1);
  }

  @override
  void onChange(Change<int> change) {
    super.onChange(change);
    print(change);
  }

  @override
  void onError(Object error, StackTrace stackTrace) {
    print('$error, $stackTrace');
    super.onError(error, stackTrace);
  }
}

注:onError は Cubit 内でオーバーライドして、特定の Cubit のすべてのエラーを処理できます。

onErrorは、BlocObserver内でオーバーライドして、グローバルに報告されたすべてのエラーを処理することも可能です。

class SimpleBlocObserver extends BlocObserver {
  @override
  void onChange(BlocBase bloc, Change change) {
    super.onChange(bloc, change);
    print('${bloc.runtimeType} $change');
  }

  @override
  void onError(BlocBase bloc, Object error, StackTrace stackTrace) {
    print('${bloc.runtimeType} $error $stackTrace');
    super.onError(bloc, error, stackTrace);
  }
}
Exception: increment error!, #0      CounterCubit.increment (file:///main.dart:21:56)
#1      main (file:///main.dart:41:7)
#2      _startIsolate.<anonymous closure> (dart:isolate-patch/isolate_patch.dart:301:19)
#3      _RawReceivePortImpl._handleMessage (dart:isolate-patch/isolate_patch.dart:168:12)

CounterCubit Exception: increment error! #0      CounterCubit.increment (file:///main.dart:21:56)
#1      main (file:///main.dart:41:7)
#2      _startIsolate.<anonymous closure> (dart:isolate-patch/isolate_patch.dart:301:19)
#3      _RawReceivePortImpl._handleMessage (dart:isolate-patch/isolate_patch.dart:168:12)

Change { currentState: 0, nextState: 1 }
CounterCubit Change { currentState: 0, nextState: 1 }

Bloc

Blocはより高度なクラスで、状態変化のトリガーとして関数ではなく、イベントを使います。
BlocもBlocBaseを継承しているので、Cubitと同様のAPIを持っています。違いは引数に受け取るeventだけ。

Blocの関数を呼び出して直接新しい状態を出すのではなく、
イベントを受け取り、入ってきたeventを出ていくstateに変換しています
cubitとは異なり、stateとeventを使って状態管理を行っています。

– Bloc の作成

Blocの作成はCubitの作成と似ていますが、管理する状態に加え、Blocが処理できるイベントも定義します。
イベントは、ボタンを押すなどのユーザー操作や、ページロードなどのライフサイクルに応して追加されます。

abstract class CounterEvent t{} //イベントの定義

class CounterIncrementPressed extends CounterEvent {}

class CounterBloc extends Bloc<CounterEvent, int> {//状態管理するBloc ,state の型はint
  CounterBloc() : super(0);
}

– state の変更

Blocでは、Cubitの関数とは異なり、on <> でイベントハンドラを登録する必要があります。イベントハンドラは、入力されたイベントを0個以上の出力される状態に変換する役割を担っています。
ここで関数を指定してコールバック関数として用いることも多いです。

abstract class CounterEvent {}

class CounterIncrementPressed extends CounterEvent {}

class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<CounterIncrementPressed>((event, emit) {
      // handle incoming `CounterIncrementPressed` event
    })
  }
}
*abstract class CounterEvent {}

class CounterIncrementPressed extends CounterEvent {}

class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<CounterIncrementPressed>((event, emit) {
      emit(state + 1);
    });
  }
}

– Bloc の使い方

Basic な使い方

まずCounterBlocのインスタンスを作成します。
次に、CounterIncrementPressed イベントを追加して、状態の変更をトリガーします。
最後に、Blocの状態を再び表示し、0から1へと変化させ、Blocのcloseを呼び出し、状態Streamを閉じます。

Future<void> main() async {
  final bloc = CounterBloc();
  print(bloc.state); // 0
  bloc.add(CounterIncrementPressed());
  await Future.delayed(Duration.zero);
  print(bloc.state); // 1
  await bloc.close();
}

Stream を利用した使い方

Cubit と同様にBloc にもStream を利用した使い方があります。

CounterBloc をsubscriptionし、状態が変化するたびに print を呼び出しています。そして、CounterIncrementPressed イベントを追加して、イベントハンドラー「on」をトリガーし、新しい状態をemit してます。
最後に、更新を受け取る必要がなくなったときに、subscriptionのキャンセルを呼び出し、Bloc を閉じます。

Future<void> main() async {
  final bloc = CounterBloc();
  final subscription = bloc.stream.listen(print); // 1
  bloc.add(CounterIncrementPresed());
  await Future.delayed(Duration.zero);
  await subscription.cancel();
  await bloc.close();
}

– Bloc の監視

Bloc は BlocBase を継承しているので、onChange を使って Bloc の状態変化を監視できます。

abstract class CounterEvent {}

class CounterIncrementPressed extends CounterEvent {}

class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<CounterIncrementPressed>((event, emit) => emit(state + 1));
  }

  @override
  void onChange(Change<int> change) {
    super.onChange(change);
    print(change);
  }
}

main.dart で呼び出しましょう。

void main() {
  CounterBloc()
    ..add(CounterIncrementPressed())
    ..close();
}
Change { currentState: 0, nextState: 1 }

BlocとCubitの大きな違いは、Blocがイベントがトリガーであるため、状態変化のトリガーとなったイベントの情報も取得できることです。これを行うには、onTransition をオーバーライドします。

ある状態から別の状態への変化をトランジション(Transition)と呼びます。
Transitionは、現在の状態、イベント、次の状態から構成されます。

abstract class CounterEvent {}

class CounterIncrementPressed extends CounterEvent {}

class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<CounterIncrementPressed>((event, emit) => emit(state + 1));
  }

  @override
  void onChange(Change<int> change) {
    super.onChange(change);
    print(change);
  }

  @override
  void onTransition(Transition<CounterEvent, int> transition) {
    super.onTransition(transition);
    print(transition);
  }
}

出力例ではevent:increment がトリガーとなって状態が0から1に変化したことが確認できますね。

Transition { currentState: 0, event: Increment, nextState: 1 }
Change { currentState: 0, nextState: 1 }

BlocObserver

cubitであつかったBlocObserver と同様の扱いです。

class SimpleBlocObserver extends BlocObserver {
  @override
  void onChange(BlocBase bloc, Change change) {
    super.onChange(bloc, change);
    print('${bloc.runtimeType} $change');
  }

  @override
  void onTransition(Bloc bloc, Transition transition) {
    super.onTransition(bloc, transition);
    print('${bloc.runtimeType} $transition');
  }

  @override
  void onError(BlocBase bloc, Object error, StackTrace stackTrace) {
    print('${bloc.runtimeType} $error $stackTrace');
    super.onError(bloc, error, stackTrace);
  }
}
void main() {
  BlocOverrides.runZoned(
    () {
      CounterBloc()
        ..add(CounterIncrementPressed())
        ..close();
    },
    blocObserver: SimpleBlocObserver(),
  );
}
Transition { currentState: 0, event: Increment, nextState: 1 }
CounterBloc Transition { currentState: 0, event: Increment, nextState: 1 }
Change { currentState: 0, nextState: 1 }
CounterBloc Change { currentState: 0, nextState: 1 }

Blocインスタンスのもう一つの特徴は、Blocに新しいイベントが追加されるたびに呼び出されるonEventをオーバーライドできることです。onChangeやonTransitionと同じように、onEventはローカルにもグローバルにもオーバーライドすることができる。

abstract class CounterEvent {}

class CounterIncrementPressed extends CounterEvent {}

class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<CounterIncrementPressed>((event, emit) => emit(state + 1));
  }

  @override
  void onEvent(CounterEvent event) {
    super.onEvent(event);
    print(event);
  }

  @override
  void onChange(Change<int> change) {
    super.onChange(change);
    print(change);
  }

  @override
  void onTransition(Transition<CounterEvent, int> transition) {
    super.onTransition(transition);
    print(transition);
  }
}
class SimpleBlocObserver extends BlocObserver {
  @override
  void onEvent(Bloc bloc, Object? event) {
    super.onEvent(bloc, event);
    print('${bloc.runtimeType} $event');
  }

  @override
  void onChange(BlocBase bloc, Change change) {
    super.onChange(bloc, change);
    print('${bloc.runtimeType} $change');
  }

  @override
  void onTransition(Bloc bloc, Transition transition) {
    super.onTransition(bloc, transition);
    print('${bloc.runtimeType} $transition');
  }
}
Increment
CounterBloc Increment
Transition { currentState: 0, event: Increment, nextState: 1 }
CounterBloc Transition { currentState: 0, event: Increment, nextState: 1 }
Change { currentState: 0, nextState: 1 }
CounterBloc Change { currentState: 0, nextState: 1 }

– エラーの取り扱い

各BlocにはaddErrorとonErrorメソッドがあり、BlocのどこからでもaddErrorを呼び出すことで、エラーが発生を確認できます。onErrorをオーバーライドすることで、すべてのエラーに対応できます。

abstract class CounterEvent {}

class CounterIncrementPressed extends CounterEvent {}

class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<CounterIncrementPressed>((event, emit) {
      addError(Exception('increment error!'), StackTrace.current);
      emit(state + 1);
    });
  }

  @override
  void onChange(Change<int> change) {
    print(change);
    super.onChange(change);
  }

  @override
  void onTransition(Transition<CounterEvent, int> transition) {
    print(transition);
    super.onTransition(transition);
  }

  @override
  void onError(Object error, StackTrace stackTrace) {
    print('$error, $stackTrace');
    super.onError(error, stackTrace);
  }
}

Cubit or Bloc

Cubit の利点

Cubitを使う最大のメリットは、「シンプルなことです。
Cubitを作成するには、state、stateを変更するための関数の2つを定義するだけで済むため、Cubitは理解しやすく、コードも少なくて済みます。

一方、Blocを作成するには、state、event、EventHandlerの実装を定義する必要があるため、複雑です。

Counterの実装を比べてみると以下のようになります。これだけでも明らかにコード量が異なりますね。

class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);

  void increment() => emit(state + 1);
}
abstract class CounterEvent {}
class CounterIncrementPressed extends CounterEvent {}

class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<CounterIncrementPressed>((event, emit) => emit(state + 1));
  }

Cubitの実装はより簡潔で、event を個別に定義する代わりに、関数がeventのように動作します。
また、Cubitを使用する場合、状態変化を引き起こすためにemitを呼び出すだけでよいですね。

Bloc の利点

– トレースのしやすさ

Blocを使う最大のメリットは、stateの変化と、その変化のトリガーを正確に把握できることです。

一般的なユースケースは、AuthenticationStateの管理かもしれません。
簡単のために、AuthenticationState を enum で表現します。

enum AuthenticationState { unknown, authenticated, unauthenticated }

アプリの状態が認証済みから非認証に変わる理由としては、ユーザがログアウトボタンをタップして、アプリからサインアウトするよう要求する場合があります。あるいは、ユーザーのアクセストークンが取り消され、強制的にログアウトされる場合もあるでしょう。
Blocを使うと、このようにアプリの状態がどうやってある状態に変化したかを明確にトレースできます。

Transition {
  currentState: AuthenticationState.authenticated,
  event: LogoutRequested,
  nextState: AuthenticationState.unauthenticated
}

上記のTransitionは、状態が変化した理由を理解するために必要なすべての情報を与えています。もしCubitを使ってAuthenticationStateを管理していたら、ログは以下のようになります。

Change {
  currentState: AuthenticationState.authenticated,
  nextState: AuthenticationState.unauthenticated
}

これは、ユーザーがログアウトしたことを知らせますが、デバッグやアプリケーションの状態が時間とともにどのように変化するかを理解するのに重要な理由を説明してくれません。

– 高度なイベント変換

BlocがCubitより優れているもう一つの点は、
buffer、debounceTime、throttleなどのリアクティブ・オペレータを利用する必要がある場合です。
Blocにはイベントシンクがあり、入ってくるイベントの流れを制御したり、変換したりすることができます。

例えば、リアルタイム検索を作る場合、バックエンドへのリクエストをデバウンスして、
レート制限を受けないようにしたり、バックエンドのコストや負荷を下げたりしたいと思うでしょう。
Blocでは、カスタムEventTransformerを提供することで、入ってくるイベントがBlocによって処理される方法を変更することができます。

EventTransformer<T> debounce<T>(Duration duration) {
  return (events, mapper) => events.debounceTime(duration).flatMap(mapper);
}

CounterBloc() : super(0) {
  on<Increment>(
    (event, emit) => emit(state + 1),
    /// Apply the custom `EventTransformer` to the `EventHandler`.
    transformer: debounce(const Duration(milliseconds: 300)),
  );
}

上記のコードにより、わずかな追加コードで受信イベントを簡単にデバウンスすることができます。

まとめ

この記事ではBlocアーキテクチャの中心となるbloc ライブラリの説明を通じて、BlocとCubitの違いを説明しました。

flutter_blocについては次の記事に後編としてまとめましたので引き続きご覧ください。

カテゴリー

よかったらシェアしてね!
  • URLをコピーしました!

コメント

コメントする

目次