Flutter のレンダリング機構について調べてみた
- Authors

- Name
- ごとれん
- X
- @ren510dev
目次
- 目次
- はじめに
- クロスプラットフォームとレンダリング方式
- Native Rendering(OEM)
- WebView Rendering
- Proprietary Rendering
- Flutter のアーキテクチャ
- Skia と Impeller
- レンダリングパイプライン
- 最適化のメカニズム
- レンダリング機構
- VSync シグナル
- UI スレッドと GPU スレッド
- スレッド間通信
- プロセス設計
- レンダリングパイプラインのコードリード
- 1. Animate
- 2. Build
- 3. Layout
- 4. Compositing Bits
- 5. Paint
- 6. Submit
- 7. Rasterization and Compositing
- Android / Chromium / Flutter の比較
- まとめ
- 参考・引用
はじめに
Flutter は単一のコードベースからモバイル、Web、デスクトップといった複数のプラットフォームに向けて、ネイティブコンパイルされたアプリケーションを構築できる UI フレームワークです。 Flutter は独自のエンジンとシンプルなパイプライン設計により、React Native や WebView と比較して高いレンダリングパフォーマンスを発揮するとされています。
クロスプラットフォームでありながら Flutter が高性能な描画を実現できる原理について気になったので、今回のブログでは Alibaba Cloud の記事を参考に、レンダリングプロセスと最適化戦略について Deep Dive してみます。
クロスプラットフォームとレンダリング方式
クロスプラットフォームにおける一般的なレンダリング方式としては、以下の 3 つの解決策が利用できます。
特に Flutter がクロスプラットフォーム開発において高性能と言われる理由は、OEM ウィジェット(iOS / Android 標準の UI 部品)や WebView を使用せず、独自のレンダリングエンジンを使用している点にあります。
Native Rendering(OEM)

JavaScript でロジックを書き、UI は OS 標準の部品(OEM Widgets)をブリッジ経由で呼び出します。 パフォーマンスが高い一方で、OS 毎の仕様差異が多く、ブリッジ通信がボトルネックになることがあります。
- 例:React Native
WebView Rendering

HTML / CS S /JavaScript を WebView 上で動作させます。 DOM 操作やブリッジ通信のコストがあり、ネイティブほどの性能は出にくい傾向があります。
- 例:Cordova / Ionic
Proprietary Rendering

