BEMAロゴ

エンジニアの
成長を支援する
技術メディア

【Flutter公式解説】『How Flutter Works』徹底解剖!内部構造から学ぶFlutter開発

はじめに

こんにちは、株式会社メンバーズ Cross ApplicationOpen in new tab カンパニーの田原です。

FlutterのUIはどうやって高速に描画されているんだろう?」「Widgetが画面に表示されるまでの内部の仕組みは?

これらの疑問は、より質の高いFlutterアプリ開発に繋がる大切な一歩です。その答えとなるのが、Flutter公式YouTubeチャンネルで公開された6部構成の動画シリーズ『How Flutter works』です。2025年4月末から5月上旬にかけて公開されたこのシリーズは、まさに開発者必見の決定版と言えるでしょう。

この記事では、この『How Flutter works』シリーズの内容を体系的にまとめ、さらに具体的な補足を加えることで、Flutterが裏側でどう動いているのかを初心者から中級者までが深く理解できるよう徹底解説します。Flutterの基本的な開発経験がある方も、これから本格的に学習したい方も、この記事が、あなたのFlutterに関する知識を飛躍させ、より質の高いアプリ開発へと繋がる強力な一助となることを願っています。

この記事は、以下のトピックで構成されています。

  • Flutterの全体像と基本原理
  • Flutterの「3つのツリー」を理解する
  • StatefulWidgetのライフサイクルとStateクラス
  • RenderObjectの詳細と描画プロセス
  • Flutter EngineとEmbedder: ネイティブプラットフォームとの連携

1. Flutterの全体像と基本原理

Flutterの動作原理を理解する上で、まずはその全体像と根幹をなすアーキテクチャについて見ていきます。

Flutterとは何か?

Flutterは、Googleが強力にサポートするDart言語で記述された、宣言型マルチプラットフォームUIフレームワークです。

宣言型UIフレームワーク

「宣言型」とは、UIの状態を直接「宣言」することで、その状態に応じてUIが自動的に更新される仕組みを指します。Flutterは、iOSのSwiftUIOpen in new tab、AndroidのJetpack ComposeOpen in new tab、Webの ReactOpen in new tabなど、他のモダンなリアクティブUIツールキットと同様に、宣言的にUIを構築できます。

たとえば、画面に表示されるボタンの色をユーザーの操作によって変えたい場合を考えてみましょう。

  • 命令型UIでは、「ボタンを探して色を赤に変えろ」と具体的な手順を指示します。
  • 宣言型UIでは「ボタンの状態が『アクティブ』なら赤色、『非アクティブ』なら灰色」と宣言するだけで、状態が変われば自動的にUIを更新してくれます。

このように、アプリケーションの状態に応じて動作し、特定のフレーム(画面の1コマ)のUIを返すコードを記述するのが宣言型UIの特徴です。Flutterではこれを「UIは状態の関数である」と表現します。

出典:UI is a function of (immutable) stateOpen in new tab

マルチプラットフォーム対応

「マルチプラットフォーム」とは、Flutterで書かれたコードが非常に多くの環境で実行できることを意味します。具体的には、iOSやAndroidデバイスはもちろん、macOS、Windows、Linux、Webといった幅広いプラットフォームで動作します。

さらに、LG がWebOSOpen in new tab向けに Flutter を導入しているように、様々な企業やコミュニティが他の環境向けに独自のランタイムを管理しています。これにより、Flutter は世界中のテレビやモニターにまで広がりを見せています。

Dart言語の役割と特徴

DartOpen in new tabは、Flutterの基盤となるプログラミング言語で、Googleによって2013年に初めてリリースされました。Flutterのパフォーマンスと開発体験のほとんどは、このDart言語によって実現されています。

  • クライアント最適化言語: Dartは、UI(クライアントサイド)の構築に特化して設計された言語です。実行時の解釈(インタープリター)を介さず、クライアントのネイティブマシンコードに直接コンパイルされるため、ローエンドのデバイスでも優れたパフォーマンスを発揮できます。
  • 特徴: Dartはオブジェクト指向で厳密に型付けされており、C#やKotlinといった言語に似た特性を持っています。また、非同期処理においては、JavaScriptに似たシングルスレッドのイベントループモデルを採用しており、効率的なI/O処理やUIの応答性を維持することができます。

出典:Event LoopOpen in new tab

Widgetの役割とUI構築の基本

flutter createでプロジェクトを作成した後、実際にUIを構築していく際は、Widget(ウィジェット)と呼ばれるクラスを使います。Widgetは、FlutterアプリのUIを構成する最小のブロックであり、「UIの設計図」と言えます。

Flutterでは、各Widgetがbuildメソッドを実行することで、そのWidgetがどのような見た目になるかをFlutterに伝えます。このbuildメソッドは、BuildContextという情報を受け取ります。

  • BuildContext: 現在のWidgetがUIツリーのどこに位置するか、画面のサイズ、向き、フォントのスケーリング、テキストの方向などのランタイム情報へのアクセスを提供します。これにより、Widgetはアプリが動作している環境に合わせたUIを柔軟に構築できます。

buildメソッドは常に単一のWidgetを返すため、副作用のない純粋な関数である必要があります。Flutter開発者は、buildメソッド内でさまざまな計算や処理を行えますが、最終的には必ずWidgetを返す必要があります。

Flutterは、各Widgetのbuildメソッドを再帰的に実行し、それらを自動的にツリー構造に構成します。このようにして形成されるのが「WidgetTree」です。宣言型UIの原則に基づき、アプリケーションの状態が変わるとWidgetツリーが再構築され、UIもそれに合わせて更新されます。

2. Flutterの「3つのツリー」を理解する

