Flutterのアプリ設計(Bloc)

この記事はFlutter 全部俺 Advent Calendar 21日目の記事です。

このアドベントカレンダーについて

このアドベントカレンダーは @itome が全て書いています。

基本的にFlutterの公式ドキュメントとソースコードを参照しながら書いていきます。誤植や編集依頼はTwitterにお願いします。

Flutterアプリの設計

今日から3日間は、Flutterのスタンダードな設計について紹介していきます。

Flutterは他の開発環境に比べて若いため、設計に関する議論もまだ活発に行われているとは言いづらい状況ですが、 最近は公式の推奨やコミュニティの活発化によっていくつかのスタンダードなパターンが出てきました。

このアドベントカレンダーではその中からBlocパターン、Redux、MobXの3つをピックアップして紹介します。

これらの記事ではFlutterのBuildContextInheritedWidgetProviderパッケージの理解を前提としています。 6日目の記事7日目の記事を先に読んでおくとわかりやすいと思います。

Scoped Modelパターン

今日はBlocパターンの紹介をしますが、Blocパターンを理解するために、導入として 7日目の記事でもProviderパッケージの 利用例として紹介したScoped Modelパターンについて触れていきます。

Scoped Modelは状態管理のためのオブジェクトをProviderによってFlutterのWidgetツリーに紐づけることで、 Provider以下のWidgetから状態の変更と状態が変更されたときの再描画をできるようにする設計です。

たとえば以下のようなWidgetツリーがあるとします。

Widgetツリー

青い丸で表されたWidgetを押すと、赤い丸で表されたWidgetに表示されている数字が増えるようなアプリを作るために、 カウントを管理するようなクラスを用意しておきます。この状態管理のためのクラスが、Scoped ModelのModelにあたります。

class CounterModel extends ChangeNotifier {
  int count = 0;

  void increment() {
    count++;
    notifyListeners();
  }
}

このクラスは、自身の持つcountの値が変更されたことを通知できるように、ChangeNotifierを実装しています。

さて、作成した状態管理クラスを青Widgetと赤Widgetで共有する必要がありますが、 Flutterにはそのための機能がデフォルトで用意されていました。そう、InheritedWidgetです。

Providerを挟んだWidgetツリー

新たに追加された緑色の丸がInheritedWidgetです。 CounterModelクラスを保持するInheritedWidget(図ではProviderパッケージを使ったChangeNotifierProvider)を Widgetツリーの上流に挟み込むことによって、それ以下のWidget全てから、共通のCounterModelを取得することができるようになります。

赤丸のWidgetで、CounterModelProviderからCounterModelを取り出して、CounterModelの変更通知を受け取れるように しておきます。

class CounterText extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Text(
      Provider.of<CounterModel>(context).count.toString(),
      style: Theme.of(context).textTheme.display1,
    );
  }
}

青丸のボタンは、押されたときにCounterModelincrementメソッドを呼ぶだけです。

child: Consumer<CounterModel>(
  builder: (_, model, __) {
    return FlatButton(
      onPressed: model.increment,
      child: Icon(Icons.add),
    );
  }
),

これで、簡単なカウンターの状態管理をScoped Modelによって行うことができるようになりました。

ちなみに、CounterModelを取得できるのは、InheritedWidgetの仕様から緑丸のWidgetより下流のWidgetのみでした。

つまり、CounterModelは以下の図の塗りつぶされた部分に閉じているということになります。

CounterModel Scope

これが、Scoped(スコープ付きの) Modelという名前の由来です。

Scoped Modelの問題点

Scoped ModelはFuchsiaのソースコードでも使われていて、十分実用的な設計パターンですが、以下のような問題点があります。

今回のサンプルでは、状態がcount変数だけだったので、問題になりませんでしたが、例えば count1count2count3の3変数を管理しなければいけない場合を想定してください。

class CounterModel extends ChangeNotifier {
  int count1 = 0;
  int count2 = 0;
  int count3 = 0;

  void increment1() {
    count1++;
    notifyListeners();
  }

  void increment2() {
    count2++;
    notifyListeners();
  }

