add-to-appの中核となる概念:Flutterエンジン、Dart isolate、プラットフォームチャネル
Flutterエンジン、Dart isolate、プラットフォームチャネル。add-to-appにおけるあらゆる設計判断の土台です。
add-to-appプロジェクトについて妥当なアーキテクチャ判断を下すには、その前にFlutterのドキュメントにある次の3点をしっかり理解しておく必要があります。
- Flutterエンジンとは実際何なのか
- Dartのisolateモデルによって、共有できるものがどう制限されるのか
- プラットフォームチャネルで何ができて、何ができないのか
私たちの経験では、add-to-appチームが本番で直面する想定外の事態のほとんどは、この3つのいずれかを中途半端にしか理解していなかったことに起因しています。
Flutterエンジン
Flutterエンジンは、Dartコードを実行し、(SkiaまたはImpellerを通じて)レンダリングパイプラインを駆動し、ホストプラットフォームとやり取りするランタイムです。各FlutterEngineは、独自のDart isolate、独自のレンダリングサーフェス、独自のプラットフォームチャネル基盤を持ちます。単独のエンジンはスナップショットとGPUコンテキストも独自に読み込みますが、グループから生成されたエンジンはそれらを共有します(詳しくは後述します)。
ホストアプリから見ると、エンジンは重量級のオブジェクトです。起動には実際の処理が伴います。Dart VM(プロセス単位で共有)のセットアップ、AOTスナップショットの読み込み、レンダリングリソースの立ち上げなどです。これを必要になった時点で行うと、ユーザーは遅延に気づきます。
エンジンから画面へ
純粋なFlutterアプリにはエンジンがちょうど一つあり、起動時に生成されます。すべての画面は、そのエンジンのNavigatorの中のルートにすぎません。add-to-appではこの前提が崩れます。画面スタックを所有するのはホストアプリになり、「Flutter画面」の実体はネイティブのコンテナです。iOSではFlutterViewController、AndroidではFlutterActivityまたはFlutterFragmentで、Flutterエンジンをレンダリングサーフェスにアタッチします。
この後のすべての判断を左右するルールが一つあります。一つのエンジンが駆動できるレンダリングサーフェスは、一度に一つだけです。エンジンが持つisolateは一つ、ウィジェットツリーも一つ、Navigatorも一つです。したがって、必要なエンジンの数は「アプリごとに一つ」でも「ルートごとに一つ」でもありません。同時に生きていなければならないFlutterサーフェスの数であり、それはナビゲーションフローによって決まります。
順番に訪れる一つのFlutterセクション → エンジン一つ
Flutterが、ユーザーが入り、中を移動し、出ていく一つの領域にだけ存在し、その横に2つ目のFlutterサーフェスが必要にならないのであれば、エンジンは一つで足ります。エンジンをキャッシュしておき、ユーザーがFlutterセクションに入ったときにアタッチし、中での画面間の移動はFlutter自身のNavigatorに任せます。ユーザーが戻ってくるたびに、エンジンとその状態が再利用されます。
これは最もコストの低い構成で、Flutterの「キャッシュされたエンジン」に関するガイダンスが前提としているものです。トレードオフは、Flutterの世界がちょうど一つしかないことです。すべてのFlutter画面が、一つのisolate、一つのナビゲーションスタック、一つの状態プールを共有します。Flutterが連続した一つのフローを担い、Flutter画面の間にネイティブ画面が挟まらない限り、これでうまく機能します。
ネイティブとFlutterが交互に並ぶ → 複数のエンジン
スタックの中で両者が交互に並ぶと、つまりネイティブのAがFlutterのBをpushし、BがネイティブのCをpushし、CがFlutterのDをpushすると、2つのFlutter画面が同時に生きている状態になり得ます。Bはバックスタックに残って状態とレンダリングサーフェスを保持し、その上にDがあります。一つのエンジンが駆動できるサーフェスは一度に一つなので、Bを生かしたままDを描画することはできません。生きているFlutter画面それぞれに専用のエンジンが必要です。
BとDはバックスタックに共存するため、2つのエンジンが同時に生きています。これがInlayの土台となっているモデルです。Flutterをホストするネイティブコンテナ(全画面のview controllerやactivity、あるいは透過的なダイアログコンテナ)はそれぞれ専用のエンジンを持ち、エンジンは入るときに生成され、出るときに破棄されます。そのコンテナの中で通常のFlutter → Flutterナビゲーションによってpushされる画面は、コンテナのエンジンを共有します。このドキュメントでは、これをコンテナごとのエンジンと呼びます。各コンテナは完全に独立しており、独自のナビゲーション状態と独自のisolateを持ちます。その代償として、エンジンの数が増えます。
ネイティブ画面の中にFlutterを埋め込む → ビューごとのエンジン
ここまでは、どのFlutterサーフェスも画面全体を占めていました。埋め込みは、Flutterビューをネイティブ画面の中のコンポーネントとして配置するケースです。ネイティブのリストの中にFlutterで描画したカード、それ以外はネイティブのダッシュボードにFlutterのチャート、ネイティブのタブバーの中で両方とも常駐する2つのFlutterタブ、といった例です。
埋め込まれたビューもレンダリングサーフェスであることに変わりはなく、エンジン一つにつきサーフェス一つというルールがそのまま適用されます。同じネイティブ画面にN個のFlutterビューが表示されていれば、N個のエンジンが同時に生きていることになります。3つのパターンの中で最もエンジンを多く必要とするパターンです。それらのエンジンがすべて一つの画面上で並行して動くからです。しかも、エンジンの数は問題の中でも小さいほうです。サイズ調整、ジェスチャー、ライフサイクルの連携については、埋め込みのページで扱います。
多数のエンジンを動かしても負担にならない理由:FlutterEngineGroup
エンジンの重さを考えると、画面ごとにエンジンを持つのは非常に高くつくように聞こえるかもしれません。実際、画面ごとに素朴にFlutterEngine()を生成すればそうなります。これを手頃なコストにしているのがFlutterEngineGroupです。同じグループから生成されたエンジンは共通リソースを共有します。Flutterのドキュメントでは、GPUコンテキスト、フォントメトリクス、isolateグループのスナップショットが挙げられています。そのため、追加のエンジン一つあたりのコストは180 kB程度とコールドスタート時間のごく一部で済み、最初のエンジンと同じだけのコストはかかりません。(Dart VM自体は、グループの有無にかかわらず、プロセス内のすべてのエンジンで共有されます。)
グループが共有しないのはisolateの状態です。グループ内のエンジンも、それぞれ独自のisolateとヒープを持ちます(後述のDart isolateを参照)。つまりグループは複数のエンジンを低コストで動かせるようにしますが、エンジン同士がDartのメモリを共有できるようにするわけではありません。その帰結を次の2つのセクションで扱います。
InlayはFlutterEngineGroupを管理し、ネイティブコンテナ(ネイティブから
pushされる各画面やダイアログ)ごとにエンジンを一つ生成します。同じコンテナ
内で連続するFlutter画面は、そのエンジンを共有します。そのため上記のマルチ
エンジンモデルがデフォルトになり、エンジンを手動で生成したりキャッシュ
したりする必要はありません。
Dart isolate
isolateはDartにおける並行処理の単位で、多くの言語のスレッドとは振る舞いが異なります。isolateはメモリを共有しません。それぞれが独自のヒープを持ち、あるisolateのガベージコレクタが別のisolateのものと協調する必要はありません。isolate同士はポートを介したメッセージの受け渡しでのみ通信し、メッセージはコピー(または転送)され、参照で共有されることはありません。
各Flutterエンジンは、Dartのエントリポイントを独自のisolateで実行します。マルチエンジン構成のadd-to-appプロジェクトでは、他のすべてがこの事実にかかっています。2つのエンジンに支えられた2つのFlutter画面は、互いのDartオブジェクトを見ることができません。シングルトン、グローバル変数、staticフィールド、一方のエンジンで埋めたインメモリキャッシュ。そのどれも、もう一方には存在しません。各エンジンは起動時に、プログラムの状態の独自のコピーを受け取っているのです。
これにより、多くの開発者が最初に思いつく「値をグローバル変数に入れて、別の画面から読めばいい」という方法は使えません。読むべき共有のグローバル変数が存在しないからです。エンジンをまたいで見える必要があるものは、両方のisolateから到達できる場所に置かなければならず、両方が到達できる唯一の場所はホストプラットフォームです。データは境界をまたがなければならず、実際にはそれはプラットフォームチャネルを意味します。(ネイティブコードへのもう一つの経路であるFFIについては次のページで扱いますが、共有状態の役には立ちません。)
Dart VM自体はプロセス単位で共有され、FlutterEngineGroup内ではGPU
コンテキスト、フォントメトリクス、isolateグループのスナップショットも共有
されます。それでも各エンジンが負担するのは、独自のisolateヒープと独自の
ビュー/サーフェスです。エンジン一つあたり約180 kBは、ここに使われます。
プラットフォームチャネル
プラットフォームチャネルは、Dartとホストプラットフォーム(iOSではSwiftまたはObjective-C、AndroidではKotlinまたはJava)をつなぐ橋渡しです。Dartがメッセージを送り、ネイティブ側がそれを処理し、必要に応じて応答します。ほぼすべてのプラグインの背後にある仕組みであり、add-to-appプロジェクトではFlutterとネイティブコードが実際にやり取りする手段です。
チャネルには、その上に構築するものすべてを形づくる2つの性質があります。
第一に、チャネルは非同期です。すべての呼び出しはfutureで完了する往復処理であり、境界をまたいで「この値を今すぐ同期的に読む」ことはできません。相手側の値が必要なら、awaitします。第二に、チャネルは共有メモリではなく、メッセージパッシングです。引数と戻り値はメッセージコーデックでシリアライズされ、コピーされて渡り、向こう側でデコードされます。データを移動するのであって、データへの参照を共有するわけではありません。デフォルトのコーデックが扱えるのは決まった型(数値、文字列、真偽値、リスト、マップ、Uint8Listなどの型付きバイトバッファ)だけなので、それ以上に複雑なものはこれらの型に平坦化する必要があります。
チャネルはエンジンにスコープされてもいます。各FlutterEngineは独自のバイナリメッセンジャーを持つため、あるエンジンに登録したチャネルは別のエンジンからは見えません。すべてのエンジンが出会う唯一の場所はネイティブ側であり、そのためプラットフォームがエンジン間の状態の自然なハブになります。あるエンジンからの書き込みはネイティブに降りていき、ネイティブがそれを他のすべてのエンジンに配信します。
これらの性質から、2つの落とし穴が直接生まれます。コーデックには型がないため、デフォルトではdynamicなマップを渡し、両側がその形に合意していると信じることになります。そしてDart側とネイティブ側がずれた瞬間に、これは何の警告もなく壊れます。だからこそInlayは、文字列キーのマップを手書きさせるのではなく、境界をまたぐすべてのものに対して型付きのコードを生成します。また、シリアライズはタダではありません。細かい呼び出しを頻繁に行ったり、毎フレーム大きなペイロードを渡したりすると、コストが積み重なります。チャネルは個別のイベントや状態変更には向いていますが、ホットパスには向いていません。
私たちのフレームワークInlayは、これらの概念をどう抽象化しているか
この3つの概念は、本番のadd-to-appプロジェクトが何度も繰り返し解決することになる問題です。Inlayの役割は、それぞれを開発者の手から引き取り、エンジン、isolate、チャネルではなく、画面と状態の観点で作業できるようにすることです。それぞれがフレームワークにどう対応しているかを見ていきます。
エンジン:コンテナごとに一つ、自動で管理
Inlayはプロセス全体で一つのFlutterEngineGroupを所有し、ネイティブコンテナごとにエンジンを一つ立ち上げます。ネイティブからpushされる画面やダイアログはそれぞれ専用のエンジンを持つコンテナを得て、エンジンはコンテナと同じ期間だけ生存し、その中で連続してpushされるFlutter画面はエンジンを共有します。これは前述の交互のナビゲーションのシナリオで説明したマルチエンジンモデルをデフォルトにしたものです。そのため、ネイティブとFlutterが交互に並ぶスタックも、生きているサーフェスの数を数えることなくそのまま動きます。(ネイティブ画面の中の部分的なビューは別の話です。埋め込みを参照してください。)
すべてのエンジンが同じグループから生成されるため、エンジンあたりのコストは低く抑えられ、隠れたプリウォーム済みエンジンによって最初のナビゲーションも瞬時に感じられます。FlutterEngine()を呼んだり、エンジンをキャッシュしたり、FlutterViewController / FlutterActivityを手動で配線したりする必要はありません。それらはすべてInlayNavigatorの裏側にあります。すべてのエンジンは同じDartエントリポイント(デフォルトではinlayMain、setDartEntrypointで変更可能)を実行するので、エンジンが最終的にどの画面を描画するかにかかわらず、アプリを初期化する場所は一つです。プリウォームの制御についてははじめにを参照してください。
isolate:共有状態はDartではなくプラットフォーム側に置く
各エンジンは独自のヒープを持つ独自のisolateなので、画面をまたぐ状態を置くための共有Dartメモリはありません(Dart isolateの制約)。Inlayの答えはKeyValueStorageです。これはプラットフォーム側に置かれる単一のインメモリストアで、すべてのisolateとネイティブのスコープが到達できる唯一の場所にあります。各エンジンと各NativeStorageScopeは、それを同期した薄いビューを保持します。どのエンジンからの書き込みも他のすべてのエンジンにブロードキャストされ(ループを避けるため、送信元はスキップされます)、オブジェクト参照を共有することなく画面間の一貫性が保たれます。開発者は型付きの@InlayStoreスキーマを定義してフィールドを読み書きするだけで、isolateをまたぐ配管は見えないままです。全体のモデルについては状態管理を参照してください。
プラットフォームチャネル:生成され、型安全で、隠蔽される
InlayにおけるネイティブとFlutterの間のあらゆるやり取り(ルートのpush、戻る操作、保存された値の読み書き)はプラットフォームチャネルを通りますが、チャネルを手書きすることは一切ありません。固定のトランスポートチャネルはPigeonで生成され、アノテーションを付けたスキーマから作られる型付きのルートクラスとストアクラスはInlay独自のジェネレーターが生成し、そのトランスポート越しに値をシリアライズします。いずれにしても、メッセージの形はDart、Swift、Kotlinの各側でまったく同じように型付けされるため、前述の型のないdynamicマップの落とし穴は起こり得ません。2つのナビゲーションスタック間の戻るジェスチャーとpopの連携は、InlayBackButtonDispatcherとInlayNativePopGestureObserverが処理します。チャネル自体は依然としてその下にあり、前述のとおり非同期のメッセージパッシングを行っています。Inlayはそれに型安全でコード生成されたインターフェースを与えているだけです。これによって得られるAPIについてはナビゲーションを、2つの生成レイヤーがどう組み合わさるかについてはネイティブとFlutterの通信を参照してください。
全体を通じたパターンはこうです。Inlayはこれらの仕組みが存在すること自体を 隠すのではなく、ボイラープレートと落とし穴を隠します。何か一筋縄ではいかない 問題が起きたときに、パフォーマンス、状態、スレッドについて筋道を立てて考え られるのは、やはりこのページの3つの概念を理解しているからです。