Flutterには、UIの状態と実際の描画処理を効率的に分けるための仕組みがあります。これは、私たちが作ったWidgetの裏側で、さらに3つのツリー構造が使われていることで実現されています。Flutter開発者が知っておくべき主要なツリーは以下の3つです。

  • WidgetTree
  • ElementTree
  • RenderObjectTree

なぜ3つのツリーが必要なのか?

Flutterの大きな強みの一つに「状態とレンダリングの分離」という概念があります。これにより、アプリケーションの状態が変化しても、UI全体をゼロから再構築するのではなく、変更があった部分だけを効率的に更新できます。この分離を実現する鍵が、WidgetTree、ElementTree、RenderObjectTreeの「3つのツリー」なのです。

この分離された構造が、Flutterの強力な機能である「ホットリロード」も実現しています。ホットリロードは、アプリの実行中にコードを変更しても、状態を維持したままUIを即座に更新できる機能です。

WidgetTree

WidgetTreeは、FlutterアプリケーションのUIを構成する最も基本的なツリーであり、UIの「設計図」そのものです。 画面に何を表示するか、どのようなレイアウトにするか、そのすべての設定がWidgetの階層構造としてこのツリーに記述されています。

個々のWidgetは「不変(immutable)」であり、必要に応じて「再構築可能(disposable)」です。つまり、UIの一部に変更が必要になった場合、Flutterは既存のWidgetを「変更」するのではなく、新しい状態を反映した新しいWidgetとして再構築します。この「使い捨て」で再構築される仕組みによって、アプリケーションコードが簡素化され、UIの更新が高速に動作します。

WidgetTreeの重要な役割

  • 宣言的なUI記述: アプリケーションの状態に応じて、UIがどうあるべきかを「宣言」します。
  • 効率的なUI更新: 状態が変化するとWidgetツリーが再構築されますが、FlutterはElementツリーを介して変更があった部分だけを効率的に更新します。
  • UI階層の定義: 親子関係によってUIの論理的な構造を定義し、レイアウトやイベント伝播の基盤となります。

Widgetの種類

Widgetには、主に以下の3つの種類があります。

  • StatelessWidgetOpen in new tab:親Widgetのbuildメソッドでインスタンス化され、UIのその部分が再構築されるまで存在します。表示内容が状態によって変化しない、静的なUI要素に使用します。(例: Text, Icon)
  • StatefulWidgetOpen in new tab:StatelessWidgetと同様に使い捨てですが、より長く存続するStateOpen in new tabオブジェクトによって支えられています。長時間存続するリソースが必要な場合や、アプリケーションの状態の変化を監視する場合にはStatefulWidgetを使用します。(例: Checkbox, TextField)
  • RenderObjectWidgetOpen in new tab:より低レベルな描画制御が必要な場面で使用されます。これについては後ほど詳しく解説します。(例: Align, Padding)

4つ目の種類としてInheritedWidgetOpen in new tabも存在しますが、これはデータを渡すだけでそれ自体は何もレンダリングしません。

ElementTree

ElementTreeは、WidgetTreeと、実際に画面への描画を担当するRenderObjectTreeの間に位置する「橋渡し役」です。 このツリーの主な役割は、UIの論理的な構造を管理し、アプリケーションの状態が変化した際に変更があった部分だけを効率的に更新することです。

Elementの役割

ElementOpen in new tabは、Widget(UIの設計図)と、Flutterの深層にあるレンダリング層を結びつける接着コード(グルーコード)のような存在です。Widgetが頻繁に再構築されるのに対し、Elementは長寿命なオブジェクトで構成されており、UI要素の永続的なアイデンティティ(識別子)を保持します。

BuildContextの役割

私たちがFlutter開発でよく目にするBuildContextOpen in new tabオブジェクトは、実は対応するWidgetのElementを指しています。 Widgetは自身のUIを構築するために最終的にElementを作成します。そして、このElementはbuildメソッドが実行される際に、画面サイズ、テーマ、アセットなどのランタイム情報にアクセスするためにBuildContextとして自身を渡します。

WidgetツリーとElementツリーの連携

新しいWidgetがWidgetTreeに最初に追加されると、Flutterフレームワークは、そのWidgetに対応するcreateElementメソッドを呼び出し、結果として生成されたElementがElementTreeにアタッチされます。この初期段階では、WidgetTreeとElementTreeは全く同じ構造を持ちます。

次に、FlutterはWidgetTreeを再帰的に処理しながら、新しいWidgetのbuildメソッドを呼び出します。ここで重要なのは、buildメソッドを実際に呼び出すのはElementであるという点です。ElementはBuildContextパラメータに自身を渡しながらbuildメソッドを実行します。これにより、さらに多くの子Widgetが生成され、その過程が繰り返されてツリーが構築されていきます。

Elementツリーが長寿命であることの最大の利点は、WidgetTreeが頻繁に再構築されても、ElementTreeは変更のあった部分のみを効率的に比較し、不必要なRenderObjectの再構築や再描画を防ぐことで、パフォーマンスを最適化できる点にあります。

RenderObjectTree

RenderObjectTreeは、実際に画面に描画されるUIの「見た目」を決定するツリーです。 このツリーを構成する各RenderObjectは、色や形、サイズ、配置といったピクセル単位での描画情報を持ち、物理的なレイアウトと実際の描画を担います。

RenderObjectの役割

RenderObjectOpen in new tabは、Elementツリーから受け取った情報に基づいて、Widgetの値を最終的にGPUで実行される「描画コマンド」へと変換します。Elementと同様に、RenderObjectも長寿命なオブジェクトであり、頻繁なWidgetの再構築から描画処理を分離し、パフォーマンスを最適化する役割を担います。

