🎄

【Flutter】Navigator.of(context) から理解する 3つのツリー

2020/12/02に公開

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

この記事は、 Flutter アプリ開発で頻繁に利用する Navigator.of(context) の実装を読みながら、Flutter を理解する上でとても重要な「3つのツリー」についての理解を深める記事です。

ターゲット

この記事は、以下のような方が読むことを想定しています。

  • Widget を使って簡単なアプリを作ることができるようになった方。
  • Widget は知っているけど、 Element とか RenderObject とか言われてもよくわからない方。
  • Navigator.of(context) ってよく書くけど実際アレ何なんだろう?という方。

逆に、Flutter の公式ドキュメントやソースコードをすでに読んでいて、Flutter のレイアウトの仕組みについてある程度の知識がある方にとっては新たな発見はないかもしれません。

はじめに

Flutter アプリを開発していて、 ElementRenderObject といったクラス名を目にしたことがあるでしょうか?

Inside Flutter にも書かれているように、 Flutter はとても API デザインにこだわりが入れられたフレームワークです。 "everything is a widget" というスローガンの通り、アプリ開発者は、これらのクラスを知らなくても Widget の扱い方を知っていればたいていのアプリは楽に作れるようになっています。

しかし、実際に API リファレンスやソースコードを読んでいると、実際には Widget だけではなく ElementRenderObject など、 Flutter が UI を生成する上で欠かせない要素が他にも存在することが見えてきて、現実には "everything is a widget" がアプリ開発者向けの表面的な話であることが分かってきます。

確かにこれらの要素の役割や存在意義を知らなくてもそれなりのアプリを開発できてしまうのが Flutter の良さではありますが、これを理解することはエラーメッセージを読み解き、ドキュメントやフォーラム上の議論を読んで理解し、またパフォーマンス改善に臨んだりする際の大きな助けになるはずです。

実際、 Snackbar を表示させようとしたら context の扱い方を間違えてエラーになった、 ListView のスクロール位置を保持するためによく分からないけど key をつけたらうまく動た、といった「Flutter あるある」な問題の根元には、この Element や RenderObject、そしてそれらが形成するツリー構造への不理解があると言えます。

Widget の使い方だけを覚えて「手クセ」でアプリを作り続けてしまうのではなく、どんな時にどの Widget をどう使うのが適切なのかを、「Flutter の仕組み」を根拠に判断できるようになることを目指して、 まずは Flutter の3つのツリー に興味を持ってみると良いでしょう。

そのための最初の入り口として、この記事が役に立てれば嬉しいです。

参考

この記事を書くにあたって参照したドキュメントや記事などは以下の通りです。

Youtube

公式ドキュメント
Inside Flutter

ソースコード
https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/widgets/framework.dart

日本語の記事

まずは Youtube でざっと概要をつかみ、次に公式ドキュメントの Inside Flutter をじっくり2, 3日使って読んで Flutter のレイアウト処理の効率化の工夫について理解を深めたあと、ソースコードを読んで「実際のところどうなっているのか」を自分の目で見て確認し、最後に日本語の記事で認識齟齬がないか確かめる、という具合に私の場合は進めました。[1]

本文

この記事は、まず Navigator.of のソースコードを読んでみて、そこで出てきた疑問を解消するために Flutter の「3つのツリー」について解説し、もう一度 Navigator.of のソースコードを読むことでその仕組みを理解するとともに、実際に「3つのツリー」に基づいて Flutter の API が設計されていることを確認していきます。

それではまず始めに、画面遷移でよく使う Navigator.of の実装を少し観察してみましょう。[2]

Navigator.of の実装は以下の通りです。[3]

  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(() {
      ... 省略
    }());
    return navigator!;
  }

こちら から、 GitHub 上でも確認できます。[4]

このメソッドは、みなさんの利用目的やシグニチャから分かる通り、画面遷移に必要な NavigatorState オブジェクトを取得するためのメソッドです。

