FlutterのBLoCパターンについて得た知見

FlutterのBLoCパターンについて得た知見

本稿は、FlutterのBLoCパターンについて調べていく中で得られた知見をまとめたものです。

記事メタ情報

筆者の背景・執筆の動機・対象読者について

筆者の背景

普段はwebの開発に当たることが多く、大規模アプリの開発よりは小規模アプリや準静的ページの開発が多いです。

Androidアプリは高校1年生のころにJava(当時はKotlinなどという言語は存在しなかった……)でほとんど使い物にならない書籍管理アプリを作った程度の経験値です。iOSアプリの開発は未だに未経験です。

今回、webの案件が一段落したので、Flutterを使ってネイティブアプリに手を出してみることにしました。Widgetの使い方やTransition、Animationの使い方は何となく分かったのですが、アプリ内でのデータの受け渡しがよく分かりませんでした。Flutter経験者の友人に相談したところ、「BLoCパターンというアーキテクチャGoogleは推しているっぽいよ」とのことだったので、集中して調べてみることにしました。

執筆の動機

BLoCパターンとは何か」という情報は英語記事を中心に山ほど出てくるのですが、日本語での記事や実際にどう実装すれば良いのかといった記事が非常に少なかったです。

同じことを調べる人の手間を省くためにも、自分的に参考になった記事へのリンクを交えつつ知見をまとめていきたいと思います。

対象読者

BLoCパターンについて調べている、主に初心者が対象読者です。

経験者が満足できるような情報を提供できる自信はありません……。間違っていたり改善できるような知見が書かれていたらぜひTwitter @dev_sudame までリプ or DMお願いします!

目次

前提知識編

Flutterの概要

Flutterとは、Googleが開発しているモバイルネイティブアプリのフレームワークです。Dart言語を用いて記述し、簡単なアプリであればFlutterで書くだけでAndroid/iOSの両方に対応できます。

Flutterの公式ページには次のような説明がされています。

Flutter allows you to build beautiful native apps on iOS and Android from a single codebase.

(訳) Flutterで一つのコードベースからiOSAndroidの美しいネイティブアプリを開発できます。

Flutterの大きな特徴としては、レイアウトもUIコンポーネントも、UIのほぼすべてを Widget を階層的に積み重ねて記述する点が挙げられます。

今回Futterに入門される方は、参考リンクにも挙げたFlutterの効率良い学び方を一読することをオススメします(著者のmonoさんの記事はどの記事も非常に参考になるのでありがたい限り……!)。

本稿はFlutter自体の入門解説は想定していないので、詳しい情報は参考リンクに譲ることにします。

参考リンク

  • Flutter - Beautiful native apps in record time
    • Flutterの公式サイトです。Flutterは公式ドキュメントが(英語ですが)非常に充実しています。基礎的な部分は公式サイトを見ておけば大丈夫だと思います。
  • Flutter YouTube Channel
    • FlutterはYouTubeで大量に解説動画を出してくれています。Google Developersチャンネルから分離したFlutterの公式チャンネルが用意されています。
  • Flutterの効率良い学び方
    • 入門時のロードマップが示されています。初心者向けの記事リンクも充実しているので、Flutterを学び始めたときのハブとして活用させていただきました。

Flutterのデータ/ステート管理

アプリケーション設計で不可欠かつ最も重要なのはステート管理(状態管理)です。アプリケーションが現在どのような状態にあるのかを適切に管理できなれければ、画面への情報の表示も外部との通信もできません。

Flutterには確固たる一つのステート管理法が存在するわけではなく、開発者が各々手に馴染むものを、適材適所で採用するスタイルのようです。公式ドキュメントには以下のステート管理法が紹介されています。

  • setState
  • InheritedWidget & Scoped model
  • Redux
  • BLoC / Rx
  • MobX

それぞれについて軽く説明していきます。

setState

setStateは最も素朴なステート管理法で、Flutter公式の最初のチュートリアルでも使われています。習得は簡単ですが、大規模アプリケーションの開発には向かなさそうです。

InheritedWidget & Scoped model