Flutterアーキテクチャの鍵となるのは、多段階かつ高度に階層化・抽象化されたレンダリングループです。このループの中で、各レイヤーは段階的にデータを変換し、OSが画面に表示できるピクセルバッファ(最終的な画像データ)へと近づけていきます。

RenderObjectの作成は、描画プロセスにおける最初の重要なステップです。RenderObjectは、Widgetの属性を利用してツリー構造に自らを組み立て、以下のコアなジョブを実行します。

  • Layout(レイアウト): UI要素のサイズと位置を決定します。親から与えられた制約(利用可能な空間)に基づいて、自身と子要素の最適なサイズを計算し、配置を決定します。
  • Painting(描画): 画面に表示されるピクセルを生成するための描画コマンドを生成・記録します。ここではまだ直接ピクセルが描画されるわけではありません。例えば、「この位置に、この色で、この形を描く」といった具体的な命令をシステムに伝えます。
  • Hit Testing(ヒットテスト): ユーザーのタッチやマウスイベントなどが、画面上のどのUI要素に当たったかを検出します。これにより、ユーザーのインタラクションに応じた適切な処理が可能になります。
  • Accessibility(アクセシビリティ): スクリーンリーダーなどのアクセシビリティツールに必要な意味的な情報を提供します。視覚的な情報だけでなく、そのUI要素が何であるか、どう操作できるかといった情報を外部サービスに伝えます。

グラフィックスエンジンによる描画

RenderObjectが生成した描画コマンドは、直接画面にピクセルバッファを生成するものではありません。これらの描画コマンドは、最終的にFlutterエンジン内のグラフィックスエンジンである Skia(スキア)または Impeller(インペラー)によって処理され、実際のピクセルデータへと変換されます。

  • SkiaOpen in new tab: Google ChromeやAndroidなどに搭載されているオープンソースの2Dグラフィックスエンジンです。長年にわたりFlutterの基盤描画エンジンとして使用されてきました。
  • ImpellerOpen in new tab: より一貫したパフォーマンスと先進的な機能を実現するために開発されている、Skiaの代替レンダラーです。現時点では、一部のFlutterプラットフォームを除いて、ほとんどがすでにImpellerに移植されており、将来的なFlutterアプリの描画性能がさらに向上することが期待されています。

これらのグラフィックスエンジンが、RenderObjectからの描画コマンドを解釈し、GPU(Graphics Processing Unit)が理解できる形式に変換することで、最終的なUIが画面に高速かつ滑らかに描画されます。

3. StatefulWidgetのライフサイクルとStateクラス

StatefulWidgetOpen in new tabは、その状態を管理するためにStateオブジェクトを使用します。このStateオブジェクトは、StatefulWidgetが再構築されても破棄されず、長寿命でデータやリソースを保持し続けます。

Stateオブジェクトには、開発者が理解しておくべき5つの主要なメソッドがあります。これらはStatefulWidgetのライフサイクルを制御します。

  • initState()
  • didChangeDependencies()
  • didUpdateWidget()
  • build()
  • dispose()

それぞれのメソッドの役割は以下の通りです。

  1. initState()
    • Stateオブジェクトが初めて作成された際に一度だけ呼び出されます。
    • インスタンスのリソースの初期化、つまりStateオブジェクトが変化するデータを追跡できるようにするための初期設定を行う場所です。
    • StatefulWidgetのプロパティにアクセスする必要がある場合は、このメソッドが最適です。
  2. didChangeDependencies()
    • initState()の直後、またはStateオブジェクトが依存するInheritedWidgetが変更された際に呼び出されます。
    • InheritedWidgetは、ツリーの上位に位置し、子孫Widgetが依存して情報を継承できる特殊なWidgetです。InheritedWidgetが変更されると、それを要求するすべての子孫のリストを保持し、強制的に再構築します。これにより、Flutterは中間にあるWidgetを介さずにWidgetツリーの深い位置に値を渡すことができます。
    • このメソッドは、依存関係が変更されたことを検出して、必要な処理を行うために使用されます。
  3. didUpdateWidget(covariant T oldWidget)
    • 関連付けられているStatefulWidgetが再構築され、新しいWidgetが古いWidgetに置き換わった際に呼び出されます。
    • 前のWidgetのプロパティ(oldWidget)と新しいWidgetのプロパティを比較し、重要な変更を検出してアクションを実行することができます。
  4. build(BuildContext context)
    • UIを構築するために、Widgetが返すWidgetツリーを定義するメソッドです。
    • didChangeDependencies() や didUpdateWidget() の後、または setState() が呼び出された後に実行されます。
    • didChangeDependencies → didUpdateWidget → buildの流れは「Renderフェーズ」と呼ばれ、UIの更新サイクルにおける重要な段階です。
  5. dispose()
    • Stateオブジェクトがツリーから完全に削除される際に呼び出されます。
    • イベントリスナーの解除、アニメーションコントローラーの破棄など、リソースを解放するための最終的なクリーンアップを行います。

setState()の役割

StatefulWidgetの状態を変更するには、setState() Open in new tabメソッドを呼び出す必要があります。setState() を呼び出すと、Stateオブジェクトを保持するElementが再描画が必要なElementのリストに追加され、次のフレームで再構築されます。これにより、buildメソッドが再度実行され、UIが更新されます。

4. RenderObjectの詳細と描画プロセス

これまでWidgetツリーとElementツリーについて見てきましたが、実際にユーザーの画面に何かを表示できるのはRenderObjectだけです。もしWidgetツリーにRenderObjectWidgetが含まれていなければ、アプリは何もレンダリングしません。

RenderObjectの基本と役割

