Flutter の Widget ツリーの裏側で起こっていること
Widget・State・Element・RenderObject の関係とそのライフサイクルを理解する
Flutterでアプリを普通に開発していると、触れるのはWidgetと(Stateful Widgetの)Stateがメインとなりますが、その裏側のElement・RenderObjectがどうなっているかを知ると品質・パフォーマンスともに良い正確なコードを書きやすくなると思っています。
また、公式ドキュメントのPerformance considerationsに書いてあることも、そのあたりを分かっていないと、きちんと理解するのが難しく、理解があやふやなまま何となく単なるお作法として形式的に守ることになりがちな気がしています。
というわけで本記事ではWidgetの裏側のライフサイクルや実際の描画処理に至るまでの流れを説明していきます。
説明に使うサンプルアプリ
新規プロジェクトを作成したらできる雛形とほぼ同じで、少しシンプルに調整してます(右下のボタンが四角い青色なのは普通のFABだとエフェクトがかかってCount部分以外も再レンダリングされてしまうのでGestureDetectorとContainerで自前で組んでそれを抑制しました)。
こちらがソースコードです:
初期のElementツリーが構築されるまでの流れ
まず、アプリが起動すると、ルートのElementからbuildが連鎖的に走って、次のようなWidgetツリーおよびElementツリーが構成されます。
(実際にはもっとたくさんのWidgetのツリーになっていますが簡略表示しています。)
図が混むので省略していますが、Element要素はそれぞれWidgetを参照しています。
なされる処理をもう少し細かく書くと次のようになっていて、ルートのApp Widgetからそれに対応するElementが次々と生成されていき、末端にたどり着くまで繰り返されます。ルートからWidgetという設計図を頼りに上からElementツリーがじわじわ形成されていくイメージです。
- main 関数からrunApp にルートに生成したApp Widgetを渡す
- ルートElementはApp Widgetを inflateWidget によって子Widgetとして取り込もうとする
- そのために必要なApp Widget の Elementが createElement によって生成される
- 続けて、その生成されたElementは親Elementにmountされる
- mount内で呼ばれる performRebuild メソッドの中でApp Widgetのbuildメソッドが呼ばれて子Widgetが生成される
- App WidgetのElementは同様にそれをupdateChild内の inflateWidget によって子Widgetとして取り込もうとする
- 以降、その子WidgetからElementが生成されて…の繰り返し
WidgetはElementツリーが生成されるまで要求された通りにbuildで子Widgetを返すだけで状態を持たない一方、Elementは状態を持っていてこのあと生成済みのElementおよびそのツリー構造をできる限り再利用しようとします。
また、Widgetツリーはそれ自体の関係性は保持されておらず、Elementから参照されているだけです。
Widgetは以下のことだけに徹した軽量オブジェクトです。
- レンダリングに必要な情報の保持(例えばText Widgetでは文字列・スタイル情報など。座標などの具体的なレンダリング情報では無い。)
- 自身のElementの生成
- 子Widgetのbuild
StatefulWidgetの場合
上の流れはStatelessWidgetの場合で、StatefulWidgetの場合少し差があります。
まず、StatefulWidgetは直接buildメソッドを持たず、createStateメソッドでStateを生成して、そのStateが StatefulElement をbuildし、StateとStatefulElementは相互参照します。
上述の通り、Elementは状態を持ちますし、もちろんStateもその名の通り状態を持っていて、ElementとStateはライフサイクルをともにします。Elementツリー的には、StatefulWidgetによって生成されたStatefulElementの場合に限ってはStateと相互参照するようになるという違いが生まれて、StatefulElementの内部実装的にもそのstateを管理するためにStatelessElementと比べて実装が少しだけ複雑です。
ツリーの図も以下のように状態を持つElementツリーとStateでグルーピングした方が分かりやすいかもしれません。
buildメソッドを持つのがStatefulWidgetではなくStateとなっている理由はStatefulWidgetのbuildメソッドのドキュメントに記されています。簡単に言うと、アプリケーション開発者がStatefulWidgetを継承したWidgetを扱いやすくするためです。
RenderObjectツリーの構築
上記の説明ではアプリが起動してからのElementツリーが構築にしか触れておりませんが、実際のUIへの反映にはこのElementツリー構築に伴い構築される RenderObject のツリーが重要です。
まず、Elementには大別して2種類あります。
普通にFlutterアプリを書いてて通常継承して利用する以下の3つは、createElementが呼ばれた時に前者のComponentElementを返します。それ自体は描画には直接関係せず、描画は子Widget以下のRenderObjectElementに委ねます。
- StatelessWidget
- StatefullWidget
- InheritedWidget
RenderObjectツリー構築に関係するのは後者のRenderObjectElementです。createElementが呼ばれた時にRenderObjectElementを返すWidgetはRenderObjectWidgetという抽象クラスを継承しています。
身近な例だと以下などです。
- RichText WidgetがLeafRenderObjectWidgetを介してRenderObjectElementを継承している(よく使う Text WidgetはbuildメソッドでRichTextを返しています)
- Container Widgetがbuildメソッドで返すDecoratedBoxなどは、SingleChildRenderObjectWidgetを介してRenderObjectWidgetを継承している
前者のLeafRenderObjectWidgetはその名の通りレンダリングに直接関与する末端のWidgetであり、後者は同じく自身でレンダリングに関与つつもさらに子Widgetも持ちます。
冒頭の説明では、まずWidgetツリーとElementツリーのことにしか触れませんでしたが、実はElementツリー構築とともにそれがRenderObjectElementの場合はRenderObjectツリーも構築されていきます。
- RenderObjectWidgetがcreateElementを呼ばれた時、RenderObjectElementを返す
- Elementがmountされた時、参照しているRenderObjectWidgetの持つcreateRenderObjectを呼んでrenderObjectを生成して参照を保持する
- renderObjectはさらにattachRenderObjectを通してRenderObjectツリーに挿入される
このあと、RenderObjectツリーの情報をもとに、レイアウト・ペイント処理が走ってネイティブビューへの描画がなされて、ようやく画面表示されます。
(あとでもう少し詳しく触れます。)
以上、3種類のツリーが出てきましたが、役割分担としては次のようになっています。
- Widget: ステートレス・immutable・軽量・設計図
- Element: ステートフル・mutable・WidgetとRenderObjectの仲介役
- RenderObject: ステートフル・mutable・レンダリングを担う
RenderObjectも合わせて図示すると次のようになります。
表示するべきカウントの「0」はElementは直接持っておらず、参照しているText WidgetからRenderObjectへ転記しているのが特に仲介役らしいところです。
WidgetとElementを分けている理由
例えば、iOSのUIKitはこれを分けておらず、アプリ開発者が直接Elementを触るような作りになっています。すなわち、アプリ開発者がアプリ初期状態を定義してさらに状態が変わる度にそれに応じてUIKitにどの部分をどう変更するか伝えて、それの積み重ねでUI更新が成り立っています。
一方、FlutterはステートレスなWidgetでUIを宣言的に構築するという作りになっています。この場合、アプリ開発者はUIのどの部分を変更するか決めるのではなく、「この瞬間にこうなってほしい」ということを宣言します。すべてがステートレスではアプリの状態を保てなくなってしまうので、それはStatefulWidgetのStateで補っています。つまり、「このStateの時はこう表示してほしい」ということのみ宣言するということです。
こうすると、アプリ開発者はUIの状態管理自体はする必要がなくなり、設計図にあたるWidgetを定義した上で状態を保持するStateの管理だけで済むようになります。
Stateの変化に伴い、Elementツリーを更新する場合、愚直に行うとWidgetツリーとStateを使って、アプリ起動直後と同様に上からElementツリーを構築し直せば、正しいElementツリーが構築できます。基本的な概念的にはこうですが、このやり方では富豪的すぎてパフォーマンス的に不利(特にアニメーションのカクツキなどの大きな要因になる)なので、以下の工夫がなされます。
- アプリ開発者がサブツリーに限定した更新(setState)になるように気をつける
- Flutterのフレームワークとして、Widgetツリーの更新を効率よくElementツリーに適用できるようにしてある
後者のために、フレームワークとして以下の工夫がなされています。
- WidgetがStatelessな軽量オブジェクトである一方、Stateを持つなど生成コストが比較的高めのElementは可能な限り使い回す(Elementとともに生成される重いRenderObjectの再生成を抑えるため、という方が本質的かもしれません)
- Widgetツリーの差分をO(N)で検出して、既存Elementの更新・新規作成・破棄を適切に行って最新のWidgetツリーに効率よく追従する
ElementとRenderObjectを分けている理由
こちらはライフサイクルが一緒なので、分けている必然性はWidgetとElementよりは低く、主に責務の適切な分離のためかなと思っています。
また、Elementの更新はRenderObjectに伝わりますが、RenderObjectは保持している値と比較して更新の必要がなければその処理をスキップするという最適化も行なっています。
……という考察をしていましたが、その後、Separation of the Element and RenderObject trees というドキュメントが追加されていました👀
StateのsetStateが呼ばれてから画面更新までの流れ
前半なぞったのは初期表示までの流れでしたが、次にStateが更新されてからの画面反映までの流れをなぞっていきます。
StateのsetStateが呼ばれる
状態を変更・反映させるためにはStateのsetStateを呼びます。
(scoped_modelやBLoCパターンではsetStateを明示的に呼ばずに済みますが、実際にはフレームワーク・Widgetの中で呼ばれています。)
カウンターアプリを例にすると、+ボタンが押されると、State内で次の処理を呼びます。
setState(() => _counter++);
setStateの実装はassertだらけで、実質以下の処理をしているだけです。
- 与えられたコールバックを実行
- 相互参照関係にあるelementのmarkNeedsBuildを実行
markNeedsBuildはその名の通り、buildの必要性をマーキングした上でリビルドを予約していて、具体的には次の処理となっています。
_dirty
フラグを立てる- BuildOwnerのscheduleBuildForを呼ぶ
- その中でBuildOwnerの保持する_dirtyElementsにelementが追加される(element更新待ちのキューに入るイメージ)
まずは上記処理が同期的に呼ばれて一旦終了で、この瞬間は実際の更新処理はまだなされていません。またこの瞬間に各所から何度もsetStateが呼ばれても更新待ちのElementが増えるだけ(同じElementだった場合は無視)で、実際の更新が連続で呼ばれるわけではないです。
_dirtyElements を処理
実際の更新処理は次のようになされます。
- 毎フレームごとに呼ばれるhandleDrawFrameが発火
- _dirtyElementsが走査されて、中のelementにperformRebuildが呼ばれる
- 冒頭に書いてあるアプリ起動時の流れと似た流れで、setStateされたStateのElementサブツリーが再構築される
- _dirtyElementsをclear
こういった、フラグを立てた上で処理を予約して実際の処理が走ったらフラグを戻すというパターンは、Flutterの更新系の内部実装として頻出です。
setStateからリビルドまでの流れを図示すると次のようになります。
Elementサブツリーの差分更新
冒頭に書いてあるアプリ起動時の流れと似た流れで、setStateされたStateのElementサブツリーが再構築される
こう書きましたが、setStateからのリビルドでは、すでに既存Elementツリーが構築済みなので、再構築の仕方が少し変わってきます。
アプリ起動直後はElementツリーが存在しないので、Elementはすべて新規に生成されてツリーが構築されていきます。
一方、setStateからのリビルドではそのElementサブツリーがすでにあるため、参照している古いWidgetと新しいWidgetのbuild結果とを照らし合わせながら、効率よくElementサブツリーを更新していきます。
ざっくり以下のようなことをしています。
- 古いWidgetと新しいWidgetが同一のオブジェクト(参照が同じ)だった場合はそのまま再利用(constおよびキャッシュされているWidgetが該当する)
- 同じruntimeType・同じKey(不指定の場合もともにnullで条件に合致する)のElementが存在すればそのElementを再利用して新しいWidgetで更新
また、子が複数の時にもO(N)で無駄なく差分更新されるように工夫されていて、それは RenderObjectElementのupdateChildrenにてなされています。
というわけで、例えば以下が呼ばれただけの場合、Elementサブツリーの構造は変わらないこともあり、すべて既存のElementを流用しつつ参照しているWidgetのみ新しく生成されたものに更新することになります。
setState(() => _counter++);
以上のsetStateからElementツリー更新をまとめると次のようになります。
- setStateが呼ばれたStateのElement以下のサブツリーを更新しようとする
- そのサブツリー構築に必要なWidget(軽量オブジェクト)はconstおよびキャッシュされているWidgetを除いてすべて再生成される
- Widgetを設計図として更新されるElementはできる限り既存のものを流用するようになっていて、かつ差分反映処理はO(N)で済むように考慮されている
ElementサブツリーをRenderObjectサブツリーに適用
アプリ起動直後はRenderObjectElementのmountタイミングでcreateRenderObjectによってRenderObjectを作成・保持していましたが、setStateの際は再利用されたElementの場合は作成済みの保持しているRenderObjectを同じく再利用します。
再利用する際は、RenderObjectWidgetのupdateRenderObject が呼ばれます。ここではRenderObjectに対してWidgetが持つ情報をsetしていきます。
例として、RichTextのupdateRenderObjectは次のような実装となっています。
これだけ見ると、更新対象のElementサブツリーに対応するRenderObject群はすべて更新されそうに見えますが、それぞれのRenderObjectの具象クラスのsetterには差分に応じて必要な更新のみを行うための最適化処理が挟み込まれています。再レンダリング負荷が低い順に次のようになります。
- 更新がまったく不要だったら何もしない
- 再ペイントが必要だったらそれだけ行う
- 再レイアウトが必要だったらそれと、さらにそれに合わせて必然的に必要な再ペイントを行う
上のRichTextの例では、RenderParagraphのtextから順に更新していますが、そのsetterは次のように実装されていて、テキスト内容に差分がまったく無ければ即returnして再レンダリング要求がなされないようになっています。
つまり、Counterアプリの場合、基本的に変わるのはcountの数値だけなのでそのText(のRenderParagraphというRenderObject)だけが再レンダリング対象です。
このように、RenderObjectによって前回までのフレームでのレンダリング内容との差分的に本当に必要な再レンダリングだけをするように最適化されていることと、各々のRenderObjectはレイアウト情報を保持していることにより、局所的な画面更新がなされた時の再レンダリングコストは低めに抑えられています。
また、今回のサンプルの場合は _count
が変化しない限りレンダリング結果は不変なので、仮に以下のように呼んだ場合は再レンダリング処理は全く走りません。
setState(() {});
ただ、再レンダリング処理自体はされないものの、そこに至るまでの以下の処理はなされるので大きなサブツリーを持つStateに対して空のsetStateをするとそれなりの負荷はかかります。
- Widgetサブツリーの再生成
- Elementサブツリーの更新
- RenderObjectの差分チェック
再レイアウト・再ペイント要求処理
・再ペイントが必要だったらそれだけ行う
・再レイアウトが必要だったらそれとそれに合わせて必然的に必要な再ペイントを行う
上のように書きましたが、StateのsetStateを呼んだ時にelementのmarkNeedsBuildを実行されるのと同様に、まず主に以下が呼ばれます。
- 再ペイント要求: markNeedsPaint が呼ばれて、PipelineOwnerの
_nodesNeedingPaint
にRenderObjectが登録されつつ_needsPaint
フラグが立つ - 再レイアウト要求: markNeedsLayout が呼ばれて、PipelineOwnerの
_nodesNeedingLayout
にRenderObjectが登録されつつ_needsLayout
フラグが立つ
そしてその後、PipelineOwnerのrequestVisualUpdateを呼んで、一旦処理が終わります。
実際の再レイアウト・再ペイント処理
まずは、再レイアウト処理について記述します。
- 毎フレームごとに呼ばれるhandleDrawFrameが発火
- PipelineOwnerのflushLayoutが実行される
_nodesNeedingLayout
の要素のRenderObjectのperformLayoutが呼ばれる- _needsLayoutフラグが戻る
- markNeesPaintが呼ばれる(上で再レイアウト要求がされた場合必然的に再ペイントを行うと書いたのはこの処理に相当)
再ペイント処理は次の流れです。
- 毎フレームごとに呼ばれるhandleDrawFrameが発火
- PipelineOwnerのflushPaintが実行される
- _needsLayoutフラグを戻して、
_nodesNeedingPaint
の要素のRenderObjectのpaintが呼ばれる
ここから、例えばiOSだったらUIViewをCanvasと見立てて実際の描画をしていきますが、ここまでくるとflutter/engine の領域になってきて、実際のアプリ開発でここまで知る必要がないことがほとんどだと思います。
逆に、flutter/flutter 領域は、アプリ開発者的に深いところまで知っていた方が良いかというと、役立つ場面が多いと思っています。
まず、Widget・State・Element・RenderObject の関係とそのライフサイクルを理解していると、品質・パフォーマンスともに良い正確なコードを書きやすくなります。
さらに、例えばCupertino Style(iOS Style)のアクションシートでもRenderBox(RenderObjectの派生クラス)を継承した実装をしていたり、凝ったハイパフォーマンスなUI実装をするにあたりこのあたりまで知っていないとできないこともあり得ます。
(こちらに関しては、必要になったタイミングで勉強すれば良いと思いますが。)
とはいえ、ElementはともかくRenderObjectまで意識する必要があるのは実際のアプリ開発では限定的であり、とりあえず以下の要点を抑えておけば良いとも思っています。
- RenderObjectツリーの初期構築コストはWidgetツリー・Elementツリーと比べて最も高い(レイアウト計算などするため)
- ただ、RenderObjectは極力使い回されるのでその初期構築コストがかかるのは限定的(同じく状態を持っていて既存のオブジェクトが極力使い回されるElementに管理されているため)
- レイアウト情報を保持しており、Widgetツリーがリビルドされても再レンダリングはツリー全体にはなされずに本当に必要な箇所のみに限定されるため、パフォーマンス劣化を過度に恐れる必要は無い(気にしなくて良い、という意味ではない)
というわけで、Widget・Element・RenderObjectの初期構築〜setState起点にツリー更新してUIに反映していく流れをなぞり終えました。
本記事ではWidgetツリーの裏側のElementツリーとRenderObjectツリーについての説明にとどまりましたが、これらを理解した上でいかに無駄なくアプリの状態を更新していくかの具体的な実装について次の記事で触れていきます🐶
参考
まず、このInside Flutter およびそのリンク先は本記事で書いたような内容理解の補助にとても大事なドキュメントです。
主にデバッグ実行しながらソースを読んで、こういうドキュメントも併読して、Flutterの内部理解を進めました。
また本記事を書いている途中に見つけた以下の記事も良いです(3つめは中国語ですが)。
あと、Reactの仮想DOMの原典であるこちらもシンプルな説明で参考になりました(Flutterの新しいWidgetツリーをElementツリーに適用するところと概念的に一緒です)。