🏫

【FlutterGakkai】マクロの登場で InheritedWidget の復権なるか

2024/08/01に公開

第6回 FlutterGakkai にて、「マクロの登場で Inherited の復権なるか」 というタイトルで発表をさせていただきました。

発表の様子は Youtube で公開されていますので、もし映像の方に興味がありましたら開いてみてください。

https://www.youtube.com/live/Cw-MnYAjR3o?si=rjcrirbkK2a1NZo8&t=5876

今回の発表では冒頭の茶番やライブコーディングなど、自分の中では初めての試みをいくつか取り入れてみました。いろいろとトラブルはあったものの、オフライン参加されている方々はそれなりに盛り上がっていただけたようでよかったです。

一方でオンライン参加の方には、ライブコーディング中にマイクの音声が入っていなかったり、映像がストップしてしまったりと、伝えるべき部分が伝わらない箇所があったようでしたので、改めてこの記事にまとめたいと思います。

また、時間の関係で割愛した論点もいろいろとありますので、それらについてもこの記事で補足していきたいと思います。

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 なガイドを以下に公開していますので、参考にしてみていただければと思います。

https://chooyan.hashnode.dev/series/flutter-macros

マクロを使ってみる

【宣伝】この部分については、8/5(月) 12:00 ~ の以下のオンラインイベントでいくつかのマクロ利用例のデモをしますので、そちらを見てみていただければと思います。(後日同じ内容をここに追記します)

https://yumemi.connpass.com/event/325497

InheritedWidget x マクロ

さて、そんなマクロを InheritedWidget の改善に利用するとどうなるのでしょうか。

結果から見ると、冒頭の長々しいコードは以下のように改善されます。

()
()
class ClockTheme {
}

具体的なコードはマクロクラスの作りや要件によって変わり得ますが、コード量としてはここまで削減できることが期待できます。[2]

コード量が減り、小難しい .of(context) メソッドの実装方法も意識する必要がなくなり、かなり使い勝手が良くなりそうです。

InheritedWidget と状態管理パッケージ

ではこれが実現したとき、Riverpod や BLoC などの状態管理パッケージは不要になるのでしょうか。

残念ながら、答えは No です。

確かに「 InheritedWidget の最小限の記述を減らす」ことはできるものの、実際のアプリ開発においては「最小限の記述」だけでは解決できない課題や「InheritedWidget である以上避けられない」課題に直面することが少なくありません。

たとえば InheritedWidget は単純な「破棄」ができません。破棄をしようと Widget ツリーから抜くと、Widget の深さが変わるためサブツリー全体の Element や State が破棄・再生成されてしまうためです。UI 上は「いきなり初期表示に戻った」ように見えてしまう可能性があるため、GlobalKey を使うなど何かしらの対策を入れる必要があります。

FutureStream が絡む状態の生成や更新処理も、自分で Widget ツリーの仕組みを意識しながら適切に管理してあげる必要があります。

状態は「保持」して「更新」できればそれで良いわけではない難しい課題であるため、InheritedWidget が少し使いやすくなったからといって既存の状態管理パッケージを完全に置き換えられるということにはならないと思います。

ただし、「選択肢が増える」という影響は考えられそうです。

とてもシンプルなアプリでは InheritedWidgetStatefulWidget を適切に使い分ければ過不足なく状態管理できる場合もありそうですし、riverpodbloc といった、Flutter に依存しないパッケージ単体と InheritedWidget を組み合わせて最小限の依存で要件を満たす、という選択肢も出てくるかもしれません。

このあたりは、どのような要件があるのか、どこまでをプロジェクト内の工夫で賄うのか、どこからは状態管理パッケージの機能で対応するのか、などに応じて検討する形になるでしょう。

まとめ

ここまで、 InheritedWidget とは何か、マクロとは何か、InheritedWidget をマクロで改善すると開発がどのように変わるのかについて順を追って考えてみました。

結論として、マクロの登場で状態管理がガラッと変わるようなことはなさそうなものの、「新しい選択肢」として InheritedWidget を利用したパターンが登場するくらいの影響はあるのではないかと思ったりしています。

まだまだマクロ自体が試験的な公開である以上、実際リリースされた時に何ができるのかを正確に当てることはできませんが、今この段階で触ってアイデアを溜めておくのも悪くはないかなと思います。[3]

脚注
  1. 少なくとも自分はそんな感じです。 ↩︎

  2. まだマクロ自体の問題で動作確認まではできないため、「これくらいのコードだけで同じことが実現できそう(コード生成はできた)」という程度にとどまっています。 ↩︎

  3. 触りづらいのは確かなのであまり気軽にオススメするものでもないですが、、 ↩︎

GitHubで編集を提案

Discussion