RenderObjectOpen in new tabは、UIの見た目(レイアウト、描画)に関する具体的な情報を担当する、Flutterレンダリングパイプラインの低レベルなコンポーネントです。Elementと同様に長寿命なオブジェクトであり、頻繁に再構築されるWidgetとは異なり、パフォーマンス最適化のためにUI要素の物理的な状態を保持し続けます。

ほとんど全てのRenderObjectは RenderBoxOpen in new tab のサブクラスになります。RenderBoxは、UI要素を2Dデカルト座標系(x, y座標)で配置・描画するために必要な情報(サイズや位置など)を管理します。

RenderObjectは、主に以下の4つのコアなジョブを実行します。

  • Layout(レイアウト): UI要素のサイズと位置を決定します。親から与えられた制約(利用可能な空間)に基づいて、自身と子要素の最適なサイズを計算し、配置を決定します。
  • Painting(描画): 画面に表示されるピクセルを生成するための描画コマンドを生成・記録します。ここではまだ直接ピクセルが描画されるわけではありません。
  • Hit Testing(ヒットテスト): ユーザーのタッチやマウスイベントなどが、画面上のどのUI要素に当たったかを検出します。
  • Accessibility(アクセシビリティ): スクリーンリーダーなどのアクセシビリティツールに必要な意味的な情報を提供します。
abstract class RenderBox extends RenderObject {
  // レイアウトを計算し、自身のサイズを決定する
  void layout();
  // 自身の描画内容をCanvasに記録する
  void paint(Canvas canvas, Offset offset);
  // スクリーンリーダーなどのアクセシビリティ情報を提供する
  void describeSemanticsConfiguration(SemanticsConfiguration config);
  // タッチイベントなどが自身に当たったか判定する
  bool hitTest(BoxHitTestResult result, {required Offset position});
}

RenderObjectとWidgetの種類

RenderObjectの作成と更新には、特別なWidgetである RenderObjectWidgetOpen in new tab が関わります。StatelessWidgetやStatefulWidgetとは異なり、RenderObjectWidgetはbuildメソッドを持ちません。代わりに、RenderObjectを生成・更新するための以下の2つのメソッドを提供します。

  • createRenderObject: 対応するRenderObjectインスタンスを生成します。
  • updateRenderObject: RenderObjectのプロパティを更新します。
abstract class RenderObjectWidget extends Widget {
  const RenderObjectWidget({super.key});

  RenderObjectElement createElement();
  RenderObject createRenderObject(BuildContext context);
  void updateRenderObject(
      BuildContext context, covariant RenderObject renderObject) {}
  void didUnmountRenderObject(covariant RenderObject renderObject) {}
}

では、日常的に使うStatelessWidgetやStatefulWidgetは、どのように画面に描画されるのでしょうか?

StatelessWidgetやStatefulWidget自体は、直接的なレンダリング作業を行いません。つまり、RenderObjectツリーには、これらの高レベルなWidgetに直接対応するRenderObjectは存在しないのです。

これらのWidgetの主な役割は、UIを直感的に構築するための高レベルな抽象化を提供することです。彼らのbuildメソッドは、最終的に別のWidgetを返しますが、この返されたWidgetの階層の中には、必ずどこかの段階でRenderObjectWidgetが含まれることになります。

これらの高レベルなWidgetの役割は、UIを直感的に構築するための抽象化を提供することです。彼らのbuildメソッドは最終的に別のWidgetを返しますが、この返されたWidgetの階層の中には、必ずどこかの段階でRenderObjectWidgetが含まれることになります。

具体例: Text Widgetの場合

例えば、画面にテキストを表示するために Text WidgetOpen in new tab を使用したとします。Text Widget自体はStatelessWidgetです。しかし、その内部のbuildメソッドでは、文字列の描画という具体的なレンダリングを行うために、RichTextOpen in new tabというRenderObjectWidgetが利用されています。Text Widgetは、このRichText Widgetを構築し、表示したいテキストやスタイル情報などを渡す役割を担っています。

このように、StatelessWidgetやStatefulWidgetは、ユーザーがUIを直感的に構築するための高レベルな抽象化を提供します。そして、それらの背後で、より低レベルな描画処理を担うRenderObjectWidgetを生成し、実際のレンダリング作業を間接的にRenderObjectツリーに委ねているのです。したがって、ユーザーの画面に何かを表示できるのは、最終的にRenderObjectWidgetとその配下のRenderObjectのみと言えます。RenderObjectWidgetを導入しないWidgetツリーは、定義上何もレンダリングしません。

ほとんど全てのRenderObjectはRenderBoxのサブクラスになります。RenderBoxは、UI要素を2Dデカルト座標系(x, y座標)で配置・描画するために必要な情報(サイズや位置など)を管理します。

RenderObjectの主要メソッドとレイアウト・描画原則

RenderObjectがUI要素のサイズと位置を決定し、描画を指示する上で、非常に重要な原則が「Constraints go down, sizes go up, parent sets position.」です。この原則は、Flutterのレイアウトシステムの中核を成します。

Constraints go down (制約は下に伝わる)

親のRenderObjectは、子RenderObjectに対して「この範囲内でサイズを決めてね」という制約(BoxConstraints)を与えます。例えば、RenderPaddingのようなRenderObjectは、親から与えられた制約から自身が持つパディング量を差し引いた制約を子に渡します。

layoutメソッドの主な役割は、親から継承された制約を考慮して、そのRenderObjectがどのくらいの大きさになるのかを計算することです。

void layout(BoxConstraints constraints) {
  // 親から与えられた制約から自身のパディング分を減らす
  final innerConstraints = constraints.deflate(padding);
  // 子のレイアウトを計算するため、子に新しい制約を渡す
  child?.layout(innerConstraints);
  // 子のサイズが確定したら、それを考慮して自身のサイズを決定する
  size = constraints.biggest; // 例: 親の制約いっぱいに広がる
}

