はじめに
私のFlutterでの開発歴は約4年になります。
これまでのプロジェクトでは、状態管理ライブラリとして主にRiverpod(古くはProvider、StateNotifier、現在はNotifier/AsyncNotifier)を採用してきました。
Riverpodの宣言的な記述と柔軟性は素晴らしく、開発スピードを大いに高めてくれていますし、多くの案件で採用されている状態管理ライブラリだと思います。
実際に私が参画した案件のほとんどでRiverpodが採用されていました。
しかし、Flutter界隈を見渡すと、大規模なプロジェクトやエンタープライズ領域では、依然としてBlocがデファクトスタンダードとして採用されているケースが多くあります。
「食わず嫌い」のままではエンジニアとしての幅が狭まってしまうと感じ、今回、改めてBlocの基礎を学び直すことにしました。
本記事は、公式のflutter_blocライブラリのREADMEを読み解きながら、Riverpodに慣れ親しんだ視点で、Blocの基礎的な構成要素とウィジェットの使い方を整理した記事になります。
Blocの基本概念:イベント駆動と単方向データフロー
Bloc(Business Logic Component)の核心は、UI(プレゼンテーション層)とビジネスロジックを完全に分離することにあります。
Riverpodでは、UIからNotifierのメソッドを直接呼び出して状態を更新することが一般的ですが、BlocではUIが直接ロジックを操作することはありません。代わりに、UIはEvent(イベント)を発行します。
Blocがそのイベントを受け取り、処理を行い、新しいState(状態)をStreamとして流します。UIはそのStateを購読して再描画します。
この流れを図解すると以下のようになります。
Riverpod使いとしての気づき
最大の違いはイベント駆動である点です。Riverpodが「メソッドを呼んで状態を変える」のに対し、Blocは「何が起きたか(イベント)を伝え、結果(状態)を待つ」というアプローチです。これにより、データの流れが強制的に単方向になります。
実装の準備(カウンターアプリを例に)
具体的なウィジェットを見ていく前に、Blocを動かすために必要な要素(Event, State, Bloc本体)を、おなじみのカウンターアプリを例に定義します。
1. Event(イベント)
UIからBlocに送られるアクションを定義します。
Riverpodではメソッド定義で済む部分ですが、Blocではクラスとして明示的に定義します。
// イベントの基底クラス
abstract class CounterEvent {}
// カウントアップを要求するイベント
class CounterIncrementPressed extends CounterEvent {}
// カウントダウンを要求するイベント
class CounterDecrementPressed extends CounterEvent {}
ここがRiverpodと大きく違う点です。「メソッドを定義する」代わりに「イベントクラスを定義する」ため、コード量は確実に増えます(ボイラープレート)。
2. State(状態)
Blocが管理するデータそのものです。カウンターアプリの場合はシンプルに int で表現できます。複雑なアプリでは、クラスを定義して状態を持たせます。
今回は単純なint型の状態を使用するため、専用クラスは定義しません。
状態は int 型、初期値は 0 とします。
3. Bloc本体
Eventを受け取り、ロジックを処理して、新しいStateを放出(emit)する場所です。
import 'package:flutter_bloc/flutter_bloc.dart';
import 'counter_event.dart';
// Blocクラスの定義。<受け取るEventの型, 管理するStateの型>を指定します。
class CounterBloc extends Bloc<CounterEvent, int> {
// コンストラクタで初期状態を設定します(今回は0)。
CounterBloc() : super(0) {
// 'on<Event>' メソッドでイベントハンドラを登録します。
// CounterIncrementPressedイベントが来たら、現在のstateに+1した値をemitする
on<CounterIncrementPressed>((event, emit) {
emit(state + 1);
});
// CounterDecrementPressedイベントが来たら、現在のstateに-1した値をemitする
on<CounterDecrementPressed>((event, emit) {
emit(state - 1);
});
}
}
RiverpodのNotifierに近いですが、メソッド内で状態を変えるのではなく、イベントに対応するハンドラ (on<T>) を登録するという形が特徴的です。
flutter_blocの主要ウィジェットを読み解く
READMEに記載されている主要なウィジェットを、Riverpodの概念と照らし合わせながら見ていきます。
1. BlocProvider
役割:DI(依存性注入)
Blocのインスタンスを作成し、ウィジェットツリーの下位に提供するためのウィジェットです。
・Riverpodでの類似概念: ProviderScope や、個々のProvider定義。
void main() {
runApp(const App());
}
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
// CounterBlocをツリーに提供する
return BlocProvider(
// createでBlocのインスタンスを生成
create: (_) => CounterBloc(),
child: const MaterialApp(
home: CounterPage(),
),
);
}
}
create関数内でBlocがインスタンス化されます。デフォルトでは、このBlocが必要になったタイミング(遅延評価)で生成されます。
2. BlocBuilder
役割:UIの構築(リビルド)
Blocの状態(State)を監視し、状態が変化するたびにビルダー関数を実行してUIを再描画します。
Riverpodでの類似概念: ref.watch(provider) を使ったWidgetのビルド、またはConsumerウィジェット。
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Bloc Counter')),
body: Center(
// BlocBuilderでStateを監視してUIを構築
child: BlocBuilder<CounterBloc, int>(
builder: (context, state) {
// stateには現在のカウント値が入ってくる
return Text(
'$state',
style: Theme.of(context).textTheme.displayLarge,
);
},
),
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FloatingActionButton(
// UIからイベントを発行する
// context.read<T>()でBlocインスタンスを取得し、add()でイベントを投げる
onPressed: () => context.read<CounterBloc>().add(CounterIncrementPressed()),
child: const Icon(Icons.add),
),
const SizedBox(height: 8),
FloatingActionButton(
onPressed: () => context.read<CounterBloc>().add(CounterDecrementPressed()),
child: const Icon(Icons.remove),
),
],
),
);
}
}
BlocBuilder<Blocの型, Stateの型> とジェネリクスを指定するのがポイントです。
UIツリーの上位にあるBlocProviderから対象のBlocを見つけ出します。
3. BlocListener
役割:副作用の実行(描画以外)
状態の変化に応じて、SnackBarの表示、ダイアログの表示、画面遷移といった「一回切りのアクション(副作用)」を実行するためのウィジェットです。UIの再描画は行いません。
Riverpodでの類似概念: ref.listen(provider, (previous, next) { ... })
BlocListener<CounterBloc, int>(
listener: (context, state) {
// 状態が特定の値になったらSnackBarを表示する例
if (state == 10) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('10になりました!')),
);
}
},
child: Container( /* UIコンポーネント */ ),
)
Riverpodではref.watchとref.listenが同じbuildメソッド内に混在しがちですが、Blocでは「描画はBuilder」「副作用はListener」とウィジェットレベルで明確に役割が分かれています。
4. BlocConsumer
役割:Builder と Listener の統合
BlocBuilder と BlocListener を合わせた機能を持ちます。状態に応じてUIをリビルドしつつ、特定の条件下で副作用も実行したい場合に便利です。
BlocConsumer<CounterBloc, int>(
listenWhen: (previous, current) {
// 特定の条件でのみlistenerを実行したい場合に指定(オプション)
return current > previous; // カウントアップした時だけ通知
},
listener: (context, state) {
// 副作用を実行
if (state % 5 == 0) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('5の倍数です')));
}
},
buildWhen: (previous, current) {
// 特定の条件でのみリビルドしたい場合に指定(オプション)
// 例えば、偶数の時だけ画面を更新するなど
return current % 2 == 0;
},
builder: (context, state) {
// UIを構築
return Text('$state');
},
)
listenWhenやbuildWhenを使うことで、パフォーマンスの最適化や細かい制御が可能になります。
Riverpod使いから見たBlocの所感
初めてBlocに触れてみて、Riverpodとの違いを強く感じた点をまとめます。
メリット・学び:
・強制力のある単方向データフロー: UIはイベントを投げることしかできず、状態変更のロジックはBloc内に完全に隠蔽されます。これにより、コードの責任範囲が非常に明確になります。
・副作用管理の明確さ: BlocListener という専用の場所があるため、「ビルド中に副作用を起こしてはいけない」というFlutterの鉄則を自然と守ることができます。
・イベントソーシング的な思考: 「何が起きたか」をイベントとして定義するため、ユーザーの行動履歴やデバッグが追いやすくなりそうです。
デメリット・手間:
・ボイラープレートの多さ: Riverpodなら数行で済む処理に対し、イベントクラス、Blocクラス、BlocBuilder...と記述量が多くなります。小規模なアプリではオーバーキルに感じるかもしれません。
・学習コスト: BlocProvider、BlocBuilder、context.read、context.watch など、それぞれの役割と使い分けを理解する必要があります。
BuildContextの拡張メソッド (read, watch, select)
flutter_blocは内部でproviderパッケージの機構を利用しているため、BlocBuilderなどのウィジェットを使わずに、BuildContextの拡張メソッドを使ってStateにアクセスすることも可能です。
Riverpodの ref.read / ref.watch に慣れている場合、こちらの方が直感的に書けるケースも多いです。
それぞれの使い分けとコード例を紹介します。
1. context.read<T>()
用途:イベントの送信、コールバック内でのアクセス
Widgetのリビルドを発生させずにBlocのインスタンスを取得します。
主にonPressedなどのイベントハンドラ内で使用します。
・Riverpod: ref.read(provider.notifier).method()
・Bloc: context.read<Bloc>().add(Event)
FloatingActionButton(
// ここで .watch を使うと、ボタンを押すたびに無駄なリビルドが走るため .read を使う
onPressed: () {
context.read<CounterBloc>().add(CounterIncrementPressed());
},
child: const Icon(Icons.add),
)
2. context.watch<T>()
用途:値の監視とリビルド
BlocのStateを監視し、変化があるたびにこのコードを含むWidget(buildメソッド)全体をリビルドします。BlocBuilderのネストを避けたい場合や、単純な値の表示に便利です。
・Riverpod: ref.watch(provider)
@override
Widget build(BuildContext context) {
// このWidgetのbuildメソッドの冒頭で宣言すると、
// stateが変わるたびにScaffold全体が再描画される
final count = context.watch<CounterBloc>().state;
return Scaffold(
body: Center(
child: Text('$count'),
),
);
}
注意点: BlocBuilder はリビルド範囲をその子要素に限定できますが、context.watch を build メソッドのルートで呼ぶと画面全体がリビルドされる可能性があります。パフォーマンスを気にする場合はスコープに注意が必要です。
3. context.select<T, R>()
用途:部分的な監視(最適化)
Stateの中の「特定の値」だけが変わった時にリビルドしたい場合に使います。Stateが複雑なオブジェクト(例: Userクラス)の場合に非常に有効です。
・Riverpod: ref.watch(provider.select((s) => s.property))
// 例:Stateが {name: 'John', age: 25} というオブジェクトの場合
@override
Widget build(BuildContext context) {
// UserBlocのStateのうち、'name' が変わった時だけリビルドする
// 'age' が変わってもこのWidgetは反応しない
final name = context.select((UserBloc bloc) => bloc.state.name);
return Text(name);
}
使い分けのまとめ
Riverpodユーザー視点での使い分けチャートです。
| メソッド | Riverpod相当 | 主な用途 |
|---|---|---|
| context.read | ref.read | イベント送信 (add)、初期化処理 |
| context.watch | ref.watch | 単純な値の表示、コード量を減らしたい時 |
| context.select | ref.watch(select) | 特定プロパティのみ監視したい時 (パフォーマンス最適化) |
| BlocBuilder | (Consumer相当) | リビルド範囲を明確に限定したい時 (推奨) |
公式ドキュメントやコミュニティでは、コードの可読性とリビルド範囲の明確化のためBlocBuilderの使用が基本的には推奨 されていますが、context.watch/selectを使うとネストが減りコードがスッキリするため、小さなコンポーネントでは積極的に採用しても良いでしょう。
番外編
なぜ context.listen はないのか?
Riverpodではref.listenをbuildメソッド内に書けますが、Blocにはcontext.listenというメソッドはありません。
これはライフサイクル管理の違いによるものです。
Blocはウィジェットベースのライブラリであるため、ストリームの購読(listen)と解除(cancel)を StatefulWidgetのライフサイクル(initState / dispose)に委ねています。そのため、副作用を書きたい場合は必ずBlocListenerウィジェット を配置する必要があります。
ハイブリッドな実装パターン(推奨)
「ネストは減らしたいが、副作用も扱いたい」という場合、以下のようにBlocListenerで囲み、中身は context.watchで書く という構成が非常に実践的です。
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
// 1. 副作用は必ず BlocListener (または BlocConsumer) で管理する
return BlocListener<CounterBloc, int>(
listener: (context, state) {
if (state == 10) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('10になりました!')),
);
}
},
// 2. UI構築は context.watch を使ってフラットに書く
child: Scaffold(
appBar: AppBar(title: const Text('Bloc Context Pattern')),
body: Center(
child: const CounterText(), // 下記のサブウィジェットを使用
),
floatingActionButton: FloatingActionButton(
// 3. イベント送信は context.read
onPressed: () => context.read<CounterBloc>().add(CounterIncrementPressed()),
child: const Icon(Icons.add),
),
),
);
}
}
// 描画部分を切り出したウィジェット
class CounterText extends StatelessWidget {
const CounterText({super.key});
@override
Widget build(BuildContext context) {
// ここで watch することで、このウィジェットだけがリビルドされる
// BlocBuilderのネストがなくなり、見た目が ref.watch に近くなる
final count = context.watch<CounterBloc>().state;
return Text(
'$count',
style: Theme.of(context).textTheme.displayLarge,
);
}
}
使い分けの結論
・基本: 公式推奨のBlocBuilderを使う(リビルド範囲が明確で安全)。
・応用: コードを簡潔にしたい、あるいは小さなコンポーネントを作る場合はcontext.watch / select を使う。
・鉄則: 副作用はcontextメソッドに頼らず、必ずBlocListenerを使う。
まとめ
Riverpodに慣れ親しんだ身からすると、Blocは理解しやすかったなと思いました。
一部実装方法が似ている部分もあり、それぞれの思想さえきちんと理解できていれば、多くの方が簡単に応用できるのかなと思いました。
Riverpodは柔軟性が高い反面、Providerの破棄タイミング(autoDisposeなど)とWidgetのライフサイクルの整合性を意識するのに苦労するシーンがありました。
一方でBlocは、基本的にWidgetツリーに紐付いてインスタンスが生成・破棄されるため、「画面が閉じれば状態も消える」という挙動がデフォルトです。
この「Widgetベースのライフサイクル管理」に乗っかるスタイルは、画面固有の状態管理において非常に迷いが少なく、楽だと感じました。
今後は、より実践的なAPI通信を含む状態管理などで、Blocの真価を探っていきたいと思います。
参考リンク
