ゼロから始めるFlutter状態管理Bloc!シンプル事例で新たなステージへ!

もくじ

StatelessWidget! タッキーです。
連日ブログを投稿したところ、「タッキー無双の始りか????」なんてお言葉をいただきました。
まさか3発目いくと思っていないはずなので、もはやネタ的に勢いで書いています。

Flutterにおける状態管理

Flutterアプリ開発において、状態管理は非常に重要な概念です。
状態管理とは、アプリの様々な要素(ボタンの状態、入力されたテキスト、ネットワークから取得したデータなど)を管理し、それらの変化をUIに反映させる仕組みのことです。

アプリは複数の画面で構成されていることがありますが、複数の画面が同じデータを参照し、更新が必要になるケースがあります。
また、ユーザーではなくネットワーク通信やデータベースアクセスなどでデータの変化は見えないところでも行われています。

そのため、状態管理は非常に重要な概念となります。

また、テストにおいても有益に働きます。
状態を管理することで、各Widget(画面そのものであったりパーツであったり)のテストを独立して行うことができます。
状態をモック化することで、様々なシナリオの作成ができます。

様々な状態管理方法

Flutterではパッケージを追加することで、便利な機能を容易に追加ができます。
状態管理もその1つですが、状態管理といっても様々なものがあります。
更新頻度が高いものですと以下で、記事の数も多い印象です。

  • Provider
  • Riverpod
  • BLoC

今回ご紹介するのはBlocです。

Blocの概念

大きな特徴といえば、ビジネスロジックをUIから分離していることです。
画面を構成しているWidgetでは、画面の構成のみとなります。
ビジネスロジックはBloc側に集約することで、同じデータを参照している場合などで複数画面に同じロジックが発生しません。
共通的なビジネスロジックを関数化して複数箇所で書くということもないのです。
あくまで画面のコーディングは画面のみ。

Blocは、以下の3つの要素で構成されます。

  • State: アプリの状態を表すデータです。
  • Event: 状態を変化させるためのイベントです。
  • Bloc: Eventを受け取り、Stateを更新するロジックを実装します。

ユーザーがボタンを押すなどのイベントが発生すると、Blocがそのイベントを受け取り、それに応じてStateを更新します。
Stateが更新されると、UIが自動的に再描画されます。

イベントには新しいデータ(例えばユーザーが入力したテキスト等)を渡すこともでき、そのデータは「ビジネスロジック」を通過してStateが更新できるのです。
ユーザーだけではなく、バックグラウンドで知らない間に来たネットワーク経由から得たデータなども同様です。

シンプル事例でのご紹介

構成としては以下となります。

my_app
  ├ lib
     ├main.dart ※
     └app
       ├my_app.dart
       └bloc
         ├bloc.dart
         ├event.dart
         └state.dart
※ 本記事での必要外は省略

必要パッケージ

flutter_blocequatableになります。
インストール及びバージョンについては上記リンクのpub.dev公式をご覧ください。

UI:my_app.dart

概要としては、

  • 左に数値とボタンがある
  • 右に数値とボタンがある
  • 間に演算子記号の文字がある
  • それらの下にはクリアボタンがある
小さな画像ですみません
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import './bloc/bloc.dart';

class MyAppWidget extends StatelessWidget {
  const MyAppWidget({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocProvider(                               // ※1
      create: (_) => MyAppBloc(),                      // ※2
      child: const Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              _Left(),
              _Operator(),
              _Right(),
            ],
          ),
          _Initial(),
        ],
      ),
    );
  }
}

class _Left extends StatelessWidget {
  const _Left();

  @override
  Widget build(BuildContext context) {
    return BlocSelector<MyAppBloc, MyAppState, int>(  // ※3
      selector: (state) => state.left,                // ※4
      builder: (context, left) {                      // ※5
        return Column(
          children: [
            Text(
              "$left",                                // ※6
              style: const TextStyle(fontSize: 24, color: Colors.blue),
            ),
            ElevatedButton(
              onPressed: () {
                context.read<MyAppBloc>().add(const LeftUpdateEvent());  ※7
              },
              child: const Text('Left'),
            ),
          ],
        );
      },
    );
  }
}

