「Fan 直播」の Flutter 混合開発実践において、Flutter 混合開発の経験をまとめて共有します。本記事では、Flutter の原理から出発し、Flutter の描画原理を詳しく紹介し、これを通じて 3 つのクロスプラットフォームソリューションを比較します。その後、第三章では Flutter 混合開発モードの解説に入り、主に 4 つの異なる Flutter 混合モードの原理分析を行います。最後に、混合プロジェクトのエンジニアリング探索について簡単に共有します。
「深く理解することで、浅く説明できる」という言葉があります。技術については、深く理解することで最も単純でわかりやすい言葉で説明できるのです。これまで、私はいくつかの Flutter に関する記事を書いてきましたが、性質的には学習ノートやソースコードの読み取りノートに近く、やや難解で、断片的で乱雑でした。本記事は段階的なまとめとして、できるだけわかりやすい言葉で、順を追って Flutter 混合開発の知識を共有します。重要な内容にはソースコードやソースコード内の重要な関数を用いて解説しますが、ソースコードを段落ごとに貼り付けることはありません。ソースコードの学習効果は主に自分自身にあるため、ソースコードの学習に興味がある方は、Framework と Engine のソースコードを自分で読むことができますし、私の過去のいくつかの記事を読むこともできます。
さて、無駄話はこれくらいにして、早速始めましょう!
1. Flutter の核心原理#
1.1 Flutter アーキテクチャ#
注:この図は Flutter System Overview から引用しています。
従来の慣習として、Flutter の原理に関する記事では、冒頭にこの図が掲載されます。良いか悪いかに関わらず、まずはこの図を示し、その後はほとんどが自己理解に頼ることになります。この図は本当に便利です。
この図を示すことで、まず全体的に Flutter とは何かを簡単に理解することができ、そうでなければ「盲人が象を触る」状態に陥りやすいです。
Flutter アーキテクチャは階層設計を採用しており、下から上に 3 つの層に分かれています。順に、Embedder、Engine、Framework です。
- Embedder:オペレーティングシステム適応層で、レンダリング Surface の設定やスレッドの設定などを実現します。
- Engine:Flutter レンダリングエンジン、文字のレイアウト、イベント処理、Dart ランタイムなどの機能を実現します。Skia グラフィック描画ライブラリ、Dart VM、Text などが含まれており、Skia と Text は上層インターフェースに対して下層のレンダリングとレイアウトを呼び出す能力を提供します。
- Framework:Dart で実装された UI SDK で、上から下に 2 つのスタイルコンポーネントライブラリ、基本コンポーネントライブラリ、グラフィック描画、ジェスチャー認識、アニメーションなどの機能が含まれています。
詳細については、この図とソースコードを組み合わせて体験することでより良い理解が得られます。しかし、この記事はソースコードの解析ではないため、この作業はここでは展開しません。次に、Flutter の描画プロセスを例にとって、Flutter がどのように機能するかを説明します。これにより、ソースコードの考え方をより良く理解するのに役立ちます。
1.2 Flutter の描画原理#
Flutter の描画プロセスは、以下の図のように大まかにまとめられます。
まずはユーザーの操作があり、Widget Tree の更新がトリガーされ、その後 Element Tree が構築され、再描画領域が計算され、情報が RenderObject Tree に同期され、その後コンポーネントのレイアウト、コンポーネントの描画、レイヤーの合成、エンジンのレンダリングが実現されます。
前提知識として、描画プロセスに関与するデータ構造を見てから、描画の各具体的な段階を具体的に分析します。
1.3 Flutter 描画プロセスにおけるデータ構造#
描画プロセスに関与する重要なデータ構造には、3 つの木と 1 つのレイヤーが含まれています。RenderObject は Layer を保持しており、まずは 3 つの木の関係を見てみましょう。
例えば、以下のようなシンプルなレイアウトがあるとします。
それに対応する 3 つの木の関係は以下の図のようになります。
1.3.1 Widget Tree#
最初の木は Widget Tree です。これはウィジェット実装の基本的な論理単位であり、ユーザーが UI を記述する方法です。
注意すべきは、**Widget は不変(immutable)** であり、ビューの設定情報が変更されると、Flutter は Widget を再構築して更新を行います。データ駆動型の UI を構築する方法です。
なぜ Widget Tree を不変に設計したのか?Flutter の UI 開発は反応型プログラミングであり、「シンプルは速い」という考え方を主張しています。上から下に Widget Tree を再構築して更新するという考え方は比較的シンプルで、追加の関係を考慮する必要がなく、どのノードに影響を与えるかを気にする必要がありません。また、Widget は単なる設定データ構造であり、作成は軽量で、破棄も最適化されていますので、全体の木を再構築することによるパフォーマンスの問題を心配する必要はありません。
1.3.2 Element Tree#
2 つ目の木は Element Tree です。これは Widget のインスタンス化されたオブジェクトであり(以下の図のように、Widget はcreateElement
ファクトリメソッドを提供して Element を作成します)、実行時の Dart コンテキストに永続的に存在します。これは構築のコンテキストデータを保持し、構造化された設定情報を最終的なレンダリングに接続する橋渡しをします。
Element Tree を Dart コンテキストに永続的に存在させる理由は、Element Tree の再構築と再描画のコストが非常に大きいためです。したがって、Element Tree から RenderObject Tree への Diff プロセスがあり、最小の再描画領域を計算します。
注意すべきは、Element は Widget と RenderObject の両方を保持していることです。しかし、Widget も Element も実際には最終的なレンダリングを担当しておらず、彼らは単に「指示を出す」だけで、実際に設定情報をレンダリングするのは RenderObject です。
1.3.3 RenderObject Tree#
3 つ目の木は RenderObject Tree、つまりレンダリングオブジェクトの木です。RenderObject は Element によって作成され、Element.renderObject
に関連付けられています(以下の図のように)。これは Element の情報を同期して受け取り、同様に、Dart Runtime のコンテキストに永続的に存在し、主にビューのレンダリングを実現するオブジェクトです。
RenderObject Tree は Flutter の表示プロセスで 4 つの段階に分かれています:
- レイアウト
- 描画
- 合成
- レンダリング
この中で、レイアウトと描画は RenderObject 内で完了し、Flutter は深さ優先のメカニズムを使用してレンダリングオブジェクトの木を走査し、木の中の各オブジェクトの位置とサイズを決定し、それらを異なるレイヤーに描画します。描画が完了した後、合成とレンダリングの作業は Skia に処理されます。
さて、問題が生じます。なぜ 3 つの木が必要で、2 つではないのか?なぜ中間の Element Tree が必要で、Widget Tree から直接 RenderObject Tree を構築することはできないのでしょうか?
理論的には可能ですが、実際には不可能です。なぜなら、RenderObject Tree を直接構築すると、レンダリングによるパフォーマンスの損失が大幅に増加するからです。Widget Tree は不変ですが、Element は可変です。** 実際には、Element のこの層が Widget ツリーの変化を抽象化し(React/Vue の VDOM Diff に似ています)、本当に変更が必要な部分だけを RenderObject Tree に同期させることで、再描画領域を最小限に抑え、レンダリング効率を向上させています。**Flutter の思想は、かなりの程度でフロントエンドの反応型フレームワークである React/Vue を参考にしています。
さらに、VDOM について補足します。私たちは、Virtual DOM のいくつかの利点を知っています:
- **Diff アルゴリズムにより、できるだけ少ない DOM ノードを操作することが保証されます。** これは Flutter の Element Tree においても表れています。
- **UI 宣言型プログラミングにより、コードのメンテナンス性が高いです。** これは Dart で宣言的に UI コンポーネントを記述する際に表れます。
- ** 実際のノードを抽象化することで、クロスプラットフォームの実現が容易になります。** これは Flutter 側では表れていません。なぜなら、Flutter 自体がクロスプラットフォームの自己描画エンジンだからです。しかし、別の視点から見ると、Element の Widget Tree を Dart 以外の、実行時コンパイルをサポートする他の言語(例えば JavaScript)で構築できれば、動的化を実現できるのではないでしょうか?実際、現在の MXFlutter はこの考え方で動的化を実現しています。
1.3.4 Layers#
最後に Layer を見てみましょう。これは RenderObject に依存しており(RenderObject.layer
を通じて取得)、描画操作のキャリアであり、描画操作の結果をキャッシュすることもできます。Flutter は異なるレイヤー上で描画を行い、これらのキャッシュされた描画結果のレイヤーをルールに従って重ね合わせ、最終的なレンダリング結果、つまり私たちが言う画像を得ます。
上の図のコードに示されているように、Layer の基底クラスには 2 つの属性_needsAddToScene
と_subtreeNeedsAddToScene
があります。前者はシーンに追加する必要があることを示し、後者はサブツリーがシーンに追加する必要があることを示します。通常、状態が更新された場合にのみシーンに追加する必要があるため、これらの 2 つの属性は直感的に「自分が更新する必要がある」と「サブツリーが更新する必要がある」と理解できます。
Layer はmarkNeedsAddToScene()
を提供して自分を「更新が必要」とマークします。派生クラスは自分の状態が変化したときにこのメソッドを呼び出して自分を「更新が必要」とマークします。例えば、ContainerLayer の子ノードの追加や削除、OpacityLayer の透明度の変化、PictureLayer の picture の変化などです。
1.4 Flutter 描画プロセスの分解#
描画プロセスは以下の 6 つの段階に分かれます:
- Build
- Diff
- Layout
- Paint
- Composite
- Render
Diff と Render を除いて、この記事では詳しく説明しません。残りの 4 つの段階に注目しましょう。
注:このフローチャートは 複雑なビジネスが Flutter の高性能と高流動性をどのように保証するか?| 余暇技術 から出典されており、Flutter の核心的な描画プロセスを比較的明確に表現しています。
1.4.1 Build#
build メソッドを実行する際、コンポーネントのタイプに応じて 2 つの異なるロジックが存在します。
私たちは、Flutter 内の Widget が StatelessWidget と StatefulWidget に分かれることを知っています。すなわち、無状態コンポーネントと有状態コンポーネントです。
StatelessWidget とは、build の情報が完全に設定パラメータ(引数)から構成されていることを意味します。言い換えれば、一度作成されると、データの変化に関心を持たず、再描画にも反応しません。
StatefulWidget とは、親コンポーネントが初期化時に渡す静的設定に加えて、ユーザーのインタラクションや内部データの変化(ネットワークデータの応答など)を処理し、それを UI に反映させる必要があるコンポーネントです。このようなコンポーネントは、State クラスを使用して Widget を構築する設計方式を実現します。State の build メソッドが UI を構築し、最終的にbuildScope
メソッドを呼び出します。これにより、_dirtyElements
を走査し、rebuild/build を呼び出します。
注:上記の 2 つの図は 《Flutter 核心技術と実戦 | 陳航》 から出典されています。
1.4.2 Layout#
レイアウトクラスの Widget のみが layout をトリガーします(Container、Padding、Align など)。
各 RenderObject ノードは 2 つのことを行う必要があります:
- 自分の performLayout を呼び出してレイアウトを計算します。
- 子の layout を呼び出し、親の制約を渡します。
このように再帰的に一巡し、各ノードは親ノードの制約を受けて自分のサイズを計算し、親ノードは自分のロジックに従って各子ノードの位置を決定し、全体の Layout プロセスを完了します。
1.4.3 Paint#
レンダリングパイプラインでは、まず再描画が必要な RenderObject を見つけます。CustomPainter を実装している場合は、CustomPainter の paint メソッドを呼び出し、次に子の paint メソッドを呼び出します。CustomPainter を実装していない場合は、直接子の paint を呼び出します。
paint を呼び出す際、いくつかの変換を経て、layer->PaintingContext->Canvas
となり、最終的に paint は Canvas 上に描画されます。
1.4.4 Composite#
合成では主に 3 つのことを行います:
- すべての Layer を Scene に組み合わせます。
ui.window.render
メソッドを通じて Scene を Engine に提出します。- Engine はすべての Layer の最終的な表示効果を計算し、画面にレンダリングします。
2. クロスプラットフォームソリューションの比較#
クロスプラットフォーム開発は必然の流れであり、本質的にはビジネスコードの再利用率を高め、異なるプラットフォームへの適応に伴う作業量を減らし、開発コストを削減します。各プラットフォームの差異が平準化される前に、「迅速かつ良好かつ経済的」に各プラットフォームで体験がほぼ一致するプログラムを開発することがクロスプラットフォーム開発です。
一般的に、業界ではクロスプラットフォームソリューションには以下の 3 つが存在すると広く認識されています:
- Web コンテナソリューション
- 汎用 Web コンテナソリューション
- 自己描画エンジンソリューション
以下でそれぞれを詳しく説明します。
2.1 Web コンテナ#
Web コンテナとは、Web 関連技術に基づいてブラウザコンポーネントを使用してインターフェースと機能を実現することを指します。これには、一般的に言われる WebView に基づく「H5」、Cordova、Ionic、WeChat ミニプログラムが含まれます。
このようなハイブリッド開発モデルでは、Web を一度開発するだけで、複数のシステムのブラウザコンポーネントで同時に実行でき、基本的に一貫した体験を維持します。これは、現在まで非常に人気のあるクロスプラットフォーム開発モデルです。Web とネイティブシステム間の通信は、JSBridge を介して行われ、ネイティブシステムは JSBridge インターフェースを介して Web が呼び出す能力を公開します。ページの表示は、ブラウザコンポーネントが標準のブラウザレンダリングプロセスに従って Web を自動的に読み込み、解析、レンダリングします。
このようなソリューションの利点は:シンプルで、自然にホットアップデートをサポートし、エコシステムが繁栄し、互換性が強く、開発体験が友好的であることです。
もちろん、欠点も明らかです。そうでなければ、後の 2 つのソリューションは何の意味もありません。主に体験に関する問題があります:
- ブラウザのレンダリングプロセスは複雑で、ページはオンラインで読み込む必要があり、体験はネットワークに制約されます。したがって、Web にはホワイトスクリーンの時間(PWA を除く)が存在し、インタラクションの体験はネイティブ体験と非常に明確に異なります。
- 両端で JSBridge インターフェースをそれぞれ実装する必要があり、JSBridge の通信効率は一般的です。
2.2 汎用 Web コンテナ#
次に、汎用 Web コンテナソリューションが登場します。代表的なフレームワークは React Native、Weex、Hippy です。
- ブラウザのレンダリングを放棄し、ネイティブコンポーネントを使用することで、インタラクション体験を保証します。
- 内蔵のオフラインパッケージをサポートし、読み込みにかかる時間を回避して長時間のホワイトスクリーンを防ぎます。
- 依然としてフロントエンドに優しい JavaScript 言語を使用して、開発体験を保証します。
クロスプラットフォーム通信では、React Native は依然として Bridge の方式でネイティブが提供するメソッドを呼び出します。
このソリューションの理想は美しいですが、現実は厳しいもので、実践の中で問題が発見されました:
- ネイティブコンポーネントを直接呼び出すことで体験とパフォーマンスが向上しましたが、異なるプラットフォームで同じネイティブコンポーネントのレンダリング結果には差異があり、クロスプラットフォームの差異を平準化するには膨大な作業量が必要です。
- Bridge の通信効率は一般的で、高頻度の通信が必要なシーンではフレームの欠落を引き起こす可能性があります。
2.3 自己描画エンジン#
では、私たちは簡単に差異を平準化し、同時にパフォーマンスを保証することができるのでしょうか?
答えは「できます」。それが自己描画エンジンです。ネイティブコンポーネントを呼び出さず、自分で描画します。それが Flutter です。警察が React Native に容疑者の外見を尋ねると、React Native は容疑者の外見を生き生きと描写することしかできません。警察が描き終えた後、React Native はそれが似ているかどうかを答えなければなりません。しかし、Flutter は自分自身が素描の達人であり、容疑者の画像を自分で描いて警察に見せることができます。この 2 者の効率と表現の差は言うまでもありません。
- Skia グラフィックライブラリを通じて OpenGL レンダリングを直接呼び出し、高パフォーマンスを保証し、同時に差異を平準化します。
- 開発言語は JIT と AOT の両方をサポートする Dart を選択し、開発効率を保証し、JavaScript に比べて数十倍の実行効率を向上させます。
このような考え方を通じて、Flutter は異なるプラットフォーム間の差異をできるだけ減らし、ネイティブ開発と同じ高パフォーマンスを維持できます。また、システム機能については、Plugin を開発することで Flutter プロジェクト間の再利用をサポートできます。したがって、Flutter は 3 つのクロスプラットフォームソリューションの中で最も柔軟なものとなり、現在業界で注目されているフレームワークとなりました。
通信効率についても、Flutter のクロスプラットフォーム通信効率は JSBridge よりもはるかに高いです。Flutter は Channel を介して通信を行います。その中で:
- BasicMessageChannel は、文字列と半構造化された情報を伝達するために使用され、全二重で、双方向でデータを要求できます。
- MethodChannel は、** ソリューション呼び出しに使用されます。** つまり、Dart 側がネイティブ側のメソッドを呼び出し、Result インターフェースを介して結果データをコールバックします。
- EventChannel は、ユーザーデータストリームの通信に使用され、Dart 側がネイティブ側のリアルタイムメッセージをリッスンし、ネイティブ側がデータを生成するとすぐに Dart 側にコールバックします。
その中で、MethodChannel は開発中に比較的多く使用されます。以下の図は、標準的な MethodChannel の呼び出し原理図です。
では、なぜ Channel の性能が高いと言えるのでしょうか?MethodChannel の呼び出し時の呼び出しスタックを整理すると、以下の図のようになります。
このプロセス全体が機械語の伝達であり、JNI の通信は JavaVM 内部の通信効率と同じです。このプロセス全体の通信は、ネイティブ側の内部通信に相当します。しかし、ボトルネックも存在します。methodCall はエンコードとデコードが必要で、実際には主要な消費はエンコードとデコードにあります。したがって、MethodChannel は大規模なデータを伝達するのには適していません。
例えば、カメラを呼び出して写真を撮ったり動画を録画したりしたい場合、写真を撮ったり動画を録画したりする過程で、プレビュー画面を Flutter UI に表示する必要があります。MethodChannel を使用してこの機能を実現しようとすると、カメラがキャプチャした各フレームの画像をネイティブから Dart 側に伝達する必要があります。このようにすると、コストが非常に大きくなります。なぜなら、画像や動画データをメッセージチャンネルを介してリアルタイムで伝送することは、必然的にメモリと CPU の巨大な消費を引き起こすからです。そのため、Flutter は Texture に基づく画像データ共有メカニズムを提供しています。
Texture と PlatformView はこの記事の探討範囲外ですので、ここでは深入りしません。興味のある読者は、関連資料を自分で調べて拡張知識を理解してください。
それでは、次に本文の第三章に進みましょう。Flutter 混合開発モードの探索です。
3. Flutter 混合開発モード#
3.1 混合モード#
Flutter 混合プロジェクトの構造には、主に以下の 2 つのモードがあります:
- 統一管理モード
- 三端分離モード
統一管理モードとは、標準的な Flutter アプリケーションプロジェクトであり、その中の Flutter の生成物プロジェクトディレクトリ(ios/
とandroid/
)は、ネイティブ混合コンパイルが可能なプロジェクトです。React Native で混合開発を行うように、プロジェクト内で混合開発を行うだけです。しかし、このような欠点は、ネイティブプロジェクトのビジネスが大きくなると、Flutter プロジェクトがネイティブプロジェクトに非常に強く結合され、プロジェクトのアップグレードが非常に面倒になることです。したがって、この混合モードは、Flutter ビジネスが主導し、ネイティブ機能が補助的なプロジェクトにのみ適用されます。しかし、初期の Google が Flutter Module をサポートしていなかった時期には、混合開発はこの 1 つのモードしか存在しませんでした。
その後、Google は混合開発に対してより良いサポートを提供しました。Flutter アプリケーションに加えて、Flutter Module もサポートされました。Flutter Module とは、その名の通り、モジュール化の方法で Flutter をネイティブプロジェクトに導入することをサポートします。** その生成物は、iOS の Framework または Pods、Android の AAR であり、ネイティブプロジェクトは他のサードパーティ SDK を導入するように、Maven や Cocoapods を使用して Flutter Module を導入することができます。** これにより、真の意味での三端分離の開発モードを実現します。
3.2 混合スタック原理#
問題の簡潔さのために、ここではライフサイクルの統一性と通信層の実装を考慮せず、混合ナビゲーションスタックは主に以下の 4 つのシーンでの問題を解決する必要があります:
- ネイティブから Flutter への遷移
- Flutter から Flutter への遷移
- Flutter からネイティブへの遷移
- ネイティブからネイティブへの遷移
3.2.1 ネイティブから Flutter への遷移#
ネイティブ -> Flutter、この場合は比較的簡単です。Flutter Engine は、iOS の FlutterViewController と Android の FlutterView という既存の Plugin を提供しています(自分でラッピングすれば FlutterActivity を実現できます)。したがって、このシーンでは、起動された Flutter Engine を直接使用して Flutter コンテナを初期化し、初期ルートページを設定した後、ネイティブの方法で Flutter ページに遷移できます。
3.2.2 Flutter から Flutter への遷移#
Flutter -> Flutter、業界には 2 つのソリューションが存在し、後で詳しく紹介します。それぞれは:
- Flutter 自身の Navigator ナビゲーションスタックを使用する
- 新しい Flutter コンテナを作成し、ネイティブナビゲーションスタックを使用する
3.2.3 Flutter からネイティブへの遷移#
Flutter -> ネイティブ、ここで注意すべきは、この遷移には 2 つのケースが含まれることです。一つはネイティブページを開く(open、push を含むがこれに限定されない)、もう一つはネイティブページに戻る(close、pop を含むがこれに限定されない)ことです。
上の図のように、この場合は比較的複雑です。Dart と Platform 側の通信を行うために MethodChannel を使用する必要があります。Dart が open または close の指示を出すと、ネイティブ側が対応するロジックを実行します。
3.2.4 ネイティブからネイティブへの遷移#
ネイティブ -> ネイティブ、この場合は特に言うことはなく、ネイティブのナビゲーションスタックを直接使用すればよいです。
3.3 混合モード#
混合スタックの問題を解決し、Flutter 自身の混合開発サポートの不足を補うために、業界ではいくつかの混合スタックフレームワークが提案されています。全体的に見て、以下の 4 つの混合モードから外れることはありません:
- Flutter Boost を代表とする WebView ナビゲーションスタック
- Flutter Thrio を代表とする Navigator ナビゲーションスタック
- 複数エンジン混合モード
- View 基本の混合モード
以下で、それぞれの原理と利点・欠点について詳しく説明します。
3.3.1 Flutter Boost#
Flutter Boost は、闲鱼チームがオープンソースで提供する Flutter 混合フレームワークで、成熟しており、業界での影響力が高いです。ナビゲーションスタックの処理の考え方は、3.2 節で述べた混合スタックの原理を回避していませんが、注意すべきは、Flutter が Flutter に遷移する際に、新しい FlutterViewController を作成し、ネイティブナビゲーションスタックを使用して遷移する方法を採用していることです。以下の図のように:
このようにする利点は、使用者(ビジネス開発者)が Flutter コンテナを WebView のように操作でき、Flutter ページが Web ページのように見えるため、論理的にシンプルで明確であり、すべてのナビゲーションルートのロジックをネイティブ側で処理することです。以下の図は、open メソッドを呼び出す際の Flutter Boost の時系列図(重要な関数のパス)であり、ここで 2 つの情報が確認できます:
- 混合ナビゲーションスタックのロジックは、主にネイティブ層、通信層、Dart 層を含みます。
- Flutter Boost の open メソッドの実装ロジックは比較的シンプルです。
しかし、欠点もあります。Flutter ページを開くたびに新しい ViewController を作成する必要があり、** 連続して Flutter から Flutter への遷移がある場合には追加のメモリオーバーヘッドが発生します。** この問題に対処するために、別のチームが Flutter Thrio を開発しました。
3.3.2 Flutter Thrio#
上記で述べたように、Flutter から Flutter への遷移のシーンで Flutter Boost が追加のメモリオーバーヘッドを持つため、ハロ出行チームは今年 4 月に Flutter Thrio 混合フレームワークをオープンソース化しました。Thrio が Flutter Boost に対して行った最も重要な変更は、Flutter から Flutter への遷移のシーンで、Thrio が Flutter Navigator ナビゲーションスタックを使用することです。以下の図のように:
連続した Flutter ページ遷移のシーンでのメモリテストのグラフは以下の通りです:
このグラフから、以下の点が確認できます:
- 赤い領域は Flutter Engine の起動によるメモリ増加で、基本的に 30MB に近いです。Flutter Engine は比較的重いオブジェクトです。
- FlutterViewController によるメモリ増加は一般的に 12〜15MB 程度です。
このように、Thrio はこのシーンで一定の最適化を行っています。しかし、それに伴い、実装の複雑性が増します。Flutter Boost の利点はシンプルで、ルートがすべてネイティブナビゲーションスタックに収束することです。一方、Flutter Thrio はネイティブナビゲーションスタックと Flutter Navigator を混合しているため、実装は相対的に複雑になります。ここで、Flutter Thrio の open 時の重要な関数のパスを整理しましたが、Thrio のナビゲーション管理は確かに複雑になっています。
3.3.3 複数エンジンモード#
上記で述べた 2 つの混合フレームワークは単一エンジンに基づいていますが、複数エンジンのフレームワークも存在します。複数エンジンについて話す前に、Engine、Dart VM、isolate に関するいくつかの前提知識を紹介する必要があります。
第一章では Engine 層のソースコード解析には触れず、Framework 層の原理を詳しく説明しました。これは第一章の一貫性を保つためでもあり、ここで Engine についても個別に言及することで、記憶と理解が容易になるからです。
Dart VM、Engine と isolate#
(a)Dart 仮想マシンが作成された後、Engine オブジェクトが作成され、DartIsolate::CreateRootIsolate()
が呼び出されて isolate が作成されます。
(b)各 Engine インスタンスは UI、GPU、IO、Platform Runner のためにそれぞれ新しいスレッドを作成します。
(c)isolate は、論理的に隔離されたメモリを持っています。
(d)isolate 内のコードは順番に実行され、Dart プログラムの並行性は複数の isolate を実行する結果です。もちろん、CPU 集約型タスクを処理するために複数の isolate を起動することができます。
(a)から、(1)各 Engine は 1 つの isolate オブジェクト、すなわち Root Isolate に対応します。
(b)から、(2)Engine は比較的重いオブジェクトです(前述の通り)。
(c)と(1)から、(3)Engine 同士は相互に隔離されています。
(d)と(3)から、(4)Engine は共有メモリの並行性がなく、競合の可能性がなく、ロックも必要なく、デッドロックの問題も存在しません。
さて、これらの 4 つの結論を覚えておき、次に window を見てみましょう。
Window#
window は描画のウィンドウであり、Flutter Framework(Dart)と Flutter Engine(C++)を接続するウィンドウです。
クラスの定義から見ると、window は Framework と Engine を接続するウィンドウです。Framework 層では、window はui.window
のシングルトンオブジェクトを指し、ソースコードファイルは window.dart です。一方、Engine 層では、ソースコードファイルは window.cc であり、相互作用する API は非常に少ないですが、一対一で対応しています:
これらは主に Framework 層が Engine 層の Skia ライブラリにラップされた関連 API を呼び出すことを示しています。それでは、window の第二の意味、すなわち描画のウィンドウについても触れなければなりません。
機能的には、インターフェース描画の観点から、window も描画のウィンドウです。Engine 内では、描画操作がPictureRecorder
オブジェクトに出力されます。このオブジェクト上でendRecording()
を呼び出すとPicture
オブジェクトが得られ、適切なタイミングでPicture
オブジェクトをSceneBuilder
オブジェクトに追加(add)する必要があります。SceneBuilder
オブジェクトのbuild()
メソッドを呼び出してScene
オブジェクトを取得し、最終的に適切なタイミングでScene
オブジェクトをwindow.render()
メソッドに渡して、最終的にシーンをレンダリングします。
この図は:Flutter Framework ソースコード解析(1)—— 開篇と描画エンジンの使用法から出典されています。
インスタンスコードは以下の通りです:
複数エンジンモード#
以上のことから、(1)(3)(5)に基づいて、以下の図のような複数エンジンモードが得られます:
これには以下の特徴があります:
- アプリ内に複数のエンジンが存在する
- 各エンジン内に複数の FlutterVC が存在する
- Engine 同士は隔離されている
これら 3 つの特徴に基づいて、通信層の実装を想像してみましょう。仮に 2 つのエンジンが存在し、各エンジン内に 2 つの FlutterVC があり、各 FlutterVC 内に 2 つの Flutter ページがある場合、このようなシーンでの遷移は非常に複雑になります(下図は Thrio オープンソースリポジトリの README から出典されています):
明らかに、Engine 同士の論理的隔離はモジュール間の自然な隔離性をもたらしますが、問題も多くあります。
まず、上の図に示されているように、通信層の設計は非常に複雑になります。さらに、通信層の核心ロジックは依然としてネイティブ側で実装する必要があり、これによりクロスプラットフォーム開発の利点がある程度失われます。
次に、私たちは繰り返し、Engine は比較的重いオブジェクトであることを指摘しました。複数の Flutter Engine を起動すると、リソース消費が過剰になります。
最後に、Engine 同士は共有メモリを持たないため、この自然な隔離性は実際には利点よりも欠点が多く、混合開発の視点から見ると、アプリは 2 つのキャッシュプールを維持する必要があります —— ネイティブキャッシュプールと DartVM が保持するキャッシュプール。しかし、複数の Engine が介入すると、後者のキャッシュプールのリソースは互いに通じておらず、リソースのオーバーヘッドがさらに大きくなります。
従来の複数エンジンモードがもたらすこれらの問題を解決するために、別のチームが View レベルの混合モードを提案しました。
3.3.4 View レベルの混合モード#
View レベルの混合モードの核心は、各ウィンドウに windowId の概念を追加し、それらが同じ Root Isolate を共有できるようにすることです。私たちは、1 つの isolate がui.window
のシングルトンオブジェクトを持つことを述べました。したがって、少し変更を加え、Flutter Engine に ID の概念を Dart 層に渡すことで、Dart 層が複数のウィンドウを持つことができれば、複数の Flutter Engine が 1 つの isolate を共有できるようになります。
以下の図のように:
これにより、真に View レベルの混合開発が実現でき、複数の FlutterViewController を同時に保持でき、これらの FlutterVC はメモリを共有できます。
ただし、欠点も明らかです。Engine コードに変更を加える必要があるため、メンテナンスコストが非常に高くなります。さらに、複数の Engine のリソース消費の問題も、このモードでは Engine を継続的に削減することで解決する必要があります。
4. エンジニアリング探索#
4.1 コンパイルモード#
Dart は自然に 2 つのコンパイルモード、JIT と AOT をサポートしています。
4.1.1 JIT と AOT#
JIT(Just In Time)は、即時コンパイル / 実行時コンパイルで、Debug モードで使用され、コードを動的に配信して実行できますが、実行性能は実行時コンパイルの影響を受けます。
AOT(Ahead Of Time)は、事前コンパイル / 実行前コンパイルで、Release モードで使用され、特定のプラットフォーム用にバイナリコードを生成できます。実行性能が良く、実行速度が速いですが、毎回実行する際には事前にコンパイルする必要があり、開発デバッグ効率が低くなります。
4.1.2 Debug、Release、Profile#
対応する Flutter アプリには 3 つの実行モードがあります:
- Debug
- Release
- Profile
したがって、開発デバッグプロセスでは、JIT をサポートする Debug モードを使用する必要がありますが、製品環境では、性能を保証するために AOT をサポートする Release モードでパッケージを構築する必要があります。
これにより、私たちの統合と構築にも一定の要求が生じます。
4.2 統合と構築#
統合とは、混合プロジェクト内で Flutter Module の生成物をネイティブプロジェクトに統合することを指し、2 つの統合方法が存在します。違いは以下の通りです:
ソースコード統合は Flutter dev ブランチに必要ですが、生成物統合は Flutter dev 以外のブランチに必要です。ここで、私たちの混合プロジェクトは、2 つの異なる統合プロジェクトを同時にサポートする必要があります。Flutter dev ブランチでソースコード統合開発を行い、その後依存関係を抽出して生成物をリモートに公開します。例えば、iOS は pods として構築し、Cocoapods に対応するリポジトリに公開し、Android は AAR として構築し、Maven に対応するクラウドに公開します。これにより、他のブランチのプロジェクトは直接 gradle または pod install を使用して Flutter 依存モジュールを更新できます。
もちろん、実行モードには Debug、Release、Profile の 3 つが存在し、それに対応する統合生成物もこの 3 つのバージョンに分かれますが、生成物統合ではデバッグができないため、Debug バージョンと Profile バージョンの依存関係を抽出して公開する意味はありません。したがって、依存関係を抽出して公開する際には、Release バージョンの生成物のみを公開すればよいのです。
4.3 ワークフロー#
「Fan 直播」Flutter 混合プロジェクトが構築された後、私たちは初歩的な Flutter ワークフローを形成しました。今後も Flutter 混合開発モードを不断に改善し、Flutter のエコシステムの構築に積極的に参加していきます。