OS の UI 部品を使わず Skia や Impeller と呼ばれる 2D グラフィックスエンジンを使って UI コンポーネント自体を自前で描画します。 これにより OS のバージョンに依存しない一貫した UI とネイティブアプリに匹敵するパフォーマンスを実現します。
- 例:Flutter
Flutter のアーキテクチャ
Flutter のシステムは、大きく 3 つのレイヤに分かれています。
- Framework(Dart)
- 開発者が直接触れる層
- Material / Cupertino ウィジェット、レンダリング、アニメーション、ジェスチャが含まれる
- Engine(C++)
- Flutter のコア部分
- Skia(2D レンダリングエンジン)、Dart Runtime(Dart の実行環境と GC)、Text(テキストレンダリング)が含まれる
- Embedder(Platform Specific)
- 各 OS(iOS、Android、Linux 等)とのインターフェース
- レンダリング用の Surface 設定、スレッド管理、プラグイン通信等を担当する
Flutter のレンダリングプロセスを理解する上で最も重要なのが、Widget、Element、RenderObject という 3 つのツリーの関係性です。 3 つのツリーを分ける理由は端的に述べると パフォーマンス のためです。
描画計算(RenderObject)は非常に重い処理です。 UI の一部だけが変わった時(例:テキストの色変更)、Widget(設計図)は丸ごと作り直しても低コストですが、重い RenderObject を作り直すのは無駄です。 Element が変更点だけを RenderObject に伝えることで、高コストな計算を最小限に抑えます。
Widget Tree
Widget Tree は UI の不変(Immutable)な設計図として機能します。 非常に軽量で、状態が変わるたびに破棄・再生成されます。
- 例:
Container、Text等の開発者が記述するクラス
Element Tree
Element Tree は Widget と RenderObject の仲介役として機能します。 可変(Mutable)で、Widget が再生成されても、Element は可能な限り再利用されます。
主な機能は以下の通りです。
- 論理的な構造を管理する
StatefulWidgetの場合、そのStateオブジェクトを保持する- 差分検知(Diffing)を行い、必要な場合のみ RenderObject を更新する
RenderObject Tree
RenderObject Tree は実際のレイアウト計算と描画命令の管理を担当します。 可変(Mutable)で、生成コストが高い構造です。
主な機能は以下の通りです。
- Layout:サイズと位置の計算
- Paint:描画命令の生成
- Hit Testing:タッチイベントの判定
Skia と Impeller
Flutter は OS の UI 部品を使わず、Skia や Impeller といった独自のグラフィックスエンジンで UI を直接描画 します。 これにより、Native Rendering や WebView Rendering と比較して以下のメリットがあります。
Bridge 通信のオーバヘッドがない
React Native では、JavaScript スレッドとネイティブスレッド間で Bridge を介した非同期通信が必要です。 UI の更新のたびにデータをシリアライズ / デシリアライズして Bridge を通過させるため、高頻度な更新(スクロール、アニメーション等)でボトルネックになります。
Flutter は Dart コードから描画エンジンへ直接描画命令を渡すため、この通信コストが発生しません。
OS の UI 部品に依存しない
Native Rendering では OS 標準の UI 部品(iOS の UIKit、Android の Android Views)を呼び出すため、OS バージョンやメーカー毎の挙動差異に対応する必要があります。
Flutter は全てのピクセルを自前で描画するため、iOS でも Android でも完全に同一の見た目と動作を保証できます。
DOM 操作のコストがない
WebView Rendering では、UI の変更が DOM ツリーの更新 → スタイル計算 → Layout → Paint → Composite というブラウザのレンダリングパイプラインを経由します。 DOM ノードが多いとこのパイプライン全体が重くなり、60fps の維持が困難になります。
Flutter はブラウザを介さず GPU 上で直接ラスタライズするため、DOM のオーバーヘッドがありません。
Skia
Skia は Google が開発するオープンソースの 2D グラフィックスライブラリで、Chrome、Android、Firefox 等多くのプロダクトで採用されています。
Flutter は当初から Skia をレンダリングバックエンドとして使用しており、RenderObject ツリーから生成された描画命令(Layer ツリー)を Skia が GPU 上でラスタライズします。 ただし Skia はランタイムにシェーダをコンパイルするため、初回描画時やアニメーション中に一瞬カクつく Shader Compilation Jank と呼ばれる事象が発生することがあります。参考

Impeller
Impeller は Flutter チームが Skia の課題を解決するために開発した新しいレンダリングエンジンです。
Skia との主な違いは以下の通りです。
- シェーダをビルド時に事前コンパイル(AOT)するため、ランタイムでの Shader Compilation Jank が発生しない
- Metal(iOS / macOS)や Vulkan(Android)といったモダンな GPU API を直接活用する
- Flutter 専用に設計されているため、汎用ライブラリである Skia より効率的なレンダリングパスを実現する
Flutter 3.16 以降、iOS では Impeller がデフォルトのレンダリングバックエンドとして使用されています。 Android でも Flutter 3.22 以降、Impeller がデフォルトで有効化されています。
レンダリングパイプライン
画面が表示されるまでの 1 フレームの処理フローは以下の通りです。