InheritedWidget & Scoped modelはFlutterに独特のステート管理法だと思います。

InheritedWidgetはFlutterに組み込まれている基本的なWidgetの一つで、子孫Widgetからデータにアクセスさせたいときなどに用いられます。

Scoped modelはFlutterのプラグインとして提供される機能です。ただ、Flutter公式のステート管理のチュートリアルにも用いられており、準公式的な立ち位置にいます。InheritedWidgetと同様、子孫Widgetからデータに簡単にアクセスさせる時に用いられるようです。

Redux

webアプリケーションの開発の際に用いるフレームワークReact」で広く用いられるステート管理法の一つです。こちらに関しては記事が充実しているので説明は省略します。

BLoC / Rx

本記事で詳しく触れていく管理法です。公式ドキュメントでは(なぜか)触れられていませんが、Google I/O 2018で紹介されるなど、Googleが強く推していると見られる実装方法です。

MobX

React界隈を中心にReduxと対比されるステート管理フレームワークです。Reduxよりも手数が少なくリアクティブな実装をできるようですが、僕は使ったことがなく、全然調べられていません。

参考リンク

BLoCとは

BLoCは「Business Logic of Component」の略です。ビジネスロジック(Business Logic)は、Wikipediaによると、こう定義されています。

ビジネスロジック(英: business logic)は、データベース上のデータに対する処理手順といったようなものを指す、ソフトウェア工学的な用語である。

Flutterで言うビジネスロジックは、View(Widget)とModelをつなぐ部分にあります。Viewからデータを受け取り、Modelとやりとりをしてステートの更新をし、Viewにステートの変更を通知する役割を持っています。

Google I/O 2018では以下のような画像で紹介されました。

電波塔のようなアイコンの部分でデータの変更を取得し、スピーカーのようなアイコンの部分に変更されたデータを流し込んでいるようなイメージです。具体的な実装方法については後ほど説明するので、ここでは概念的な説明に留めておきます。

参考リンク

Streamの概念について

さて、Dart言語には言語組み込みの概念として Stream が定義されています。Streamは入り口と出口があり、入り口には sink、出口には listen を用意しておきます。

Stream の役割は入り口と出口を結ぶことです。入り口と出口は別の場所にあって構いません。先に出したビジネスロジックの例で言えば、4本の Stream が使われています。水色の2本は入口がView(Widget)で出口がbusiness logic、紫(?)色の2本はbusiness logicが入口でView(Widget)が出口に設定されています。

補足 Google I/Oで紹介された画像は、business logicが主語の文脈で紹介されています。このため、business logicに入力されるデータをlistenし、business logicから出力されるデータをsinkに流し込んでいるイメージです。逆にView(Widget)を主語に考えれば、business logicへデータを出力することはsinkに流しこむことに、business logicからデータを受け取ることはデータをlistenすることに対応します。

参考リンク

RxDartについて

RxDartはDartサードパーティライブラリです。Dartには上述の通り、デフォルトで強力なStreamクラスを持っていますが、RxDartはさらに使いやすい機能を提供してくれます。

Rx の接頭辞からも分かる通り、RxJavaやRxJSで有名なReactiveXプロジェクトの一つとして整備されているライブラリです。AndroidアプリでRxJavaを使ってリアクティブ開発をしていた方にはRxDartが馴染みやすいかもしれません。

Dart純粋のStreamクラスにはキャッシュ機能が無く、例えば新規にStreamを開通させたときに初期値を設定したりするのが難しいのですが、RxDartを用いればそれが簡単に実現できます。

Rx系ライブラリではSubjectというクラスを使ってデータの受け渡しを行います。Subject.add()でデータを流し込み、Subject.stream.listen()でデータのリッスンが可能です。Subject系のクラスには用途によって様々な兄弟クラスが存在します。兄弟クラスの詳細については参考リンクの解説に詳しいので、そちらに譲ることにします。

RxDartはFlutter開発においても非同期ライブラリでは覇権を握っていると言っても過言ではなく、特に何の説明もなく解説記事に使われていたりもするので、軽く頭に入れておくと良いです。

