【Flutter】2つの of(context) から理解する 3つのツリー
Qiita のアドベントカレンダー "Flutter #2" に投稿する予定の「【Flutter】of(context) を丁寧に理解する」記事を書くための断片的な情報です。
ひとつの記事としてまとめて投稿でき次第(12/3 に投稿予定)、このスクラップを Close する予定です。
書く予定の内容
- はじめに
- Navigator.of(context) を少し読んでみる
- 3つのツリー(特に Element tree)について
- Navigator.of(context) を理解する
- Theme.of(context) を読んでみる
- InheritedWidget について
- Theme.of(context) を理解する
- まとめ
参考資料
Youtube
公式ドキュメント
ソースコード
日本語の記事
- https://engineer.recruit-lifestyle.co.jp/techblog/2019-12-24-flutter-rendering/
- https://medium.com/flutter-jp/dive-into-flutter-4add38741d07
自分の Tweet
Navigator.of の実装。
static NavigatorState of(
BuildContext context, {
bool rootNavigator = false,
}) {
// Handles the case where the input context is a navigator element.
NavigatorState? navigator;
if (context is StatefulElement && context.state is NavigatorState) {
navigator = context.state as NavigatorState;
}
if (rootNavigator) {
navigator = context.findRootAncestorStateOfType<NavigatorState>() ?? navigator;
} else {
navigator = navigator ?? context.findAncestorStateOfType<NavigatorState>();
}
assert(() {
if (navigator == null) {
throw FlutterError(
'Navigator operation requested with a context that does not include a Navigator.\n'
'The context used to push or pop routes from the Navigator must be that of a '
'widget that is a descendant of a Navigator widget.'
);
}
return true;
}());
return navigator!;
}
まずこのメソッドは、戻り値を見れば分かる通り NavigatorState
を返却するメソッドである。
メソッドの実装1行目で、返却するための navigator
変数を用意して、最後の行では return navigator
しているため、残りの処理は全て navigator
に代入すべきインスタンスを探す処理になっている。
で、まず最初の if 文を読んでみると、
if (context is StatefulElement && context.state is NavigatorState) {
navigator = context.state as NavigatorState;
}
この部分では、 context
が StatefulElement かどうか、また StatefulElement であるなら .state
が NavigatorState であるかどうかをチェックしている。
次に
if (rootNavigator) {
navigator = context.findRootAncestorStateOfType<NavigatorState>() ?? navigator;
} else {
navigator = navigator ?? context.findAncestorStateOfType<NavigatorState>();
}
となっていて、rootNavigator == true
なら findRootAncestorStateOfType<NavigatorState>()
した結果(結果が null であれば navigator)を、 rootNavigator == false
であれば findAncestorStateOfType <NavigatorState>()
した結果(navigator がすでに null でない場合は navigator )を navigator
に代入している。
あとは assert()
処理なので一旦割愛。
一旦、よく使うであろう rootNavigator == false
の場合(つまり、 Navigator.of(context).push()
とかって書いた場合)を考えると、
context.state
自体が NavigatorState だった場合はそれを、そうでなければ findAncestorStateOfType()
で探した結果を返す感じになっている。
どちらでも見つからない場合は assert()
でエラーが起きるようになっている。
さて、ではこれが何を探しているのか、返却する NavigatorState とは何なのか、また条件に出てくる StatefulElement が何者なのかを理解するために、まずは Flutter の 「3 つのツリー」について理解する必要がある。
3つのツリーについては、まずは参考資料の Youtube の動画を一通り観てみることで概要が理解できる。
次に公式ドキュメントの Inside Flutter の "Aggressive composability" 部分を「じっくり」読むことで、それぞれのツリーがどのような役割を果たしているのか、なぜ 3つのツリーを形成する作りになっているのかが理解できるようになっている。
あとは補足として日本語の記事やソースコードを読んでいくとより理解が深まると思う。
以下、要約(「ちゃんと」理解するために、必ず参考資料も一通り目を通すことをオススメします)
Flutter における 3 つのツリーとは、
- Widget ツリー
- Element ツリー
- RenderObject ツリー
である。
通常アプリ開発者が目にするのは Widget のみのため、 Widget (と State)のみによって UI が決定するイメージを持ってしまうこともあるが、実際はこの 3つに分かれている。
役割としては、Widget ツリーは "configuration" を表し、Widget オブジェクトは必ず immutable である。
RenderObject は Flutter フレームワークから UI の描画のために参照され、実際に「どのように画面表示するか」を計算、決定するものである。Element は Widget や RenderObject を、アプリケーションのその時その時の状態ごとにどう扱うかを管理するものである。
HTML のように、開発者が書いたレイアウト構成がそのまま描画されるのではなく、このような 3 つの役割分担がされている一番の理由は「効率的な描画」のためである。
Flutter は、ユーザーの操作などによって常に変化する UI を、画面がどれだけ複雑になっても効率的に描画するために、可能な限り描画に利用するオブジェクトを再利用する設計をしている。
「ある特定の時点で何を表示するか」を保持する Widget は状態の変化に合わせて何度でも作り直されるため、オブジェクトの生成が少しでも軽くなるよう "configuration" だけを保持するようになっているし、状態が不変であることを求められるし(Widget クラスのシグネチャに @immutable
がついている)、可能な場合は const でオブジェクト生成することを求められる。
一方で RenderObject は、Widget の内容が変わっても、可能な限り「オブジェクトの再生成」ではなく「プロパティの上書き」で対処し、オブジェクト生成の負荷を極力減らすように作られている。
Widget, RenderObject どちらのオブジェクトも保持していて、再生成するのか、使い回すのかのコントロールをするのが Element である。
↑↑↑この部分はもっとわかりやすく詳しく書きたい。特にツリーがどのように形成されるのか↑↑↑
ここで Navigator.of
に目を戻して、ソースコードを一つずつ丁寧に理解していく。
まず NavigatorState
は、StatefulWidget である Navigator
が createState()
で生成するオブジェクトである。 createState()
で生成された State で、ここに画面遷移の履歴などの状態が保持されている。
次に、context is StatefulElement
を判定していることからも分かる通り、 Widget の build()
に渡される context とは、Widget が createElement
で生成した Element のことである。
↓このあたりを読むと分かる。
Element クラスは BuildContext クラスを implements している。
widget.build()
の引数に this を渡している。
StatefulWidget の State は Element に保持されるので
NavigatorState も Element によって保持されている。
しかし、 Navigator.of
の引数で渡される context
がいつも Navigator が生成した Element かはわからない。というか通常は Navigator のサブツリーのどこかにある Widget が生成した Element を使うことがほとんど。
なので、context.findAncestorStateOfType<NavigatorState>()
を使ってツリーの上の方にある NavigatorState をまず探す。実装は以下の通り。
T findAncestorStateOfType<T extends State<StatefulWidget>>() {
assert(_debugCheckStateIsActiveForAncestorLookup());
Element ancestor = _parent;
while (ancestor != null) {
if (ancestor is StatefulElement && ancestor.state is T)
break;
ancestor = ancestor._parent;
}
final StatefulElement statefulAncestor = ancestor as StatefulElement;
return statefulAncestor?.state as T;
}
親子関係を保持しているツリーは Widget ではなく Element なので、 Element の parent
をたどりながら ツリー の上へ上へと NavigatorState を探していく。(ここでは T
は NavigatorState
)
見つかったら、その State を返却して終了。
つまり、Navigator.of(context)
は context
(実態は Element)自身とその親の中から、 NavigorState を保持する StatefulElement を探すメソッド。
この上へ上へという走査は Widget ではできない。 Widget は child(もしくは children)はフィールドとして保持しているものの、 parent というフィールドは持っていないため。Widget は常に「自分自身」の設定のみを保持している。親子関係や状態は全て Element や State が管理している。
次に Theme.of(context) について調べてみる。
コードは以下の通り。
static ThemeData of(BuildContext context) {
final _InheritedTheme? inheritedTheme = context.dependOnInheritedWidgetOfExactType<_InheritedTheme>();
final MaterialLocalizations? localizations = Localizations.of<MaterialLocalizations>(context, MaterialLocalizations);
final ScriptCategory category = localizations?.scriptCategory ?? ScriptCategory.englishLike;
final ThemeData theme = inheritedTheme?.theme.data ?? _kFallbackTheme;
return ThemeData.localize(theme, theme.typography.geometryThemeFor(category));
}
ローカライズ関連のコードを除くと、以下の2行がテーマを取得するためのメインの処理であることが分かる。
final _InheritedTheme? inheritedTheme = context.dependOnInheritedWidgetOfExactType<_InheritedTheme>();
(中略)
final ThemeData theme = inheritedTheme?.theme.data ?? _kFallbackTheme;
ざっくり読んでみると、 _InheritedTheme
型のオブジェクトを context. dependOnInheritedWidgetOfExactType ()
(先ほどと同様、context
は BuildContext を implements した Element) を使って取得している。
取得した inheritedTheme が保持する .theme.data
がお目当てのテーマ色などのデータの塊である。
_InheritedTheme
は InheritedWidget
を継承した Widget で、それを取得するための dependOnInheritedWidgetOfExactType()
を理解するために、まずは InheritedWidget の仕組みを理解する必要がある。
InheritedWidget の紹介動画はこちら。
これを見ると、Widget ツリーの上の方で管理するデータに、ツリーの下の方からアクセスするための仕組みであることや、より使いやすくするために Provider パッケージが便利であることが説明されている。
Inside Flutter にも以下のように少しだけ説明されている。
During build, Flutter also avoids walking the parent chain using InheritedWidgets. If widgets commonly walked their parent chain, for example to determine the current theme color, the build phase would become O(N²) in the depth of the tree, which can be quite large due to aggressive composition. To avoid these parent walks, the framework pushes information down the element tree by maintaining a hash table of InheritedWidgets at each element. Typically, many elements will reference the same hash table, which changes only at elements that introduce a new InheritedWidget.
要約すると、親のデータが必要になるたびに Widget ツリーを上へ上へと走査してしまうと、計算量が O(N²) になって遅くなってしまうので、子が使うことを想定したデータは InheritedWidget のハッシュテーブルに入れておき、その参照を Element ツリーの上から下へと渡していく工夫がされている。
つまり、どの Element も InheritedWidget のハッシュテーブルへの参照を保持しているため、 Theme.of(context) の中でやっているとおり、ツリーの上の方にある _InheritedTheme を context から引き出すことができる、という仕組みになっている。
ちなみに、dependOnInheritedWidgetOfExactType
の実装はこちら
T? dependOnInheritedWidgetOfExactType<T extends InheritedWidget>({Object? aspect}) {
assert(_debugCheckStateIsActiveForAncestorLookup());
final InheritedElement? ancestor = _inheritedWidgets == null ? null : _inheritedWidgets![T];
if (ancestor != null) {
assert(ancestor is InheritedElement);
return dependOnInheritedElement(ancestor, aspect: aspect) as T;
}
_hadUnsatisfiedDependencies = true;
return null;
}
_inheritedWidgets
(InheritedWidget のハッシュテーブル)は、キーがType、value が InheritedElement になっていて、型を指定すれば InheritedWidget が createElement() した InheritedElement
オブジェクトが取得できるようになっている。(InheritedWidget が取得できるわけではないことに注意)
InheritedElement から InheritedWidget を取得するのは dependOnInheritedElement()
で、実装は以下の通り。
InheritedWidget dependOnInheritedElement(InheritedElement ancestor, { Object? aspect }) {
assert(ancestor != null);
_dependencies ??= HashSet<InheritedElement>();
_dependencies!.add(ancestor);
ancestor.updateDependencies(this, aspect);
return ancestor.widget;
}
基本的には InheritedElement が保持する widget
フィールドのオブジェクトを返却するだけなのだが、それと同時に InheritedElement が保持する _dependents
に this (つまり呼び出し元の Element)をセット追加している。(詳しくは updateDependencies()
を追っていくと分かる)
これは、InheritedWidget の内容に変更があった場合、一度でも of(context)
でデータを参照しようとした Element(と、その Element が保持する Widget)にその変更を通知できるようにするためである。
つまり、 Element は InheritedWidget への参照を保持しているが、 反対に InheritedWidget も自身を使っている Element の参照を保持している、相互参照の形になっている、ということが読み取れる。
まとめると、Theme.of(context) は、context が参照している InheritedWidget の一覧の中から Theme を探し、その Theme が保持する .data
フィールドを返却するメソッドである。
この仕組みを理解することで、Element ツリーの親子関係とは少し外れた扱いをされている InheritedWidget の仕組みと、それをラップする Provider パッケージの仕組みが理解できるようになる。
記事にして公開したので Close します。