  void increment3() {
    count3++;
    notifyListeners();
  }
}

このときに、increment1を呼ぶと、count2count3を使っているWidgetは値が更新されていないにもかかわらずリビルドされてしまいます。 なぜなら、CounterModel全体が一つのChangeNotifierであるため、「count1が更新された」ということを伝える手段がなく、 単に「CounterModelの中の変数のどれかが変更された」としか伝えられないからです。

それを解決するためにScoped Modelで実装する際にはModelをできるだけ細分化することになりますが、細分化されすぎた状態は混乱を招きます。 そもそも、状態管理のスコープは描画の都合から切り分けて考えたいものです。

2020/2/16追記

実際には、FlutterのWidget側で最適化されるため、上記のようなScoped Modelによるリビルドがパフォーマンスに影響を与えることは稀です。

Scoped Modelに対するBlocの利点は、別の値の変更によって同じModel内のエラーが意図せず複数回表示されてしまうようなことを抑制するなどの 細かい制御ができることなので、常にBlocがScoped Modelよりも優れているわけではありません。

あくまでそれぞれのケースに合わせてメリット・デメリットを勘案しながら最適な設計を選ぶことが重要です。

Blocパターンによる改善

このような問題を解決するために導入するのがBlocパターンです。 Blocパターンでは、CounterModel全体をChangeNotifierにする代わりに、各変数をStreamで囲みます。

class CounterModel extends ChangeNotifier {
  int count1 = 0;
  int count2 = 0;
  int count3 = 0;
}
class CounterModel {
  Stream<int> count1 = Stream.value(0);
  Stream<int> count2 = Stream.value(0);
  Stream<int> count3 = Stream.value(0);
}

実は、Scoped ModelパターンとBlocパターンの根幹の違いはここだけです。 CouterModel自体をChangeNotifierとするのではなく、それぞれの変数を別のStreamとすることで、 それぞれの状態変更が別々に監視できるようになりました。

DartのStreamクラスは送出専用なので、新しく値を流すことができません。 そのため実際には以下の3パターンのいずれかを併用します。

StreamControllerを使うパターン

class CounterModel {
  final StreamController controller1 = StreamController<int>();
  final StreamController controller2 = StreamController<int>();
  final StreamController controller3 = StreamController<int>();

  Stream<int> get count1 => controller1.stream;
  Stream<int> get count2 => controller2.stream;
  Stream<int> get count3 => controller3.stream;
}

StreamControllerクラスは送出のためのStreamと、値を送り出すためのSinkクラスを持っているので、 値を更新することができます。

RxDartパッケージを使うパターン

Android/iOSアプリを作っているとおなじみのRxがDart向けにも用意されています。 StreamControllerは現在の値(最後に送出した値)を保持できないので、 「現在の値を1増やす」という操作ができません。

StreamControllerの代わりにRxDartBehaviourSubjectを使うことで 値の保持ができるようになります。

それ以外はStreamControllerを使った例と変わりません。

async*を使ったパターン

async*yieldを併用することによってStreamを作ることができます。 詳しくは4日目の記事を読んでください。

値を受け取るWidget側も少しだけ変更が必要です。値がStreamから流れてくるので、それを受け取るために StreamBuilderを使います。

class CounterText extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return StreamBuilder<int>(
      stream: Provider.of<CounterModel>(context).count1,
      initialData: 0,
      builder: (context, snapshot) {
        return Text(
          snapshot.data.toString()
          style: Theme.of(context).textTheme.display1,
        );
      }
    )
  }
}

これだけの変更でScoped ModelからBlocパターンに変更することができました。 もともとの概念がとても近い設計であることがよくわかります。

Providerを使ってModelを提供する設計は、Blocパターンに限らず多くの設計パターンで 重要なアイデアになるので、Flutter開発において必須の知識です。この機会に是非慣れておきましょう。

20日目: Flutterのテスト :

https://itome.team/blog/2019/12/flutter-advent-calendar-day20

22日目: Flutterのアプリ設計(Redux) :

https://itome.team/blog/2019/12/flutter-advent-calendar-day22