🔩

忙しい人のためのFlutter's Rendering Pipeline

2022/12/04に公開

この記事は、Flutter Advent Calendar 2022の4日目になります。


Flutter界隈のみなさまいかがお過ごしでしょうか、mjhdです。

師走もこれからいよいよ寒さが増す今日この頃ですが、私はVRゴーグルを付け流行りのメタバースというやつに入り浸りVRイベントとVR睡眠を繰り返す日々を送っております。VRの世界には常夏や常冬がありふれているため季節感も何もなく現実世界では電熱線に巻かれヌクヌクしながら電脳世界で雪山を登ったり飛び降りたり。この記事もVR空間内で執筆しています。部屋はヌクヌクで寒くはないのですがVR空間内でふと現実の年齢を思い出すと身体がブルブルッと震えます。VR内では永遠の18歳のはず。何かがおかしい。どうしてこうなった。

すみません、Flutterの話をしますね…

以前、Flutterレンダリングパイプライン入門 - CA Developers Blogという記事を執筆しました。
今回はその延長として入門記事で紹介したFlutter’s Rendering Pipeline - Youtubeという動画の日本語訳を執筆しようと思ったのですが、あまりにも文量が多くなってしまったため、この記事では動画「Flutter’s Rendering Pipeline」の要約をかいつまんで説明したいと思います。(と言いつつ、書いてみたら結構な文量になっちゃった…)

読むことで得られる知識

この記事を読むことで、以下のような内容について理解が進むと思います:

  • Flutterのレンダリングパイプライン設計の背後にある思想が理解できる
  • Flex Layoutの具体的なレイアウト処理について理解できる
  • RelayoutBoundary/RepaintBoundary/RasterCacheなどのパフォーマンス最適化機構について理解できる

Highly Subjective Roadmap to Flutter DevelopmentのFlutter学習ロードマップを引用すると、この記事の内容は「Almost there」の一歩手前、「Getting Deeper」の最終項目にあたります。ロードマップ上の今までの項目について理解しており、さらにFlutterの内部実装についても理解したい人向けとなります。

Highly Subjective Roadmap to Flutter Developmentの図から一部抜粋。Getting Deeperの最後の項目として、Flutter Internalsを学ぶこと、そのためにAdam Barth氏によるFlutter's rendering pipelineの動画が良いと書かれている

はじめに

RenderingPipelineのイメージ図

入門編ではRenderingPipelineの全体感について説明し、大まかな流れを理解できるように努めました。
大まかな流れをつかめたところで、さらに重要になってくるのがFlutterが生まれた命題でもある、「速さ」へのこだわりです。
「速さ」つまりパフォーマンスのためにFlutterのRendering Pipelineでは設計時点から様々な工夫をしています。

この記事では、入門編では触れられていなかったパフォーマンスに関する設計思想や、どのような工夫が実装されているのか、開発者は何を意識すべきなのかを解説していきたいと思います。

Simple is Fast: シンプルは速い

「Simple is Fast」は基本的な設計方針です。
Flutterのレンダリングパイプラインでは至る所でこの「Simple is Fast」の原則に則ってシンプルに設計されています。
元動画をみると良く解りますが、他のシステム(WebブラウザやUIKit、Android、Cocoaなど)との比較を通じて、シンプルな設計になっていることを都度説明してくれています。

この「Simple is Fast」という設計方針は、「シンプルなアルゴリズムは性質が良く知られていて最適化がしやすい」という根拠のもと採用されています。
Flutterでは、様々なレンダリング関連の処理にシンプルな木構造と深さ優先探索を用いたり、シンプルなレイアウトシステムを使うことで高速な描画処理を実現しています。

The Mahogany Staircase

入門編などでざっくりと解説しましたが、Flutterにはたくさんの木構造があります。
この木構造を徐々に変換していき、Widget Treeから始まりLayer Tree、そして最終的にはSemantics TreeやEngineLayer Treeに変換されるこの仕組みのことを「The Mahogany Staircase」と呼んでいます
この変換処理や、木構造の更新処理の至る所で「Simple is Fast」原則に基づいたアルゴリズムを用いて、1-passで、O(N)以下で処理を完了できるよう工夫がされています。

Flutterは元々、Chromeブラウザをより高速化してモバイル端末で60FPSの処理が行えるプラットフォームとして開発された経緯があります。このモチベーションが、まさにこの「Simple is Fast」に表れていると言えます。つまり、複雑なブラウザを見直し、よりシンプルにしたものと考えられます。
以下、これらのパフォーマンスに関する工夫について解説していきたいと思います。