- Animation:タイマやアニメーション値の進行
- Build:Widget を作成して Element Tree を構築・更新する
- Layout:要素のサイズと位置を計算する
- Paint:表示内容を描画命令(Layer)として記録する
- Submit:Layer ツリーを Scene として GPU スレッドに送信する
- Rasterize & Composite:各レイヤを合成した上で GPU を使ってピクセルデータに変換してディスプレイに表示する
Layout プロセス
Flutter のレイアウトは 1 パス(1 回の走査)で行われるため高速です。
- Constraints Down:親から子へ制約(Constraints)を渡す(例:幅は 0〜300px の間で自由にしろ)
- Sizes Up:子は制約の中で自分のサイズを決めて親へサイズ(Size)を報告する
Paint プロセス
RenderObject は画面に直接ピクセルを打つのではなく描画命令(Draw Call)を記録します。 これらは Layer Tree という構造になり最終的に合成(Composite)されます。
最適化のメカニズム
Flutter がフレームレートを維持できる理由は、更新範囲を限定する仕組みにあります。
Relayout Boundary
ある要素のサイズ変更が、親や他の要素に影響を与えない場合、その境界を設定することで、レイアウト計算の連鎖をそこで止めることができます。
- 例:サイズ固定のコンテナ内の変更
Repaint Boundary
ある要素の見た目が変わっても、レイアウト(サイズ)が変わらない場合、その要素だけを独立したレイヤとして扱います。
メリットは、そのレイヤだけを書き換えれば良く、画面全体を描き直す必要がないことです。
- 例:スクロールビューの中身(スクロールしても中身の描画自体は変わらないため位置をずらすだけで済む)
レンダリング機構
VSync シグナル
レンダリング機構を理解する上で VSync(Vertical Synchronization) シグナルは重要な概念です。
VSync シグナルとはディスプレイが画面を上から下へ描き終わり、次のフレームの描画を開始するタイミングで GPU ハードウェアが発する同期信号です。
仮に、VSync 無しでフレームを描画した場合、画面の上半分は古いフレーム・下半分は新しいフレームが表示される ティアリング(描画ズレ) が発生します。VSync に同期することで、フレームの切り替えが画面のリフレッシュタイミングと一致し、滑らかな表示になります。
VSync は一般的なディスプレイでは 60Hz(約 16.67ms 毎)に 1 回発生します。
UI スレッドと GPU スレッド
Flutter のレンダリングは UI スレッドと GPU スレッドによって実行されます。
主に UI スレッドはプロデューサとして機能し、GPU スレッドはコンシューマとして機能します。
Android では Choreographer という Java クラスが VSync シグナルを管理しており、Flutter はそこに登録してコールバックを受け取ります。UI スレッドは VSync を受信するたびにフレーム描画を開始し、完了後に GPU スレッドがラスタライズするというサイクルが駆動されます。
ここで、ラスタライズ(Rasterize) とは描画命令(ベクタデータ)を実際のピクセルデータ(ビットマップ)に変換する処理を指します。

UI スレッド と GPU スレッドの連携(サイクル)により、VSync シグナルに同期して繰り返されることで滑らかな UI が実現されます。
UI スレッド(プロデューサ)
UI スレッドは Dart VM 上で Dart コード(アプリケーションコードおよび Flutter フレームワークコード)を実行します。 その後、Widget ツリー、Element ツリー、RenderObject ツリーを構築、レイアウト、描画し、描画命令を生成して Layer ツリーを生成します。 Layer ツリーは描画命令を保存します。
- VSync シグナルを受信してフレーム描画を開始
- Widget ツリー → Element ツリー → RenderObject ツリーを構築
- Layout(サイズ・位置の計算)→ Paint(描画命令の生成)→ Layer ツリーを生成
GPU スレッド(コンシューマ)
GPU スレッドは Flutter エンジン内でグラフィック関連コード(Skia)を実行し、GPU と通信して Layer ツリーを取得し、Layer ツリーをラスタライズおよびコンポジットして画面に表示します。
- UI スレッドから Layer ツリーを受け取る
- Skia を使って描画命令をピクセルデータに変換(ラスタライズ)
- 各レイヤを重ね合わせ(コンポジット)て画面に表示
スレッド間通信
UI スレッドと GPU スレッドは Producer-Consumer パターンで Layer ツリーを受け渡します。

- UI スレッドは描画パイプライン(Build → Layout → Paint)を経て Layer ツリーを生成する
RenderView.compositeFrame()でSceneBuilderを使いって Layer ツリーからSceneオブジェクトを構築するwindow.render(scene)で Scene を C++ エンジン層に渡す- エンジン内で Layer ツリーが GPU スレッドのタスクランナーにポストされる
- GPU スレッドがタスクを取り出してラスタライズを実行する
エンジン内部では同時に 1 つの LayerTree しか保持しないため、GPU スレッドが前のフレームの処理を完了するまで UI スレッドは次のフレームの生成をブロックします。 GPU スレッドの処理が遅延すると UI スレッドも待たされ、フレームドロップ(ジャンク)の原因となります。
プロセス設計
Flutter のレンダリングプロセスおよび Dart で記述された UI を画面上のピクセルに変換するまでの一連の処理は以下の通りです。
1. VSync シグナルの待機
まず、Flutter エンジンが起動すると、Android の Choreographer クラスに登録され、VSync シグナルのコールバックを待機・受信します。