Sizes go up (サイズは上に伝わる)

子RenderObjectは、自身のlayoutメソッドで確定したサイズを親RenderObjectに伝えます。親は、子のサイズを受け取って、それを自身のレイアウト計算に組み込むことができます。

Parent sets position (親が位置を設定する)

RenderObjectのpaintメソッドでは、パラメータとして受け取った変更可能なCanvasオブジェクトを使用して、描画コマンドを記録します。Canvasの関数(drawRectなど)は実際には直接ピクセルを生成するのではなく、描画情報を記録するものです。この記録された描画コマンドは、最終的にImpellerやSkiaといったグラフィックスエンジンに評価され、シェーダーを介して実行されます。

親RenderObjectは、子の位置を設定する役割を担います。例えば、RenderPaddingは子のRenderObjectをパディング分オフセットした位置に描画します。

void paint(PaintingContext context, Offset offset) {
  // 子を適切なオフセット位置に描画する
  context.paintChild(child!, offset + padding.topLeft);
}

RenderObjectの具体的な動作と更新メカニズム

RenderObjectはWidgetとは異なり、可能な限り長く存続し、多くの場合、一つの存続期間中に多くのフレームをサポートします。これは、頻繁に再構築されるWidgetとは対照的で、パフォーマンス最適化のために重要な特性です。

Elementは、WidgetのcreateRenderObjectメソッドを呼び出してRenderObjectを生成し、その後updateRenderObjectメソッドを呼び出して、Widgetから受け取った新しいプロパティ値をRenderObjectに渡します。updateRenderObjectの一般的な実装例は、以下のようにRenderObjectのプロパティを更新する形です。

// ElementがRenderObjectのプロパティを更新する際の一例
void updateRenderObject(RenderObject renderObject) {
  // Widgetから新しい値を取得し、RenderObjectのプロパティを更新します。
  renderObject.someProperty = widget.someProperty;
}

では、RenderObjectが自身の状態変化にどのように反応し、UIを更新するのかを具体的に見ていきます。ここでは例として、画面に文字列を表示するためのカスタムRenderObjectを想定し、慣例に従って RenderString と名付けたクラスを実装します。

class RenderString extends RenderBox {
  // コンストラクタで初期値を設定します。
  RenderString(this._value);

  // 文字列値を保持するプライベートフィールド
  String _value;

  // 現在の文字列値を取得するためのgetter
  String get value => _value;

  // 新しい文字列値を設定するためのsetter
  set value(String newValue) {
    // 新しい値が現在の値と同じであれば、無駄な更新を防ぐために処理をスキップします(ガード節)。
    if (newValue == _value) return;

    // 値が変更された場合、_valueを更新します。
    _value = newValue;

    // UIの更新が必要であることをFlutterに通知します。
    // 文字列の変更によってレイアウト(サイズや形状)が変わる可能性があるため、レイアウトの再計算を要求します。
    markNeedsLayout();
    // markNeedsPaint(); // レイアウトが変更されると通常は自動的に再描画も行われるため、明示的に呼び出す必要はありません。
    // スクリーンリーダーなどのアクセシビリティツールに、新しい文字列値を伝えるため、セマンティクス情報の更新を要求します。
    markNeedsSemanticsUpdate();
  }
}

RenderObjectは、Widgetから受け取った値を、変更可能なプライベートフィールド(例:_value)に保存し、getterとsetterを利用します。setterには、入力値が古い値と同じ場合に備えてガード節が含まれています。これは、不必要な再描画や再計算を防ぎ、パフォーマンスを向上させるための重要な工夫です。

layout、paint、describeSemanticsConfigurationといったメソッドは、RenderObjectが担当するUIの特定の部分(この例では文字列)を、画面上にどのようにレンダリングするかに関するさまざまな詳細(サイズ、見た目、アクセシビリティ情報など)を決定します。これらすべての決定は、RenderObjectがWidgetから受け取ったプロパティ値から導き出されます。

例えば、上記のRenderStringの場合:

  • layout:valueプロパティ(文字列)が変更されると、文字列内の文字が占めるサイズ(幅や高さ)が変わる可能性があります。layoutメソッドは、この新しい文字列に基づいてレイアウトを再計算し、適切なサイズを決定します。
  • paint:フォント情報に基づいて、個々の文字を描画するためのコマンドを生成します。文字列がテキストスタイル(例:フォントサイズ、フォントカラー)でレンダリングされた場合、フォントサイズはレイアウトに影響し、フォントカラーは描画(ペイント)に影響する可能性があります。
  • describeSemanticsConfiguration:スクリーンリーダーなどのアクセシビリティツリー内の文字列を、新しいvalueに合わせて更新します。

これらのsetterメソッドのいずれかが実行され、layout、paint、describeSemanticsConfigurationの3つの主要メソッドのいずれかで使用される情報が変更されるたびに、RenderObjectは、markNeedsLayout()markNeedsPaint()markNeedsSemanticsUpdate() といった、"markNeeds" で始まる関連メソッドを呼び出す必要があります。

重要な注意点: これらのmarkNeedsメソッドの呼び出しは、Dartのコンパイラによる静的分析では検出されません。そのため、RenderObjectを実装する開発者自身が、プロパティの変更に応じて、どの種類のUI更新(レイアウト、描画、セマンティクス)が必要になるかを判断し、適切なタイミングでこれらのメソッドを呼び出すことを意識する必要があります。これを怠ると、RenderObjectは最初のフレームでは初期値を正しくレンダリングしますが、その後のupdateRenderObjectで新しい値が渡されても、画面が更新されなくなってしまいます。