しかし、 "everything is a widget" というのであれば、なぜ Widget である Navigator を検索するような処理になっていないのでしょうか。なぜ検索のために contextStateElement といったワードばかり出てきて、 Widget が一言も出てこないのでしょうか。

これらのクラスが何者なのか、なぜ Widget を処理に使わないのかを理解するために、Flutter の「3つのツリー」について説明していきます。

Flutter の「3つのツリー」

ここまで何度か繰り返した通り、 Flutter には「3つのツリー」という考え方が存在し、 Widget, Element, RenderObject の3つの要素がそれぞれツリー構造を形成してレイアウトのためのデータを生成しています。

以下は、それぞれのツリーに含まれる各オブジェクトの参照関係に着目して作成したツリーのイメージ図です。

ツリーを形成する各ノードの種類によって参照関係に差はあるものの、まず最初に把握しておきたいのは以下の2点です。

  • Element ツリーが3つのツリーの中心的な役割を果たしている。
  • Widget には、それとペアになる Element が必ず存在している。

これを踏まえた上で、 Widget, Element, RenderObject それぞれの役割を簡単に見ていきたいと思います。

Widget

Widget は、UI の設定情報(configuration)を保持し、フィールドが全て final の不変(immutable)なオブジェクトです。

Widget の主な仕事は、レイアウトを決定するための各種設定(例えば文字のスタイルや文言など)を保持すること、また自身のペアとなる Element オブジェクトを Widget.createElement によって生成することです。

実は Widget 自体は親の Widget の参照を持っておらず、また StatelessWidget や StatefulWidget に至っては子の参照すら持たないことから、 Widget 自体のツリー構造はそこまで完全なものでなく、先祖を遡ったり子孫を辿ったりする用途では使えないことが分かります。

RenderObjectWidget(Container や RichText, Row などのちょっと離れた親クラス)には、それに加えて RenderObject を生成する RenderObjectWidget.createRenderObject というメソッドが定義されています。RenderObject ツリーを形成するための RenderObject オブジェクトの生成も Widget の役割のひとつです。

Widget オブジェクト自身は、アプリの状態の変化に伴って何度も何度も Element によってオブジェクトが生成し直されるようになっています。逆に言えば、状態の変化によって変化する部分だけを Widget として切り出し、ツリー構造を管理する Element やレイアウト計算を担当する RenderObject のオブジェクトは極力使い回すことで Flutter は高速な描画処理を実現しています。

Element

Element は Flutter の UI を構築する「3つのツリー」をための中心的な役割を担うツリーです。

必ず Widget.createElement によってオブジェクトを生成され、生成した Widget への参照を自身のフィールドに保持しています。また、 Widget の種類によっては対応する State や RenderObject への参照も同じようにフィールドに保持しています。

基本的に Element は親の参照も子の参照も持っているため、 その参照をたどってツリー内の先祖や子孫を走査することが可能になっています。3つのツリーのうち、これができるのは Element ツリーのみです。

Element オブジェクトの生成こそ Widget が行いますが、一度生成されたあとは Widget, State, RenderObject, そしてそれらの親子関係やライフサイクル全ての管理を行うのがこの Element の役割です。

ちなみに、 Element クラスは、抽象クラスである BuildContext の実装です。