参考リンク

  • rxdart | Dart Package
    • RxDartの配布ページです。右カラムの中にAPIリファレンスへのリンクがあるので、そちらも参考にすると良いです。
  • Rxで知っておくと便利なSubjectたち - Qiita
    • Rx系ライブラリに共通なSubject系クラスの解説がされています。DartはStreamの機能が標準で強力なため、はじめのうちはSubject系クラスのみ理解しておけば良さそうです。
  • ReactiveX
    • Rx系ライブラリの親元の公式サイトです。Observer/Observableなどの概念の解説があるので、「Subjectって結局なんだよ」と思い始めたら読むと良いと思います。
    • 残念なことに日本語対応はしていません。

実装編

ディレクトリ構成

BLoCパターンを実装した場合、どのようなディレクトリ構成にするのが良いのかは悩みどころです。一般的には、次のような構成を取っていることが多いようです。(というか、僕はこうしています。)

┣ lib
┃  ┣ blocs
┃  ┣ models
┃  ┣ views
┃  ┗ helpers
┣ android
┗ ios

見ての通りですが、blocsの中にはBLoCクラスを入れます。modelsにはModelにあたるクラスを、viewsの中にはViewとなるWidgetを組み合わせたものを入れておきます。helpersの中には、後述するBLoCProviderなどのヘルパー系クラスを入れてあります。

参考リンク

  • felangel/bloc
    • ToDoアプリの例です。このリポジトリでは、viewsscreenswidgetsに分離しています。これもよく見るパターンです。アプリ内のページの数が増えてきたら、こちらの方が管理しやすそうです。
  • vaygeth89/reactive_flutter_todo_database_app
    • こちらもToDoアプリの例です。多少ディレクトリの名前は違いますが、ほぼ同じ構成です。Firebaseやweb API、あるいはデバイスローカルのストレージなど、Flutterアプリ外と通信する場合はrepositoriesディレクトリを設けることが多いです。

BLoCパターンが守るべき条件

BLoCパターンを始めに提唱したのはAngularDartです。

その提唱者によるGithHub issueコメントでは、BLoCを用いる場合には以下の条件を守る必要があるとされています。

  1. Inputs and outputs are simple Streams/Sinks only
  2. Dependencies must be injectable and platform agnostic
  3. No platform branching allowed
  4. Implementation can be whatever you want if you follow the previous rules

簡単に訳してみると以下のようになります。

  1. BLoCの入出力はStreamとSinkのみである
  2. BLoCの依存関係は必ず注入可能であり、プラットフォームに依存してはいけない
  3. BLoC内でプラットフォームによる条件分岐をしてはいけない
  4. 以上のことを守りさえすれば、どのように実装しても構わない

「もちろん、すべてのコンポーネントBLoCを持つ必要はありません。『ある程度複雑な(complex enough)』コンポーネントBLoCを持つべきです。」とした上で、コンポーネント側(Flutterで言えばWidget側)が満たすべき条件としては、以下が挙げられています。

  1. Each "complex enough" component has a corresponding BLoC
  2. Components should send inputs "as is"
  3. Components should show outputs as close as possible to "as is"
  4. All branching should be based on simple BLoC boolean outputs
  1. 「ある程度複雑(complex enough)」なコンポーネントは、対応するBLoCを持つべきである
  2. コンポーネントは入力されたものを「そのまま」BLoCに送るべきである
  3. コンポーネントBLoCからの出力を可能な限り「そのまま」表示するべきである
  4. すべての条件分岐はBLoCからのシンプルな真偽値の出力に基づいて行われるべきである

以上の条件を満たすようにアプリケーションの状態管理を設計すれば、自然とBLoCパターンに則ることになります。

参考リンク

BLoCクラスの実装

BLoCクラスには最低限以下の要素が必要です。

  • 入力用のストリーム
    • View(Widget)などからBLoCに入ってくる方向のストリーム
  • 出力用のストリーム
    • BLoCからView(Widget)などに出ていく方向のストリーム

BLoCでは、入力用のストリームをリッスンし、適切な処理を施して出力用のストリームに流し込みます。