例えば、RenderStringのvalueのsetterで_valueが更新されると、占有するスペースの量が変わる可能性があるため、markNeedsLayout() を呼び出す必要があります。また、古い文字が表示され続けるのを防ぐため、再描画も必要になりますが、RenderObjectのレイアウト処理が実行されると常に再描画も行われるため、実際には markNeedsPaint() を省略できます。

最後に、この新しい文字列の値はスクリーンリーダーやその他のアクセシビリティツールで利用できるようにする必要があるため、markNeedsSemanticsUpdate() を呼び出す必要があります。

このように、セッター内でこれらのmarkNeedsメソッドを呼び出すことで、このRenderObjectはWidgetのupdateRenderObjectメソッドから受け取った変更に適切に反応し、フレームごとに変化する値をレンダリングするようになります。

Hit Testing(ヒットテスト)

Flutterの中で、位置とサイズに関する情報を持つのは主にRenderObjectです。したがって、ユーザーのインタラクション(クリック、タッチなど)がどのUI要素に当たったかを検出するヒットテストの解決はRenderObjectが担当します。

ユーザーが画面をクリック、タッチすると、ブラウザやホストOSが生のジェスチャイベントをFlutterエンジンに渡し、Flutterエンジンがその情報をFlutterフレームワークに転送します。フレームワークはRenderObjectツリーを順に辿り、各RenderObjectに「この座標は境界内にあるか?」という問い合わせをします。各RenderObjectはその問い合わせに答え、通常は子オブジェクトにその処理を委ねます。

bool hitTest(BoxHitTestResult result, {required Offset position}) {
  // 自身の境界内に座標が含まれるか
  if (_size!.contains(position)) {
    // 子要素にヒットしたか、または自身にヒットしたかを確認
    // 子要素のヒットテストが優先される
    if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
      // ヒットした場合、自身を結果に追加
      result.add(BoxHitTestEntry(this, position));
      return true;// ヒット成功
    }
  }
  return false;// ヒットせず
}

Accessibility/Semantics

RenderObjectは、視覚的な描画だけでなく、アクセシビリティ(障がいを持つ方がデジタルコンテンツを利用しやすくするための配慮)に関する情報も提供します。これは主にdescribeSemanticsConfigurationメソッド内で実行されます。

このメソッドの役割は、SemanticsConfigurationオブジェクトを設定することです。SemanticsConfigurationには、そのRenderObjectが表現するUI要素に関する意味的な情報(例えば、ボタンであること、テキストの内容、操作方法など)が含まれます。

設定されたSemanticsオブジェクトはFlutterエンジンを通じて、ブラウザやホストOSのアクセシビリティサービス(例:iOSのVoiceOverOpen in new tab、AndroidのTalkBackOpen in new tab)と同期されます。これにより、スクリーンリーダーなどのアクセシビリティツールは、UIの最新の状態と意味を正確に認識できます。結果として、視覚に障がいのある方やその他の特別なニーズを持つユーザーが、アプリのコンテンツをより効果的に理解し、操作できるようになります。

簡単に言えば、describeSemanticsConfigurationは「このUI要素が何であるか、そしてどのように振る舞うべきか」という情報を、アクセシビリティツールに伝えるための橋渡し役なのです。

@override
void describeSemanticsConfiguration(SemanticsConfiguration config) {
  // ここでアクセシビリティ関連の情報を設定
  // 例:
  // config.label = 'ボタン'; // この要素が「ボタン」であることをスクリーンリーダーに伝える
  // config.hint = 'タップして次の画面に進みます'; // 操作のヒントを与える
  // config.isButton = true; // この要素がボタンとして機能することを示す
}

5. Flutter EngineとEmbedder: ネイティブプラットフォームとの連携

純粋なDartプログラムは、基本的にどのクライアントでも直接実行できます。しかし、Flutterモバイルアプリは、デバイス上で動作するためにネイティブアプリのコンテナ(実行環境)が必要です。これは、iOSやAndroidのアプリのメインエントリポイントがDartで記述されていないことを意味します。Flutterアプリも、他の一般的なネイティブアプリと同様に、ホストOSに起動される必要があります。

Flutterアプリの実行環境の違い

Flutterアプリは多様なプラットフォームで動作しますが、その起動方法や実行環境はプラットフォームによって少し異なります。

  • モバイルアプリ(iOS/Android): Flutterモバイルアプリは、コンパイルされたDartコードを内包する通常のネイティブアプリケーションとして実行されます。例えばAndroidでは、コンパイルされたDartコード(バイナリデータ)が標準のAndroidアプリケーション内で動作します。iOSでも同様に、通常のXcodeプロジェクト内でコンパイルされたDartコードが実行され、フレームのレンダリングやユーザーのジェスチャ応答など、アプリの動作を司ります。
  • Webアプリ: Web版のFlutterアプリは状況が少し異なります。Webブラウザ上で動作するため、FlutterアプリはコンパイルされたJavaScriptまたはWebAssemblyとして実行を開始します。

flutter createコマンドを実行すると、モバイルの場合はXcodeやAndroid Studioのプロジェクト、Webの場合はHTMLファイルなど、各プラットフォームに特化したプロジェクトが生成されます。これらには、Flutterエンジンを起動し、Dartコードの実行を開始するための橋渡しとなる「グルーコード」が含まれています。

例えばiOSを見てみます。生成されたiOSフォルダの中には、RunnerおよびFlutterという名前の入れ子になったフォルダが生成されます。メインのiOSアプリであるRunnerフォルダ内には、info.plistやAppDelegate.swiftなどの一般的なファイルが含まれています。AppDelegateファイルには、FlutterAppDelegateを継承するAppDelegateが含まれます。

