この記事では、Flutter の BLoCパターン がどのように動作するかを説明します。
LeanMind の Github にある Flutter boilerplate が、この投稿を読む際に役立つかもしれません。
まずは簡単なイントロダクションから始めましょう。
このパターンは 2018年のDartカンファレンス 📹 で発表された、比較的新しいものです。
Paolo Soares と Cong Hu(どちらもGoogleの社員)は、Flutterで作成されたモバイルアプリとAngular Dartで作成されたWebアプリ間でコードを再利用できるようにする ことを目標にしました。
主なコンセプトは、ビューとモデルの間に中間層を設ける というものです。
この中間層は、ビューから受け取るイベントに応じて状態を管理し、データを操作します。
次の図を描いてみました 🎨

これから出てくるコードはすべてこの図に関連しており、「プロフィール」と呼ぶことにします。
最小限のコードを4つのファイルに分けて作成します:
// profile_bloc.dart
class ProfileBloc extends Bloc<ProfileEvent, ProfileState> {
@override
ProfileState get initialState => throw UnimplementedError();
@override
Stream<ProfileState> mapEventToState(ProfileEvent event) async* {
throw UnimplementedError();
}
}
// profile_screen.dart
class ProfileScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
throw UnimplementedError();
}
}
// profile_event.dart
abstract class ProfileEvent {}
// profile_state.dart
abstract class ProfileState {}使用するパッケージは次のとおりです。これらを pubspec.yaml に追加します:
flutter_bloc:bloc の作成およびビュー内での使用built_value:不変オブジェクトを生成するbuild_runner:必要なコードを自動生成する
それでは、初期状態を作成しましょう。これを InitialState と呼び、プロパティは持ちません。
// profile_state.dart
abstract class InitialState extends ProfileState implements Built<InitialState, InitialStateBuilder> {
InitialState._();
factory InitialState([void Function(InitialStateBuilder) updates]) = _$InitialState;
}うーん…少し説明が必要ですね:
💭 InitialState という抽象クラスで、先ほど作成した ProfileState を継承し、Built を実装しています。プライベートコンストラクタを作成して外部からアクセスできないようにし、オプションの関数を受け取るファクトリーを定義します。これは、後でパラメータが必要なときにどのように構築されるかを示します。
次に、import のすぐ下に次の行を追加します:
// profile_state.dart
part 'profile_state.g.dart';次のコマンドを実行します:
flutter pub run build_runner watch --delete-conflicting-outputs
これにより、build_runner パッケージがコードを自動生成し、InitialState を動作させるために必要なファイルが作られます。
完璧です! 👏
最初の状態を作成しましたが、まだ使われていません。では、bloc に初期状態を指定しましょう:
// profile_bloc.dart
@override
ProfileState get initialState => InitialState();これで状態ができました。次はビューを bloc に関連付けます。どうやって? flutter_bloc の BlocBuilder を使います。
// profile_screen.dart
@override
Widget build(BuildContext context) {
return BlocBuilder<ProfileBloc, ProfileState>(
builder: (BuildContext context, ProfileState state) {
return Container(
color: Colors.blue[900],
child: Center(child: CircularProgressIndicator()),
);
},
);
}このようにして、状態が変わるたびにビューが再描画され、現在の状態に応じたUIが表示されます。今のところ状態は1つだけなので、中央にスピナーが表示されます。
次に、repository からデータを取得するイベントを作りましょう。LoadDataEvent というイベントを作成します。
// profile_event.dart
part 'profile_event.g.dart';
abstract class ProfileEvent {}
abstract class LoadDataEvent extends ProfileEvent implements Built<LoadDataEvent, LoadDataEventBuilder> {
LoadDataEvent._();
factory LoadDataEvent([void Function(LoadDataEventBuilder) updates]) = _$LoadDataEvent;
}状態が InitialState のときにイベントを発火させます:
// profile_screen.dart
class ProfileScreen extends StatefulWidget {
@override
_ProfileScreenState createState() => _ProfileScreenState();
}
class _ProfileScreenState extends State<ProfileScreen> {
ProfileBloc bloc;
@override
Widget build(BuildContext context) {
return BlocBuilder<ProfileBloc, ProfileState>(
builder: (BuildContext context, ProfileState state) {
if (state is InitialState) {
bloc = BlocProvider.of<ProfileBloc>(context);
bloc.add(LoadDataEvent());
}
return buildSpinner();
},
);
}
Widget buildSpinner() {
return Container(
color: Colors.blue[900],
child: Center(child: CircularProgressIndicator()),
);
}
@override
void dispose() {
bloc?.close();
super.dispose();
}
}いくつかの重要なポイント:
- InitialState のときに LoadDataEvent を発火する条件分岐を作成。
BlocProvider.of<ProfileBloc>(context)を使って bloc を取得。- StatelessWidget から StatefulWidget に変更。ビューを閉じる際に bloc も閉じる必要があるためです。
次に bloc の mapEventToState にイベントを追加し、repository からデータを取得します:
// profile_bloc.dart
@override
Stream<ProfileState> mapEventToState(ProfileEvent event) async* {
if (event is LoadDataEvent){
String name = await getName();
}
}
// 仮のrepository
Future<String> getName() async {
await Future.delayed(Duration(seconds: 2));
return "Any Name";
}...データは取得できましたが、次に何をするのでしょう?
次に、新しい状態を2つ作成します。イベント LoadDataEvent が発火すると、LoadingState に変わり、データ取得後に RecoveredDataState になります。
// profile_state.dart
abstract class LoadingState extends ProfileState implements Built<LoadingState, LoadingStateBuilder> {
LoadingState._();
factory LoadingState([void Function(LoadingStateBuilder) updates]) = _$LoadingState;
}次に、プロパティを持つ RecoveredDataState を作ります:
// profile_state.dart
abstract class RecoveredDataState extends ProfileState implements Built<RecoveredDataState, RecoveredDataStateBuilder> {
String get name;
RecoveredDataState._();
factory RecoveredDataState([void Function(RecoveredDataStateBuilder) updates]) = _$RecoveredDataState;
}3つの状態ができましたね! 🎉
では、LoadDataEvent を完成させましょう:
// profile_bloc.dart
@override
Stream<ProfileState> mapEventToState(ProfileEvent event) async* {
if (event is LoadDataEvent) {
yield LoadingState();
String name = await getName();
yield RecoveredDataState((builder) => builder..name = name);
}
}yield や async* に気づきましたか?
これらについては こちらの記事 を読むことをおすすめします(英語ですが、わかりやすいです)。
RecoveredDataState では、builder を使って name を渡しています。もし surname も追加したい場合は次のようにします:
yield RecoveredDataState((builder) => builder
..name = name
..surname = surname
);または次のようにも書けます:
yield RecoveredDataState((builder) {
builder.name = name;
builder.surname = surname;
return builder;
});次にビューを更新しましょう。新しい2つの状態を処理する必要があります。
// profile_screen.dart
@override
Widget build(BuildContext context) {
return BlocBuilder<ProfileBloc, ProfileState>(
builder: (BuildContext context, ProfileState state) {
if (state is InitialState) {
bloc = BlocProvider.of<ProfileBloc>(context);
bloc.add(LoadDataEvent());
}
if (state is RecoveredDataState) {
return buildName(state.name);
}
return buildSpinner();
},
);
}
Widget buildName(String name) {
return Container(
color: Colors.grey[300],
child: Center(
child: Text(name, style: TextStyle(fontSize: 16)),
),
);
}このように、どの状態でもスピナーが表示されますが、RecoveredDataState の場合は state.name を使って名前を表示します。
最後に、RecoveredDataState のビューを TextField と RaisedButton に変更し、ボタン押下で新しいイベントを発火させ、ErrorState ❌ や SuccessState ✔ に遷移させることで図が完成します。
これで、ビューは状態に基づいて構築され、状態はイベントに基づいて変化する という仕組みが理解できたと思います。
読んでくれてありがとうございました! 📖
この記事は、Lean Mindのブログでスペイン語で公開された記事「BLoC Pattern con Flutter」の翻訳です。本バージョンはAIによって翻訳されたものですので、文章の一貫性が損なわれていたり、不自然な箇所があった場合はご了承ください。ご理解いただき、ありがとうございます。