以下に簡単なサンプルコードを示します。ToDoアプリを作ることを考えます。

モデル

ToDoの一つのアイテム(Task)は以下のように定義しておきます。

Taskは titleisCompletedid を持っています。細かい部分はコメントを読んでください。

class Task {
  final String title;
  final bool isCompleted;
  final int id;

  Task({
    this.title = '',
    this.isCompleted = false,
    this.id,
  });

  // 新しいTaskを作るときに呼び出す
  // Taskのインスタンスを更新するのではなく、新しいインスタンスを立てる
  Task copyWith({String title, bool isCompleted, int id}) {
    return new Task(
      title: title ?? this.title,
      isCompleted: isCompleted ?? this.isCompleted,
      id: id ?? this.id,
    );
  }

  // idが同じであれば同じTaskであるとみなす
  // ==演算子の上書き
  @override
  bool operator ==(other) => this.id == other.id;

  // 同一オブジェクトであるとみなすためにはhashCodeを同一にする必要がある
  // ここではhashCodeをTaskのidにすることで解決
  @override
  int get hashCode => this.id;
}

BLoC

モデルに基づいてBLoCを組み立てます。

先に紹介したBLoCの原則の1つ「コンポーネントは入力されたものを『そのまま』BLoCに送るべきである」にやや反しますが、View(Widget)からBLoCに入力情報を送るときは TaskEvent を作って送ります。

TaskEventaction として create update delete の3つを指定でき、それと task を合わせて送出することで「どのTaskに何の処理するのか」を表現しています。

先に説明した通り、TaskBlocクラスには入力用と出力用のストリームがそれぞれ用意してあります。また、それらのストリームを制御する用のプライベートフィールドも定義しています。

なお、TaskBlocクラスはBlocクラスを継承していますが、この事情は後ほど説明します。

// dart lang
import 'dart:async';

// 3rd party packages
import 'package:bloc_provider/bloc_provider.dart';
import 'package:rxdart/subjects.dart';

// self packages
import 'package:sudame_todo_bloc/models/task.dart';

// CRUDアクションの設定(Readは無し)
enum TaskEventAction {
  create,
  update,
  delete,
}

// TaskをC(R)UDする指示を出すイベントクラス
class TaskEvent<T> {
  final TaskEventAction action;
  final Task task;

  TaskEvent({this.action, this.task});
}

// BLoC本体
class TasksBloc implements Bloc {
  // Taskのリスト
  static List<Task> _taskList = [];

  // 入力Streamの制御
  final StreamController<TaskEvent> _inputController = StreamController();

  // 出力Streamの制御
  final BehaviorSubject<List<Task>> _outputController =
      BehaviorSubject.seeded(_taskList);

  // 入力Stream
  Sink<TaskEvent> get setTask => _inputController.sink;

  // 出力Stream
  Stream<List<Task>> get getTaskList => _outputController.stream;

  // Taskを新規で作る
  void _createTask({Task task}) {
    final int _id = _taskList.length;
    _taskList.add(new Task(
      title: task.title ?? '',
      isCompleted: task.isCompleted ?? false,
      id: _id,
    ));
  }

  // Taskの更新
  void _update(Task task) {
    _taskList[task.id] = task;
  }

  // Taskの削除
  void _delete(Task task) {
    _taskList.removeWhere((t) => task == t);
  }

  // 入力Streamのリスナ
  void _taskEventListener(TaskEvent e) {
    if (e.action == TaskEventAction.create) {
      // createアクションだった場合
      _createTask(task: e.task);
    } else if (e.action == TaskEventAction.update) {
      // updateアクションだった場合
      _update(e.task);
    } else if (e.action == TaskEventAction.delete) {
      // deleteアクションだった場合
      _delete(e.task);
    }
    // Taskのリスト更新後、出力Streamに流し込む
    _outputController.add(_taskList);
  }

  // コンストラクタ
  TasksBloc() {
    // 入力Streamをリッスンしてリスナを登録
    _inputController.stream.listen(_taskEventListener);
  }

