ネイティブアプリへのFlutterの埋め込み(とFlutterへのネイティブビューの埋め込み)
ネイティブ画面の中のFlutterビューと、Flutterの中のネイティブビュー。それぞれの仕組みとコスト、そしてどちらかに踏み切る前に試してほしいことを解説します。
このドキュメントの大半は、2つの世界の間できれいに受け渡しが行われることを前提にしています。つまり、画面はFlutterかネイティブのどちらかであり、両者が出会うのはユーザーが一方から他方へ遷移するときだけです。埋め込みは、この前提が成り立たなくなるケースです。ネイティブのスクロールビューの中にFlutterのカードが置かれたり、Flutterのリストの中にネイティブの広告バナーが置かれたりします。2つのレイアウトシステムと2つのジェスチャーシステムが1つの画面を共有することになり、これまで遷移ごとに1回だけ越えていた境界を、毎フレーム越えることになります。
このページでは両方向を扱います。ネイティブの中のFlutter(部分ビュー)と、Flutterの中のネイティブ(プラットフォームビュー)です。私たちのアドバイスは、インタビューしたチームの多くが自力でたどり着いた結論と同じです。可能な限り埋め込みは避けてください。解決できない問題ではありませんが、本来なら画面の移行に充てられる時間を消費します。計画ガイドで最初の画面としての埋め込みをすでに除外しているのはそのためです。以下では、コストがどこから生じるのか、代わりに何を試すべきか、そしてどうしても他に方法がない場合にどう正しく実装するかを説明します。
埋め込みがadd-to-appの難所である理由
フルスクリーンのコンテナがうまく機能するのは、それぞれの技術が自分の画面を管理し続けられるからです。Flutterは画面全体を任されます。既知のサイズに対してレイアウトし、すべてのタッチを最初に受け取り、自身が収まるコンテナから表示・非表示のタイミングを知らされます。埋め込みは、この3つを一度に奪います。
- レイアウト - 誰かが埋め込まれたビューにその大きさを伝えなければなりませんが、どちらの側も相手が何を求めているかを自然には知りません。Flutterのサーフェスは与えられたサイズでそのまま描画するだけですし、Flutterのウィジェットツリー内にあるネイティブの
UIViewやAndroidのViewは、Flutterが管理するボックスを通じて計測される必要があります。 - ジェスチャー - 2つのジェスチャーシステムが同じタッチを奪い合います。典型的な症状は、縦スクロールする親の中に横方向のカルーセルがあるケースです。先にドラッグを認識した側が勝ち、私たちの経験では、たいてい間違った側が勝ちます。
- ライフサイクル - フルスクリーンのコンテナは、作成、表示、非表示、破棄が予測可能な順序で行われます。埋め込まれたビューは、ホストがresumed状態のままでも、画面外へスクロールされたり、再利用されたり、再アタッチされたり、覆い隠されたりします。そしてFlutterエンジンもネイティブSDKも、こうした遷移を通知されることを前提としています。
さらに、2つのレンダラーを1つのフレームに合成しなければなりません。合成の方法は方向によって異なり、パフォーマンスコストの大部分はここにあります。
ネイティブ画面の中のFlutter(部分ビュー)
部分ビューとは、ネイティブ画面の一部を占めるFlutterのサーフェスです。ネイティブのダッシュボード内のFlutterウィジェット、ネイティブのタブバー内のFlutterタブ、ネイティブのリスト内のFlutterの行などがこれにあたります。技術的にはフルスクリーンのコンテナと同じオブジェクト(AndroidではFlutterFragmentやFlutterView、iOSではFlutterViewController)で、フレームが小さいだけです。残念ながら、そのオブジェクトを取り巻くあらゆるものが複雑になります。
ビューごとにエンジン1つのコストがかかる
1つのエンジンが同時に駆動できるレンダリングサーフェスは1つだけです(コアコンセプトを参照)。そのため、画面上に見えているFlutterビューはそれぞれ別のエンジンになります。FlutterEngineGroupを使えば追加コストは低く抑えられます(およそ180 kBと、コールドスタートのごく一部)が、無料ではありません。各ビューは独自のヒープを持つ独立したisolateであり、Dartの起動コードを個別に実行し、すべてのプラグインのインスタンスを個別に保持し、最初のフレームを個別に描画しなければなりません。Flutterのカードが4枚ある画面では、4つのDartプログラムが同時に動いています。Inlayの非表示のプリウォーム済みエンジンはグループの共有リソースをロードした状態に保ちますが、それでも各ビューは自分のエンジンを生成し、自分の最初のフレームを描画します。私たちの以前のプロジェクトでは、すでにウォームなエンジン上でも、Flutter画面の最初のフレームはネイティブ画面より約100 ms遅れていました。
ビューは別々のisolateなので、互いにDartの状態を共有できません。1つのネイティブ画面上にある2枚のFlutterカードは、アプリの別々の場所にある2つの画面と同じくらい隔離されています。両方が参照する必要があるものは、すべてプラットフォームを経由させる必要があります。Inlayの場合はストアを経由します。
サイズ決定:ビューは自分の高さを知らない
最近まで、Flutterビューにはネイティブの親から固定サイズを与える必要がありました。高さが決まっているタブやバナーなら問題ありませんが、データによって高さが変わるコンテンツでは困ります。私たちが知るあるチームは、ユーザーのデータによって高さが変わるダッシュボードカードを埋め込んだところ、状態によってカードが途中で切れたり、まったく表示されなかったりしました。彼らの解決策は、ルートウィジェットにGlobalKeyを付け、レイアウト後にRenderBoxで計測し、メソッドチャネル経由でネイティブ側にコンテナのリサイズを依頼するというものでした。動きはしますが、まさにできるだけ減らしたい類の手書きの配管コードです。
Flutter 3.41で追加されたコンテンツサイズのビューにより、この作業の大部分が不要になりました。iOSではFlutterViewControllerにisAutoResizableを設定し、Androidではマニフェストでコンテンツサイジングを有効にして、FlutterViewにcontent_wrapのサイズを指定します。
let vc = FlutterViewController(engine: engine, nibName: nil, bundle: nil)
vc.isAutoResizable = true落とし穴はDart側にあります。ツリーのルートは**制約なし(unbounded constraints)**でレイアウトされる必要があるため、親からサイズを必要とするもの(ListView、GridView、LayoutBuilder、Scaffold)は自動サイズのビューの最上位に置けません。実際には、埋め込むのは画面ではなく、自身でサイズが決まるColumnやカードになります。ウィジェットを手早く確認するには、単体のFlutterアプリでUnconstrainedBoxで包み、オーバーフローエラーなしで描画されるかを見てください。
ジェスチャー:ネストしたスクロール
部分ビューはほぼ必ずネイティブのスクロールビューの中に置かれることになり、その中に自らスクロールやドラッグをするものが含まれた途端、2つのジェスチャーシステムが争い始めます。先ほどのチームも、カード内の横方向カルーセルでこの問題に直面しました。ネイティブのスクロールビューがすべてのドラッグを縦スクロールとして扱い、カルーセルのスクロールがほぼ不可能になったのです。彼らの回避策は、Flutter側にドラッグ検出器を置き、横方向のドラッグを認識したらメソッドチャネル経由でネイティブ側に通知し、ジェスチャーの間だけスクロールビューの縦スクロールを無効にするというものでした。
ここには汎用的な解決策がありません。境界をまたいで共有されるジェスチャーアリーナが存在しないからです。インタラクティブなコンテンツを持つ埋め込みビューごとに、ホストとの間で取り決める小さなプロトコルが必要になり、そのプロトコルを書いて保守するのはあなた自身です。
ライフサイクル:生のビューよりFragmentとビューコントローラーを優先する
Androidでは、FlutterFragment(およびInlayのInlayFlutterFragment)はライフサイクルを認識します。エンジンのアタッチ・デタッチを行い、ホストに合わせてresumeとpauseを転送します。生のFlutterViewはそうではありません。attachToFlutterEngineを自分で呼び出し、lifecycleChannel. / appIsPaused()を駆動し、クリップボード、入力メソッド、システムUIの処理を配線する必要があります。最悪のケースは、RecyclerView内でFlutterビューを再利用することです。Flutterのissueトラッカーには、この構成に特有のバグの履歴があります(たとえば、高速スクロール中に再アタッチされたビューが真っ黒に描画されるなど)。それらが修正されていても、行ごとにエンジンのライフサイクルを手動で駆動することに変わりはありません。Flutterのサーフェスを再利用されるリストの行に置かなければならないなら、境界の位置が間違っている可能性が高いでしょう。
iOSでは、FlutterViewControllerを子ビューコントローラーとして追加し、viewWillAppear / viewDidDisappearが届くようにしてください。これらのコールバックで、コントローラーはレンダリングサーフェスを有効化・無効化し、resumedとpausedのライフサイクルイベントをDartに送ります。viewだけを追加した場合、これらは一切起こらず、Flutterは常に表示中かつresumed状態であると認識し続けます。
部分ビューに対するInlayの立ち位置
Inlayのコンテナモデルはフルスクリーンとダイアログのコンテナを中心に設計されています。エンジンはコンテナがpushされたときに作成され、popされたときに破棄されます。Flutterからのpop()はActivityを終了するか、NavigationStackをpopするか、ダイアログを閉じます。構成要素を小さなフレームに配置することは可能です。InlayNavigator.を任意のFragmentContainerViewに、createFlutterViewControllerを子ビューコントローラーとして、InlayFlutterScreenをサイズ指定したModifierで、InlayFlutterViewをSwiftUIのレイアウト内に、といった具合です。それでもエンジン、型付きルート、共有ストアは利用できます。ただし、Inlay 0.1には第一級の部分ビューAPIはありません。コンテンツのサイズ決定、ネストしたスクロールの調整、そして「この画面を閉じる」ではなく「このビューを取り除く」を意味するpop()は、すべてあなたに委ねられます。それでもこの道を選ぶなら、AndroidではComposeラッパーか、useBackDispatcher = trueを指定したcreateFragmentを使ってください(これによりpopはホストActivityのfinish()を呼ぶのではなく、バックディスパッチャーを経由します)。埋め込むウィジェットは自身でサイズが決まるようにし、ジェスチャーのプロトコルは自分で書く前提で計画してください。
Flutterの中のネイティブビュー(プラットフォームビュー)
逆方向は、Flutterのウィジェットツリー内に描画されるネイティブのView / UIViewです。DartではAndroidViewやUiKitViewを使い、ネイティブ側に登録されたファクトリーがそれを支えます。ほとんどの開発者は、プラットフォームビューに直接ではなくプラグインを通じて出会います。google_maps_flutter、webview_flutter、google_mobile_adsのバナー、一部の動画プレイヤーは、内部的にはプラットフォームビューです。
合成の仕組みとそのコスト
Flutterは独自のサーフェスに描画しますが、プラットフォームビューはネイティブのビュー階層内のオブジェクトです。一方を他方の中に置くということは2つのパイプラインを合成することを意味し、その方法はプラットフォームごとに異なります。
Androidには3つのモードがあります。
| モード | 仕組み | コスト |
|---|---|---|
Texture Layer(デフォルト、AndroidView) | ネイティブビューがテクスチャに描画し、Flutterはそれを他のレイヤーと同様に描画します。 | パフォーマンスは良好で、Flutterのすべての変換が機能します。スクロールするWebViewのように高頻度で更新されるコンテンツではカクつきます。SurfaceViewベースのプラットフォームビューではアクセシビリティが壊れます。Flutter自身がTextureViewに描画しない限り、テキストの拡大表示が壊れます。 |
Hybrid Composition(PlatformViewLink + initSurfaceAndroidView) | ネイティブビューはAndroidの階層に残り、その上下のFlutterコンテンツは、Androidのコンポジターが重ねるImageReaderベースのビューに描画されます。 | ネイティブの忠実度とアクセシビリティは完全ですが、ビューが画面上にある間はFlutterのラスタースレッドがプラットフォームスレッドに統合されます。複雑なFlutter UIが、OSのメッセージやプラグイン呼び出しと1つのスレッドを奪い合うことになり、フレーム落ちが発生します。 |
Hybrid Composition++(実験的、Flutter 3.44以降、マニフェストのEnableHcppメタデータでオプトイン) | Flutterとプラットフォームビューの両方がネイティブのサーフェスに描画し、SurfaceFlingerがそれらを合成します。 | HCの忠実度とTexture Layerのパフォーマンスを両立しますが、Android API 34以上とImpellerのVulkanバックエンド(OpenGL ESフォールバックではない)が必要で、透明なビューとの重なり順に既知の制約があります。 |
iOSのモードはハイブリッドコンポジションの1つだけです。UIViewがビュー階層に挿入され、Flutterのコンテンツはその周囲でレイヤーに分割されます。動作はしますが、独自のトレードオフがあります。プラットフォームビュー上のアニメーションは遅くなることがあり、ShaderMaskやColorFilteredは適用できず、BackdropFilterは制限付きでしか機能しません。
Flutterのドキュメントは、両プラットフォームのアニメーションについて同じ工夫を提案しています。ネイティブビューのスクリーンショットを撮り、トランジションの間はそれを単なるテクスチャとして表示し、終わったらライブのビューに戻すというものです。ライブの経路がいかに高コストかがよくわかります。
マルチエンジン構成でのプラットフォームビュー
通常のFlutterアプリでは、プラットフォームビューのファクトリーを一度登録すれば済みます。コンテナごとにエンジンを持つモデルでは、すべてのプラグインが必要なのと同じように、すべてのエンジンにファクトリーが必要です(プラグインを参照)。InlayではこれはsetOnEngineCreatedを意味します。Androidではこのフックがファクトリー登録のための想定された場所であり、iOSではファクトリーはエンジンのプラグインレジストラーを通じて登録します。
InlayNavigator.shared.setOnEngineCreated { engine in
GeneratedPluginRegistrant.register(with: engine)
let registrar = engine.registrar(forPlugin: "AdBannerView")!
registrar.register(
AdBannerViewFactory(messenger: registrar.messenger()),
withId: "ad-banner"
)
}登録は簡単な部分です。計画しておくべきなのは、プラットフォームビューの背後にあるネイティブオブジェクトは1つのエンジンに属し、エンジンはコンテナに属するという点です。地図、WebView、広告SDKのビューは、それを使うFlutter画面がpushされるたびにゼロから作成され、popされると破棄されます。地図であれば、遷移のたびに目に見えるコールドスタートが発生します。広告SDKであれば、SDK自身のビューのライフサイクル(インプレッション計測、ビューアビリティ、プリロード)が、アプリと同じ期間ではなく画面と同じ期間しか生きないビューに対して動くことになります。SDKが永続することを期待するものは、すべてネイティブ側で保持して再アタッチする必要があります。これは状態を持つプラグインと同じ問題で、そこにビューが付いているだけです。
実際にはどうだったか
私たちがインタビューしたあるチームは、最初にホーム画面をFlutterで書き直しました。リリースにこぎつけるためには、Flutter画面にネイティブのウィジェットを埋め込む必要がありました。社内の広告ライブラリをFlutterで再実装できなかったからです。画面はリリースされましたが、その結果、会社にはどちらの方向でも埋め込みを避けるという社内方針が生まれました。そう考えたのは彼らだけではありません。私たちが一緒に仕事をしている別のチームも、同様の見解を示しました。人は個々のコントロールを埋め込むことを考えがちですが、ほとんどの場合、埋め込みを正しく実装するよりもコントロールを書き直すほうが簡単で速い、というものです。詳しい経緯は私たちが見てきたパターンにあります。
代わりにすべきこと
私たちの経験では、「このネイティブ画面の中にFlutterが必要だ」という要件の多くは、実は画面の境界をどこに置くかについての要件です。埋め込みを避けられることが多いのはそのためです。以下の選択肢を順に検討することをおすすめします。
- 境界を画面の端に移す。 ネイティブ画面に1つのFlutterコンポーネントが必要なら、画面全体をFlutterにできないか、あるいはそのコンポーネントを当面ネイティブのままにできないかを検討してください。全体が1つの技術でできた画面には、上で説明した問題が一切ありません。逆方向も同様です。Flutter画面に単なるUIでしかないネイティブのコントロールが必要なら、埋め込むよりFlutterで書き直すほうが安く済むことがよくあります。
- 埋め込む代わりにオーバーレイする。 ネイティブ画面の上に表示されるFlutterコンポーネント(ボトムシート、ピッカー、確認ダイアログ)は、その画面の中にある必要はありません。Inlayのダイアログルートは、ネイティブ画面の上にある透明なフルスクリーンのコンテナにFlutterを描画します。これにより、サイズやジェスチャーの調整なしに、ネイティブ画面上にFlutterのコンテンツを表示できます。ネイティブ側も、ネイティブルートを通じてFlutterの上に同じことができます。
- SDKはネイティブのまま、描画はFlutterで。 ネイティブの依存がピクセルではなくロジックである場合(トークンを返す決済SDK、レコメンドエンジン、アナリティクスなど)は、それをPigeon APIの背後にラップし、UIはFlutterで描画してください。これは、ビューそのものがコントラクトであるSDK(ビューアビリティを計測する広告SDK、DRM付きの動画プレイヤー、一部の地図)には使えません。そして、まさにこのカテゴリーこそ埋め込みが避けられなくなりがちな領域です。
- 無害なケースは受け入れる:フルサイズのビューを並べる。 各タブがフルサイズのFlutterコンテナであるネイティブのタブバーや、1つのペインがFlutterである分割レイアウトは、技術的には埋め込み(1つの画面に複数のエンジン)ですが、難しい部分はどれも当てはまりません。フレームは固定で、何かの中で何かがスクロールすることはなく、ライフサイクルもきれいです。あなたのケースがこれに帰着するなら、予算に組み込むべきはビューごとのエンジンのコストだけです。
- 埋め込む。そしてそれを1つのプロジェクトとして扱う。 残るのは、サードパーティのビューがもう一方の技術の画面の中に存在しなければならない少数のケースです。サイズ決定とジェスチャーの作業を予算に組み込み、ファクトリーはエンジンごとに登録し、Androidでは必要な合成モードを選んで計測し、埋め込む領域はできるだけ小さく、静的に保ってください。
経験則
- 可能な限り埋め込みは避ける。 埋め込みを多用した、私たちが話を聞いたどのチームも、次はもっと減らすと言っていました。
- 埋め込みビューはどれも、エンジンのコストに加えて追加の作業を生む。 サイズ決定、ジェスチャー、ライフサイクルのそれぞれに個別の解決策が必要です。決める前にその作業量を見積もってください。
- 部分ビューよりオーバーレイを優先する。 ネイティブ画面上のFlutterダイアログやシートで、「ネイティブ画面にFlutterを」という要件の大半を、合成の問題なしに満たせます。
- 固定フレームでないものにはコンテンツサイズのビュー(Flutter 3.41以降)を使い、
ListView、LayoutBuilder、Scaffoldは埋め込みのルートに置かない。 - プラットフォームビューのファクトリーはエンジンごとに
setOnEngineCreatedで登録し、その背後にあるネイティブオブジェクトは各コンテナとともに作成・破棄されるものと想定する。 - Androidでは、合成モードを選ぶ前に計測する。 Texture Layerがデフォルトで、ほとんどの場合はそれで十分です。Hybrid Compositionはスレッドを統合し、フレームを犠牲にします。
- 再利用されるリストの行にFlutterのサーフェスを置かない。 デザインがそれを必要とするなら、境界は別の場所にあるべきです。