class _Operator extends StatelessWidget {
  const _Operator();

  @override
  Widget build(BuildContext context) {
    return BlocSelector<MyAppBloc, MyAppState, String>(
      selector: (state) => state.operator,
      builder: (context, operator) {
        return Text(
          operator,
          style: const TextStyle(fontSize: 50),
        );
      },
    );
  }
}

class _Right extends StatelessWidget {
  const _Right();

  @override
  Widget build(BuildContext context) {
    return BlocSelector<MyAppBloc, MyAppState, int>(
      selector: (state) => state.right,
      builder: (context, right) {
        return Column(
          children: [
            Text(
              "$right",
              style: const TextStyle(fontSize: 24, color: Colors.red),
            ),
            ElevatedButton(
              onPressed: () {
                context.read<MyAppBloc>().add(const RightUpdateEvent());
              },
              child: const Text('Right'),
            ),
          ],
        );
      },
    );
  }
}

class _Initial extends StatelessWidget {
  const _Initial();

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () {
        context.read<MyAppBloc>().add(const MyAppInitialEvent());
      },
      child: const Text('clear'),
    );
  }
}

※1:Blocの生成を行うクラスです。
複数のBlocではMultiBlocProviderを利用します。

※2:createで指定したBlocの生成をします。
設計ミス等で同一Blocが複数箇所で生成されると、後者のインスタンスが有効となるようです。
Blocが生成されていない場合、他画面から該当Blocにアクセスしようとすると例の赤い画面となります。
そのため、複数箇所での使用が想定されるBlocの場合はそれらの上位で生成しておく必要があります。
リスト化するなども良い手ですので本編とは違う例でご紹介します。

import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/single_child_widget.dart';

// 各Blocのimport

List<SingleChildWidget> get blocList => [
      BlocProvider(
        create: (context) => MyAppBloc(),
      ),
      BlocProvider(
        create: (context) => MyAppBloc2(),
      ),
      BlocProvider(
        create: (context) => MyAppBloc3(),
      ),
    ];
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

// blocListがあるファイルをimport

void main() {
  runApp(const Hoge());
}

class Hoge extends StatelessWidget {
  const Hoge({super.key});

  @override
  Widget build(BuildContext context) {
    return MultiBlocProvider(
      providers: blocList,
      child: MaterialApp(
        home: const Scaffold(
          body: Fuga(),
        ),
      ),
    );
  }
}

※3:対象のデータが更新された場合に、再描画します。
BlocSelectorでは単一のデータを対象とします。
このクラスでは単一のデータしか使用しないため、再描画のトリガーとなる値をしぼることで関係ないデータが起因による再描画を防ぎます。
複数のデータを取り扱う場合はBlocBuilderでState全体を対象とします。

※4:BlocSelectorを利用しているため、対象となるStateを指定します。
アローではなく{}も使用できますので、複雑な条件式等も使用可能です。

※5:対象となるStateは「state.left」ですが、配下で使用する際の変数名を定義可能です。
この例ではシンプルにそのまま「left」としているだけです。

※6:Text WidgetにStateのleftの値を渡しています。
値に変更があると再描画されるため、Text Widgetの表示も変更となります。

※7:ボタンが押された場合に、LeftUpdateEvent()がコールされます。
先の説明でもありあましたが、イベント発生→Blocイベントを受け取り→それに応じてStateを更新となる、最初のイベント発生となるわけです。

他のクラスも同様なので、割愛します。

State:state.dart

必要なデータ群となります。

part of './bloc.dart';

class MyAppState extends Equatable {  // ※1
  const MyAppState({
    required this.operator,
    required this.left,
    required this.right,
  });

  final String operator;              // ※2
  final int left;
  final int right;

  MyAppState copyWith({               // ※3
    String? operator,
    int? left,
    int? right,
  }) {
    return MyAppState(
      operator: operator ?? this.operator,
      left: left ?? this.left,
      right: right ?? this.right,
    );
  }