  // ステートが破棄された場合、Streamを閉じて破棄する
  @override
  void dispose() async {
    await _inputController.close();
    await _outputController.close();
  }
}

正直な話、まだ実務レベルでFlutterを書いているわけではありませんし、それほど知識のインプットもできていないので、上のコードを信じ込むことはせず、あくまで参考程度にしてください。

全体のソースはこちらにあります。

参考リンク

BLoCインスタンスの扱い方

概要

当然ですが、BLoCBLoCだけでは意味が無く、View(Widget)と相互にやり取りすることで機能が生まれます。

BLoCへ情報を送り込むWidgetBLoCから情報を受け取るWidgetは一般的に異なります(同一Widgetで済むならばBLoCパターンにする必要は無いですね)。ここで必要なのは、異なるWidgetから同一のBLoCインスタンスにアクセスするということです。それぞれのWidgetが好き勝手にBLoCインスタンスを立ち上げても、ストリームが異なってしまいますので通信はできません。

異なるWidgetから同一のBLoCインスタンスにアクセスするために考えられる方法は2つあります。

  1. BLoCクラスをシングルトンにする
  2. BLoCインスタンスを一度だけ生成し、インスタンスWidget間で受け渡す

この内、Flutterで良く採用されるのは2です。なぜ1が採用されないのか、僕の知識と調べた範囲では分かりませんでした。ご存知の方はぜひ教えていただきたいです。

2の方法では、Widgetの階層構造の上位でBLoCインスタンスを生成・保持し、下位のWidgetからアクセスすることを考えます。setStateなどでBLoCインスタンスを下位Widgetに順に伝播させていっても良いですが、階層構造が大きくなるとコードが煩雑になり、しかも伝播の流れの中にあるWidgetはその都度再ビルドされていまいます。

そんな状況を解決してくれるのが InheritedWidget です。

InheritedWidget

公式リファレンスには以下のように紹介されています。

Base class for widgets that efficiently propagate information down the tree. (訳) 階層構造の下位に効率的に情報を伝播するwidgetのベースクラスです。

この説明からも、BLoCインスタンスを効率的に階層構造の下位に伝播するのに最適なクラスだという雰囲気が伝わってきます。InheritedWidgetを用いれば、たとえ階層構造が深くなったとしても、下位のWidgetから一発で(O(1)で)情報にアクセスできます。

具体的な使い方として、公式リファレンスの例を見てみましょう。

class FrogColor extends InheritedWidget {
  const FrogColor({
    Key key,
    @required this.color,
    @required Widget child,
  }) : assert(color != null),
       assert(child != null),
       super(key: key, child: child);

  final Color color;

  static FrogColor of(BuildContext context) {
    return context.inheritFromWidgetOfExactType(FrogColor) as FrogColor;
  }

  @override
  bool updateShouldNotify(FrogColor old) => color != old.color;
}

重要なのはstaticなofメソッドです。ofメソッドの中ではcontext.inheritFromWidgetOfExactType()というメソッドを呼んでいます。inheritFromWidgetOfExactType()メソッドは、ざっくり言うと「親Widgetたちの中で最も近い、指定された型を持つWidgetを返す」機能を持ったメソッドです。やや難しいですが、ここで言う「親Widget」にはofメソッドを定義したクラス(ここではForgColorクラス)も含まれます。従って、FrogColorクラスの子孫WidgetFrogColorクラスのofメソッドを呼び出した場合、FrogColor Widgetそのものが返ることになります。

この動作については参考リンクに詳しいので、そちらを参照してください。

先の例を続けるならば、FrogColor Widgetの子孫Widgetからは以下のようにしてFrogColor Widgetを呼び出すことができます。ここで contextBuildContext 型です。例えば StatelessWidgetbuild メソッド内では BuildContext にアクセスできます。

final FrogColor _frogColor = FrogColor.of(context);

ところで、先の例では ofメソッドは FrogColor Widgetそのものを返していましたが、ofメソッドはWidgetを返す必要はありません。例えば、FrogColor クラスは Color フィールドを持っていますが、これを返してもOKです。以下のような書き方になるはずです。

