英語の勉強がてら、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
ツリーは特定の子要素の実装に依存しません。例えば、RenderBox
classには、具体的な子要素を表す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
を構築する
…