マルチエンジンのadd-to-app構成におけるFlutterプラグイン
Flutterプラグインがエンジンごとに登録される仕組み、iOSのadd-to-appでは登録が自動で行われない理由、エンジンの生成と破棄で誤動作するプラグインの種類を解説します。
ほぼすべてのadd-to-appチームが遭遇する障害があります。たいていは、すべてがすでに動いているように見えてから数週間後のことです。Flutterモジュールは単体では問題なく動き、Androidのホスト内でも問題なく動きます。ところがiOSのホストアプリでは、shared_preferences、url_launcherなど、ネイティブのiOSコードを持つプラグインへの呼び出しがすべてMissingPluginExceptionを投げます。プラグインのドキュメントには何の警告もありません。通常のFlutterアプリではこの問題は起こらないからです。エンジンは1つだけで、アプリのテンプレートが起動時にそのエンジンへプラグインを登録してくれます。
add-to-appでは、この文の前半も後半も変わります。エンジンは多数あり、登録を行うテンプレートは存在しません。 このページでは、その仕組みとInlayでの扱い方を説明し、登録だけでは解決しない、より難しい問題も取り上げます。画面ごとにエンジンが生成・破棄されるとき、プラグインに何が起こるのかという問題です。
プラグイン登録の実際の仕組み
プラグインのネイティブ側は、一度に1つのエンジンにアタッチされます。登録とは、モジュールのプラグインリストを順にたどり、各プラグインを特定のエンジンのbinary messengerにアタッチすることです。生成されるGeneratedPluginRegistrantが行っているのがまさにこれです。これはフレームワークではなくあなたのモジュールの中に生成されるため、どのプラグインも(Inlayを含めて)代わりに呼び出すことはできません。呼び出せるのは、モジュールの依存関係リストを参照できるコードだけです。
2つのプラットフォームは、これをまったく異なる方法で扱います。
| Android | iOS | |
|---|---|---|
| 誰がプラグインを登録するか | Flutterのembeddingが自動で行います。すべてのFlutterEngineは、作成時にリフレクションでGeneratedPluginRegistrantを探します。 | 誰もしません。ホストが自分で、すべてのエンジンに対してGeneratedPluginRegistrant.を呼び出す必要があります。 |
| 何もしないとどうなるか | すべてのエンジンでプラグインが動作します。 | すべてのエンジンで、すべてのプラグイン呼び出しがMissingPluginExceptionで失敗します。 |
| registrantの置き場所 | モジュールのAndroidプロジェクトに生成され、リフレクションで見つけられます。 | FlutterPluginRegistrantモジュールです。FlutterNativeIntegration内のSwift packageターゲット(SwiftPM)、pod(CocoaPods)、またはFlutterPluginRegistrant.(ビルド済みフレームワーク)のいずれかです。どの場合もimport FlutterPluginRegistrantで読み込みます。 |
この非対称性は、Androidでは動き続けるからこそ見落としやすいものです。Androidで先に開発するチームは、iOS側の問題をクラッシュレポートで初めて知ることがよくあります。
Inlayのエンジンへのプラグイン登録
Inlayはエンジンを内部で作成します。ネイティブのコンテナごとに1つ(ネイティブからプッシュされる各画面やダイアログ、またはFlutterから明示的に新しいコンテナへプッシュされたもの)と、非表示のプリウォーム済みエンジンが1つです。そのため、ホストはこれらのエンジンに直接アクセスできません。そこで使うのがsetOnEngineCreatedフックです。このコールバックは、Inlayが作成するすべてのエンジンに対して、エンジンの起動直後にちょうど1回実行されます。
// AppDelegate - before start(), so the prewarmed engine is covered too.
import FlutterPluginRegistrant
InlayNavigator.shared.setOnEngineCreated { engine in
GeneratedPluginRegistrant.register(with: engine)
}
InlayNavigator.shared.start()コールバックはstart()の前に設定してください。プリウォーム済みのエンジンも、表示中のエンジンと同じようにDartのエントリポイントを実行します。そのため、起動中(runAppの前)に使われるプラグインは、そのエンジンでも登録が必要です。コールバックを設定した時点でプリウォーム済みのエンジンがすでに存在する場合、Inlayはそのエンジンに対してすぐにコールバックを呼び出します。
エンジンの入れ替わり:登録では解決しない部分
登録によってプラグインはアタッチされますが、プラグインが自身のライフタイムについて置いている前提は変わりません。ほとんどのプラグインは、エンジンは1つで、起動時に作られ、プロセスが終了するまで生き続けるという、シングルエンジンのメンタルモデルを前提に書かれています。あなたの構成がそこからどれだけ離れるかは、選んだエンジン戦略によります。キャッシュした単一のエンジンは入れ替わらないので、その場合このセクション全体は関係ありません。しかし、コンテナごとにエンジンを持つモデル(Inlayが採用している、コンテナのプッシュ時にエンジンを作成しポップ時に破棄するモデル)では、プラグインのアタッチとデタッチが絶えず繰り返され、生きている各コンテナがすべてのプラグインについてそれぞれ独立したインスタンスを持ちます。
それがどれほど問題になるかは、プラグインの種類によって異なります。
- ステートレスな呼び出し型のプラグイン(
package_info_plus、device_info_plus、url_launcher、share_plus)は安全です。各呼び出しはプラットフォームAPIへの自己完結した往復であり、呼び出し間で何も保持されないので、エンジンが破棄されても失われるものはありません。 - 画面ごとの状態を持つプラグイン(
video_player、オーディオプレイヤー、カメラプレビュー)は動作しますが、2つの注意点があります。状態は1つのコンテナのエンジンとともに生まれて消えるため、次のFlutter画面には何も引き継がれません。また、プッシュやポップの遷移中は一時的に2つのエンジンが同時に生きているため、カメラやオーディオセッションのような排他的なリソースでは問題になります。排他的なハードウェアを使う画面は、1つのFlutter画面にまとめるか、ネイティブで実装してください。 - プロセス全体で共有されるSDKをラップするプラグイン(
firebase_coreとその関連パッケージ)は、エンジンごとにアタッチされますが、設定するのはネイティブのシングルトンです。最初のFlutter画面に初期化させるのではなく、アプリ起動時にネイティブ側でSDKを一度だけ初期化してください。Dartからのエンジンごとの初期化は、良くてもプッシュのたびの無駄な処理であり、悪ければ画面間の競合状態を招きます。 - バックグラウンド実行やコールバックを使うプラグイン(
firebase_messagingのバックグラウンドハンドラ、workmanager、位置情報のストリーム)は、本当に互換性のないカテゴリです。これらはコールバックを届ける先として、長寿命のエンジンが1つあることを前提にしています。画面ごとのエンジンは、コールバックが発火する頃にはすでに消えています。また、これらのプラグインの中には独自のヘッドレスエンジンを起動するものもあり、そのエンジンはあなたのエントリポイントの配線について何も知りません。このモデルでは、これらのDart APIは使えないものとして扱ってください。
シングルエンジンの前提に逆らわず、うまく付き合う
本番環境で通用するパターンは、画面ごとの関心事にはプラグインを、アプリレベルの関心事にはネイティブ側を使うことです。share_plusを呼び出したり動画を再生したりするFlutter画面は、画面ごとのエンジンが提供するライフタイムをそのまま使っています。プッシュ通知、バックグラウンド同期、位置情報のトラッキング、アナリティクス、セッション状態など、画面より長く生き続ける必要があるものはすべてホストアプリが担当し、Flutterが必要とするものを型付きの境界を通じて公開します。状態はストアで、アクションはネイティブルートで公開します。この分担は、プラグインの回避策を積み重ねるよりも移行の最終段階にもうまく耐えます。アプリレベルのサービスには、すでに単一のオーナーがいるからです。
サンプルアプリから真似する価値のある習慣が1つあります。早い段階で表示されるFlutter画面に、プラグインから取得する値を1つ置き(サンプルではpackage_info_plusでホストアプリのバージョンを表示しています)、ネイティブのUIテストでそれをアサートすることです。アサーションは1つだけですが、登録せずに追加されたプラグインや、Flutterのアップグレード後に壊れたregistrantなど、ある種のリグレッション全体を、他に何も警告してくれないプラットフォームで検出できます。