  const MyAppState.initial()         // ※4
      : this(
          operator: '=',
          left: 0,
          right: 0,
        );

  @override
  List<Object?> get props => [       // ※5
        operator,
        left,
        right,
      ];
}

※1:必要なデータを持つクラスです。
Dartでは、通常、オブジェクトの比較は==演算子で行われます。
しかし、カスタムクラスの場合、デフォルトではオブジェクトの参照が比較されるため、値が同じでも異なるインスタンスであればfalseが返されてしまいます。
そこでEquatableを使うことで、オブジェクトの値に基づいた比較を行うことができます。
つまり、同じ値を持つオブジェクトは等しいと判断できるようになります。

※2:管理するデータとなります。
型は基本のint / boolなどといったものだけでなく、カスタムクラスも指定できます。
そのため、nameとageを持つParsonクラスを型に、parsonといた形でも可能です。

※3:データ更新時などに使います。
null許容をしており、必要なデータのみ更新し他はstateにある値のままとなります。

※4:データの初期値です。
ここで管理することで、このデータの初期値の管理はどこでやってるの?が無くなり、一目瞭然となります。

※5:Equatableではpropsというプロパティに、比較したいプロパティのリストを指定します。
このpropsに含まれるプロパティの値が一致すれば、その2つのオブジェクトは等しいと判断されます。
そのため、データ全てが対象となります。

Event:event.dart

データ群の更新イベントです。
このイベントが呼ばれることで、ビジネスロジックやデータ更新、そして再描画へとつながります。

part of './bloc.dart';

abstract class MyAppEvent {                               // ※1
  const MyAppEvent();
}

class MyAppInitialEvent extends MyAppEvent {              // ※2
  const MyAppInitialEvent();
}

class OperatorUpdateEvent extends MyAppEvent {            // ※3
  const OperatorUpdateEvent({required this.operator});
  final String operator;
}

class LeftUpdateEvent extends MyAppEvent {                // ※4
  const LeftUpdateEvent();
}

class RightUpdateEvent extends MyAppEvent {               // ※5
  const RightUpdateEvent();
}

※1:Event用の抽象クラスです。
この抽象クラスを継承することで、サブクラスたちがイキイキと動きます!

※2:初期化用のクラスです。
抽象クラスをさっそく継承していますね。
名称にInitialと入っているため、明確に目的がわかります。(動詞の方が良さげですが)

※3:演算子記号文字列のイベントクラスです。
引数ではoperatorをrequired指定となっております。
つまり、更新するときには必ず何かしらの文字が必要となります。
1つしかデータがないうえに、名称で目的がわかるのでrequired指定でなくても良いかもしれません。
例であることと、個人的に些細なミスの防止もかねております。

※4:Leftにある数値の更新イベントです。
このイベントでの目的は後述するBlocの章でもわかる通り、インクリメントしかしません。
先の演算子のように、引数で値をもらうわけでもないです。
目的を明確にするためには良いクラス名ではないです。

※5:こちらはRight側
Leftと同様です。

Bloc:bloc.dart

イベント後に、受け取ったデータなどを元に必要に応じてビジネスロジックをここで行います。

import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

part './event.dart';
part './state.dart';

class MyAppBloc extends Bloc<MyAppEvent, MyAppState> {  // ※1
  MyAppBloc() : super(const MyAppState.initial()) {     // ※2
    on<MyAppInitialEvent>(_initialEvent);               // ※3
    on<OperatorUpdateEvent>(_operatorUpdateEvent);
    on<LeftUpdateEvent>(_leftUpdateEvent);
    on<RightUpdateEvent>(_rightUpdateEvent);
  }

  void _initialEvent(                                   // ※4
    MyAppInitialEvent event,
    Emitter<MyAppState> emit,
  ) {
    emit(const MyAppState.initial());
  }

  void _operatorUpdateEvent(                            // ※5
    OperatorUpdateEvent event,
    Emitter<MyAppState> emit,
  ) {
    emit(state.copyWith(operator: event.operator));
  }

  void _leftUpdateEvent(                                // ※6
    LeftUpdateEvent event,
    Emitter<MyAppState> emit,
  ) {
    emit(state.copyWith(left: state.left + 1));
    _operatorUpdate();
  }

