🌾

【Flutter】InheritedWidget とは何か

2020/12/04に公開

この記事は Flutter #3 アドベントカレンダー 2020 - Qiita 5 日目の記事です。


Provider パッケージを使っていると InheritedWidget を直接使うような場面はほとんど無いのではないかと思います(私は一度も使ったことがありません)。

しかし、 InheritedWidget は Flutter の「3つのツリー」 の中でもかなり特別扱いを受けている Widget です。 ThemeDefaultTextStyle, MediaQuery あたりのたびたび目にするクラスもこの InheritedWidget を継承することで任意の Widget から使えるようになっていまうす。

また、Provider パッケージが InheritedWidget のラッパーであることも考えると、この InheritedWidget を理解することによって Flutter フレームワークそのものへの理解が深まるとともに、 Provider を含む InheritedWidget を活用した様々なクラスもより活用できるようになるのではないでしょうか。

ということで、この記事では InheritedWidget と関連する周辺のソースコードを追いながら 「InheritedWidget とは何か」 を探っていきます。

参考

この記事を読む上で、以下の参考資料も併せて読むことを推奨します。

https://api.flutter.dev/flutter/widgets/InheritedWidget-class.html

InheritedWidget の API ドキュメントです。説明文やフィールドの定義に加え、リンク先の動画も併せて視聴することで、まずは InheritedWidget の基本的な使い方を理解できると思います。

https://zenn.dev/chooyan/articles/77a2ba6b02dd4f

この記事の前提知識となる、Flutter の「3つのツリー」について書いた記事です。ちょっと長くなっていますが、ここに書いた内容を理解することで、この記事の内容が 5 倍くらい理解しやすくなると思います。

本文

特別扱いされる InheritedWidget

InheritedWidget は、Flutter の3つのツリーの中でも特別扱いされている Widget です。

Flutter の描画ツリーの中心である Element ツリーを形成する Element は、基本的に自身を生成した Widget の参照を保持して1対1のペアを形成しますが、それに加えてツリーの祖先をさかのぼって見つかる全ての InheritedWidget (およびそのサブクラス)[1] への参照を保持する _inheritedWidgets というフィールド を持っています。

以下の図は、 Element ツリー上の全ての Element が、自身より祖先に存在する InheritedElement への参照を保持していることを表すイメージです。

イメージの見やすさの関係上、イメージでは 1 つの InheritedWidget, InheritedElement しか登場しませんが、 _inheritedWidgets の型は Map<Type, InheritedElement> になっていて、ツリー上に複数の InheritedWidget(を継承した Widget)があった場合はその Widget の型(Type)をキーとして、それらの Widget とペアになっている InheritedElement への参照をそれぞれ保持することができるようになっています。[2]

InheritedWidget の用途

InheritedWidget は基本的に、 祖先の Widget が持つデータを子孫の Widget で使いたい という場合に使われます。

例えば、アプリ全体のテーマ(色やフォントなど)を決定する Theme は StatelessWidget を継承した Widget クラスですが、 Theme.build が呼ばれると InheritedWidget を継承した _InheritedTheme を返却します。これが Widget ツリーの中に組み込まれて Theme を必要とする子孫の Widget から(Element を通して)参照できるようになっています。


Widget build(BuildContext context) {
  return _InheritedTheme(
    theme: this,
    child: CupertinoTheme(
      ..省略
    ),
  );
}

他にも、ログイン状態を管理するデータのように、画面に関係なくアプリ全体で使いまわしたいデータがある場合はアプリ開発者自身が InheritedWidget を継承した Widget クラスを作成することで、同じように各画面で同じデータを参照・更新することが可能になります。

なぜ InheritedWidget を使うのか

確かに、 Element ツリーを上へ上へとたどれば、指定した型の任意の Widget や Element を取得することが可能です。そしてその走査処理をするためのメソッドは Element.findAncestorStateOfType として用意されており、 Navigator.of などでも実際に利用されています。(以前の記事を参照)