import Flutter
import UIKit

@main
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}

FlutterAppDelegateクラスの役割は、XcodeのStoryboardで確認できるFlutterViewControllerを起動することです。FlutterViewControllerはFlutterエンジンを起動する役割を担っています。

既存のネイティブアプリへのFlutter組み込み

生成されたFlutterプロジェクトには、FlutterEngineを立ち上げるためのグルーコードがすでに含まれています。このグルーコードは、既存のネイティブiOSまたはAndroidプロジェクトに手動で追加することも可能です。

このように、既存のネイティブアプリケーションにFlutterを組み込み、Flutterがそのプロジェクトの一部(例えば、特定の画面やコンポーネント)の制御を開始するプロセスを、「アプリへの追加(Add Flutter to an existing app)」と呼びます。この機能により、既存のコードベースを活用しながら、Flutterの強力なUIフレームワークを段階的に導入できます。

Flutter Engineの役割

FlutterエンジンはDartで書かれていません。その多くはC++で書かれています。

これは、FlutterはChromeのフォークとして始まったため、Flutterはエンジンの基礎となった膨大なC++コードを継承したためです。さらに、特定のグラフィック操作の場合、C++はより適した言語です。

Flutterエンジンは、入力データ(inbound data)出力データ(outbound data)の両方について、Flutterと外部の世界の接続を行います。

入力データ(inbound data)

デバイス情報、マウスやジェスチャのイベント、グラフィックのメモリハンドル、ローカリゼーションやテキストスケーリングなどのユーザー設定、ライトモード・ダークモードなどのシステム設定、使用されているグラフィックスエンジン(OpenGL, Metal, Vulkan)などの基本的な詳細が含まれます。

これらの受信データは、ユーザーの操作やシステムの状態変化をFlutterアプリに伝えるために、通常はFlutterエンジンを介してFlutterフレームワークへと渡されます。例えば、ユーザーが画面をタップすると、そのジェスチャイベントはOSからエンジンに渡され、さらにエンジンからFlutterフレームワーク内の適切なWidgetに届けられ、UIの更新などの処理が実行されます。

出力データ(outbound data)

アクセシビリティ情報や、各フレームの新しいピクセルバッファなどがあります。

これらの情報は、アプリのUIを画面に表示したり、アクセシビリティツールと連携したりするために、エンジンからOSやハードウェアへと渡されます。

描画コマンド、シェーダー呼び出し、ピクセルバッファの関係

  1. 描画コマンド(RenderObjectの役割): RenderObjectツリーの各RenderObjectは、自身のpaintメソッド内で、画面に何を描画するかを示す抽象的な「描画コマンド」を生成・記録します。例えば、「この座標にこの色の四角形を描画する」「このフォントでこのテキストを表示する」といった命令です。これらのコマンドはまだ実際のピクセルではありません。
  2. グラフィックスエンジンによる変換(Flutter Engineの役割): RenderObjectが記録したこれらの描画コマンドは、最終的にFlutterエンジンに渡されます。Flutterエンジンは、現在のプラットフォームで利用可能なグラフィックスエンジン(ImpellerまたはSkia)を利用して、これらの抽象的な描画コマンドを、GPUが理解できる具体的な「シェーダー呼び出し」へと変換します。シェーダーとは、GPU上で実行される小さなプログラムのことで、色や光の計算、図形の描画などを担当します。クリッピング(切り抜き)や不透明度(透明度)などもこの段階で解決されます。
  3. ピクセルバッファの生成(GPUの役割): シェーダー呼び出しがGPUによって実行されると、その結果として「ピクセルバッファ」が生成されます。ピクセルバッファとは、画面に表示されるすべてのピクセル情報を格納したメモリー領域のことです。ここには、最終的な色情報がピクセルごとに格納されており、このピクセルバッファがOSに渡され、画面に表示されることで、私たちの目にUIが映し出されるのです。

まとめると、RenderObjectが「何を描くか」という描画コマンドを生成し、Flutter EngineがそれをGPUが実行できるシェーダー呼び出しに変換し、GPUが実際にその命令を実行して画面の最小単位であるピクセルバッファを作り出す、という流れになります。

Embedderの役割

  • エンジンレイヤー(Engine Layer):
    • これは、プラットフォームに依存しないFlutterのコア機能を提供するレイヤーで、通常「Flutter Engine」といった場合に指し示す部分です。
    • 描画コマンドの処理、スレッドの管理、Dart仮想マシンの実行など、FlutterがUIを構築・描画するために必要な共通の機能すべてを含んでいます。
  • 埋め込みレイヤー(Embedder Layer)
    • 各ホスティングプラットフォーム(iOS、Android、Web、macOS、Windows、Linux、さらにはカスタムデバイスなど)に特化した実装を担当するレイヤーです。これが「Embedder」と呼ばれるものです。
    • Embedderの主な役割は、Flutterエンジンを特定のプラットフォーム環境に「埋め込む(embed)」ことです。具体的には、以下の重要な機能を提供します。
      • プラットフォームとの統合: OSのイベントループへの接続、ネイティブのウィンドウ管理、入力イベント(タッチ、マウス、キーボードなど)の受け取りとFlutterエンジンへの転送を行います。
      • グラフィックAPIの抽象化: プラットフォーム固有のグラフィックAPI(iOSのMetal、AndroidのOpenGL ES/Vulkanなど)とFlutterエンジン(Impeller/Skia)の間で橋渡しをし、描画結果を画面に表示できるようにします。
      • システムサービスへのアクセス: カメラ、GPS、Bluetoothなどのハードウェア機能や、通知、共有シートといったOSレベルのサービスへのアクセスを提供し、これらをPlatform Channelsを通じてFlutterフレームワークに公開します。
      • ライフサイクル管理: アプリケーションのライフサイクルイベント(起動、一時停止、再開、終了など)をOSから受け取り、Flutterエンジンに伝達します。