abstract class Element extends DiagnosticableTree implements BuildContext {

アプリ開発者が StatelessWidget や StatefulWidget をコーディングする際必ず目にする Widget.build で引数として渡される context は、その Widget とペアになっている Element オブジェクトであることを覚えておくと、コードを読んだり書いたりする際に役に立つかもしれません。

RenderObject

RenderObject は、最終的に Flutter フレームワークが画面に UI を描画する際に参照するオブジェクトです。Widget やその親子関係を基に計算された、「何を」「どう」描画するかのデータはすべてここに集まります。

RenderObject オブジェクト自体は、 Element の処理によって RenderObjectWidget (を継承した Container や RichText などの各 Widget)の createRenderObject メソッドが呼び出され、生成されます。

生成されたオブジェクトは Element のフィールドとして保持されます。 RenderObject 自身は親への参照も子への参照も持たず、また Inside Flutter にも

strictly speaking, the RenderObject tree is a subset of the Element tree

と書かれている通り、 RenderObject ツリーはそれ自身が親子の参照を持った独立したツリーということではありません。

Element ツリー上の各 Element が RenderObject を保持しており、 Element の親子関係に連動して RenderObject も扱われるため、便宜上「ツリー」と表現されているようです。

描画する内容を計算する際は、 Element から受け取った子の RenderObject オブジェクト(自身が子の RenderObject オブジェクトの参照を保持しているわけではないため)の parendData というフィールドに自身の持つレイアウトの制約(Constraints)を渡し、その子はさらに子に対して自身の制約を渡し、という具合に下へ下へと制約を渡した上で、末端の RenderObject オブジェクトからその制約に従って計算した描画内容を親へ親へと返却していくことで、ツリー全体の描画内容が決定していく作りになっています。

このことは Inside Flutter の Sublinear layout に詳しく書かれているため、読んでみると良いでしょう。


ここまで読むと分かるとおり、3つのツリーはどれも Widget を出発点として生成されます。また、 Element ツリー、 RenderObject ツリーの生成や更新、先祖や子孫の走査などはすべて Flutter フレームワークが見えないところで行っているため、我々アプリ開発者は Widget の作り方や扱い方だけを知っていればアプリが作れるようになっている、というわけです。

なお、ここにはアプリが動作する時系列に応じた処理の流れは一切触れずに、各要素の役割や参照関係だけを説明しています。具体的にアプリの起動時やユーザーの操作時に何が起きているかを知りたい場合は、参考資料にあげている 動画ドキュメント 、およびソースコードを読んだりブレークポイントを貼って実際にサンプルアプリを動かしたりすることをオススメします。

日本語の記事では 「Flutter の Widget ツリーの裏側で起こっていること」 も大変勉強になります。(有名な記事なのでもう読んだことある方がほとんどかと思いますが)

さて、Flutter の3つのツリーについて把握したところで、もう一度 Navigator.of のソースコードを読んでみましょう。

先ほど書いた通り、このメソッドは引数で受け取った context を使って、適切な NavigatorState オブジェクトを返却するのが主な役割です。

このメソッドのソースコードを上から順番にみていくと、まず目につくのは以下の if 文です。

if (context is StatefulElement && context.state is NavigatorState) {
  navigator = context.state as NavigatorState;
}

context が StatefulElement 型であるかどうか、そうであるなら context.state が NavigatorState 型であるかどうかをさらに調べています。

Navigator のソースコード を読むと分かる通り、 Navigator は StatefulWidget のサブクラスで、 Navigator.createElement で StatefulElement 型のオブジェクトを、 Navigator.createState で画面遷移の状態を管理する NavigatorState 型のオブジェクトを生成します。

つまり、この Navigator.of に渡された context がすでに Navigator によって生成された StatefulElement である場合、親を走査するまでもなく適切な NavigatorState はこの StatefulElement が保持する State、つまり context.state であると判断し、一旦 navigator 変数に代入しています。

しかし、通常我々が書くコードでそうなることはほとんどありません。 context が Navigator のものではない多くの場合は以下の if 文がメインの処理になります。

if (rootNavigator) {
  navigator = context.findRootAncestorStateOfType<NavigatorState>() ?? navigator;
} else {
  navigator = navigator ?? context.findAncestorStateOfType<NavigatorState>();
}

一旦 rootNavigatortrue の場合については後回しにして、下の else の方を見ていきます。

先ほどの最初の if 文で NavigatorState オブジェクトが得られなかった場合、 ?? の右側の context.findAncestorStateOfType<NavigatorState>() が呼ばれます。(得られていた場合はこのメソッドを呼ぶまでもなくその NavigatorState オブジェクトを返却します。)

これが何をしているのかをここから説明していきます。

findAncestorStateOfType<T> は、定義自体は BuildContext クラスにありますが、実装は サブクラスの Element にあります


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?;
}

