😺

【Flutter】3種のツリーの関係【おまけで仮想DOMとの比較】

に公開

はじめに

ツリーへの理解はFlutterのレンダリングパイプラインと最適化の仕組みを理解する上で不可欠な要素です。

パフォーマンスを最大化する内部構造として3つのツリーがあります。これらの理解を深め、Flutter開発のレベルを1つ深くしていきましょう。

https://docs.flutter.dev/resources/inside-flutter

Flutterの内部概要

要点

  • Flutterは大量のウィジェットを並べて開発する
    • Everything is a Widget という哲学とアグレッシブコンポジションという思想
  • そのためのパフォーマンス最適化の仕組みを持っている

Flutterは大量のウィジェットを並べて開発する

Flutterでアプリケーションを開発する際、私たちはたくさんのウィジェットを組み合わせてUIを作ります。ボタン、テキスト、レイアウトを調整するコンテナなど、画面を構成するすべての要素がウィジェットです。これは「Everything is a Widget(すべてはウィジェット)」というFlutterの哲学に基づいています。

この哲学をさらに掘り下げると、「Aggressive composability(アグレッシブ・コンポジション)」という設計思想が見えてきます。これは、より小さな部品(ウィジェット)を積極的に組み合わせて、複雑なUIを構築していく考え方です。

https://zenn.dev/peter_norio/articles/06d7899ccdb139

まるで、レゴブロックを積み重ねていくように、ウィジェットを階層的に配置することで、画面を柔軟かつ直感的に作れるようになっています。

パフォーマンス最適化の仕組みを持っている

しかし、ウィジェットをたくさん組み合わせるということは、大量のオブジェクトを扱うことになります。このままではパフォーマンスの問題が起きかねません。そこでFlutterは、この「すべてはウィジェット」という思想を最大限に活かしつつ、高いパフォーマンスを実現するための独自の最適化メカニズムを持っています。この仕組みが、これから解説する3つのツリー構造に隠されています。

参考

Widget Tree

要点

  • ウィジェットの階層構造を表現する
  • 現在の表示画面に対応するツリーが生成される
  • build()メソッドが呼ばれるたびにツリーが再構築される
    • 主なタイミング:初回マウント時、状態変更時、親の変更時、画面遷移時
  • Widget Treeは不変かつ軽量で頻繁に再構築されるもの

ウィジェットの階層構造を表現する

私たちがコードで書いたウィジェットは、階層的なツリー構造を形成します。これがWidget Tree(ウィジェットツリー)です。

現在の表示画面に対応するツリーが生成される

このツリーは、まるで建物の設計図のように、画面の見た目や構造を定義しています。Columnの中にTextImageが配置される場合、Columnが親、TextImageが子となり、その関係性がツリーとして表現されます。

build()メソッドが呼ばれるたびにツリーが再構築される

このWidget Treeは、画面が初めて表示されるときや、build()メソッドが呼び出されるたびに新しく生成されます。状態が変更されたり(setState)、画面が切り替わったりするたびに、Flutterは新しいウィジェットツリーを作り直します。

具体的には、以下のようなタイミングで再構築されます。

  • 初回マウント時: アプリが起動して最初の画面が表示されるとき、または新しいウィジェットが画面に初めて追加されるときに、ウィジェットツリーが作られます。
  • 状態変更時: StatefulWidget内でsetState()メソッドが呼ばれると、そのウィジェットとその子孫ウィジェットのbuild()メソッドが再実行され、新しいウィジェットツリーが生成されます。
  • 親の変更時: 親ウィジェットが新しいウィジェットを返した場合、その子ウィジェットのbuild()メソッドも再実行されます。
  • 画面遷移時: Navigatorを使って新しい画面に遷移するとき、新しい画面に対応するウィジェットツリーがゼロから構築されます。

Widget Treeは不変かつ軽量で頻繁に再構築されるもの

「新しく作り直す」と聞くと、パフォーマンスが心配になるかもしれません。しかし、Flutterのウィジェットはとても軽く、変更できない(不変、イミュータブル)という特徴を持っています。そのため、新しいツリーを何度作っても、パフォーマンスへの影響はほとんどありません。