2. Dart フレームワークからのレンダリング呼び出し
VSync シグナルを受信すると Flutter に登録された VsyncWaiter::fireCallback() コールバックが発火して、Animator::BeginFrame() を実行、最終的に Window::BeginFrame() メソッドが呼び出されます。
Window インスタンスは、下位エンジンと Dart フレームワークを接続するブリッジとしてに役割を担っており、入力イベント、レンダリング処理、アクセシビリティ処理といった大半のプラットフォーム関連操作を実行します。

3. Layer ツリーの生成と GPU スレッドへの送信
Window::BeginFrame() が呼び出されると、RendererBinding クラス内の RendererBinding::drawFrame() メソッドが呼び出されます。 このメソッドは UI 上のレイアウトおよび再描画が必要なノードを処理します。
レンダリング中に画像が含まれる場合、画像はワーカスレッドでデコードされ、その後 I/O スレッドに渡されて画像テクスチャが生成されます。 I/O スレッドは GPU スレッドと EGL コンテキストを共有しているため、GPU スレッドは I/O スレッドが生成した画像テクスチャに直接アクセスすることができます。

4. GPU スレッドでのラスタライズとコンポジット
Dart フレームワークが描画を完了すると、描画命令が生成され Layer ツリーに保存されます。
その後 Animator::RenderFrame() が呼び出され、Layer ツリーが GPU スレッドに送信されます。 GPU スレッドは Layer ツリーをラスタライズおよびコンポジットし、画面に表示します。
GPU スレッドは Animator::RequestFrame() を呼び出し、次の VSync シグナルを受信します。

