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で一つのコードベースからiOSとAndroidの美しいネイティブアプリを開発できます。
Flutterの大きな特徴としては、レイアウトもUIコンポーネントも、UIのほぼすべてを Widget
を階層的に積み重ねて記述する点が挙げられます。
今回Futterに入門される方は、参考リンクにも挙げたFlutterの効率良い学び方を一読することをオススメします(著者のmonoさんの記事はどの記事も非常に参考になるのでありがたい限り……!)。
本稿はFlutter自体の入門解説は想定していないので、詳しい情報は参考リンクに譲ることにします。
参考リンク
Flutterのデータ/ステート管理
アプリケーション設計で不可欠かつ最も重要なのはステート管理(状態管理)です。アプリケーションが現在どのような状態にあるのかを適切に管理できなれければ、画面への情報の表示も外部との通信もできません。
Flutterには確固たる一つのステート管理法が存在するわけではなく、開発者が各々手に馴染むものを、適材適所で採用するスタイルのようです。公式ドキュメントには以下のステート管理法が紹介されています。
- setState
- InheritedWidget & Scoped model
- Redux
- BLoC / Rx
- MobX
それぞれについて軽く説明していきます。
setState
setStateは最も素朴なステート管理法で、Flutter公式の最初のチュートリアルでも使われています。習得は簡単ですが、大規模アプリケーションの開発には向かなさそうです。
InheritedWidget & Scoped modelはFlutterに独特のステート管理法だと思います。
InheritedWidgetはFlutterに組み込まれている基本的なWidgetの一つで、子孫Widgetからデータにアクセスさせたいときなどに用いられます。
Scoped modelはFlutterのプラグインとして提供される機能です。ただ、Flutter公式のステート管理のチュートリアルにも用いられており、準公式的な立ち位置にいます。InheritedWidgetと同様、子孫Widgetからデータに簡単にアクセスさせる時に用いられるようです。
Redux
webアプリケーションの開発の際に用いるフレームワーク「React」で広く用いられるステート管理法の一つです。こちらに関しては記事が充実しているので説明は省略します。
本記事で詳しく触れていく管理法です。公式ドキュメントでは(なぜか)触れられていませんが、Google I/O 2018で紹介されるなど、Googleが強く推していると見られる実装方法です。
MobX
React界隈を中心にReduxと対比されるステート管理フレームワークです。Reduxよりも手数が少なくリアクティブな実装をできるようですが、僕は使ったことがなく、全然調べられていません。
参考リンク
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開発においても非同期ライブラリでは覇権を握っていると言っても過言ではなく、特に何の説明もなく解説記事に使われていたりもするので、軽く頭に入れておくと良いです。
参考リンク
実装編
BLoCパターンを実装した場合、どのようなディレクトリ構成にするのが良いのかは悩みどころです。一般的には、次のような構成を取っていることが多いようです。(というか、僕はこうしています。)
┣ lib
┃ ┣ blocs
┃ ┣ models
┃ ┣ views
┃ ┗ helpers
┣ android
┗ ios
見ての通りですが、blocs
の中にはBLoCクラスを入れます。models
にはModelにあたるクラスを、views
の中にはViewとなるWidgetを組み合わせたものを入れておきます。helpers
の中には、後述するBLoCProviderなどのヘルパー系クラスを入れてあります。
参考リンク
BLoCパターンが守るべき条件
BLoCパターンを始めに提唱したのはAngularDartです。
その提唱者によるGithHub issueコメントでは、BLoCを用いる場合には以下の条件を守る必要があるとされています。
- Inputs and outputs are simple Streams/Sinks only
- Dependencies must be injectable and platform agnostic
- No platform branching allowed
- Implementation can be whatever you want if you follow the previous rules
簡単に訳してみると以下のようになります。
- BLoCの入出力はStreamとSinkのみである
- BLoCの依存関係は必ず注入可能であり、プラットフォームに依存してはいけない
- BLoC内でプラットフォームによる条件分岐をしてはいけない
- 以上のことを守りさえすれば、どのように実装しても構わない
「もちろん、すべてのコンポーネントがBLoCを持つ必要はありません。『ある程度複雑な(complex enough)』コンポーネントがBLoCを持つべきです。」とした上で、コンポーネント側(Flutterで言えばWidget側)が満たすべき条件としては、以下が挙げられています。
- Each "complex enough" component has a corresponding BLoC
- Components should send inputs "as is"
- Components should show outputs as close as possible to "as is"
- All branching should be based on simple BLoC boolean outputs
- 「ある程度複雑(complex enough)」なコンポーネントは、対応するBLoCを持つべきである
- コンポーネントは入力されたものを「そのまま」BLoCに送るべきである
- コンポーネントはBLoCからの出力を可能な限り「そのまま」表示するべきである
- すべての条件分岐はBLoCからのシンプルな真偽値の出力に基づいて行われるべきである
以上の条件を満たすようにアプリケーションの状態管理を設計すれば、自然とBLoCパターンに則ることになります。
参考リンク
BLoCクラスには最低限以下の要素が必要です。
BLoCでは、入力用のストリームをリッスンし、適切な処理を施して出力用のストリームに流し込みます。
以下に簡単なサンプルコードを示します。ToDoアプリを作ることを考えます。
モデル
ToDoの一つのアイテム(Task)は以下のように定義しておきます。
Taskは title
と isCompleted
と id
を持っています。細かい部分はコメントを読んでください。
class Task {
final String title;
final bool isCompleted;
final int id;
Task({
this.title = '',
this.isCompleted = false,
this.id,
});
Task copyWith({String title, bool isCompleted, int id}) {
return new Task(
title: title ?? this.title,
isCompleted: isCompleted ?? this.isCompleted,
id: id ?? this.id,
);
}
@override
bool operator ==(other) => this.id == other.id;
@override
int get hashCode => this.id;
}
BLoC
モデルに基づいてBLoCを組み立てます。
先に紹介したBLoCの原則の1つ「コンポーネントは入力されたものを『そのまま』BLoCに送るべきである」にやや反しますが、View(Widget)からBLoCに入力情報を送るときは TaskEvent
を作って送ります。
TaskEvent
は action
として create
update
delete
の3つを指定でき、それと task
を合わせて送出することで「どのTaskに何の処理するのか」を表現しています。
先に説明した通り、TaskBlocクラスには入力用と出力用のストリームがそれぞれ用意してあります。また、それらのストリームを制御する用のプライベートフィールドも定義しています。
なお、TaskBlocクラスはBlocクラスを継承していますが、この事情は後ほど説明します。
import 'dart:async';
import 'package:bloc_provider/bloc_provider.dart';
import 'package:rxdart/subjects.dart';
import 'package:sudame_todo_bloc/models/task.dart';
enum TaskEventAction {
create,
update,
delete,
}
class TaskEvent<T> {
final TaskEventAction action;
final Task task;
TaskEvent({this.action, this.task});
}
class TasksBloc implements Bloc {
static List<Task> _taskList = [];
final StreamController<TaskEvent> _inputController = StreamController();
final BehaviorSubject<List<Task>> _outputController =
BehaviorSubject.seeded(_taskList);
Sink<TaskEvent> get setTask => _inputController.sink;
Stream<List<Task>> get getTaskList => _outputController.stream;
void _createTask({Task task}) {
final int _id = _taskList.length;
_taskList.add(new Task(
title: task.title ?? '',
isCompleted: task.isCompleted ?? false,
id: _id,
));
}
void _update(Task task) {
_taskList[task.id] = task;
}
void _delete(Task task) {
_taskList.removeWhere((t) => task == t);
}
void _taskEventListener(TaskEvent e) {
if (e.action == TaskEventAction.create) {
_createTask(task: e.task);
} else if (e.action == TaskEventAction.update) {
_update(e.task);
} else if (e.action == TaskEventAction.delete) {
_delete(e.task);
}
_outputController.add(_taskList);
}
TasksBloc() {
_inputController.stream.listen(_taskEventListener);
}
@override
void dispose() async {
await _inputController.close();
await _outputController.close();
}
}
正直な話、まだ実務レベルでFlutterを書いているわけではありませんし、それほど知識のインプットもできていないので、上のコードを信じ込むことはせず、あくまで参考程度にしてください。
全体のソースはこちらにあります。
参考リンク
概要
当然ですが、BLoCはBLoCだけでは意味が無く、View(Widget)と相互にやり取りすることで機能が生まれます。
BLoCへ情報を送り込むWidgetとBLoCから情報を受け取るWidgetは一般的に異なります(同一Widgetで済むならばBLoCパターンにする必要は無いですね)。ここで必要なのは、異なるWidgetから同一のBLoCインスタンスにアクセスするということです。それぞれのWidgetが好き勝手にBLoCインスタンスを立ち上げても、ストリームが異なってしまいますので通信はできません。
異なるWidgetから同一のBLoCインスタンスにアクセスするために考えられる方法は2つあります。
- BLoCクラスをシングルトンにする
- BLoCインスタンスを一度だけ生成し、インスタンスをWidget間で受け渡す
この内、Flutterで良く採用されるのは2です。なぜ1が採用されないのか、僕の知識と調べた範囲では分かりませんでした。ご存知の方はぜひ教えていただきたいです。
2の方法では、Widgetの階層構造の上位でBLoCインスタンスを生成・保持し、下位のWidgetからアクセスすることを考えます。setStateなどでBLoCインスタンスを下位Widgetに順に伝播させていっても良いですが、階層構造が大きくなるとコードが煩雑になり、しかも伝播の流れの中にあるWidgetはその都度再ビルドされていまいます。
そんな状況を解決してくれるのが 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
クラスの子孫WidgetがFrogColor
クラスのof
メソッドを呼び出した場合、FrogColor
Widgetそのものが返ることになります。
この動作については参考リンクに詳しいので、そちらを参照してください。
先の例を続けるならば、FrogColor
Widgetの子孫Widgetからは以下のようにしてFrogColor
Widgetを呼び出すことができます。ここで context
は BuildContext
型です。例えば StatelessWidget
の build
メソッド内では 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);
final Bloc bloc = new Bloc();
static Bloc of(BuildContext context) {
return (context.inheritFromWidgetOfExactType(BlocProvider) as BlocProvider).bloc;
}
@override
bool updateShouldNotify(BlocProvider old) => bloc != old.bloc;
}
子孫クラスからは、下記のようにしてBLoCインスタンスにアクセスできます。
final Bloc _bloc = BlocProvider.of(context);
InheritedWidget
を用いることで下位Widgetに効率的にBLoCインスタンスを伝播させることには成功しました。しかしまだ問題があります。それは BLoCインスタンスを適切に生成・破棄する ということです。
使われなくなったWidget(dispose
されたWidget)に紐付いているBLoCは、ストリームを適切に閉じてあげる必要があります。ストリームを閉じない場合、Widgetは存在しないのにBLoCは生き続け(当然ストリームも生き続け)、メモリリークやバグが起こる温床となります。使わなくなったものは適切に片付けなければいけません。
Widgetが使われなくなった場合、破棄されるタイミングで State
クラスの dispose
メソッドが呼び出されます。従って、このdispose
メソッドの中でストリームを閉じる処理を呼び出せば良いことになります。
また同様に、Widgetが使われ始めるタイミングでBLoCインスタンスを生成する必要もあります。これは State
クラスの initState
メソッド内で行えば良いです。
実際には動作しませんが、気持ちとしては以下のようなコードになります。
class BlocProviderState extends State {
Bloc _bloc;
@override
initState() {
_bloc = Bloc();
super.initState();
}
@override
dispose() {
_bloc.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider(
bloc: _bloc,
child: MyHomePage(),
);
}
}
まとめ
ここまでで、BLoCインスタンスを生成し、下位Widgetに伝播し、破棄することが可能になりました。長くなったのでまとめておきます。
State
クラスのinitState
メソッド内でBLoCインスタンスを生成する
State
(StatefulWidget
) の子にInheritedWidget
を配置し、子孫たちにBLoCインスタンスを伝播させる
State
クラスのdispose
メソッド内でBLoCインスタンスを破棄する
以上を実装すれば、大規模なFutterアプリでもBLoCパターンを用いたステート管理が実現できるはずです。
参考リンク
総括
かなりの大長編となってしまいました。
僕がFlutterに初めて触れてBLoCパターンについて知りたかったことをすべて詰め込むという意気込みで書きなぐりましたが、まだ不足している情報があるかもしれません。「あれも入れたほうが良い」「ここでハマった」等がありましたら追記しますのでお声掛けください。
繰り返しますが、私は完全な初心者なので、本稿の情報には誤りが多分に含まれている可能性があります。気付き次第修正を入れますので、こちらもどうぞお気軽にお声掛けください。
お疲れ様でした。
(おまけ) 全参考リンク集
記事内に登場したすべての参考リンクを集めてみました。リンク集としてご活用ください。