注目すべきは真ん中の while 文で、Element オブジェクト自身が保持している _parent が StatefulElement 型、かつ state フィールドが指定された State 型(今回は NavigatorState型) かどうかをチェックし、合致するものが見つかればその時点で break し、違っていればさらにその親が同じ条件に合致するかどうかをチェックしています。

先ほどの Element ツリーの説明の通り、 Element オブジェクトは必ず自身の親の参照を保持していますので、条件に当てはまるものが見つかるまでツリーを上へ上へと走査していきます。

最終的に合致した Element が保持する state フィールドのオブジェクトを、<T> で指定された型にキャストして返却する、というのがこのメソッドの全体像です。

これにより、 Navigator.of の引数に渡された context (Element オブジェクト)を起点にして、先祖の誰かが保持している NavigatorState を見つけてくる、という処理が実際のソースコードで確認できました。

Element ツリーの項目で説明した通り、ツリーの親(もしくは子)を上へ上へ(下へ下へ)と走査する処理には親子関係をちゃんと保持している Element ツリーが使われる、というのがこのメソッドの実装でも見られますね。

なお、 rootNavigatortrue の場合はどうなるのかというと、 Element.findRootAncestorStateOfType メソッド によって Element ツリーの根元に到達するまで同じ条件での判定を繰り返しています。

これにより、同じツリーの祖先を遡った先に複数の NavigatorState があった場合、その中でも一番根元(root)に近いものを返却するような実装になっています。例えば下タブなどが含まれるアプリで、タブ内の遷移と画面全体の遷移を使い分けたいときなどに役立ちます。

以上が Navigator.of(context) の主な処理の流れです。「3 つのツリー」(特に Element ツリー)の理解ができていれば比較的簡単にイメージでき、またこのあたりのソースコードを追うことで確かに Flutter が Element ツリーを中心に動いていることが納得できたのではないでしょうか。

まとめ

以上、 Navigator.of のコードを読みながら、 Flutter の3つのツリーについて調査してみました。

この記事の内容を理解しようがしまいが結局 Navigator.of(context).pushXxxx() を書いて画面遷移を実装することに変わりはないのですが、 Flutter の中で起きていることを理解し、ソースコードからそのメソッドの実装を理解することで、「画面の一部分だけを遷移できるようにしたい」のようなちょっとしたイレギュラーケースでも実現方法のアタリが付けやすくなるのではないでしょうか。

また、Element ツリーのイメージができていることで API の理解やエラーへの対処が速く、また正確になる例はいくらでもありますので、余裕のある時に一度じっくり公式のリソースやソースコード、誰かが書いた記事などを読みながらじっくり勉強してみるのもとても大事だと考えています。

この記事を書くための調査をする上で、他にもたくさんの知見(InheritedWidget の仕組みや Key の仕組みなど)が得られましたので、それはまた別の記事にまとめていこうと思います。そちらも読んでいただけたら嬉しいです。

↓ 書きました

https://zenn.dev/chooyan/articles/bd8b5990eb210f

https://zenn.dev/chooyan/articles/7c2bf39f81cbed

脚注
  1. "Inside Flutter" やその他の資料をみながら試行錯誤した様子は Twitter につらつら投稿していますので、興味があればご覧ください。 ↩︎

  2. Navigator.push をよく使う方は Navigator.of を使う機会はないかもしれませんが、 ソースコードを読めば分かる通り Navigator.pushNavigator.of をメソッド内でやってくれているだけですので、同じ話として説明します。 ↩︎

  3. 以下、説明する上で重要な部分は記事中にソースコードを載せ、参考程度に見ていただきたいソースコードは GitHub へのリンクを貼ることにします。 ↩︎

  4. 手元に Flutter の開発環境がある場合は、 Navigator.of を使っているコードからメソッドの定義元にジャンプすることで、同じソースコードを IDE で読むことも可能です。読みやすい方を参照してください。 ↩︎

Discussion