主役: RenderObject

入門編などで出てきたRenderObjectがこの記事の主役になります。
RenderObjectは主に以下のような処理を担っています:

  • 親と子供を持ち、木構造を構築する(全ての木構造のベースになるAbstractNodeの機能)
  • layout()を持ち、レイアウト処理を行う
  • paint()を持ち、描画とLayerの生成を行う
  • visitChildren()を持ち、子供を探索する
  • parentDataを保持する
  • などなど…(その他にもhitTest()などもありますがこの記事では触れません)

そして、RenderObjectは抽象クラスであり、具体的なレイアウト処理や描画処理は実装側に移譲しています。(performLayout, performResize, paintなど)
また、子供が何の型なのか、子供が複数いるのか、単数しかいないのかなども全て実装側に移譲されています。(visitChildrenの実装も移譲されています)
なぜここまで実装が移譲されているかというと、この方がシンプルで最適化がしやすいという利点もあることながら、設計当初、今後複雑なレイアウトを実現するためにレイアウトアルゴリズム自体を柔軟に変更できるよう抽象化したい、という経緯があったようです。
最終的には、FlutterのRenderBoxという基本的なレイアウトアルゴリズムのみで十分複雑なレイアウトが実現できるため、現在ではRenderBoxかRenderSliverやその派生RenderObjectぐらいしか見かけることはなかなかありません。

RenderBoxはデカルト座標上におけるConstraintsであるBoxConstraintsを用いて子供をレイアウトしていくクラスです。BoxConstraintsはmin/maxのwidth/heightから構成されます。
これ以外にも、FlutterのサンプルコードにはSecterConstraintsを使うRenderSectorという曲座標系を用いたレイアウトアルゴリズムなども実装されていますので、興味がある方はみてみるとより理解が深まると思います。

このように、RenderBox自体は子供が何であるか、レイアウトアルゴリズムが何であるかに関して全く何の意見も持っていません。
開発者が、デカルト座標系を使う、や曲座標系を使う、Sliverを使うなどの知識を実装することで様々なレイアウトアルゴリズムをFlutter上に載せることができるのは、なかなか綺麗で面白い点だと思います。

無駄のないアルゴリズム: Linear

RenderObjectについておさらいと簡単な設計思想を解説したところで、実際にパフォーマンスに関する工夫をご紹介したいと思います。
FlutterがRenderObjectの木構造(ここではRenderObject Treeと呼びます)に関して行う処理は、主にLayoutとPaintです。そして、これらの処理はLinearで行われるよう工夫されています。
Linearという用語が出てきましたが、これは前述した1-passで、O(N)以下となるようなアルゴリズムを採用している、という部分に対応しています。
Linearの場合は、例えばRenderObject Treeがあったとして、RenderObjectの個数をNとしてO(N)で解決できるような処理となります。

元動画中では他のレンダリングシステムとの比較として、UIKitではより複雑なConstraintsを扱っているという話がされていたりします。
このような点からも、Flutterの「Simple is Fast」という思想が見て取れます。

Layoutフェーズ: Constraints go down, Sizes go up

まず、RenderObject TreeのLayoutフェーズに関する説明をしていきたいと思います。
Constraints go down, Sizes go up」、この用語はとても有名なのでご存じの方も多いと思います。
FlutterのLayoutアルゴリズムは、木構造の上から1-passで走査していき、Constraintsを親から子に、Sizeを子から親に受け渡し、これを再起的に実行することでレイアウトを決定します。
データフローの説明としてはこれ以上の説明は特にないのですが、実際我々が使っているレイアウトがどのように計算されているかを実際に追ってみることで、この処理により具体的なイメージが付くようにしてみましょう。

Flex Layout

では、例としてFlex Layoutのレイアウト手順について簡単に説明したいと思います。
ここにおける説明は大事な部分だけ取り出しています。より詳細な説明がみたい方は、ぜひ元動画その訳をご参照ください。

Flex Layoutのステップ

図のように、大まかに以下のようなステップでレイアウトされています:

  1. 固定サイズの子供のSizeを調べる
  2. 残りのスペースを合計のFlex Factorで分配し、Flexサイズの子供のSizeを決定する
  3. 子供のparentDataに、子供のサイズから求めたoffsetを格納していく