  void _rightUpdateEvent(                              // ※7
    RightUpdateEvent event,
    Emitter<MyAppState> emit,
  ) {
    emit(state.copyWith(right: state.right + 1));
    _operatorUpdate();
  }

  void _operatorUpdate() {                            // ※8
    if (state.left > state.right) {
      add(const OperatorUpdateEvent(operator: '>'));
    } else if (state.left < state.right) {
      add(const OperatorUpdateEvent(operator: '<'));
    } else {
      add(const OperatorUpdateEvent(operator: '='));
    }
  }
}

※1:肝心なBlocのクラスです。
Blocクラスを継承し、ジェネリクスで対象のEventとStateを指定しています。
Eventの章でご紹介した抽象クラスである「MyAppEvent」が、ここで登場します。
この抽象クラスがいるおかげで、継承したサブクラスのEvent達がイキイキしてきます!

※2:コンストラクターに、Stateの章でご紹介したInitial関数が登場します。
この引数部で初期化の値をそのまま記載しても良いです。
初期化関数を作成することで、再度初期化が別の箇所で必要になった場合に流用可能であることがメリットですね!

※3:イベントが呼ばれた時に動く関数を指定しています。
イベントと似た名称にすると楽です。

※4:Eventの章でご紹介した「MyAppInitialEvent」がコールされ、Blocが受け取った際に動く処理となります。
引数にはMyAppInitialEventをevent、Emitter emitとあります。
eventでは、イベント発火時の値が入ってきます。
Eventクラスに引数でデータを渡していると、その変更後の値がきます。
emitを使い、データ更新を実行していますが、よく見ると初期化のInitial関数が登場していますね!
データ更新後、演算子更新のEventをコールしています。

※5:演算子の記号文字を更新している実処理部です。
Eventクラス側では引数で文字列を受け取っています。
この値がBlocを通してこちらにきます。
event.operatorが更新後の値となり、stateのcopyWithでoperatorのみを更新しています。

※6:Left側の数値の更新をこちらで行います。
operatorとは違い、Eventでは引数をもらっていません。
Event章でも説明しましたが、こちらでは現在値のインクリメントを実施しています。
state.copyWith(left: state.left + 1)の処理は、state.left + 1で現在値を1足した値をcopyWithでleftのstateのみ更新しています。
その後、_operatorUpdate()がコールされていますね!(後述)

※7:Right側となります。
Leftと同じです。

※8:leftとrightのインクリメント後に呼ばれるものです。
現在値のleftとrightを比較し、比較演算子の文字を決定、その文字を引数に比較演算子のEventをコールしています。

動作の動画

シンプルなコードにするため、その影響で見た目がショボイです。
また、小さかったので画質が荒く、すみません。
このように、インクリメントしていくと、左右の値の変化はもちろん、値により比較演算子の変化が起きているのがわかります。
左右の値、比較演算子は共に値の変化時に、その箇所のみが再描画されています。

あとがき

ノリで作ったわりには、そこそこしっかりした記事になりました。
私はまだFlutter勉強中の身のため、記事の内容に間違い等がございましたらご容赦ください。

簡単にAndroid、iOS、Windows、Mac、Linux、Webのアプリが作成できます。
自作アプリの中にはAndroidとWindows用のものがありますが、問題なく機能しています。
上記の動画はWeb版であり、Chrome上で動いているものを録画しました。

Flutter楽しいですよ!
ネイティブ言語にはパフォーマンスでは負けますが、それはゲームとかシビアな世界の話です。
シンプルなものでまったく問題はなく、Flutter開発者のみでマルチにリリースできるため開発コストが低くなります。

比較的若い言語のため、目下の問題は技術者不足みたいです。
開発現場での採用が増えていき市場がにぎやかになってほしいです。
当ブログから触発されて始めた方とかいましたら嬉しいですね!

Google公式の環境構築手順と、スタートガイドを参照してHello,world!しましょう!
このブログの記事もコピペで動きますよ!

よき開発ライフを!