エンジンレイヤーと埋め込みレイヤーは全体として、Flutterフレームワークをホスティングプラットフォームに接続し、受信または送信の通信を処理する役割を果たします。Embedderが存在することで、Flutterは特定のプラットフォームの特性に合わせて最適化された方法で動作し、デバイスのネイティブ機能を最大限に活用できるようになります。

出典:Flutter architectural overviewOpen in new tab

Flutterのスレッドモデル

Flutterアプリは、複数の異なるスレッドを使って動作し、それぞれが特定の役割を担うことで、スムーズなUIレンダリングと応答性の高いアプリケーションを実現しています。主なスレッドは以下の3つです。

  • プラットフォームスレッド(Platform Thread)
    • これは、アプリがホストOS(iOS、Androidなど)によって起動される際に提供されるメインのスレッドです。
    • 例えばiOSでは、これが一般的なアプリの「メインスレッド」となり、FlutterViewController内のFlutter関連の初期化コードや、OS固有のUIイベント(通知、ネイティブUIとの連携など)のほとんどがこのスレッドで実行されます。
    • このスレッドの主な役割は、OSとFlutterエンジン間の通信を管理することです。
  • UIスレッド(UI Thread)
    • Flutterエンジンが独自に開始するスレッドで、すべてのDartコードがこのスレッドで実行されます
    • Widgetのbuildメソッドやビジネスロジック、状態管理に関する処理などは、すべてこのUIスレッドで行われます。
    • このスレッドの主な役割は、Widgetツリーの構築、Elementツリーの更新、そしてRenderObjectツリーのレイアウト計算など、UIの論理的な構造を決定することです。
  • ラスタースレッド(Raster Thread)
    • UIスレッドと同様に、Flutterエンジンが開始するもう一つの重要なスレッドです。
    • このスレッドは、UIスレッドから渡されたRenderObjectの描画コマンド(何を描画するかという命令)を受け取り、それをGPUが実際に画面に表示できる「ピクセル」へと変換する処理(ラスタライズ)を担当します。
    • このスレッドの主な役割は、UIの最終的な描画処理を効率的に実行し、滑らかなアニメーションやスクロールを実現することです。

これらのスレッドが連携することで、例えばUIスレッドで複雑な計算が行われている間でも、ラスタースレッドが前フレームのUIを描画し続けることができ、UIの「カクつき」を防ぎ、快適なユーザー体験を提供します。

Platform Channels

Dartからネイティブ関数を呼び出したり、ネイティブコードからDart関数を呼び出したりするのは一般的な要件ですが、これにはスレッドホップ(スレッド間の切り替え)が必要になります。これを簡単にするために、FlutterエンジンにはPlatform Channelsと呼ばれる仕組みがあり、関数呼び出しをどちらの方向にもルーティングできます。

出典:Architectural overview of platform channelsOpen in new tab

Flutterエンジンは現在のプラットフォーム用のEmbedder機能を使用して、Platform Channelsの背後に必要な機能を提供し、コードを必要な場所にルーティングします。

必要な定型コードの生成に役立つ pigeonOpen in new tab パッケージと組み合わせることで、Flutter開発者はDartから任意のネイティブコードを呼び出すことができ、また、ネイティブプラットフォームコードから、任意のDartコードを呼び出すことができます。これは、あらゆるプラットフォーム上のすべてのネイティブAPIがFlutter開発者に利用可能になることを意味します。

カメラ、マイク、GPSなどのハードウェア情報から、生体認証ロックやホーム画面ウィジェットなどのソフトウェア機能まで、標準的な機能の大部分は、簡単なコマンドでインストールできる既存のプラグインによってサポートされています。ただし、iOSまたはAndroidの最新の機能がプラグインでまだサポートされていない場合は、pigeonでいくつかのカスタムPlatform Channelsを生成するだけで、必要なAPIを統合できるようになります。

まとめ

この記事では、Flutterの内部構造を深く掘り下げて解説しました。

  • WidgetTreeがUIの「設計図」として宣言的にUIを記述し、
  • ElementTreeがWidgetとRenderObjectの「橋渡し役」として効率的な更新を管理し、
  • RenderObjectTreeが実際の「描画」とレイアウト、イベント処理を担うこと、

そして、

  • StatefulWidgetのStateオブジェクトが状態を保持し、そのライフサイクルを制御すること、
  • RenderObjectが「Constraints go down, sizes go up, parent sets position.」という原則に基づいてレイアウトされ、
  • 最終的にFlutter EngineEmbedderがネイティブプラットフォームとの連携を司り、UIを画面に描画する、

という一連の流れを理解できたのではないでしょうか。

これらのコンポーネントがどのように連携しているかを理解することで、より効率的でパフォーマンスの高いFlutterアプリケーションを開発するための基礎が築けます。Flutterの奥深い世界をさらに探求し、素晴らしいアプリを構築してください!

最後までお読みいただきありがとうございました!

この記事が役に立ったと思ったら、
ぜひ「いいね」とシェアをお願いします!

リンクをコピーXでシェアするfacebookでシェアする

この記事を書いた人

田原 葉
田原 葉
2024年にメンバーズに中途で入社。前職はiOSエンジニア。現在はCross ApplicationカンパニーでFlutter技術をメインにモバイルアプリ開発支援を担当。
詳しく見る
ページトップへ戻る