この軽量なWidget Treeの仕組みこそが、次の段階であるElement Treeが活躍するための土台となります。Flutterは、このWidget Treeを基に、より効率的な処理を行うための次のツリーを構築します。

参考

Element Tree

要点

  • WidgetツリーとRenderツリーの間に位置する
  • createElement()メソッドによって生成される
    • build()メソッドが呼ばれ、ウィジェットの階層構造が確定すると、Flutterはツリー内の各ウィジェットに対してcreateElement()メソッドを呼び出す。
  • 状態を保持し、UIに反映するデータを管理する
  • 効率的なツリー更新をするパフォーマンス最適化の要所
  • ツリーの変更があった場合、差分のみを再構築する
    • Widgetツリーの変更に応じ、新しいWidgetツリーと既存のElementツリーを比較し、再利用可能なElementを判断し、変更があった部分のElmentのみを再構築する。

Element Tree:パフォーマンス最適化の心臓部

前のセクションで、Widget Treeが「画面の設計図」であり、build()メソッドが呼ばれるたびに新しく作り直されることをお話ししました。しかし、毎回すべての設計図をゼロから再構築していては、効率が悪くなってしまいます。そこで登場するのが、Element Tree(エレメントツリー)です。

WidgetツリーとRenderツリーの間に位置する

Element Treeは、先ほど説明したWidget Treeと、次に紹介するRender Treeの間に位置する、いわば「画面の状態を管理するマネージャー」のような存在です。

build()メソッドが実行され、Widget Treeの階層が確定すると、Flutterはツリー内の各ウィジェットに対してcreateElement()メソッドを呼び出し、Element Treeを構築します。

状態を保持し、UIに反映するデータを管理する

このElement Treeの主な役割は、ウィジェットの状態を保持し、画面に反映させるデータを管理することです。

ツリーの変更があった場合、差分のみを再構築する

Element Treeの最も重要な役割は、効率的なツリー更新を行うことでパフォーマンスを最適化することです。

例えば、ユーザーがボタンを押して画面上のテキストが変わったとしましょう。このとき、Widget Treeは新しいテキストを含むウィジェットで再構築されます。Flutterは、この新しいWidget Treeと既存のElement Treeを比較します。このとき、テキストが変わったウィジェットのElementだけを更新し、変更がなかった部分のElementはそのまま再利用します。

このように、変更があった部分だけをピンポイントで更新します。Flutterは、この仕組みのおかげで、ウィジェットツリーがどれだけ頻繁に再構築されても、実際の描画に必要な作業を最小限に抑えることができるのです。

このElement Treeの賢い働きが、Flutterの高いパフォーマンスを支えているのです。そして、このElement Treeが最終的に、実際に画面に表示される情報を管理するRender Treeへと指示を伝えることになります。

参考

Render Tree

要点

  • 実際に画面に表示する描画情報を管理する
    • サイズや位置など
  • RenderObjectと呼ばれるオブジェクトで構成される
  • Elementツリーをもとに、描画内容を決定
  • Elementが効率的に更新されるおかげで、Render Treeの不必要な変更が防がれる
  • createRenderObject()メソッドによって生成される
    • すべてのウィジェットがcreateRenderObjectメソッドを持つわけではない
      • SingleChildRenderObjectWidgetやMultiChildRenderObjectWidgetといった、レンダリングツリーのノードを生成する特別なウィジェットにのみ存在する

Render Tree:画面に描画されるツリー

これまで、画面の設計図であるWidget Treeと、その状態を管理し最適化するElement Treeを見てきました。この二つのツリーを経て、最終的に画面に「描画」される情報を管理するのが、Render Tree(レンダーツリー)です。

実際に画面に表示する描画情報を管理する

このツリーが持つ情報は、私たちが実際に目にするUIそのものです。具体的には、各要素のサイズ、位置、色、そしてどのように画面に描画されるかといった、視覚的な情報がすべて含まれています。

RenderObjectと呼ばれるオブジェクトで構成される

Render Treeは、RenderObjectという描画用のオブジェクトで構成されています。

Elementツリーをもとに、描画内容を決定

