【Flutter】FlutterでBLoCパターンの実装を行う「flutter_bloc」の使い方まとめ

FlutterでBLoCパターンの実装を行う「flutter_bloc」の使い方をまとめました。

flutter_bloc 8.1.5

はじめに

この記事では flutter_bloc の使い方をまとめます。
flutter_bloc は Flutter で BLoC(Business Logic Component)パターンの実装を行うためのライブラリです。
BLoC パターンについては本記事では割愛しますが、以下の記事にとてもわかりやすくまとめられているので、必要に応じて参照してください。

qiita.com

セットアップ

まず 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 クラスでは状態を操作するメソッド(incrementdecrement)を定義していましたが、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個だけを流したりといったことができます。
この辺りの実装方法については本記事では割愛しますが、必要に応じて以下の公式ドキュメントを参照してください。

bloclibrary.dev

生成済みの状態インスタンスを別のページで使う

次に、あるページで生成した CubitBloc を遷移先のページで使い回すことを考えます。
前述のように 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');
          },
        ),
      ),
    );
  }
}

実行結果は下図のとおりです。

実行結果

ちなみに、ファクトリメソッドを使う場合は BlocProviderCubitBloc の寿命を管理し、不要になった時点で状態を破棄(内部で使っている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

次に、状態クラスに複数の値が定義されているケースを考えます。
以下は countmaxCount を保持し、それを更新するメソッドを持つ状態を定義したものです。

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);
}

参考

bloclibrary.dev