英語の勉強がてら、Inside Flutterを翻訳していきます。
おかしなところが多々あると思うので、そういった部分の指摘はコメントではなく編集リクエストでお願いします。
また、ドキュメントの翻訳に加え、実際のコードで役に立ちそうな部分を括弧内に記入しました。
Inside Flutter
このドキュメントでは、FlutterのAPIを可能にするFlutterのツールキットの内部的な仕組みについて説明します。
FlutterのWidget達は積極的にコンポジションを用いて作られているので、Flutterで構築されたユーザーインターフェイスには沢山のWidgetが含まれています。この仕事量をサポートするため、Flutterのデータ構造は木構造となっており、それが多くの定数化に基づいた最適化を持つように、レイアウトとWidget構築の為に準線形アルゴリズムを用いています。
いくつかの詳細を加え、このデザインはまた、ユーザーが見ることのできるWidgetを沢山生成するコールバックを用いる、無限スクロールリストを、デベロッパーが簡単に作れるようになります。
積極的なコンポジション
Flutterの特徴的な側面の一つとして、積極的なコンポジションが挙げられます。
Widgetは他のWidgetを組み合わせることによって作られ、それら自体は、徐々に基本的なWidgetから構築されています。例えば、Paddingは他のWidgetのプロパティとしてではなく、Widgetとして作られています。結果、Flutterによって作られたユーザインターフェイスは、沢山のWidgetから構成されています。
Widget構成の再帰はRenderObjectWidgetで終わります。これは、基礎となるRenderツリーのノードを作るWidgetです。Renderツリーはユーザインターフェイスのジオメトリ(形状)を保存するデータ構造で、これはレイアウト処理時に算出され、描画時とヒットテスト時に使われます。ほとんどのFlutterのデベロッパーは直接RenderObjectを扱うようなことはなく、Widgetを用いてRenderツリーを操作します。
(RenderObjectWidget.createRenderObject -> RenderObject)
Widget層での積極的なコンポジションをサポートするため、FlutterはWidget層とRenderツリー層の両方で、多くの効率的なアルゴリズムと最適化を用います。これについては次の小節で説明します。
準線形なレイアウト処理
多くのWidgetとRenderObjectについて、良いパフォーマンスを得る為の鍵となるのが効率的なアルゴリズムです。最も重要なのはレイアウト処理のパフォーマンスで、 これはRenderObjectのジオメトリ(例えば、サイズとか位置とか) を決めるアルゴリズムです。幾つかの他のツールキットはO(N^2)以上の計算量となるレイアウト処理アルゴリズムを用いています(たとえば、制約領域における固定点のイテレーションとか)。
(class _SemanticsGeometry)
Flutterは 初期化時のレイアウト処理では線形のパフォーマンス、そしてそれに続く更新時には準線形のパフォーマンスとなることに焦点を当てています。通常、レイアウト処理にかけられる時間はRenderObjectの数よりももっと遅くなります。
Flutterは毎フレームにレイアウト処理をし、レイアウト処理アルゴリズムは一方向に走査します。
親がそれらの子孫のレイアウト処理メソッドを呼び出すことによって、制約(Constraint)が木構造の下の方に渡されていきます。子孫は再帰的に彼らのレイアウト処理を行い、そしてレイアウト処理メソッドの戻り値によってジオメトリを木構造の上の方に返していきます。重要なのは、一度RenderObjectがレイアウト処理メソッドから返されたら、そのRenderObjectは次のフレームのレイアウト処理まで参照されない、ということです。この方法は、他の方法では測定とレイアウトの受け渡しが別々になっていたのを、一つにまとめます。その結果、各RenderObjectは二回参照されます。一回目は木構造を降りるとき、2回目は木構造を上がるときです。
(RenderObject.layout(Constraints) -> void,
Geometryを返すメソッドは分からず…)
Flutterはこの一般的な手順のため、いくつか特化した部分があります。最も共通的な特化部はRenderBoxで、これは二次元デカルト座標で動作するものです。ボックスレイアウトでは、制約は縦幅の最小値と最大値、横幅の最小値と最大値になります。レイアウト処理時には、子要素はこの制約の領域の中でサイズを選択し、ジオメトリを決定します。子要素のレイアウト処理の後、親要素はその制約システムに基づいて子要素の位置を決めます。子要素の位置は子要素のレイアウト処理が終わるまで決まらないので、子要素はその位置に依存することはできないことに注意してください。結果的に、親要素は子要素のレイアウトを計算せずに自由に子要素の位置を変えられるようになります。
より一般的に、レイアウト処理の間、親要素から子要素に送られる唯一の情報は制約で、子要素から親要素に送られる唯一の情報はジオメトリとなります。これらの不変性はレイアウト処理の負担を減らすことができます。:
- もし子要素が、そのレイアウトが
dirtyでないとマークされていたら、親要素が子要素に、以前と同じ制約を与える限りは、その子要素はすぐにレイアウト処理の走査から切り離せます
(See RenderObject
..markNeedsLayout
..markParentNeedsLayout
..markNeedsLayoutForSizedByParentChange
..markNeedsCompositingBitsUpdate
..markNeedsPaint
Elementと違い、_dirtyフラグはない。
)
-
親が子のレイアウトメソッドを呼び出すたびに、親要素は子要素から返されたサイズ情報を使用するかどうかを示します。 よくあることですが、親要素がサイズ情報を使用しない場合、新しいサイズが既存の制約に準拠することが保証されるため、子要素が新しいサイズを選択した場合、親要素はレイアウトを再計算する必要はありません。
-
Tight制約は、厳密に一つのジオメトリが満たすことのできる制約です。例えば、横幅の最小値と最大値、縦幅の最小値と最大値が互いに等しい場合、これらの制約を唯一満たすことができるサイズは、それと同じ縦幅と横幅を持つ場合のみです。もし親要素がTight制約を指定する場合、親要素は
それが子要素のサイズに依存しない限りは、
子要素がそのレイアウトを再計算する場合でも、親要素はそのレイアウトを再計算する必要が無くなります。
なぜなら、子要素は親要素から新しい制約が渡されない限り、そのサイズを変更できないからです。
(Constraints.isTight -> bool)
-
RenderObjectは、それが親要素から渡された制約を使うことをそのジオメトリを決定するためだけに宣言できます 。こういった宣言は、たとえ制約がTightでなくても、親要素のレイアウトが子要素のサイズに依存しようとしても、そのレンダーオブジェクトの親要素がそのレイアウトを再計算する必要がなくなることをフレームワークに知らせます。
なぜなら、子要素は親要素から新しい制約が渡されない限り、そのサイズを変更できないからです。
これらの最適化の結果として、レンダーオブジェクトツリーがdirtyなノードを含んでいる場合、これらのノードとその周りのサブツリーの、限られた部分がレイアウト処理時に走査されます。
準線形のウィジェット構築
レイアウト処理のアルゴリズムに似て、FlutterのWidget構築アルゴリズムは準線形です。構築後、Widget達はユーザインターフェイスの論理構造を保持する、Elementツリーに保持されます。
(Widget.createElement -> Element)
このElementツリーは、Widget自身は不変、つまり彼らは彼らの親要素や子要素の関係を記録できないので、不可欠のものとなります。
Elementツリーはまた、Statefulウィジェットに関連付けられたStateオブジェクトを保持します
(StatefulElement._state -> State, State._element -> StatefulElement)
ユーザからのインプットに応答するため、例えば、開発者がStateオブジェクトにてsetStateを呼び出した場合、Elementはdirtyになることがあります。
フレームワークはdirtyなElementのリストを保持し、ビルド時にそれらに直接ジャンプしてcleanなElementをスキップします。
ビルド時には、情報は一方向にElementツリーを下り、これは各Elementはビルド時に少なくとも一回参照されることを意味します。
一度クリーンにされると、帰納法により、それらの全ての祖先のElementもcleanにされるので、Elementは再びdirtyになることはできません。
(Element
..makeNeedsBuild -> void
.._dirty -> bool
..rebuild -> void,
BuildOwner
.._dirtyElements -> List
..buildScope -> void
)
Widgetは不変なので、Elementがdirtyであるとマークされていなかった場合、もし親要素が同一のウィジェットを用いてElementをリビルドした時は、Elementはビルド時の走査からすぐに抜け出すことができます。そのうえで、Elementが行えばいいのは、新しいWidgetと古いWidgetが同一であることを確立する為の、二つのWidgetの同一性の検証のみです。
開発者達は再投影パターンを実装するためにこの最適化を活用します。
この再投影パターンでは、前のビルド時にWidgetにメンバー変数として格納された、プリビルドされた子Widgetを用います。
(StatefulElement.update の assert(widget == newWidget))
ビルド時、FlutterはInheritedWidgetを使うことによって親のチェーンの走査を避けます。もしWidgetが同じようにして親を走査した場合、例えばテーマカラーを決める処理の場合、ビルド処理が木構造の深さに応じてO(N^2)となり、積極的なコンポジションによって多くの計算量を要してしまうからです。この親の走査を避ける為、フレームワークは各ElementのInheritedWidgetのハッシュテーブルを維持することによって、情報を木構造の下に流します。通常、多くのElementが同じハッシュテーブルを参照するので、新しいInheritedWidgetを作るElementのみがこのハッシュテーブルを変更します。
線形的な和解(?)
人気の信念に反して、Flutterは木構造差分アルゴリズムを採用しません。(Reactとかにあるやつ)
その代わり、フレームワークはO(N)のアルゴリズムでElementの子要素リストを個別に調べて、Elementを再利用するかどうか判断します。
子要素の線形的な和解(?)アルゴリズムは、以下のケースで最適化されます:
- 古い子要素リストが空の場合
- 二つの子要素リストが同一である場合
- 子要素リストの一部のみで、複数の
Widgetの挿入、削除があった場合 - もし各リストが同じ
Keyを持つWidgetをもつ場合、その二つのWidgetは同一である
この手法は、実行時の型情報と各WidgetのKeyを比較することによって、子要素リストのはじめと終わりを合わせる為にとられました。この手法ではすべての不一致の子を含む各リストの中央に空でない範囲を見つける可能性があります。(?????)
フレームワークはそれから 子要素をその範囲に Keyに基づいて古い子要素をハッシュテーブルに置きます。(?????)
次に、フレームワークはこの新しい子要素リストの範囲を走査し、そしてハッシュテーブルにKeyがマッチするものを問合せます。マッチしなかった子要素は捨てられ、一から再構築されます。一方、マッチした子要素は新しいWidgetと共に再構築されます。
木構造の操作
Elementを再利用することはパフォーマンス改善に重要です。ElementはStatefulWidgetの為のStateと、基礎となるRenderObjectの二つの重要なデータを持つからです。
フレームワークがElementを再利用できる場合、 ユーザインターフェイスの論理的な部分のStateは保存され、前に計算されたレイアウト情報が再利用でき、全部のサブツリーの走査を避けます。事実、Elementを再利用することは、FlutterがStateとレイアウト情報を保存するnon-localなツリーの変異をサポートする上で重要です。
(non-local treeって何…?)
開発者達は、GlobalKeyとWidgetを紐づけることにより、non-localツリーの変異を実行できます。これらのGlobalKeyはアプリケーション全体でユニークなものとなり、それはスレッド特有のハッシュテーブルに登録されます。ビルド時には、開発者はWidgetをGlobalKeyとともに、Elementツリーの任意の場所に移動できます。その場所で新しいElementを構築するより、フレームワークはハッシュテーブルをチェックし、既にあるElementを前の場所から新しい場所に再配置し、サブツリー全体を保存します。
(GlobalKey._registry)
再配置されたサブツリー内のRenderObjectは、レイアウト制約のみがレンダーツリーにて親から子に渡される情報のため、そのレイアウト情報を保存できます。新しい親要素は、その子要素のリストが変更されるため、dirtyであるとマークされます。しかし、もし以前と同じレイアウト制約が子要素に渡された場合、子要素はすぐにレイアウト処理の走査から抜け出せます。
GlobalKeyとnon-localツリーの変異は、Hero遷移やナビゲーションなどで開発者達に広く用いられています。
定数化に基づく最適化
これらのアルゴリズムの最適化に加え、積極的なコンポジションを達成する為に、いくつかの重要な定数化に基づく最適化が鍵となってきます。これらの最適化は、説明してきた主要なアルゴリズムに最も重要なものとなります。
- 子モデルの不可知論
子要素のリストを用いる、ほかの多くのツールキットとは違い、FlutterのRenderツリーは特定の子要素の実装に依存しません。例えば、RenderBoxclassには、具体的な子要素を表すfirstChild``nextSiblingのようなインターフェイスではなく、abstractなvisitChildren()メソッドがあります。多くのサブクラスは子要素のリストではなく、一つの子要素のみをメンバ変数として保持してサポートします。例えば、RenderPaddingは一つの子要素のみをサポートするので、結果、シンプルで実行に時間を要しないレイアウト処理メソッドとなっています。
(RenderObject.visitChildren)
(RenderShiftedBox._child)
-
可視化された
Renderツリーと、論理的なWidgetツリー
Flutterでは、Renderツリーはデバイスに依存しない視覚座標系で動作します。つまり、現在の読み取り方向が右から左であっても、x座標の値が小さいほど常に左になります。。Widgetツリーは通常、論理座標で動作します。つまり、視覚的な解釈は読み取り方向によって異なります。論理座標から視覚座標への変換は、WidgetツリーとRenderツリーの間のハンドオフで行われます。Renderツリー内のレイアウトと描画の計算は、WidgetからRenderツリーへのハンドオフよりも頻繁に行われ、座標変換の繰り返しを避けることができるため、このアプローチはより効率的です。 -
Textは特別なRenderObjectによって処理される
RenderObjectの大多数は、Textの複雑さを知りません。代わりに、Textは特殊なRenderObjectである、RenderParagraphによって処理されます。 テキスト対応のRenderObjectをサブクラス化するのではなく、開発者はコンポジションを使用してTextをユーザーインターフェイスに組み込みます。 このパターンは、ツリーの走査中であっても、その親が同じレイアウト制約を提供する限り、RenderParagraphはそのテキストレイアウトの再計算を回避できることを意味します。
(RichText.createRenderObject -> RenderParagraph)
-
Observableなオブジェクト
Flutterはモデル監視とリアクティブパラダイムの両方を使います。 明白に、リアクティブパラダイムが支配的ですが、Flutterはいくつかの葉のデータ構造に対して観測可能なモデルオブジェクトを使用します。 たとえば、Animationsは、値が変わるとオブザーバリストに通知します。 Flutterはこれらの観察可能なオブジェクトをWidgetツリーからRenderツリーに渡します。これはそれらを直接観察し、変更時にパイプラインの適切なステージのみを無効にします。 たとえば、_Animation_を変更すると、ビルドフェーズとペイントフェーズの両方ではなく、ペイントフェーズだけがトリガされることがあります。
これらを踏まえた上で、積極的なコンポジションで作られた木構造をまとめると、これらの最適化はパフォーマンスへの大きな影響を与えます。
無限スクロール
無限スクロールリストは知っての通り、ツールキットの課題となっています。FlutterはBuildパターンに基づいてシンプルなインターフェイスで、無限スクロールリストをサポートします。ListViewはスクロール中にユーザーに見えるようになると、必要に応じてコールバックを使用してWidgetを作成します。
可視化されたレイアウト
Flutterのほとんどのものと同様、スクロール可能なWidgetはコンポジションを用いて構築されています。スクロール可能なWidgetの外側はViewPointで、これは「内側の方が大きい」ボックスです。つまり、その子はViewPortの境界を超えて拡大でき、View内にスクロールできます。ただし、RenderBoxの子を持つのではなく、ViewPortには、sliverと呼ばれるRenderSliverの子があります。これは、ViewPort対応のレイアウトプロトコルを持ちます。
Sliverレイアウトのプロトコルは、Boxレイアウトのプロトコルの構造と一致しています。つまり、親は子に制約を渡し、代わりにジオメトリを受け取ります。ただし、制約とジオメトリデータは2つのプロトコル間で異なります。`Sliver{プロトコルでは、子供にはビューポートに関する情報(残りの可視スペースの量を含む)が与えられます。 返されるジオメトリデータによって、折りたたみ式のヘッダーや視差など、さまざまなスクロールリンク効果が有効になります。
Sliverが異なると、ビューポートで使用可能なスペースをさまざまな方法で埋めます。例えば、子要素の線形リストを作成するSliverは、Sliverが子要素を使い果たすか空間を使い果たすまで順番に各子要素をレイアウトします。同様に、子要素の2次元グリッドを作成するSliverは、表示されているグリッドの部分だけを塗りつぶします。それらはどのくらいのスペースが見えるかを知っているので、たとえそれらが無限の数の子供を生み出す可能性を持っていても、Sliverは有限数の子供を生み出すことができます。
Sliverはオーダーメイドのスクロール可能なレイアウトと効果を作成するために構成することができます。例えば、単一のViewPointは、折りたたみ可能なヘッダーとそれに続く線形リスト、それからグリッドを持つことができます。3つのSliverはすべて、Sliverレイアウトプロトコルを介して協調して、ビューポートから実際に見える子だけを生成します。それらの子がヘッダ、リスト、またはグリッドのいずれに属しているかは関係ありません。
オンデマンドでWidgetを構築する
…