では、このRender Treeはどのように作られるのでしょうか。Element TreeがcreateRenderObject()メソッドを呼び出すことで生成されます。ただし、すべてのウィジェットがこのメソッドを持っているわけではありません。SingleChildRenderObjectWidgetMultiChildRenderObjectWidgetといった、レンダリングツリーのノード(要素)を生成するための特別なウィジェットにのみ、この機能が存在します。

Elementが効率的に更新されるおかげで、Render Treeの不必要な変更が防がれる

このRender Treeは、Element Treeからの指示を受けて、描画内容を決定します。Element Treeが変更された部分だけを効率的に更新してくれるおかげで、Render Treeは必要最小限の変更で済みます。これにより、無駄な再描画が避けられ、アプリのパフォーマンスが向上します。

次のセクションでは、この3つのツリーがどのように連携し、私たちが書いたコードが最終的に画面に表示されるまでの壮大な流れを、まとめて見ていきましょう。

参考

3つのツリーの関係整理

要点

  • 画面表示までの流れ
    • 我々が書くDartコード
      build()メソッド
    • Widgetツリー
      createElement()メソッド
    • Elementツリー
      createRenderObject()メソッド
    • Renderツリー
      ↓Flutterグラフィックエンジン(Impleler、旧Skia)
    • 端末の画面表示
  • 状態の更新時
    • 状態更新や画面遷移の操作が行われる
      ↓該当の状態に依存したElementがダーティとマークされる
      ↓ダーティとマークされたElementのElement.rebuild() メソッドが実行される
      Element.rebuild() メソッド内でbuild()メソッドが再実行され、Widgetツリーが作り直される
    • 新しいWidgetツリー
      ↓新しいWidgetツリーと既存のElmentツリーを比較
      Element.update()メソッドで行われ、runtimeTypekeyが一致するかどうかを判断)
      ↓一致しない古いElementは削除されcreateElement()メソッドで変更のあった新しいElement作成し、一致するものは再利用
    • 差分更新されたElementツリー
      ↓更新が完了しダーティマークが解除される
      ↓変更されたElementがRenderObject(Renderツリー)の更新が必要なことを通知
      ↓変更があったElementからRenderObject.markNeedsLayout()markNeedsPaint()などのメソッドが呼び出される(Renderツリーの更新トリガー)
      ↓該当のレンダーオブジェクトのみを再レイアウト・再ペイントする(Renderツリーの更新実施)
    • 差分更新されたRenderツリー
      ↓Flutterグラフィックエンジン(Impleler、旧Skia)
      端末の画面表示
  • 画面遷移時
    • 新しい画面に遷移する場合、Flutterは新しい画面のウィジェットツリー、エレメントツリー、レンダーツリーをゼロから完全に構築
  • 前の画面に戻る場合
    • 前の画面のツリーはNavigatorのスタックにメモリ上で保持されているため、すでに構築済みのツリーをメモリから取り出し、再表示に利用する

3つのツリーの関係整理:画面が表示されるまで

ここまで、Widget TreeElement TreeRender Treeのそれぞれの役割を見てきました。この3つのツリーはバラバラに動いているわけではなく、連携して一つの大きな流れを作り出しています。では、私たちが書いたコードが、どのようにして美しい画面として表示されるのか、その流れを追っていきましょう。

画面が表示されるまでの流れ

  1. コードからWidget Treeへ:

    私たちが書いたDartコードは、build()メソッドを通じて、画面の設計図であるWidget Treeになります。

  2. Widget TreeからElement Treeへ:

    次に、createElement()メソッドが呼ばれ、Widget Treeの情報を基にElement Treeが作られます。Elementは、Widgetと画面上の実際の描画を結びつける役割を持っています。

  3. Element TreeからRender Treeへ:

    Element Treeの各ノードは、createRenderObject()メソッドを通じて、実際の描画情報を持つRender Treeを生成します。

  4. そして画面へ:

    最後に、Render Treeの描画情報が、Flutterのグラフィックエンジン(Impeller、旧Skia)によって端末の画面にピクセルとして描画されます。

この流れは、アプリの起動時だけでなく、状態が更新されるたびに繰り返されます。

状態更新時の再描画

