本文へスキップ
mreysei · Michael Reyes
テーマ

FlutterのBLoCパターンの仕組みを解説

著者:Michael Reyes公開更新4分で読めますカテゴリー: テクノロジー

青い背景に、Flutterのロゴと「BLoC Pattern Flutter」の文字。

この記事では、Flutter の BLoCパターン がどのように動作するかを説明します。
LeanMind の Github にある Flutter boilerplate が、この投稿を読む際に役立つかもしれません。

まずは簡単なイントロダクションから始めましょう。
このパターンは 2018年のDartカンファレンス 📹 で発表された、比較的新しいものです。
Paolo Soares と Cong Hu(どちらもGoogleの社員)は、Flutterで作成されたモバイルアプリとAngular Dartで作成されたWebアプリ間でコードを再利用できるようにする ことを目標にしました。

主なコンセプトは、ビューとモデルの間に中間層を設ける というものです。
この中間層は、ビューから受け取るイベントに応じて状態を管理し、データを操作します。

次の図を描いてみました 🎨

BLoCパターンの図。画面はBLoCにイベントを送り、状態を受け取るたびに再構築されます。BLoCはリポジトリまたはユースケースを通じてデータを読み書きします。LoadDataEventはLoadingStateを経てRecoveredDataStateへ、UpdateDataEventはLoadingStateを経てErrorStateまたはSuccessStateへ進みます。

これから出てくるコードはすべてこの図に関連しており、「プロフィール」と呼ぶことにします。

最小限のコードを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 に追加します:

それでは、初期状態を作成しましょう。これを 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によって翻訳されたものですので、文章の一貫性が損なわれていたり、不自然な箇所があった場合はご了承ください。ご理解いただき、ありがとうございます。