この仕組みにより UI は継続的に更新されます。
レンダリングパイプラインのコードリード
ここでは、前述したレンダリングパイプラインの各フェーズが Flutter の実装コード上でどのように動作しているかを追っていきます。
Flutter エンジンが起動すると Choreographer クラスに自動的に登録され、VSync シグナルを待機・受信します。 GPU ハードウェアが VSync シグナルを生成すると、コールバックがトリガされ UI スレッドを駆動してレンダリングを開始します。
1. Animate
| トリガ方法 |
|---|
SchedulerBinding.handleBeginFrame() |
Animate は handleBeginFrame() メソッドの transientCallbacks によってトリガされます。 アニメーションが存在しない場合、コールバックは null を返し、存在する場合、Ticker._tick() がコールバックされてアニメーションウィジェットが次のフレームの値を更新します。
handleBeginFrame() が終了すると handleDrawFrame() が呼び出され、以下のコールバックがトリガされます。
persistentCallbacks:レンダリングをトリガするpostFrameCallbacks:描画完了をリスナに通知する
これら 2 つのコールバックは SchedulerBinding 内のコールバックキューです。
_transientCallbacks:一時的なコールバックを保存Ticker.scheduleTick()で登録され、アニメーションを駆動する
_persistentCallbacks:永続的なコールバックを保存- このコールバック内で新しい描画フレームを要求することはできない(登録後に削除することもできない)
RendererBinding.initInstances().addPersistentFrameCallback()により追加され、drawFrame()をトリガする
_postFrameCallbacks:フレーム処理後に呼び出され、その後削除される- フレーム処理が完了したことをリスナに通知する
その後 WidgetsBinding.drawFrame() メソッドが呼び出される。 WidgetsBinding.drawFrame() は、まず BuildOwner.buildScope() を呼び出してツリー更新をトリガし、その後描画処理を実行する
次に RendererBinding.drawFrame() が呼び出され、Layout、Paint 等の処理が実行されます。
2. Build
| トリガ方法 |
|---|
BuildOwner.buildScope() |
前述の通り、handleDrawFrame() によってツリー更新がトリガされます。 BuildOwner.buildScope() は次の 2 つのタイミングで呼び出されます。
- ツリー構築(アプリケーション起動時):
runApp()メソッドによって呼び出されるscheduleAttachRootWidget()が Widget ツリー、Element ツリー、RenderObject ツリーの 3 つのツリーを構築する - ツリー更新(フレーム描画・更新時):ツリー更新では 3 つのツリーを再構築するのではなくダーティ領域(変更が必要な領域)の要素のみを更新する
初回起動時のみコールバック付き(element == null)で buildScope() が呼ばれ、3 つのツリーが構築され、フレーム更新時はコールバック無しで buildScope(rootElement!) が呼ばれます。
コールバックは 3 つのツリーを構築します。 Widget は UI 要素の抽象的な記述であるため、Widget を Element に展開し、対応する RenderObject を生成してレンダリングを駆動する必要があります。
Widget ツリー
Widget は Element の構成を記述します。 createElement メソッドが呼び出され、Element が生成され、更新が必要かどうかが判断されます。
Flutter は差分アルゴリズムを使用して Widget ツリーの変更を比較し、Element の状態が変更されたかどうかを判断します。
Element ツリー
Element は Widget ツリー上の特定位置におけるインスタンスです。 createRenderObject メソッドが呼び出され、RenderObject が生成されます。 Element ツリーは Widget と RenderObject を保持し、Widget の構成および RenderObject の描画を管理します。
Flutter は Element の状態を管理するため、開発者は Widget のみを管理します。
RenderObject ツリー
RenderObject ツリーは実際の描画処理を担当するオブジェクトで構成されます。
performLayout()でサイズと位置を計算paint()で描画命令を生成hitTest()でタッチイベントを判定
RenderObject は Widget や Element と異なり、生成コストが高いため、Element が差分検知した結果に基づいて必要な場合のみ生成・更新されます。
3. Layout
| トリガ方法 |
|---|
PipelineOwner.flushLayout() |
Layout は単方向データフローに基づいて実装されています。 具体的には、親ノードが子ノードに制約を渡し、子ノードがサイズを親ノードに返します。 サイズ情報は親ノードの parentData 変数に保存されます。
最初に RenderObject ツリーが深さ優先で走査され、その後制約が再帰的に伝播されます。 単方向データフローにより、Layout プロセスが簡素化され、レイアウト性能が向上します。

RenderObject クラスは基本的なレイアウトプロトコルのみを提供しており、子ノードモデルや座標系、具体的なレイアウトプロトコルは定義していません。 そのサブクラスである RenderBox は Android や iOS のネイティブ座標系と同じデカルト座標系を採用しており、ほとんどの RenderObject はこの RenderBox を継承して実装されています。 RenderBox にはレイアウトアルゴリズム毎に複数のサブクラスが用意されています。
RenderFlex は一般的なフレックスレイアウトで、Flex、Row、Column ウィジェットに対応します。 RenderStack はスタックレイアウトです。
Constraints
Constraints(制約)は、BoxConstraints や SliverConstraints 等があり、親ノードが子ノードのサイズを制限するために使用されます。
RenderBox は minWidth / maxWidth / minHeight / maxHeight 制約を持つ BoxConstraints を提供します。
これらの BoxConstraints によって、親ノードと同じサイズを持つ子ノード、親ノードと同じ幅を持つ縦レイアウト、親ノードと同じ高さを持つ横レイアウト等、多くの一般的なレイアウトを柔軟に実装できます。
子ノードのサイズは、これらの制約と子ノード自身のサイズ情報をもとに決定されます。 サイズが確定した後、親ノードが子ノードの位置を決定します。
RelayoutBoundary
RelayoutBoundary はレイアウト計算の伝播範囲を制限するための仕組みです。 このフラグが設定されたノードでは、子ノードのサイズが変更されても親ノードへの再レイアウトが伝播しません。 markNeedsLayout() でダーティとマークされた際にこのフラグがチェックされ、再レイアウトの範囲が境界内に限定されます。
Layout プロセスが完了すると、すべてのノードの位置とサイズが確定し、次の Paint プロセスへ進みます。
4. Compositing Bits
| トリガ方法 |
|---|
PipelineOwner.flushCompositingBits() |
Layout プロセスが終了すると、Paint プロセスの前に Compositing Bits プロセスが実行されます。 このプロセスでは、RenderObject が再描画を必要とするかどうかをチェックし、RenderObject ツリー内の各ノードの needCompositing フラグを更新します。 needCompositing が true の場合、再描画が必要となります。
5. Paint
| トリガ方法 |
|---|
PipelineOwner.flushPaint() |
Flutter は複数のレイヤに分割して描画します。 これにより変更があったレイヤだけを再描画すればよく、描画コストを抑えることができます。 Paint プロセスでは、各 RenderObject の描画命令をどのレイヤに割り当てるかを決定します。