しかしこの方法は、 Widget が増えてツリーが深くなればなるほど走査の計算量が O(N) で増えていくため、例えば描画のフレームごと(多いときは16ミリ秒に1回)に呼び出される可能性のある Widget.build 中で使ってしまうとツリーが深くなればなるほど描画のパフォーマンスが落ちてしまいます。

そこで、 Element が直接参照を保持することで、どれだけツリーが深くなっても計算量が O(1) で変わらず、レイアウトが複雑になってツリーが深くなってもパフォーマンスが低下しないよう設計されている、というわけです。

InheritedWidget による変更の通知

InheritedWidget を使う理由は、ツリーのどこからでも O(1) でアクセスできるから、というだけではありません。

InheritedWidget へアクセスする Element.inheritFromWidgetOfExactType の実装 を見てみると、


InheritedWidget? inheritFromWidgetOfExactType(Type targetType, { Object? aspect }) {
  assert(_debugCheckStateIsActiveForAncestorLookup());
  final InheritedElement? ancestor = _inheritedWidgets == null ? null : _inheritedWidgets![targetType];
  if (ancestor != null) {
    assert(ancestor is InheritedElement);
    return inheritFromElement(ancestor, aspect: aspect);
  }
  _hadUnsatisfiedDependencies = true;
  return null;
}

_inheritedWidgets![targetType] で Map から指定した型をキーとする InheritedElement オブジェクトを取得するだけでなく、そのあとに呼び出している inheritFromElement(ancestor, aspect: aspect)Element.dependOnInheritedElement -> Element.updateDependencies とたどって現れる InheritedElement.setDependencies の中で、

void setDependencies(Element dependent, Object? value) {
  _dependents[dependent] = value;
}

というように、先ほど取得した InheritedElement が保持する _dependents というフィールドに、呼び出し元の Element オブジェクトを格納しています。

このように、 一度でも自身を参照しようとした子 Element への参照を InheritedElement が保持しておくことで、 InheritedWidget が持つなんらかのデータに変更が入ったときに Inherited.notifyClient でその _dependents に含まれる子孫の Element に対して変更を通知し[3] 、次フレームでリビルド対象にする(_dirty フラグを立てる)、ということ行っています。

これによって、 InheritedWidget のデータが変更されたら即座にそれを UI に反映させる 、という仕組みが実現されているわけです。

まとめ

以上、ソースコードを読みながら InheritedWidget、およびそのペアである InheritedElement がツリーの中でどのような役割を果たしているのか、またどのように扱われているのかについて見てみました。

InheritedWidget は、 祖先の Widget が管理するデータを複数の子孫が効率的に参照するために使われる Widget です。また、一度参照した子孫の Widget (とペアになっている Element) を覚えておくことで、 InheritedWidget が管理するデータに変更が入ったら即座にそれを子孫の Widget に通知する役割も持っています。

それの役割を実現するために、 Element の基本設計として InheritedWidget(とそれが生成する InheritedElement)専用のフィールドが用意されていて、ツリーがどれだけ深くなっても O(1) で効率的に参照できる仕組みが用意されていることを、ソースコードを読みながら確認できました。

InheritedWidget とそれを扱う Element の実装を見てみることで、 InheritedWidget のアイデアや仕組みを理解するとともに、Flutter の3つのツリー自体の理解もより深まったのではないでしょうか。

脚注
  1. 厳密には、 Element ツリーの祖先をたどって見つかるのは Widget ではなく InheritedElement という InheritedWidget.createElement で生成される Element です。 InheritedElement が自身を生成した InheritedWidget への参照を保持しています。 ↩︎

  2. ただし Map のキーが Type のため、同じ型の InheritedWidget が複数ある場合はよりツリーの下で現れるもので上書きされます。この仕組みにより、例えば Widget ツリーの一部分を自作した Theme で囲ってあげることで、その子孫の Widget のテーマ色を変更する、といった仕組みを実現できます。 ↩︎

  3. 実際には、 Inherited.notifyClient の処理を追った先にある Element.markNeedsBuild の最後の行で _dirtytrue にし、 BuildOwner.scheduleBuildFor を呼び出すことで次のフレームでリビルド対象に入るようにしています。 ↩︎

Discussion