ユーザーの操作などでsetState()が呼ばれ、画面上のデータが変わったとしましょう。このとき、Flutterは無駄な再描画を避けるために、次のような最適化を行います。

  1. Elementが「ダーティ」に:

    状態変更があったウィジェットに関連するElementが、「ダーティ(Dirty)」、つまり「更新が必要」とマークされます。

  2. Widget Treeの再構築:

    ダーティとマークされたElementのbuild()メソッドが再実行され、新しいWidget Treeが生成されます。

  3. Element Treeの差分更新:

    ここでElement Treeの賢さが発揮されます。Flutterは、新しいWidget Treeと既存のElement Treeを比較し、keyやruntimeTypeが一致するElementは再利用し、変更があった部分だけを更新します。これにより、Element Tree全体を作り直す必要がなくなります。

  4. Render Treeへの通知と再描画:

    Element Treeの更新が終わると、変更があったElementからRender Treeに対して、再レイアウトや再描画が必要であるという通知が送られます。Render Treeは、通知を受け取った部分だけをピンポイントで更新し、画面がスムーズに再描画されます。

この一連のプロセスは、必要最小限の作業で画面を更新することを目指しており、Flutterの高いパフォーマンスの秘密がここにあります。

画面遷移とツリーの関係

画面の遷移もこの仕組みと密接に関わっています。新しい画面に遷移する場合、Flutterは新しい画面の3つのツリー(Widget, Element, Render)をゼロからすべて構築します。

一方、前の画面に戻る場合はどうでしょうか?Navigatorは、前の画面のツリーをメモリ上に保持しているため、再表示の際にすでに構築済みのツリーを再利用し、高速な描画を可能にしています。

この3つのツリーがそれぞれの役割を分担し、連携することで、Flutterは効率的かつ高速なUI描画を実現しています。次のセクションでは、このElement Treeの役割が、ウェブ開発における仮想DOMとどのように似ているのかを比較してみましょう。

参考

【おまけ】仮想DOMとの比較

要点

  • 仮想DOMと同様に、UIのパフォーマンスを最適化する役割
  • どちらもUIの変更を検知し、変更を最小限に抑える
  • 仮想DOMで使われる「ツリー差分アルゴリズム」は採用してない
  • Flutterは各要素を個別に調べる「O(N)アルゴリズム」を使用
  • 手法は異なるが「変更箇所のみを効率的に更新する」目的は同じ

仮想DOMとの比較:FlutterのElement Treeは「差分更新」の役割を担う

仮想DOMと同様に、UIのパフォーマンスを最適化する役割

ここまで見てきたElement Treeは、ウェブ開発の経験者にとって身近な仮想DOM(Virtual DOM)と似た役割を持っています。どちらも、UIの変更を検知し、実際の描画プロセスへの負担を最小限に抑えることでパフォーマンスを最適化するという共通の目的を持っています。

仮想DOMで使われる「ツリー差分アルゴリズム」は採用してない

Reactの仮想DOMは、新旧のツリー全体を比較し、変更された部分を特定する「ツリー差分(tree-diffing)アルゴリズム」を採用しています。これにより、最小限の変更を実際のDOMに適用します。

Flutterは各要素を個別に調べる「O(N)アルゴリズム」を使用

一方、Flutterは少し違うアプローチを取っています。Flutterはツリー全体を比較するツリー差分アルゴリズムを採用していません。代わりに、各要素の子リストを個別に調べる「O(N)アルゴリズム」という手法を使用します。この仕組みでは、ウィジェットに設定されたkeyなどを利用して、新旧のツリーを効率的に比較し、再利用できる要素を判断します。

手法は異なるが「変更箇所のみを効率的に更新する」目的は同じ

手法は異なりますが、FlutterのElement Treeも仮想DOMも、「変更があった部分だけを効率的に更新する」という目的は同じです。

参考

おわりに

Flutterがこのような独自の仕組みを採用しているのは、「すべてはウィジェット」という哲学と、不変で軽量なウィジェットを前提としているためです。この設計思想が、Render Treeへの無駄な変更を徹底的に防ぎ、最終的にスムーズで高速なUI描画を実現しているのです。

ここまで見てきたように、Flutterの高いパフォーマンスは、コードと描画の間を賢く仲介する3つのツリー構造によって支えられています。この知識が、今後のFlutter開発の一助となれば幸いです。

Discussion