【Flutter】チーム開発でflutter_blocを導入したらめちゃめちゃ良かった

はじめに

研修でのチーム開発にて、某画像検索アプリのクローンのクライアント側をFlutterで実装しました。

状態管理手法としてBLoCパターンを採用し、その際にflutter_bloc というパッケージを使用しました。

使用感として

  • 誰が書いても同じコードスタイルになる
  • テストがしやすい

という印象をもち、大人数で開発する上では非常に使いやすいと感じたのでご紹介したいと思います。

BLoCとは🤔

BLoCとは Business Logic Component の略で、簡潔に説明するとUIからビジネスロジックを分離する設計パターンです。 2018年に開かれたDart Conferenceで発表されています。

BLoCの全体像やガイドラインに関しては、以下の記事が非常にわかりやすいのでぜひご覧ください。

【Dart/Flutter】導入したBLoCパターンアーキテクチャについて全体像をまとめてみた

このBLoCパターンを実現するには、DartのStreamを用いてデータを送り、providerを用いて、必要なWidgetにDIする手法がよく使われるかと思います。

以下参考記事。

長めだけどたぶんわかりやすいBLoCパターンの解説

flutter_bloc

flutter_blocは、上記で説明したBLoCデザインパターンの実装を容易にすることができるpackageです。 手法としては、UI側から何かしらのeventを送ることで、blocを通じてstateを変更するという方法で状態を変更しています。

[引用: https://pub.dev/packages/bloc]

ディレクトリ構造

lib/
 ├ api/ 
 ├ data/ 
 ├ model/
 ├ util/
 ├ values/
 ├ view/
  └ main/
    └ home/
           ├ home_widget.dart
         └ bloc/
              └ bloc.dart
              └ home_bloc.dart
              └ home_event.dart
              └ home_state.dart

eventの定義

// home_event.dart

import 'package:equatable/equatable.dart';

abstract class HomeEvent extends Equatable {
  @override
  List<Object> get props => [];
}

class LoadData extends HomeEvent {}

データを変更させるためのイベントを定義します。

stateの定義

// home_state.dart

import 'package:equatable/equatable.dart';
import 'package:path/model/book_model.dart'; //ここのパスは適宜合うものに変える

abstract class HomeState extends Equatable {
  @override
  List<Object> get props => [];
}

class LoadingState extends HomeState {}

class LoadedState extends HomeState {
  LoadedState(this.books);

  final List<BookModel> books;

  @override
  List<Object> get props => [books];
}

class NoDataState extends HomeState {}

class ErrorState extends HomeState {
  ErrorState(this.exception);

  final Exception exception;
}

アプリの取りうるState(状態)を記述します。 StateにてEquatable packageを使う理由としては、 propsに入っているプロパティが一致しているかを判断することができ、これらの値が全く一緒であればイベントをaddしたとしても、Streamは流れないようにすることができるからです。(間違っていたら指摘してください。)

blocの定義

// home_bloc.dart

import 'package:bloc/bloc.dart';
import 'package:path/data/books_repository.dart';
import 'package:path/model/book_model.dart';

import 'bloc.dart';

class HomeBloc extends Bloc<HomeEvent, HomeState> {
  HomeBloc(this._booksRepository);

  final BooksRepository _booksRepository;

  @override
  HomeState get initialState => LoadingState();

  @override
  Stream<HomeState> mapEventToState(HomeEvent event) async* {
    if (event is LoadData) {
      try {
        final books = await _booksRepository.getBooks();
          
        if (books.isEmpty) {
          yield NoDataState();
          return;
        }

        yield LoadedState(books);
      } on Exception catch (e) {
        yield ErrorState(e);
      }
    } 
  }
}

mapEventToStateにてどのイベントがきたら、どのような状態の変化をするのかを記述します。

また、以下のようなファイルを準備しておくことで

// bloc.dart

export 'home_bloc.dart';
export 'home_event.dart';
export 'home_state.dart';

widgetblocをimportする際は、以下のような記述で済むようになります。

import 'package:path/home_widget/bloc.dart';

widgetでのBlocの使用方法

BlocProviderwidgetを用いることで、BlocクラスをDIします。

// home_widget.dart

Widget build(BuildContext context) {
    return BlocProvider<HomeBloc>(
      create: (context) =>
          HomeBloc(context.repository<BooksRepository>())..add(LoadData()),
      child: : _buildScreen(context),
    );
  }

add(EventClass())メソッドを使うことによってイベントを発火することができます。

またDIした後は、BlocProvider.of<HomeBloc>(context);のようにblocを呼び出すことが可能です。

BlocBuilderwidgetを用いると、状態によってwidgetを変更することが出来ます。

Widget _buildStaggeredGridView() {
    return BlocBuilder<HomeBloc, HomeState>(builder: (context, state) {
      bloc = BlocProvider.of<HomeBloc>(context);
      if (state is LoadedState) {
        final books = state.books;
        return // booksを使ったwidget
      } else if (state is NoDataState) {
        return // データが無いときの表示処理
     } else if (state is LoadingState) {
        return // ローディング中の表示処理
     }
      return Container();
    });
  }
}

BlocConsumerwidgetを用いると、状態によって処理を分けることができます。

Widget _buildScreen(BuildContext context) {
    return BlocConsumer<HomeBloc, HomeState>(
        listener: (context, state) {
      if (state is LoadedState) {
        Navigator.pop(context); // データ取得処理が成功した場合のみpopする
      }
    }, builder: (context, state) {
      return // widgetを生成
    });

詳しくはflutter_blocをご覧ください。

テスト

テストに関するサポートもばっちりです。

bloclibrary.dev

詳しい説明は割愛しますが、次のようにStateをテストすることができます。

 blocTest<HomeBloc, HomeEvent, HomeState>(
        'テストの説明',
        build: () async {
        // blocを生成
        // モックの作成等はここで行う
      return HomeBloc(mockPinsRepository);

    }, act: (bloc) {
        // イベントの発火
      bloc.add(LoadData());
    }, expect: <HomeState>[LoadingState(), LoadedState(books)]); //期待されるState
}

気になるポイント🙋‍♀️

  • 小規模なアプリや個人の開発だと冗長な処理になりがち

この場合はsetState()StateNotifierパターンなど、もっとシンプルなものを選べば良いと思います。

  • 記述量が増える

bloc、event、state用のクラスをわざわざ作成する必要があり、記述量がどうしても増えてしまいます。 コード生成ライブラリであるhttps://pub.dev/packages/freezed freezed packageを活用するのも一つの手です。

以下のサイトにはflutter_blocにてfreezedを使用した例が書かれています。

Flutter - bloc with freezed

まとめ

flutter_blocではbloc、event、statteを定義し、そこからデータをwidgetに流すという方針をとっているため、誰が書いても似たようなコードスタイルになりました。 blocのUIガイドラインには、

Each "complex enough" component has a corresponding BLoC.

という記述があります。

今回は1widgetに対して1blocを用意するという原則を守ることで、どこに何の処理が書いているかを判別しやすくなりました。

複数人で開発する際には非常に役に立つpackageだと思うので、是非使ってみてください。