Paint プロセスでは RenderObject ツリーを上から下へ深さ優先で走査し、各ノードの描画命令をどのレイヤに割り当てるかを決定します。 すべてのノードの走査が完了すると、描画命令がレイヤ毎にまとめられた Layer ツリーが生成されます。
RepaintBoundary
RepaintBoundary は再描画の伝播範囲を制限する仕組みで、Layout における RelayoutBoundary の描画版です。
RepaintBoundary が設定されたノードは独立したレイヤとして分離されます。 そのため、子ノードの見た目が変わっても親のレイヤを再描画する必要がなく、変更があったレイヤだけを効率的に再描画できます。 markNeedsPaint() でダーティとマークされた際にこのフラグがチェックされ、再描画の範囲が境界内に限定されます。
例えば、スクロールコンテナでは、スクロールによってコンテンツの位置は変わりますが、コンテンツ自体の描画は変わりません。 RepaintBoundary でレイヤを分離することで、スクロールのたびに画面全体を再描画せずに済みます。
Layer ツリーの構造
ルートノードの RenderView がルートレイヤを作成し、その下に複数のサブレイヤがぶら下がるツリー構造を形成します。 各 RenderObject は描画時に描画命令を生成し、対応するレイヤに保存します。
関連するレイヤは、以下のレイヤクラスを継承します。
| レイヤクラス | 用途 | 主なパラメータ |
|---|---|---|
ClipRectLayer | 矩形クリッピング | クリップ矩形、動作(none / hardEdge / antiAlias / antiAliasWithSaveLayer) |
ClipRRectLayer | 角丸矩形クリッピング | クリップ角丸矩形、動作 |
ClipPathLayer | パスクリッピング | クリップパス、動作 |
OpacityLayer | 透明度制御 | 透明度、オフセット |
ShaderMaskLayer | シェーダマスク | シェーダマトリクス、ブレンドモード |
ColorFilterLayer | カラーフィルタ | カラー、ブレンドモード |
TransformLayer | 変換(回転・拡縮等) | 変換マトリクス |
BackdropFilterLayer | 背景フィルタ | 背景画像パラメータ |
PhysicalShapeLayer | 物理形状(影・高さ) | カラー、elevation 等 |
描画の基本概念を理解したところで、描画プロセスを見ていきます。
前述の通り、最初のフレームがレンダリングされる際、RenderView(ルートノード)から描画が開始され、すべての子ノードが走査されます。
描画プロセスは以下の通りです。
Canvas オブジェクトの作成
Canvas オブジェクトは PaintContext を通じて取得されます。
PaintContext 内では PictureLayer クラスが作成され、ui.PictureRecorder を介して C++ レイヤに渡され、SkPictureRecorder インスタンスが生成されます。 このインスタンスは SkCanvas を生成するために使用され、生成された SkCanvas は Dart フレームワークに返されます。 SkPictureRecorder は生成された描画命令を記録するために使用されます。
Canvas 上で描画を実行
描画命令は SkPictureRecorder によって記録されます。
ラスタライズの実行
描画が完了すると、PaintingContext.stopRecordingIfNeeded() → PictureRecorder.endRecording() の順に呼び出され、描画命令をすべて含んだ Picture オブジェクトが生成され PictureLayer に保存されます。
すべてのレイヤの描画が終わると Layer ツリーが完成し、GPU スレッドへ送信されます。
6. Submit
| トリガ方法 |
|---|
renderView.compositeFrame() |
最後に Layer ツリーを GPU スレッドに送信する Submit 処理(Compositing)を実行します。
Submit の流れは以下の通りです。
SceneBuilderを生成し、addPicture()で Paint 済みの Picture を追加SceneBuilder.build()でSceneオブジェクトを構築window.render(scene)で Scene を C++ エンジン層に送信
エンジン層では Dart 側の Layer が C++ の flow::layer に変換されます。 flow は Skia ベースの軽量コンポジタで、GPU スレッド上で描画命令のラスタライズを実行します。
7. Rasterization and Compositing
パイプラインの最終段階では、描画命令をピクセルデータに変換するラスタライズと、各レイヤのピクセルデータを重ね合わせるコンポジットが行われます。
ラスタライズ方式の比較
| 方式 | 特徴 | 採用例 |
|---|---|---|
| 同期・直接 | • 1 スレッドで描画命令を直接ピクセルバッファに書き込む • シンプルで順序が保証される | Flutter / Android |
| 間接 | • レイヤ毎に個別のピクセルバッファを持ち、最後に合成する • 変更のないレイヤをキャッシュできる | Android |
| 非同期ブロックベース | • 画面をブロックに分割し、複数スレッドで並列ラスタライズする • 未完了ブロックはチェッカボード表示になる | Chromium |
Flutter は同期・直接ラスタライズを採用しており、コンポジット処理の中でラスタライズも同時に完了します。
GPU スレッドの処理フロー
ラスタライズのエントリポイントは Rasterizer::DoDraw() です。
内部で ScopedFrame::Raster() が呼ばれ、以下の順に処理が進みます。
LayerTree::Preroll():描画準備(サイズ計算)LayerTree::Paint():各レイヤの描画メソッドを再帰的に呼び出しSkCanvas::Flush():描画データを GPU に送信SwapBuffers():完成したフレームバッファをディスプレイに出力
最終的に GPU がフレームバッファにピクセルデータを書き込み、VSync シグナルのタイミングでディスプレイに表示されます。
Android / Chromium / Flutter の比較
Google の 3 大 UI プロジェクトのレンダリング機構の比較です。
基本設計