参考コード: https://github.com/flutter/flutter/blob/7a8c84f9d936272122c9a3348d8e32295c4660a9/packages/flutter/lib/src/rendering/flex.dart#L924

ここで、parentDataは親が子供に自由に持たせるができるレイアウトデータを格納する場所です。
格納するデータは自由なため、子供は親がどんなデータを格納しているか知ることはありません。(これにより、子供は親のレイアウトアルゴリズムに依存することがなく、親も子供を自由に扱うことができるようになり高速化に繋がります)
基本的に、RenderBox系では主にBoxParentDataかその派生クラスが使われており、中にはPaint時に使用されるUIの位置Offsetなどが格納されています。

このレイアウト処理は1-passで行われていることがわかります。(同じ子供に二度Sizeを聞くことはない)
このように、さまざまな場面で1-passを意識したレイアウトをFlutterは行っています。

Paintフェーズ

Layoutフェーズが終わった後、FlutterはPaintフェーズを行います。Paintフェーズの主な目的は「UIの見た目を決定し、Layer Treeを生成する」ことです。
入門編で解説したように、RenderObjectのpaintメソッドで行われ、必要であればLayerが構築されていきます。

このLayerの構築作業は、親から子へOffsetを渡していき、子から親へ書き込むべきLayerが返される、とみることができます。Layoutの時と同様に、これも1-passの深さ優先探索となることが分かります。

構築されたLayer Treeはdart:uiSceneBuilderを経由してEngineLayer Treeへと変換され、これがEngine側でRasterizeされ描画されます。
こちらも、Flutterの「Simple is Fast」の原則にのっとり、無駄のない処理が行われていることが分かります。

ここまででFlutterのレンダリングパイプラインのうち、RenderObjectが担うLayout, Paintフェーズの解説をしました。
実は、Flutterのパフォーマンスに対するこだわりはまだ続きます。次の章では、Linearより更に速いSub-linearに関する説明をします。

最適化: Sub-linear

今までの解説では、Linear/1-pass/O(N)などの用語を使って無駄のない処理が行われていると説明してきました。
Flutterでは、二回目以降のTree更新処理では更に最適化し、Sub-linearなレンダリングを行います。
Sub-linearという単語に聞き覚えが無い方もいらっしゃると思います。(僕はなかったです)
Sub-linearは、Linearより更に計算量が少ない、日本語に訳すと劣線形と呼ばれる性質です。
例えば、Linearなアルゴリズムにさらにキャッシュなどの工夫を取り入れたことにより、RenderObjectの個数をNとした時に、ツリー全体を調べずともサブツリーだけを走査することで解決できるアルゴリズムです。

では、具体的にどのような最適化が行われ、Sub-linearが実現されているかを説明していきます。

Layoutフェーズの最適化: RelayoutBoundary

Flutterには二つのBoundaryがあります。
一つはRelayoutBoundaryで、もう一つは後述するRepaintBoundaryです。
BoundaryはFlutterの中では、「あるRenderObjectを親から分離することで、変更の影響を閉じ込めるための防波堤」という意味合いで使われています。

RenderObject Treeの変化をサブツリーにとどめるBoundaryを説明した図

RelayoutBoundaryはRelayout、つまり二回目以降のレイアウトの更新処理を削減するための境界線で、RenderObject Treeの一部分を親と分離し、子供でどんな変化が起こったとしても親は影響を受けない防波堤のような役割を果たします。
これにより、RelayoutBoundary以下のUIが変化したとしても、RenderObject Tree全体を更新する必要はなく、RelayoutBoundary内のみの更新で済むため、RenderObject全体の個数Nではなく、より少ないSub-linearな更新処理にすることができます。

RelayoutBoundaryは以下の条件で自動的に挿入され、最適化が行われます:

  • Constraintsがtightな場合(max/minが同じ値で、子供のサイズが変化することがない)
  • parentUsesSizefalseであり、親が子供のサイズをレイアウトに使わないと宣言されている場合
  • sizedByParenttrueであり、親から受け取ったConstraintsのみによってサイズが決まると宣言されている場合

RelayoutBoundaryの存在は普段開発者には見えません。
ですが、DevToolsでWidget Treeを確認してみるとRelayoutBoundaryの存在を確認することができます。

RenderObjectにRelayoutBoundaryが設定されている様子

このように、Flutterは影ながら私たちのUIを最適化してくれています。しかし、最適化はこれだけではありません。