static Color of(BuildContext context) {
  return (context.inheritFromWidgetOfExactType(FrogColor) as FrogColor).color;
}

BLoCから話が逸れてしまいました。ここまでのInheritedWidgetの動作を踏まえれば、InheritedWidget を親としてBLoCインスタンスを子孫に効率よく伝播させることができます。簡略的には以下のように書けるでしょう(本来はもうひと手間必要です)。

class BlocProvier extends InheritedWidget {
  const BlocProvider({
    Key key,
    @required Widget child,
  }) : assert(child != null),
       super(key: key, child: child);

  // BLoCインスタンスの生成
  final Bloc bloc = new Bloc();

  static Bloc of(BuildContext context) {
    // BLoCインスタンスの提供
    return (context.inheritFromWidgetOfExactType(BlocProvider) as BlocProvider).bloc;
  }

  @override
  bool updateShouldNotify(BlocProvider old) => bloc != old.bloc;
}

子孫クラスからは、下記のようにしてBLoCインスタンスにアクセスできます。

final Bloc _bloc = BlocProvider.of(context);

BLoCインスタンスの適切な生成・破棄

InheritedWidgetを用いることで下位Widgetに効率的にBLoCインスタンスを伝播させることには成功しました。しかしまだ問題があります。それは BLoCインスタンスを適切に生成・破棄する ということです。

使われなくなったWidget(disposeされたWidget)に紐付いているBLoCは、ストリームを適切に閉じてあげる必要があります。ストリームを閉じない場合、Widgetは存在しないのにBLoCは生き続け(当然ストリームも生き続け)、メモリリークやバグが起こる温床となります。使わなくなったものは適切に片付けなければいけません。

Widgetが使われなくなった場合、破棄されるタイミングで State クラスの dispose メソッドが呼び出されます。従って、このdisposeメソッドの中でストリームを閉じる処理を呼び出せば良いことになります。

また同様に、Widgetが使われ始めるタイミングでBLoCインスタンスを生成する必要もあります。これは State クラスの initStateメソッド内で行えば良いです。

実際には動作しませんが、気持ちとしては以下のようなコードになります。

class BlocProviderState extends State {
  Bloc _bloc;

  // initState()内でBLoCインスタンスの生成
  @override 
  initState() {
    _bloc = Bloc();
    super.initState();
  }

  // dispose()内でBLoCインスタンスの破棄
  // Bloc.dispose()メソッドではストリームを閉じる処理などを想定
  @override
  dispose() {
    _bloc.dispose();
    super.dispose();
  }

  // InheritedWidgetを使って下位Widgetに生成したBLoCインスタンスを伝播
  // BlocProviderは前項で出した例のものを想定
  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      bloc: _bloc,
      child: MyHomePage(),
    );
  }
}

まとめ

ここまでで、BLoCインスタンスを生成し、下位Widgetに伝播し、破棄することが可能になりました。長くなったのでまとめておきます。

  1. StateクラスのinitStateメソッド内でBLoCインスタンスを生成する
  2. State (StatefulWidget) の子にInheritedWidgetを配置し、子孫たちにBLoCインスタンスを伝播させる
  3. Stateクラスのdisposeメソッド内でBLoCインスタンスを破棄する

以上を実装すれば、大規模なFutterアプリでもBLoCパターンを用いたステート管理が実現できるはずです。

参考リンク

総括

かなりの大長編となってしまいました。

僕がFlutterに初めて触れてBLoCパターンについて知りたかったことをすべて詰め込むという意気込みで書きなぐりましたが、まだ不足している情報があるかもしれません。「あれも入れたほうが良い」「ここでハマった」等がありましたら追記しますのでお声掛けください。

繰り返しますが、私は完全な初心者なので、本稿の情報には誤りが多分に含まれている可能性があります。気付き次第修正を入れますので、こちらもどうぞお気軽にお声掛けください。

お疲れ様でした。

(おまけ) 全参考リンク集

記事内に登場したすべての参考リンクを集めてみました。リンク集としてご活用ください。