FlutterのBLoC(Business Logic Component)のライフサイクルを正確に管理して提供するbloc_providerパッケージの解説
[2020年2月追記] 個人的には現在はBLoCパターンおよびbloc_providerパッケージは採用していません
- 記事執筆当時の2018年末にはBLoCが数少ない有望なパターンと評価していて、その実用に必要なbloc_provider パッケージを自作した
- 2019年明けくらいから provider パッケージが進化してきて、さらにGoogle Flutterチームから推奨されるまでになって、機能の被るbloc_provider パッケージはほぼ不要な存在になった
- bloc_providerに代わって、Dispose周りだけ自動的にやってくれるprovider互換の薄いラッパーである disposable_provider を用意して、それは自ら利用している
- BLoCパターンの採用が適しているのは、個人的にはAngularDartとの共通化が求められる時くらいだと感じていて、その他の大抵の場合はprovider + ChangeNotifier などで旧scoped_modelチックに組むのが素直で良いと判断している
- これは公式の Simple app state management で勧めるやり方であり、タイトルの Simple な場合に限らずとも、providerパッケージの扱い方次第で複雑・大規模な場合でもこの延長で組めると判断している
- Streams API / RxDart 自体はまだモデル層では積極利用しているが、Widgetと絡むようなあたりではChangeNotifierに寄せた方がシンプルになる感覚を持っている
- BLoC と 「provider + ChangeNotifier」 やその他のパターンの優劣はケースバイケースであり一概には言えないが、「provider + ChangeNotifier」が「StatefulWidgetにベタ書き」の次のステップとしてのスタンダードであり、かつ適切に扱えばそのパターンで大抵の要件はスムーズにこなせる(という前提で、好みや要件に応じて他のパターンの採用・併用もありという考え)。
以下本文です。
FlutterのBLoC(Business Logic Component)をBLoC Provider によって提供する実装を各所で目にするものの、実装方法がマチマチで、僕の読む限り大抵のものは特定シーンでの挙動に不安を覚えたり扱いが難しそうだなと感じました。そこで、自分が納得できるパッケージを作ってリリースしました(現時点でドキュメントが皆無なのでこれからがんばります)。
本記事では、このパッケージを作るに当たって、BLoCのProviderがどういう責務を持ってどう扱うべきと考えたのかを説明していきます。
本質的に分かりにくいテーマなので、特に後半分かりやすく説明するのがかなり難しく感じましたが、根気よく読んでみてください。
(あるいは僕が理解を誤っているところもあるかもしれないとも思っていて、その場合はご指摘お願いします。)
そもそもBLoC(Business Logic Component)パターンとは?
BLoCの説明自体は各所で良いものが揃っているので、概要説明 + 詳細記事の紹介にとどめます。
まず、原典をしっかり把握することが大事です。こちらの2018年1月開催のDart Confが初出です。
肝は以下です。
前半のBLoCが満たすべきルール4つを日本語にすると次のようになります。
- BLoCの入出力インターフェースはすべてStream/Sinkである
- BLoCの依存は必ず注入可能でプラットフォームに依存しない
- BLoC内にプラットフォームによる分岐処理を書くことは許されない
- 以上のルールに従う限り、他の実装をどうするかは問わない
概念図としては次のようになります。あるBLoCを参照しているとあるコンポーネントがSinkで情報を流して、別のコンポーネントがStreamでそれを受け取って表示、という流れです。同じBLoCオブジェクトにアクセスさえできれば、自由に値を伝播できるであろうことがイメージできると思います。
本記事ではBLoCに関しては以上の簡単な説明にとどめますが、日本語記事としては以下など参考になります。
それでは、ここから本題の「BLoC Provider」を使ってBLoCを下位ツリーに提供する方法について書いていきます。
そもそも、BLoCパターンにProviderは必須要素ではない
まず、前提としてBLoCパターンは、上の原則を守って、BLoCを使って変更をストリームベースで伝播させるだけで成立します。
最も簡単な例を示します。
初めにBLoCを用意します。 increment
を受け付ける度に、 count
ストリームからカウントアップした数値を流すシンプルなBLoCです。ここでは 以下の理由で rxdart を使っていますが、Dart標準の Streams API だけでも大体同じような感じに書けます。
- scan で値をバッファしながら都度インクリメントしたい
- ValueObservable というStreamの派生クラスで value による最後のイベントへの同期アクセスも提供したい(後述のStreamBuilderのinitialValueにセットするため・これが無いとコールバックの初回にdataがnullのスナップショットがきて扱いにくかったりチラツキの原因になる)
ValueObservable周りについてもう少し詳しく知りたい場合は、以下の一連のツイートでも触れたので参照してみてください。
これを使う場合、メインアプリ側は次のようになります。
FloatingActionButton(FAB)が押されたら、blocのincrementが呼ばれて、そのイベントが StreamBuilder (streamに値が流れて来るたびにbuilderが呼ばれてUIを更新できるWidget) を介して伝わりボタンが押される度に次々とインクリメントされていく値を受け取って表示しています。
非常にシンプルな例ですが、これがBLoCの基本で、Providerはまだ出てきていません。
ちなみに、BLoCを使わないベタな実装だと、次のようにAppを StatefulWidget にしてStateのフィールドとして var count = 0;
などを保持した上でそれを普通にTextとして表示して、ボタンが押される度に setState(() => count++);
でインクリメントしていくことになります。
このようなシンプルな例だとどちらも大差無く、むしろStatefulWidgetでsetStateする方がトータルのコード量も少なく済んで楽に感じてしまいます(とはいえBLoCパターンにしてもそのコード量の増加は大きくなくシンプルなパターンであることも読み取れると思います)。ただ、Widgetツリー構造が深くなると、setState方式の場合は上位から下位ツリーへのデータの受け渡し・下位ツリーから上位ツリーへのイベント伝播をするためにWidgetにその都度値やコールバックを渡していく必要があって、どんどん煩雑になっていく問題を抱えています。
また、どうしても上位ツリーのStatefulWidgetでsetStateしてツリー全体をリビルドするような作りになりがちで、パフォーマンス劣化につながってしまいます(詳しくは公式ドキュメントのPerformance considerations参照)。
一方、BLoCの場合も下位ツリーへのBLoCオブジェクトの受け渡しに関しては同様の課題があります。逆にいうと、下位ツリーが上位で提供された必要なBLoCに手軽にアクセスできる手段があればこの問題は解決するということです。
ここまでまとめると、次のようになります。
- BLoCパターンを使うと、離れたツリー間に、値をストリームで流して、受け手は主に
StreamBuilder
などを使ってそれを都度UIに反映することができる StreamBuilder
などを使うとWidgetツリーの末端の本当に更新が必要なところに絞ったリビルドがしやすくなってパフォーマンス的にも有利- BLoCオブジェクトの伝播はWidgetのコンストラクター経由でできるが面倒(特に階層が増えると)
- 下位ツリーへBLoCを伝播させるベターな手段があると、BLoCパターンがより扱いやすくなる🤔
BLoC Providerで解決
そこで、BLoC Providerの出番です。
最もよく見る典型的な実装は次のようになります。簡単に説明すると、 InheritedWidget にblocを保持させておいて、 of
メソッドによってそのblocへのアクセス手段を提供しています。
(実際にはGenericsを使ってBLoCを汎用的に保持できるクラスを用意したりもしますが分かりやすくするためにそれは割愛します。)
mainアプリ側は次のようになります。先ほどと違って、Appに直接blocを渡さずに上で用意したCounterBlocProviderでAppを包む構造になっています。
Appは of
メソッド経由で上位ツリーであるCounterBlocProviderの保持するblocを取得しています。
この例の場合、シンプルなツリーですが、Appが複雑なツリーの末端であっても上位ツリーのどこかでCounterBlocProviderによって包まれている限りは of
メソッド経由で簡単にblocを取得できます。
というわけで、上で触れたBLoCオブジェクトの受け渡しが解決したかのように思われます。
BLoCのdisposeはどうやって呼ぶ?
ただ、よく考えてみると元のCounterBlocで定義しているdisposeメソッドを呼んでいないということに気付きます。disposeメソッドを呼び忘れるとstreamが閉じられずメモリリークなどの意図しないバグに繋がります。
上の例の場合はごくシンプルな例であり、ルートの runApp
内でCounterBlocを生成したものをアプリが終了するまでずっと保持し続けているため実質問題ないですが、アプリの生存期間中に破棄される可能性のあるWidget上で生成したBLoCの場合は不要になったらきちんとdisposeする必要があります。
これをきちんと管理する場合、次のMyStatefulWidgetのようにStatefulWidgetのinit/disposeなどでblocの生成・破棄が必ず対で呼ばれるようにする必要があります。
(このシンプルな例の場合わざわざMyStatefulWidget挟まずにルートで保持し続けてしまうことで済ませてしまって良いですが、実際にはもっと複雑なツリーの場合を想像してください。)
基本的な実装パターンとしてはこれでOKで、まとめると次のようになります。
- BLoCをInheritedWidget経由で提供する
- BLoCの生成箇所ではStatefulWidgetを使って適切なライフサイクル管理をする
ただ、コードを見て分かる通り、いちいち書くのが面倒なボリュームになってきました。また、インクリメントボタンとその表示だけのごくシンプルなサンプルでも、この冗長さなので、手間がかかるのはもちろん、きちんとしたライフサイクル管理がなされないコードをうっかり書いてしまうことも容易に発生し得ることが想像できると思います。
ちなみに、 Build reactive mobile apps with Flutter (Google I/O ’18) — YouTube のBLoCサンプルの このソースファイル が似たような例となっています。また、このサンプルの場合は親ツリーからコンストラクターで渡されるオブジェクトに依存しているためwidgetが差し代わったタイミングのdidUpdateWidgetでblocの再生成などしていてライフサイクル管理がさらに複雑です。(必ずしもここまでの管理が必要なわけではなく、親Widgetのデータに依存していてさらにKeyが指定されてないなどの理由でStateのWidgetが違うものに差し変わってしまう場合にそのケアが必要です。しかし、このサンプルの場合はKeyが指定されていて、かつもしKeyを省いても並び順が入れ替わったりもしないためdidUpdateWidgetでblocの再生成は不要で省いても全く問題なく動くので冗長なケアに感じています。)
こういうサンプルアプリならあまりラップせずにベタに書くのが分かりやすいでしょうが、実際の大きなアプリだともう少し楽をしたいなと感じてしまいます。
また巷のコードでは、ライフサイクル管理意識自体が抜けていて、buildで毎回BLoCをnewしていて(buildは同一画面表示中でも頻繁に呼ばれ得ます)、かつそのnewに対応するdispose処理が書かれていないものも頻繁に見かけます。
まとめると、BLoCをProviderで提供するにあたっての課題は以下の2点です。
- BLoC ごとに都度Providerのコードを書くのが面倒
- BLoCを作成したWidgetがそのライフサイクル管理をするのが面倒かつミスしやすい
bloc_provider パッケージを自作
というわけで、それらの課題をクリアしたコンパクトかつ安全にかける汎用的なBLoC Providerクラスが欲しくなってきます。けっこう探したのですが、僕が満足できるものが無く、自分で作ることにしてできたのが bloc_provider です。
(これが他のどのコードよりも優れているという意味では無く、自分がBLoCはこう扱うのが良いだろうと思っているものに沿っているという意味です。また、例えばライフサイクル周りなどで勘違いしていてNGなシーンなどもあるかもしれず、その場合は GitHub Issues などで教えていただけるととてもありがたいです。)
「BLoC用のフレームワーク」ではなく、あくまで自分が課題感を感じていた「良い感じのProviderクラスの提供」のみに徹したコンパクトなパッケージとすることを意識しました。
これを使った場合の例がこちらです:
なお、BLoCクラスはパッケージのBloc抽象クラスを実装する必要があるので、今回の例の場合は次のように変更が必要です。
class CounterBloc implements Bloc {
... @override
void dispose() async {
await _incrementController.close();
await _countController.close();
}
}
この簡単な例だと分かりにくいかもしれませんが、 BLocProvider
クラスにBLoCのライフサイクル管理を閉じ込めていて、利用者はそれを気にせず使えます。また、初めにあげた例ではCounterBlocProviderを定義していましたが、これも基本的な実装がBLocProviderに含まれていてBLoCの型パラメーター(上の例だとCounterBloc)を指定するだけでProviderの提供およびProviderからofで上位ツリーのBLoCオブジェクトを取得できるようになっています。
bloc_providerの解説
これを作るにあたって、Flutterについての本質的な理解が必要でかなり勉強になったので、bloc_providerの解説を通してそれらを共有します。
まず、bloc_providerはInheritedWidgetをラップしたStatefulWidgetとなっていて、分解すると次のような構成となっていまして、太字部分が BLocProvider
です。
StatefulWidget
└── InheritedWidget
└── 下位Widget
コード全体は次のようになっています。
(2018/12月のバージョン0.0.21時点の物です。最新のソースはリポジトリーを見てください。)
細かい要件に対応するために少し肥大化していってしまいましたが、大事なところに絞って説明していきます。
なぜInheritedWidgetが必要か?
上のソースで InheritedWidgetを継承した_Inheritedクラスを用意して挟んでいますが、それは下位ツリーでBLoCを取得するための of
メソッドを O(1) の計算量にするためです。
図にすると次のようにしたいということです。
(ちなみに、上の図のリンク先の動画は本記事を書いている途中にアップされたもので、アプローチとしては今回作ったBLoC Providerと全く同様ですが、実装の仕方には差や個人的にはしっくりこないところがありました。)
普通にツリーを順に走査していくとO(N)となってツリーの肥大化とともに重くなってしまう懸念がありますが、InheritedWidgetは以下のいずれかのメソッドでO(1)アクセスが可能です。
主な違いは以下です。
- これが呼ばれると引数に渡ってきたbuild contextが登録されてWidgetに変更があった時に下位ツリーrebuildを要求できる(updateShouldNotify で制御可能)
- そのため、didChangeDependencies 以降のタイミングでしか呼べない
ancestorInheritedElementForWidgetOfExactType
- 単にInheritedWidgetのその時点でのElementを取得するだけ
- initState タイミングでも呼べる
BloC ProviderではancestorInheritedElementForWidgetOfExactTypeを使っていますが、次の理由からです。
- BLoCオブジェクトは後述の通りStatefulWidgetのStateでずっと同じオブジェクトを保持していて、InheritedWidgetで持っているBLoCもずっと不変なため下位ツリーに伝播させる必要のある変更は起こりえないだろう(updateShouldNotify も意味をなさないので常にfalseでOK)
- そもそもBLoCはオブジェクト自体の変更ではなくストリームの伝播で変更を伝えていくパターンであり、オブジェクト自体の変更にも頼った作りにするべきではない
- BLoCオブジェクトをinitStateタイミングで取得してstreamをlistenできると便利(例えばあるイベントが流れてきたら画面遷移したりSnackBarを出したいときなど)
つまり、InheritedWidgetのO(1)アクセス可能な特性は享受しつつ、変更伝播機能は無効化しています。
色々なサンプル上で動かして意図通りに動いたので、この考えで問題無いだろうと思っていますが、もし問題あるパターンが指摘されたらinheritFromWidgetOfExactType実装に変えようとは思っています🤔
InheritedWidgetを挟まないとどうなる?
ancestorWidgetOfExactType でも型の合致する上位ツリーへのアクセスができますがO(N)となってしまいます。シンプルなサンプルなら全く差が体感できないですが、規模が大きくなるにつれて徐々に問題が顕在化していくはずなので、避けた方が良いと思います。
Google I/O 2018でのBLoCに触れた発表に対応するサンプルコードでもInheritedWidgetを使っていて、これが基本なはずです。
これでBLoCにO(1)でアクセス可能な層が用意できたので、次にInheritedWidgetの上の層について説明していきます。
StatefulWidgetで包む
InheritedWidgetの上の層では、BLoCのライフサイクルを管理するためにStatefulWidgetで包みます。
また、Widgetのコンストラクターに直接BLoCをnewして渡してしまうとリビルドの度にBLoCが再生成されてしまってよろしくありません。
(その場合もStateのbuildメソッドなどで古いBLoCのdispose管理自体は可能ですがBLoCオブジェクトが頻繁に再生成されてしまう作りは良くないと考えています。)
BLoCはStateの寿命と合わせるべきだと考えていて、StateのinitStateでBLoCを作成し、StateのdisposeでBLoCのdisposeを呼ぶ作りにしました。このため、利用する側のWidgetではStateのinitStateのタイミングで唯一呼ばれるcreatorコールバック内で必要なBLoCを生成するようになっています。
リスト内の要素が入れ替わるケースなどでその要素配下のBLoC Providerの保持しているBLoCを追従させるためにはKey指定が必要なことがある
上述の通りBLoCはStateに追従したライフサイクルなので、Stateが違うWidgetに紐づいてしまうようなコードを書くとBLoCとWidgetも不合致が生じてしまいます。
典型的な例としては、リスト内の要素のWidgetが入れ替わった時に誤った実装をするとそれに追従できずに表示が入れ替わらないという問題があります。
対処としては次の2通りがあります。
- 要素のWidgetに適切なKeyを指定
- State自体がdidUpdateWidgetで新しいWidgetに合わせて自身の状態を更新
BLoC Providerの場合、上述の通りStateの生存期間中はBLoCは不変というポリシーで作ってあるので、この場合は前者のKeyによる指定が必要です。とはいえ、Keyは必ずしもいつも必要ではなく、ツリー構造とWidgetの型だけで正しく追従できるものなら省略でOKですし、その場合が大半です。
このあたりは次の動画で分かりやすく説明されています。また、この動画でも対策として前者のKeyによる方法にしか触れられておらずそれが基本かなと思っています。その方が実装の複雑さ・処理量ともに抑えられることが多いはずです。
ちなみに、スワイプしてリスト要素を消すDismissible Widgetを扱う際にKeyが必須なのも同様の理由です。
というわけで、以上のInheritedWidgetをラップしたStatefulWidgetによって、利用側はライフサイクル周りについて特に何も考えずにBLoC ProviderのcreatorコールバックでBLoCを生成するだけで、Providerが適切なタイミングでよしなにdisposeしてくれて、下位ツリーでもBLoCオブジェクトをofによってO(1)で取得できるようになりました。
個人的には、 bloc_provider パッケージはBLoCパターンを使う上でオススメできるものになったと感じています(僕自身常に使っています)が、実際には使わないにしても上記解説はFlutter理解につながるのではと思います。
bloc_provider パッケージはその他細かい要件にも耐えうるように作ったつもりですが、それについてはとりあえず以下など見てください。
サンプルにはざっくり次のようなパターンが盛り込まれています。
- 入れ子のBLoC
- BLocProvider<Bloc>を直接使わずに継承して別途用意(一々型パラメーター指定せずに済んで書きやすくなる)
- ルートから参照されておらずアプリの生存期間中にdisposeされうるBLoC
- InheritedWidgetによって実装した上位ツリーのService ProviderからBLoCにDI(依存性の注入)
BLocProvider.builder
という名前付きコンストラクターによって、child引数の代わりにbuilder引数を指定して、そのコールバックに渡されるblocを即使って子ツリーを生成- BLoC生成時に上位ツリーのBLoCのストリームと繋げた(listen)際のsubscriptionをBLoCのdisposeタイミングと一緒に破棄