Android

Chromium

Flutter

| ツリー構築 | レイアウト | ラスタライズ | |
|---|---|---|---|
| Android | Java / XML → View ツリー | Box / Flex | 同期 |
| Chromium | JavaScript / HTML → DOM ツリー | Box / Flex | 非同期ブロックベース |
| Flutter | Dart → Widget / RenderObject | Box / Flex | 同期 |
レイアウトは 3 プロジェクトとも Box モデル・Flex を採用しており、レイヤ分割で再描画を最小化する設計も共通しています。
一方で Flutter は DOM パースが不要な分、ツリー構築が軽量で、パフォーマンス面で有利です。
ラスタライズについては、Chromium の非同期方式はキャッシュ効率が高い反面メモリ消費が大きく、Flutter / Android の同期方式はメモリ効率が良い反面レイヤアニメーションの最適化が限られるというトレードオフがあります。
まとめ
今回のブログでは、Alibaba Cloud の記事を参考に Flutter のレンダリングプロセスの概要と最適化戦略についてまとめてみました。
Flutter は Skia / Impeller による独自レンダリングエンジンを持ち、OS の UI 部品や DOM パースに依存しない設計になっています。
レンダリングパイプラインは VSync シグナルを起点に、UI スレッドが Build → Layout → Paint → Submit の順で描画命令を生成し、GPU スレッドがラスタライズしてディスプレイに出力します。 レンダリングエンジンの中核は Widget・Element・RenderObject の 3 層構造となっており、軽量な設計図である Widget の差分を Element が検知して変更箇所を特定し、生成コストの高い RenderObject の再生成を最小限に抑えます。
また、RelayoutBoundary や RepaintBoundary によって再描画の伝播範囲を制限することで、影響範囲をサブツリー内に閉じ込め、不要な再計算を防ぎます。
以上の通り、Flutter は DOM 非依存のアーキテクチャと 3 層構造による差分更新の最適化を組み合わせることで、高いレンダリングパフォーマンスを実現しています。