FlutterでBLoCパターンの実装を行う「flutter_bloc」の使い方をまとめました。
- はじめに
- セットアップ
- 基本的な使い方
- Blocで状態管理クラスを作る
- 生成済みの状態インスタンスを別のページで使う
- 複数の状態を使用する
- 対象の状態をフィルタリングするBlocSelector
- 状態が変化した時にUIの再ビルド以外の処理をするBlocListener
- BlocBuilderとBlocListnerの機能を持つBlocConsumer
- Repository(など)をDIする
- 参考
flutter_bloc 8.1.5
はじめに
この記事では flutter_bloc の使い方をまとめます。
flutter_bloc は Flutter で BLoC(Business Logic Component)パターンの実装を行うためのライブラリです。
BLoC パターンについては本記事では割愛しますが、以下の記事にとてもわかりやすくまとめられているので、必要に応じて参照してください。
セットアップ
まず flutter_bloc を以下の通りインストールします。
flutter pub add flutter_bloc
基本的な使い方
さてそれでは flutter_bloc を使って簡単なアプリケーションを作ってみます。
まず以下のように Cubit クラスを継承したクラスを作り、状態と状態を更新する処理を記述します。
// counter_cubit.dart import 'package:flutter_bloc/flutter_bloc.dart'; // Cubitクラスを継承して状態管理するクラスを作成 // この例ではint型の状態を管理している(任意の型を指定可能) class CounterCubit extends Cubit<int> { CounterCubit() : super(0); // 状態を変更するメソッド // emitメソッドに新しい状態を渡すことで状態を更新できる // 更新された状態はウィジェットに反映される(後述) void increment() => emit(state + 1); void decrement() => emit(state - 1); }
説明はコメントとして記載しています。
次にこの状態を監視してUIを更新するウィジェットと、それを使ったアプリケーションを作成します。
// main.dart import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'counter_cubit.dart'; void main() => runApp(CounterApp()); class CounterApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( // ウィジェットから状態にアクセスするために BlocProvider を使用 home: BlocProvider( // 状態のファクトリメソッド // Cubitの生成と破棄をBlocProviderが自動で行ってくれる create: (_) => CounterCubit(), // デフォルトでは状態のファクトリメソッドは初めてアクセスされるまで呼ばれない // lazyをfalseにすると、BlocProviderの生成と同時に状態のファクトリメソッドが呼ばれる lazy: true, // child には状態を使用するウィジェットを指定 child: CounterPage(), ), ); } } class CounterPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Counter')), // BlocBuilder ウィジェットでラップすることで、状態の変更を検知してウィジェットを再構築できる // CounterCubitを指定しているので、CounterCubitの状態が変更されると再構築される body: BlocBuilder<CounterCubit, int>( // CounterCubitの値をTextウィジェットに表示 builder: (context, count) => Center(child: Text('$count')), ), floatingActionButton: Column( crossAxisAlignment: CrossAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end, children: <Widget>[ FloatingActionButton( child: const Icon(Icons.add), onPressed: () => context.read<CounterCubit>().increment(), ), const SizedBox(height: 4), FloatingActionButton( child: const Icon(Icons.remove), onPressed: () => context.read<CounterCubit>().decrement(), ), ], ), ); } }
これを実行すると下図の結果が得られます。
状態の定義とそれに応じたUIのビルドができていることを確認できました。
Blocで状態管理クラスを作る
前節では Cubit クラスを継承して状態管理クラスを作りましたが、これの代わりに Bloc クラスを継承する方法もあります。
Cubit クラスでは状態を操作するメソッド(increment
と decrement
)を定義していましたが、Bloc クラスを使う場合にはこれの代わりに以下のようにイベントと、そのイベントが発行された時の処理を記述します。
やっていることは Cubit クラスを使ったものと同様です。
import 'package:flutter_bloc/flutter_bloc.dart'; sealed class CounterEvent {} // イベントクラスを定義 final class CounterIncrementPressed extends CounterEvent {} final class CounterDecrementPressed extends CounterEvent {} // CubitではなくBlocを継承して状態管理するクラスを作成 class CounterBloc extends Bloc<CounterEvent, int> { CounterBloc() : super(0) { // イベントに応じて状態を変更する処理を記述 on<CounterIncrementPressed>((event, emit) { emit(state + 1); }); on<CounterDecrementPressed>((event, emit) { emit(state - 1); }); } }
次にこの状態をウィジェットから監視してイベントを発行する処理を書きます。
ほとんどCubitの時と同様です。変えた部分にはコメントを記載しています。
import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:test_flutter/counter_bloc.dart'; void main() => runApp(CounterApp()); class CounterApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: BlocProvider( // CounterCubitからCounterBlocに変更 create: (_) => CounterBloc(), lazy: true, child: CounterPage(), ), ); } } class CounterPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Counter')), // CounterCubitからCounterBlocに変更 body: BlocBuilder<CounterBloc, int>( builder: (context, count) => Center(child: Text('$count')), ), floatingActionButton: Column( crossAxisAlignment: CrossAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end, children: <Widget>[ FloatingActionButton( child: const Icon(Icons.add), onPressed: () => // Blocを使う場合はこのようにaddメソッドでイベントを通知する context.read<CounterBloc>().add(CounterIncrementPressed()), ), const SizedBox(height: 4), FloatingActionButton( child: const Icon(Icons.remove), onPressed: () => // Blocを使う場合はこのようにaddメソッドでイベントを通知する context.read<CounterBloc>().add(CounterDecrementPressed()), ), ], ), ); } }
実行結果は前節のものと同様なので割愛します。
Cubit と Bloc のどちらを使うかは設計によります。
Cubit を使うとコードの量が少なく、直感的にわかりやすく処理を書けます。
これに対して Bloc は、「ウィジェットでどのような操作がなされたか」という情報をロジックが受け取ることができます。
例えば、ある処理についてユーザがキャンセルボタンを押すか、あるいは一定時間操作をしない場合に自動的にキャンセルが行われるケースを考えます。
このようなケースでは、Cubit を使うと両方 cancel
メソッドが呼ばれるだけですが、Bloc を使うとそれぞれ別のイベントとして処理することができます。
また、Bloc
を使う場合にはイベントを変換する EventTransformer
を設定できるので、例えば複数のイベントと貯めて一気に流したり、一定時間内に複数のイベントが来たら1個だけを流したりといったことができます。
この辺りの実装方法については本記事では割愛しますが、必要に応じて以下の公式ドキュメントを参照してください。
生成済みの状態インスタンスを別のページで使う
次に、あるページで生成した Cubit
や Bloc
を遷移先のページで使い回すことを考えます。
前述のように BlocProvider
にファクトリメソッドを渡す方法だと、新しいページで新しい状態が生成されてしまいます(初期状態になってしまいます)。
そうではなく生成済みの状態を渡すには、以下のように BlocProvider.value
を使用します。
import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:test_flutter/counter_cubit.dart'; void main() => runApp(CounterApp()); class CounterApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: BlocProvider( create: (_) => CounterCubit(), child: CounterPage(), ), ); } } class CounterPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Counter')), body: BlocBuilder<CounterCubit, int>( builder: (context, state) => Center(child: Text('$state'))), floatingActionButton: Column( crossAxisAlignment: CrossAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end, children: <Widget>[ FloatingActionButton( onPressed: () => context.read<CounterCubit>().increment(), heroTag: null, child: const Icon(Icons.add), ), const SizedBox(height: 4), FloatingActionButton( onPressed: () => context.read<CounterCubit>().decrement(), heroTag: null, child: const Icon(Icons.remove), ), const SizedBox(height: 4), // AnotherPageに遷移するボタン FloatingActionButton( onPressed: () => Navigator.of(context).push( MaterialPageRoute( // BlocProvider.valueを使ってAnotherPageから既存のCounterCubitインスタンスにアクセスできるように builder: (_) => BlocProvider.value( value: BlocProvider.of<CounterCubit>(context), child: const AnotherPage(), ), ), ), heroTag: null, child: const Icon(Icons.navigate_next), ), ], ), ); } } class AnotherPage extends StatelessWidget { const AnotherPage({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Another Page')), body: Center( // AnotherPageから既存のCounterCubitインスタンスにアクセスできる child: BlocBuilder<CounterCubit, int>( builder: (context, count) { return Text('$count'); }, ), ), ); } }
実行結果は下図のとおりです。
ちなみに、ファクトリメソッドを使う場合は BlocProvider
が Cubit
や Bloc
の寿命を管理し、不要になった時点で状態を破棄(内部で使っているStreamをクローズ)してくれますが、BlocProvider.value
を使う場合にはこのBlocProvider
は寿命管理を行いません(あくまで状態を生成したBlocProvider
が管理します)。
複数の状態を使用する
複数の状態を使うには以下のように MultiBlockProvider
を使用します。
class CounterApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( // 複数の状態を使うにはMultiBlocProvierを使う home: MultiBlocProvider( providers: [ BlocProvider(create: (_) => FooCubit()), BlocProvider(create: (_) => BarCubit()), ], child: CounterPage(), ), ); } }
対象の状態をフィルタリングするBlocSelector
次に、状態クラスに複数の値が定義されているケースを考えます。
以下は count
と maxCount
を保持し、それを更新するメソッドを持つ状態を定義したものです。
import 'package:flutter_bloc/flutter_bloc.dart'; class CounterState { final int count; final int maxCount; CounterState(this.count, this.maxCount); } class CounterCubit extends Cubit<CounterState> { CounterCubit() : super(CounterState(0, 10)); void increment() { if (state.count < state.maxCount) { emit(CounterState(state.count + 1, state.maxCount)); } } void decrement() { if (state.count > 0) { emit(CounterState(state.count - 1, state.maxCount)); } } void setMaxCount(int maxCount) { emit(CounterState(state.count, maxCount)); } }
UIに count
を表示することを考えた時、リビルド回数を最小に抑えるためには count
が更新された時だけリビルドを行い、maxCount
が更新されてもリビルドを行わないようにしたいところです。
しかし、以下のように BlocBuilder
を使うと、すべての状態の変化を監視するため、maxCount
が更新された時にもリビルドされてしまいます。
import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:test_flutter/counter_cubit.dart'; void main() => runApp(CounterApp()); class CounterApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: BlocProvider( create: (_) => CounterCubit(), child: CounterPage(), ), ); } } class CounterPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Counter')), // BlocBuilderを使うとCounterStateの持つ値のどれかが変更されたらリビルドされてしまう body: BlocBuilder<CounterCubit, CounterState>( builder: (context, state) => Center(child: Text('${state.count}'))), floatingActionButton: Column( crossAxisAlignment: CrossAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end, children: <Widget>[ FloatingActionButton( child: const Icon(Icons.add), onPressed: () => context.read<CounterCubit>().increment(), ), const SizedBox(height: 4), FloatingActionButton( child: const Icon(Icons.remove), onPressed: () => context.read<CounterCubit>().decrement(), ), ], ), ); } }
これを防ぐためには BlocBuilder
の代わりに BlocSelector
を使います。
BlocSelector
を使うと以下のように、監視対象の値を指定してリビルドを最小限に抑えることができます
import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:test_flutter/counter_cubit.dart'; void main() => runApp(CounterApp()); class CounterApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: BlocProvider( create: (_) => CounterCubit(), child: CounterPage(), ), ); } } class CounterPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Counter')), // BlocSelectorを使用 // ジェネリック型パラメータの2個目は状態の型、3個目は監視する値の型を指定 body: BlocSelector<CounterCubit, CounterState, int>( // 監視対象とする状態を指定 selector: (state) => state.count, builder: (context, count) => Center(child: Text('$count'))), floatingActionButton: Column( crossAxisAlignment: CrossAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end, children: <Widget>[ FloatingActionButton( child: const Icon(Icons.add), onPressed: () => context.read<CounterCubit>().increment(), ), const SizedBox(height: 4), FloatingActionButton( child: const Icon(Icons.remove), onPressed: () => context.read<CounterCubit>().decrement(), ), ], ), ); } }
状態が変化した時にUIの再ビルド以外の処理をするBlocListener
状態が変化した時に単純にUIを再ビルドして反映するだけではなく、何か処理をしたい場合があります。
このようなケースでは以下のように BlocBuilder
の代わりに BlocListener
を使用します。
import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:test_flutter/counter_cubit.dart'; void main() => runApp(CounterApp()); class CounterApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: BlocProvider( create: (_) => CounterCubit(), child: CounterPage(), ), ); } } class CounterPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Counter')), // BlocListenerを使用 body: BlocListener<CounterCubit, int>( listener: (context, state) { // 状態が3を超えた場合にトーストを表示 if (state > 3) { Fluttertoast.showToast( msg: "State value exceeded 3", toastLength: Toast.LENGTH_SHORT, gravity: ToastGravity.TOP, ); } }, child: BlocBuilder<CounterCubit, int>( builder: (context, count) => Center(child: Text('$count')), ), ), floatingActionButton: Column( crossAxisAlignment: CrossAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end, children: <Widget>[ FloatingActionButton( onPressed: () => context.read<CounterCubit>().increment(), heroTag: null, child: const Icon(Icons.add), ), const SizedBox(height: 4), FloatingActionButton( onPressed: () => context.read<CounterCubit>().decrement(), heroTag: null, child: const Icon(Icons.remove), ), ], ), ); } }
値が4以上になったら fluttertoast をを使ってトーストを表示するようにしています。
実行結果は以下のとおりです。
ちなみに複数のBlocListener
を使いたい場合には以下のように MultiBlockListener
を使用できます。
// (前略) body: MultiBlocListener( listeners: [ BlocListener<CounterCubit, int>( listener: (context, state) => Fluttertoast.showToast(msg: 'Counter: $state'), ), BlocListener<CounterCubit, int>( listener: (context, state) => print('Counter: $state'), ), ], child: BlocBuilder<CounterCubit, int>( builder: (context, count) => Center(child: Text('$count')), ), ), // (後略)
BlocBuilderとBlocListnerの機能を持つBlocConsumer
前節の例では BlockListener
の子に BlocBuilder
を設定しましたが、BlocConsumer
を使うとこれらを並列に書くことができます。
// (前略) body: BlocConsumer<CounterCubit, int>( listener: (context, state) { if (state > 3) { Fluttertoast.showToast( msg: "State value exceeded 3", toastLength: Toast.LENGTH_SHORT, gravity: ToastGravity.TOP, ); } }, builder: (context, count) => Center(child: Text('$count')), ), // (後略)
Repository(など)をDIする
以下のようにRepositoryProvider
を使うと、リポジトリ(というか任意のクラス)をDIすることができます。
// main.dart import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'counter_cubit.dart'; void main() => runApp(CounterApp()); class CounterApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( // RepositoryProviderでリポジトリをDI home: RepositoryProvider<FooRepository>( create: (_) => FooRepository(), child: BlocProvider( // リポジトリを取得してCounterCubitを生成 create: (context) => CounterCubit(context.read<FooRepository>()), child: CounterPage(), ), ), ); } } class CounterPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Counter')), body: BlocBuilder<CounterCubit, int>( builder: (context, count) => Center(child: Text('$count')), ), floatingActionButton: Column( crossAxisAlignment: CrossAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end, children: <Widget>[ FloatingActionButton( child: const Icon(Icons.add), onPressed: () => context.read<CounterCubit>().increment(), ), const SizedBox(height: 4), FloatingActionButton( child: const Icon(Icons.remove), onPressed: () => context.read<CounterCubit>().decrement(), ), ], ), ); } }
以下のように状態がリポジトリをDIされるケースを想定しているため、RepositoryProvider
と命名されているようです。
import 'package:flutter_bloc/flutter_bloc.dart'; // リポジトリ(今回は説明ようなのでリポジトリではなんの処理もしない) class FooRepository {} class CounterCubit extends Cubit<int> { // コンストラクタでリポジトリを受け取る CounterCubit(FooRepository repository) : super(0); void increment() => emit(state + 1); void decrement() => emit(state - 1); } class FooCubit extends Cubit<int> { FooCubit() : super(0); } class BarCubit extends Cubit<int> { BarCubit() : super(0); }