【FlutterGakkai】マクロの登場で InheritedWidget の復権なるか
第6回 FlutterGakkai にて、「マクロの登場で Inherited の復権なるか」 というタイトルで発表をさせていただきました。
発表の様子は Youtube で公開されていますので、もし映像の方に興味がありましたら開いてみてください。
今回の発表では冒頭の茶番やライブコーディングなど、自分の中では初めての試みをいくつか取り入れてみました。いろいろとトラブルはあったものの、オフライン参加されている方々はそれなりに盛り上がっていただけたようでよかったです。
一方でオンライン参加の方には、ライブコーディング中にマイクの音声が入っていなかったり、映像がストップしてしまったりと、伝えるべき部分が伝わらない箇所があったようでしたので、改めてこの記事にまとめたいと思います。
また、時間の関係で割愛した論点もいろいろとありますので、それらについてもこの記事で補足していきたいと思います。
InheritedWidget とは
InheritedWidget
は、アプリの「状態」を保持する役割をもつ Flutter の標準 Widget です。
同じく状態を保持する役割を持つ StatefulWidget
はその Widget に閉じた状態を管理する一方、InheritedWidget
はアプリ全体に対してひとつの状態を共有できる、app state の管理を担当します。
また、Widget ツリーがどれだけ深くなってもパフォーマンスが落ちないように O(1) でアクセスできる 仕組みが備わっていたり、保持する状態が変更された場合に その値を利用している Widget をリビルドする 仕組みを持っていたりと、 宣言的に UI を構築する Flutter には不可欠な Widget と言えるのではないかと思います。
標準の Widget では MediaQuery
DefaultTextStyle
Theme
などがこの InheritedWidget
を内部で利用していて、「使う」側としてはアプリ開発する上で必ず触れている Widget と言えそうです。
InheritedWidget のボイラープレート問題
しかしご存知の通り、 InheritedWidget
を継承したクラスを自分で実装する機会は通常のアプリ開発ではあまりありません。代わりに Riverpod や BLoC などの状態管理パッケージを利用することがほとんどなのではないでしょうか。
これは、 InheritedWiget
を利用するために必要な「お決まり」なコードがとても多い(ボイラープレート問題)ことが原因のひとつと言えます。
試しに、TinyClock
という自作の Widget に適用すべき ClockThemeData
を保持する InheritedWidget
を作成してみましょう。名前は ClockTheme
とします。
1. InheritedWidget を継承したクラスを定義する
InheritedWidget
を自作する場合、InheritedWidget
を継承した Widget クラスを定義します。
class ClockTheme extends InheritedWidget {
}
InheritedWidget
は親クラスのコンストラクタに child
を渡す必要があります。
また、InheritedWidget
自体はクラス内で状態を生成したり変更したりする機能はないため、保持すべき ClockThemeData
はコンストラクタの引数で受け取り、フィールドに保持する形で実装しましょう。
class ClockTheme extends InheritedWidget {
const ClockTheme({
super.key,
required this.data,
required super.child,
});
final ClockThemeData data;
}
2. 必要なメソッドをオーバーライドする
InheritedWidget
のサブクラスは updateShouldNotify
メソッドをオーバーライドで実装する必要があります。
このメソッドは、InheritedWidget
自身がリビルドされたタイミングで、その InheritedWidget
を利用している子孫 Widget をリビルドするかどうか を制御するメソッドです。
通常は保持する状態(ここでは data
)が変わった場合は true
と実装する場合が多いと思います。
class ClockTheme extends InheritedWidget {
const ClockTheme({
...
});
bool updateShouldNotify(InheritedWidget oldWidget) {
return data != (oldWidget as ClockTheme).data;
}
}
3. .of() メソッドを用意する
子孫が InheritedWidget
にアクセスするためには、context
が持つ dependOnInheritedWidgetOfExactType<T>()
というメソッドを呼び出す必要があります。
ただし、これを直接呼び出すのはメソッド名が長い、<T>
に何を指定するのが適切なのか利用側が考えるにはコストがかかるなどの理由から、適切な呼び出し方を実装した .of()
メソッドを InheritedWidget
側で用意してあげるのが一般的です。
class ClockTheme extends InheritedWidget {
const ClockTheme({
...
});
static ClockThemeData of(BuildContext context) {
final theme =
context.dependOnInheritedWidgetOfExactType<ClockColorTheme>();
return theme?.data ?? ClockThemeData();
}
...
}
いろいろ書いて、これで完成です。完成したコードの全文を改めて記載します。
class ClockTheme extends InheritedWidget {
const ClockTheme({
super.key,
required this.data,
required super.child,
});
final ClockThemeData data;
static ClockThemeData of(BuildContext context) {
final theme =
context.dependOnInheritedWidgetOfExactType<ClockTheme>();
return theme?.data ?? ClockThemeData();
}
bool updateShouldNotify(InheritedWidget oldWidget) {
return data != (oldWidget as ClockTheme).data;
}
}
4. 利用する
InheritedWidget
は子孫から参照される Widget ですので、参照したい Widget よりも祖先に配置してあげる必要があります。とりあえず極端に runApp()
に渡す MyApp()
の親としてあげましょう。
runApp(
ClockTheme(
data: ClockThemeData(),
child: MyApp(),
),
);
こうすることで、子孫の Widget(今回は TinyClock
)からは以下のようにアクセス可能です。
Widget build(BuildContext context) {
final theme = ClockTheme.of(context);
}
どこかで見たような形になりましたね。
5. 状態を更新する仕組みを作る
さて、これで終わりかというとそうではありません。ここまででやったのはあくまで「状態を保持する」Widget を作ったところまでです。つまり、「状態を更新する」ことがまだできません。
InheritedWidget
も Widget である以上 immutable(不変)で、かつ StatelessWidget
のように build()
メソッドで何か UI を構築するということはありませんので、誰か別の Widget から新しい ClockThemeData
を受け取ってリビルドしてもらう 必要があります。
今回はその役割を StatefulWidget
で実装します。名前は ClockThemeMaintainer
としておきましょう。
class ClockThemeMaintainer extends StatefulWidget {
const ClockThemeMaintainer({super.key, required this.child});
final Widget child;
State<ClockThemeMaintainer> createState() => ClockThemeMaintainerState();
}
class ClockThemeMaintainerState extends State<ClockThemeMaintainer> {
var _theme = ClockThemeData();
Widget build(BuildContext context) {
return ClockTheme(
data: _theme,
child: widget.child,
);
}
}
いったんよくある StatefulWidget
の雛形に、子 Widget として先ほどの ClockTheme
を配置する build()
メソッドを実装しました。data
として渡す ClockThemeData
は State クラスのフィールドに可変な形で保持しておきます。
次に、この _theme
フィールドを更新するメソッドを用意しましょう。
class ClockThemeMaintainer extends StatefulWidget {
...
}
class ClockThemeMaintainerState extends State<ClockThemeMaintainer> {
var _theme = ClockThemeData();
void update(ClockThemeData data) {
setState(() {
_theme = data;
});
}
Widget build(BuildContext context) {
...
}
}
引数に受け取った ClockThemeData
を _theme
に代入して setState()
を呼び出すだけの、簡単な作りになっています。
6. 子孫から StatefulWidget にアクセスする
さて、ここで問題になるのが、この update
をどうやって呼び出すのか という問題です。
この StatefulWidget
はあくまで Widget ツリーの上の方に配置して状態の更新をする 役割で、「テーマ変更ボタン」等の UI を構築するのは別の子孫の Widget の役割です。
ClockThemeMaintainerState
のような State オブジェクトにアクセスするためのメソッドとして findAncestorStateOfType<T>()
が context
に用意されていますが、こちらも使う側が呼び出すにはハードルが高いため、.of()
メソッドを用意して簡単にアクセスできるようにしてあげましょう。
class ClockThemeMaintainer extends StatefulWidget {
...
static ClockThemeMaintainerState of(BuildContext context) {
return context.findAncestorStateOfType<ClockThemeMaintainerState>()!;
}
}
class ClockThemeMaintainerState extends State<ClockThemeMaintainer> {
...
}
先ほどの InheritedWidget
とは違い、State を取得するための findAncestorStateOfType<T>()
は計算量が O(n) になるため、Widget ツリーが深くなればなるほど速度は低下します。
つまり build()
メソッド内での利用は不適切で、主にボタンタップ時などの特定のタイミングで一度だけ必要な場合に利用する想定になっています。
ということで更新のための StatefulWidget
も実装できましたので、Widget ツリーの根本に配置しましょう。MyApp
の親を ClockTheme
から ClockThemeMaintainer
に変更します。
runApp(
ClockThemeMaintainer(
child: MyApp(),
),
);
ここまでできたら、画面内に用意したボタンをタップしたら TinyClock
の色を更新するコードを以下のように実装してあげます。
onTap: () {
ClockThemeMaintainer.of(context).update(selectedData);
}
こちらもどこかで見たことのあるような形になりましたね。Navigator.of(context).push()
などが同じ仕組みで成り立っています。
ということで、ここまでしてようやく「Widget ツリーの離れた位置にある InheritedWidget の状態を更新する」ことができるようになりました。
もちろん仕組みを理解できさえすれば細かくコードを調整できるメリットはあるものの、基本的には 「お決まり」なコードを書くだけ の作業になります。これが InheritedWidget
そのままの利用が避けられがちな理由のひとつと言えるでしょう。
マクロとは
では、いったん InheritedWidget
は横に置いてマクロ(Dart macros)について説明したいと思います。
マクロはひとことで説明すると 「Dart コードを生成する Dart コード」 です。
同じような仕組みとして build_runner
がありますが、こちらは
- 明示的なコマンドの実行が必要
- 生成されたコードはファイルとして保存される
という特徴がある一方、マクロは 1 文字タイプするごとに再生成され、生成されたコードはファイルに保存されません。
「コードを機会的に生成する」ということは「機械的に書かなければいけなかったボイラープレートコード」の記述の自動化による開発効率向上が期待できると言えるでしょう。
マクロの現状
とはいえ、マクロはまだ試験的な機能のため、試すには Flutter の master
チャンネルを指定したり実行時に --enable-experiment=macros
オプションをつけなければならなかったり、ドキュメントが整備されていなかったり未実装な部分があったりと、まだまだ快適に利用できるとは言えない状態です。
今の時点でマクロの実装を試したいのであれば、まずは 公式ドキュメント を読んで環境構築し、実装サンプル を見て実装方法の雰囲気を掴み、feature-specification を読んで考え方を理解する、という流れで試行錯誤することになるのではないかと思います。[1]
「とりあえず手を動かしたい」という場合は、手前味噌、且つ英語ではありますが、マクロ開発の Step-by-Step なガイドを以下に公開していますので、参考にしてみていただければと思います。
マクロを使ってみる
【宣伝】この部分については、8/5(月) 12:00 ~ の以下のオンラインイベントでいくつかのマクロ利用例のデモをしますので、そちらを見てみていただければと思います。(後日同じ内容をここに追記します)
InheritedWidget x マクロ
さて、そんなマクロを InheritedWidget
の改善に利用するとどうなるのでしょうか。
結果から見ると、冒頭の長々しいコードは以下のように改善されます。
()
()
class ClockTheme {
}
具体的なコードはマクロクラスの作りや要件によって変わり得ますが、コード量としてはここまで削減できることが期待できます。[2]
コード量が減り、小難しい .of(context)
メソッドの実装方法も意識する必要がなくなり、かなり使い勝手が良くなりそうです。
InheritedWidget と状態管理パッケージ
ではこれが実現したとき、Riverpod や BLoC などの状態管理パッケージは不要になるのでしょうか。
残念ながら、答えは No です。
確かに「 InheritedWidget
の最小限の記述を減らす」ことはできるものの、実際のアプリ開発においては「最小限の記述」だけでは解決できない課題や「InheritedWidget
である以上避けられない」課題に直面することが少なくありません。
たとえば InheritedWidget
は単純な「破棄」ができません。破棄をしようと Widget ツリーから抜くと、Widget の深さが変わるためサブツリー全体の Element や State が破棄・再生成されてしまうためです。UI 上は「いきなり初期表示に戻った」ように見えてしまう可能性があるため、GlobalKey
を使うなど何かしらの対策を入れる必要があります。
Future
や Stream
が絡む状態の生成や更新処理も、自分で Widget ツリーの仕組みを意識しながら適切に管理してあげる必要があります。
状態は「保持」して「更新」できればそれで良いわけではない難しい課題であるため、InheritedWidget
が少し使いやすくなったからといって既存の状態管理パッケージを完全に置き換えられるということにはならないと思います。
ただし、「選択肢が増える」という影響は考えられそうです。
とてもシンプルなアプリでは InheritedWidget
と StatefulWidget
を適切に使い分ければ過不足なく状態管理できる場合もありそうですし、riverpod
や bloc
といった、Flutter に依存しないパッケージ単体と InheritedWidget
を組み合わせて最小限の依存で要件を満たす、という選択肢も出てくるかもしれません。
このあたりは、どのような要件があるのか、どこまでをプロジェクト内の工夫で賄うのか、どこからは状態管理パッケージの機能で対応するのか、などに応じて検討する形になるでしょう。
まとめ
ここまで、 InheritedWidget
とは何か、マクロとは何か、InheritedWidget
をマクロで改善すると開発がどのように変わるのかについて順を追って考えてみました。
結論として、マクロの登場で状態管理がガラッと変わるようなことはなさそうなものの、「新しい選択肢」として InheritedWidget
を利用したパターンが登場するくらいの影響はあるのではないかと思ったりしています。
まだまだマクロ自体が試験的な公開である以上、実際リリースされた時に何ができるのかを正確に当てることはできませんが、今この段階で触ってアイデアを溜めておくのも悪くはないかなと思います。[3]
Discussion