InheritedWidget を完全に理解する 🎯
Flutterフレームワーク・providerパッケージを支える重要なWidget
InheritedWidgetは、Flutterフレームワーク自身を構成する根幹となるWidgetの1つです。また、通常のアプリケーションコードでも、自前で継承したWidgetを組むかあるいはそれをラップしたproviderパッケージなど経由でよく使われています。
ただ、InheritedWidgetの役割や正確な挙動を掴むのが意外と難しいため、本記事ではそれを丁寧に説明していきます。分かりやすい説明が不足している問題だと思っていて、InheritedWidgetは本質的にはシンプルです。
TL;DR
InheritedWidgetとは何かというと、ざっくり以下のように説明できます。
- 下位ツリーから直近のInheritedWidgetに
O(1)
でアクセスできる - 必要な時に限定して変更を下位ツリー内の特定のWidgetのみに伝播する(リビルドを発生させる)ことができる
- その特性を活かすためにStatefulWidgetとセットで使われることが多い
- 最近はアプリケーションコードで直接使うことは少なくなってきて、代わりにそれをラップしたproviderパッケージなど経由で使うことが多い
イメージだけでは実際に使いこなすことができないので、以下、具体的になぞっていきます。最後に、InheritedWidgetとProviderの関係についても説明します。
また、本記事で扱ったサンプルのリポジトリはこちらです:
InheritedWidgetは TL:DR に書いた通り、以下の2つの機能を持っていますが、まずはそれを分けて順番に理解していくのが分かりやすいです。
- 下位ツリーから
O(1)
でアクセスできる - 必要な時に限定して変更を下位ツリー内の特定のWidgetのみに伝播する(リビルドを発生させる)ことができる
下位ツリーから O(1) でアクセスできる特性を理解する
FlutterのUIはWidgetツリーで構成されます(厳密にはElementおよびRenderObjectツリーが関係しますが)が、上位ツリーおよび下位ツリーへのアクセスも可能です。
- 下位ツリーへのアクセス: GlobalKeyを下位のStatefulWidgetのkeyに指定してそのStateにアクセス
- 下位ツリーからのアクセス: BuildContextのfindAncestorWidgetOfExactTypeで上位ツリーの所望の型のWidgetにアクセス
後者のfindAncestorWidgetOfExactTypeは、普通にツリーを順に走査していく方式ゆえに計算量がO(N)となってツリーの肥大化とともに重くなってしまう懸念もあって、あまり積極的に使われず、どうしてもそれしか手段のない時に仕方なく使います(僕は実際に使わざるを得なくなった場面はまだありません)。
一方、InheritedWidgetは以下のいずれかのメソッドでO(1)アクセスが可能なためどんなに複雑なアプリになっても重くなる心配は不要です。
主な違いは以下です。
dependOnInheritedWidgetOfExactType
- これが呼ばれると引数に渡ってきたBuildContext(Element)が登録されてWidgetに変更があった時に下位ツリーのリビルドを発生させられる(updateShouldNotify で間引き可能)
- この監視の仕組みを実現するため、didChangeDependencies 以降のタイミングでしか呼べない
getElementForInheritedWidgetOfExactType
- 監視はせずに単に直近のInheritedWidgetのその時点でのElementを取得するだけ
- initState タイミングでも呼べる
これを利用したmain.dartファイルの例としては以下です。
ざっと解説すると、次のようになります。
- InheritedWidgetを継承した
_Inherited
を用意してそこにmessageフィールドを持たせる - さらに、慣例的に
of
メソッドを生やして上述のO(1)でのアクセス方法を提供 - 変更監視せずにアクセスだけするgetElementForInheritedWidgetOfExactTypeを利用してさらにInheritedWidgetのupdateShouldNotifyは常にfalseを返すようにして変更伝播機能を一切無効化(詳しくは後述)
_Message
をその_Inherited
で包みつつmessageプロパティには🐶
をセット_Message
は引数無しで上位ツリーから直接値を渡されていないが、_Inherited
のof
メソッド経由でセットされたmessageにアクセスできる
Flutterでは、例えば Theme.of(context)
とすることで、上位ツリー(具体的には大抵MaterialApp内のTheme)のThemeData にアクセスできるようになっていますが、これもThemeがInheritedWidgetで自身を包みつつof
メソッドでO(1)のアクセスを提供することで成り立っています。
こうやって上位のInheritedWidgetにアクセスできることで何が嬉しいかというと、主に以下の点です。
- 一々Widgetのコンストラクターの引数などで値を受け渡さずにそのデータを保持するInheritedWidgetで包むだけで良くなって、取り回ししやすくなる(いわゆるDependency Injection的にも使える)
- InheritedWidgetの保持するデータが変更された時にそれに追従することができる(次で述べる内容)
必要な時に限定して変更を下位ツリー内の特定のWidgetのみに伝播
次に、InheritedWidgetの変更伝播の仕組みについてです。ここがけっこうややこしく、かつ勘違いしやすいところです。
まず、前提として、Flutterは特定のStatefulWidgetのStateでsetStateを実行するとリビルドがなされますが、必ずしも下位ツリー全体がリビルドされるわけではありません。同一インスタンスのWidgetの場合は不変であることが保証されるため、リビルドはされません。具体的には主に次のものが相当しますが、どれも同一インスタンスのWidgetとするための手段に過ぎず本質的にはどれも一緒です。
- const を指定したWidget
- StateのフィールドにキャッシュされたWidget
- 上位ツリーからchild引数などで渡されて使い回されるWidget
詳しくはこちらを読んでみてください。const指定漏れを防ぐAnalysis Optionsについても紹介しています。
逆に言うと、これを満たさない場合は、上位ツリーがリビルドされる度に毎回それに巻き込まれてリビルドされてしまいます。この巻き込まれリビルドがなされた場合は、何もケアしなくてもリビルドされるため、わざわざInheritedWidgetで変更伝播する意味がなくなってしまいます。
つまり、InheritedWidgetの変更伝播の仕組みは、上述のリビルド抑制の仕組みを何かしら使って無駄なリビルドを塞き止めた上で、さらに必要な状態の変更のみをバイパスして伝播してリビルドする、という二段構えのものなのです。
これを理解していないと、「あれ?InheritedWidgetで変更伝播を無効化してるのになぜか変更がUIに反映される🤔」のように混乱することになって、「一体InheritedWidgetの変更伝播はどういう時に意味があるのだろう?🤔」とよく分からない感じになってしまいます。
こう書くとまどろっこしく感じるかもしれませんが、まずFlutterでお行儀良く書いていると特別な手間をかけなくとももわりと自然にリビルド抑制の仕組みが効きます。特にconst指定したWidgetによって塞き止められることが多いはずです。逆に言うと、これまでWidgetにconst指定をしてこなかった場合、きちんと指定する習慣を付けることをお勧めします。
よく「Widgetツリーのルートで近いところでsetStateすると全体のリビルドがされてパフォーマンス的に良くないので避けるべき」のような意見を聞きますが、必ずしもそうではなくWidgetにconst指定を付けられるところにはそのケアをすることで大抵問題にならない程度にまでリビルドは抑制されます(いずれにせよ、ルートのStatefulWidgetでsetStateして状態の更新を伝えるというのは、状態管理のしやすさという観点で望ましくないことが多いですが)。また、Widgetのリビルドは軽量オブジェクトを用いた差分検出でありその結果、高コストな再描画はFlutterフレームワークが間引いてくれることが多いため、そういう観点でもWidgetリビルドを初めから手間かけて最小化する必要はないです。
ちょっと前置きが長くなりましたが、具体的なコードをお見せします。動作としては、FizzBuzzで、 _count
がインクリメントされていくについてその数字に応じて表示メッセージが次のように変わります。
- 15の倍数:
Message: FizzBuzz
- 3の倍数:
Message: Fizz
- 5の倍数:
Message: Buzz
- それ以外:
Message: -
ポイントは以下です。
_count
を管理するために_HomePage
をStatefulWidgetにする- 監視する方の dependOnInheritedWidgetOfExactType でアクセス
- updateShouldNotify ではmessageが変わった時のみ
true
を返すようにする(このため、setStateされてもMessage: -
のまま不変なタイミングでは_Message
Widgetのリビルドはされない) _Message
Widgetにはconst
を指定して無駄な巻き込まれリビルドを抑制
最後の const
指定については、もしこれが無いと監視されない方の getElementForInheritedWidgetOfExactType を使ったりupdateShouldNotifyで常にfalseを返すようにしていても、UIの挙動としてはsetStateからの巻き込まれリビルドによって常に画面更新がされてしまいます。
もう少ししっかり無駄なリビルドを抑制
上のGIFを見て分かる通り、 _HomePageState
の粒度が大きく Scaffold
が毎回リビルドされているなど、まだ無駄があります。上述の通り、通常そこまで神経質になることはないものの、以下の方針で書き換えて StatefulWidget + InheritedWidget部分を状態管理に必要なところだけに絞って、UI表示に必要な部分を別Widgetとして切り出して child
引数で外から受け取るようにするとリビルドを最小限にできます。
上位ツリーからchild引数などで渡されて使い回されるWidget
詳細は割愛しますが、具体的にはこちらのサンプルを見てください:
https://github.com/mono0926/inherited_widget_explanation/blob/master/lib/main_fizzbuzz_optimized.dart
というわけで以上がInheritedWidgetのほぼ全てです。InheritedWidgetというより、Flutterフレームワークのリビルド周りの仕組みをきちんと理解する部分が難しいところな気がします🤔
公式ドキュメントや解説動画など良い資料が揃っているので、それらも併読ください。
また、InheritedWidgetを継承して特定シーンで少し使いやすくしたInheritedModelもFlutterフレームワークに含まれていますが、便利なproviderパッケージが登場・普及しつつある今ではアプリケーションコードで使うことはほとんど無いかなと思います。なので、「そういうものもあるのか」程度の認識で問題ない気がします。
providerパッケージとは?
Pragmatic State Management in Flutter (Google I/O’19) で紹介・推奨されたことで注目を集めたパッケージです。
providerパッケージは簡単に言うと、次のようなものです。
- InheritedWidget を使いやすくしたラッパー
- InheritedWidget を StatefulWidget で包んで様々な状態管理を簡単にできるようにしてくれるもの
InheritedWidgetを使いやすくしたラッパー
まず、ここから例を元に簡単に説明します。上のFizzBuzzサンプルのInheritedWidgetの絡むところをproviderで書き換えると次のようになります。
- 自前でInheritedWidgetを継承していたクラスが不要になり、
Provider.value
で下位ツリーにメッセージを渡せるようになった - 値へのアクセス・監視も
Provider.of
だけで良い
providerを利用することで表面上はすっきりしてコード量が減りましたが、内部実装的にはInheritedWidgetを使った例とほぼ等価です(providerが汎用的な作りになっているが故に色々処理が挟まっている程度の差)。
ただ、これもStateがsetStateの度にScaffoldなどを毎回リビルドしてしまっているので、UI表示に必要な部分を child
引数として切り出した パフォーマンス観点でベターな別解も載せておきます: https://github.com/mono0926/inherited_widget_explanation/blob/master/lib/main_provider_value_optimized.dart
しかし、Providerを使う場合、このように自前のStatefulWidgetと組み合わせるような使い方は通常あまりしません。Provider自体がその機能を含んでいるからです。次はそのオーソドックスな使い方をしてみます。
InheritedWidgetをStatefulWidgetで包んで様々な状態管理を簡単にできるようにしてくれるもの
というわけで、同じくFizzBuzzサンプルをさらに書き換えていきます。
- StatefulWidgetをStatelessWidgetに変えてその状態管理をproviderに任せる
- 状態管理としては、ValueNotifierを継承した
_FizzBuzz
クラスを用意して、 providerパッケージのChangeNotifierProvider
で包んで下位ツリーに受け渡す(このbuilderで初期化されたあとProvider内部のStatefulWidgetで_FizzBuzz
インスタンスは保持される) - この例のようにChangeNotifierProviderのデフォルトコンストラクタを使った場合、builder引数に渡したValueNotifierなど(ChangeNotifierのサブクラス)のdisposeメソッドを適切なタイミングで自動的に呼んでくれるのでメモリリークの明示的なケアが不要
- 更新する際に
increment()
メソッドを呼ぶ際は、Provider.of<_FizzBuzz>(context, listen: false)
のように監視は抑制した方がパフォーマンス的にベター Provider.of<_FizzBuzz>(context).message
で表示メッセージを監視- setStateが呼ばれている箇所が消えたが、それはValueNotifierのvalueが変更されると内部的にnotifyListeners()が呼ばれて、それをChangeNotifierProviderが検知してProvider.ofで監視しているWidgetに伝えて(内部のStateでsetStateして)リビルドされるため
- また、このsetStateはProviderの内部で呼ばれているため、child引数として外から渡された
_HomePage
は使い回されるのでconst
指定の有無に関わらず巻き込まれビルドのようなことは起こらずに済む
StatefulWidgetがアプリケーションコードからは無くなって、より宣言的で見やすいコードとなりました。より高度・複雑な状態管理も基本的にはこの延長でできます。StatefulWidget/InheritedWidgetをしっかり理解した基礎が整った上で、providerに触れていると使い方の応用がとてもしやすくなるはずです。
まずは今回のサンプルと同様にValueNotifier/ChangeNotifierを継承したモデルを用意してChangeNotifierProviderで下位ツリーに渡してProvider.ofで監視するパターンで慣れるのがオススメです。これはかつて定番パッケージの1つであったscoped_modelとほぼ同じアプローチです。
他にもいろいろなProviderが用意されていますが、以下の記事やドキュメントなど見ながら色々試してみるが良いです。
また、最近シリーズものの詳細解説記事も登場したので、より深く知りたい場合はそれもオススメです。
InheritedWidget・StatefulWidgetとproviderパッケージの関係を整理
以上の説明で大体掴めたかと思いますが、providerはFlutterフレームワークに含まれるInheritedWidget・StatefulWidgetの以下の機能を簡単に利用できるようにするための便利なラッパーパッケージです。
- A: InheritedWidgetによるO(1)のアクセス
- B: InheritedWidgetによる変更伝播
- C: StatefulWidgetによる状態・リソース管理
具体的には、Flutterにおける状態管理パターンの道具として以下のように使われることをイメージすると分かりやすいのではと思います。
- scoped_modelチックなパターン: A + B + C + ValueNotifier/ChangeNotifierなど
- BLoCパターン: A + C + Streams/RxDart
さらにどのパターンかに関わらず、A(+B)の特性を利用して、DI(Dependency Injection)にも使えます。
ちなみに、Widgetツリーに依存しないアプリのルートに置くサービスクラス群のようなもののDIには必ずしもProvider使う必要もなく、get_it や google/inject.dart(コンパイルタイムDI)などの選択もありだと思います。特にget_itは扱いが簡単です。もちろんパッケージ使わずに自前コードだけで済ませることもできます。providerだけで済ませるシンプルさも良いと思うので、どう書くのが良いかはケースによって迷うところですが🤔
余談
個人的には、2018年12月頃にBLoCの受け渡し用に扱いやすくInheritedWidgetをラップしたクラスが欲しいと思ってパッケージなど探している時にproviderパッケージが目に入ったので、まだまだ無名の初期から知っていました。その時は、かなり機能に乏しく単なるInheritedWidgetのGenerics版みたいな感じでBLoC用にそのまま使えるものではなかったので、今のProviderのBLoC特化版とも言える bloc_provider を自作しました。
しかし、その後providerの機能がどんどんリッチになってほぼbloc_providerを包含するようなものになってさらにFlutter公式が推奨するような存在にまでなったため、BLoC用にはproviderを内部実装にしたごく薄いラッパーの disposable_provider を作り直して最近は基本的にそちらを使っています。
つまり、providerは僕が以前から感じていた課題感をちょうどよく埋めてくれるパッケージということで、とても気に入って使っています。自作のbloc_providerは存在意義がほとんどなくなってしまったと思っていますが、採用プロジェクトでは今後も問題なく使えるはずで、仮にもしFlutterの破壊的変更などで使えなくなったらそれを直すくらいはします。
以上、InheritedWidgetとそれをラップしたproviderの説明でした 🎯
はじめの方にも貼りましたが、サンプルリポジトリがあるので、不明点はこれを弄って動作確認とかすると捗ると思います🐶