Paintフェーズの最適化: RepaintBoundary

名前から既に予想がつくかもしれませんが、Paint処理に対する防波堤がRepaintBoundaryです。
こちらも同様に、子供の描画内容の変更をRenderObject Treeのサブツリーに収めることで、Paint処理をSub-linearに行うことができる役割を担っています。

しかし、こちらはRelayoutBoundaryとは違い、自動的に挿入されません。
RepaintBoundaryというウィジェットがあるため、これで明示的に包む(か、isRepaintBoundaryを設定する)必要があります。
ですが、基本的には公式のウィジェット群や行儀のよいUIライブラリを使っている限りは、開発者が自ら設定する必要はなく、公式側で明示的に挿入してくれているはずです。

UIを自作する場合、特にスクロールUIのような場所によって描画頻度の違うUIを自作する場合は、我々が明示的に防波堤/境界線を設定する必要があります。
そうしなければ、描画頻度の高い領域に引きずられて、描画頻度の低い領域も無駄に再描画されてしまうためです。

こちらも、DevToolsを使うことでRepaintBoundaryの存在を確認することができ、更にどのぐらい効果を発揮しているかの統計値も見ることができます。

DevToolsのRepaintBoundaryの欄に「this is an outstandingly useful repaint boundary and should definitely be kept」と表示されている様子

この画像では、metricsという項目に「どのぐらい親と子で描画タイミングが違ったか(違うほど良い)」という数値と、diagnosisに「このRepaintBoundaryはとっても良いので、ぜひ残しましょう」と表示されています。

2つのBoundaryのうち、開発者が意識する可能性があるものがこのRepaintBoundaryになります。

Rasterizeフェーズの最適化: RasterCache

ここからはEngine内部の話になります。
先ほど紹介したRepaintBoundaryのさらなる効果として、RasterCacheによる描画の最適化があります。
先ほど説明した最適化はRenderObject TreeからLayer Treeを生成する処理の最適化でしたが、今回はEngine内部でEngineLayer TreeをRasterizeする際に行われる最適化です。
ここにもRepaintBoundaryが役割を果たします。

Paintフェーズが限られたサブツリーのみの更新で終わったとしても、そこで生成されたLayerツリーを毎回、描画コマンドからピクセルへ変換していては効率が悪いです。例えば、スクロールUIのように中身はほぼ変わらないが毎フレーム再描画が必要なUIが存在します。この場合、毎回描画コマンドからピクセルへ変換するより、一度ピクセル化した内容をキャッシュして次回以降内容を使いまわした方が高速です。

RepaintBoundaryを挿入することで、Layerの一種であるPictureLayer内に保持されている描画コマンド列、DisplayListが分割されます。そして、DisplayListが前回の描画内容と3回以上同一であり、かつ十分複雑な場合、Engine内にピクセル列がキャッシュされ使いまわされます。
(この3回という数字は、CPUの分岐予測などでも古典的に使われていた2bit飽和カウンタに由来しており、低コストにある程度効率よくキャッシュ判定が行えるようです)

よって、描画の頻度が異なる位置にRepaintBoundaryを適切に挟むことによって、Engineに極力同一のDisplayListを送ることができ、RasterCacheの生成を促すことができるのです。
動画中では、無限スクロールを例にとり解説されていて、これらの最適化により3世代前の古いデバイスでもおよそ1ms以内にスクロール処理を行うことができ高速であると説明されています。

RasterCacheも普段は見えませんが、checkerboardRasterCacheImagesというフラグをtrueにすることで、存在を確認することができます。

Flutter GalleryのRasterCacheを確認している様子

また、RasterCacheについては簡単にですが、こちらにも解説を載せています。

これらの最適化により、高速なFlutterの描画処理が実現されていました。

まとめ

以上が、動画「Flutter’s Rendering Pipeline」の要約でした。

稚拙な訳ですが全文訳は、スクラップとしてFlutter's Rendering Pipelineの和訳に残しています。
ぜひ、余裕のある方は元動画や和訳を見ていただけると、より面白い事実が知れるのではないかなと思います。

元動画:
https://youtu.be/UUfXWzp0-DU

それでは皆様…

良いお年をと言っている年末仕様のDash君

関連記事

この記事の内容と近い記事をご紹介します。複数の観点から内部設計を見ることで、理解が深まると思います:

過去のFlutterアドベントカレンダー記事

Discussion