Flutter add-to-appの制約
Flutter add-to-appは公式にサポートされており、本番環境でも問題なく機能しますが、スタンドアロンのFlutterアプリにはないトレードオフを伴います。その多くは同じ原因から生じています。主導権を握るのはあくまでネイティブアプリで、Flutterはその中で(独自のエンジン、独自のメモリ、独自のツールチェーンを持つ)ゲストとして動くという点です。このページでは、私たちが実際のプロジェクトで直面した制約、それぞれのコスト、そして私たちの回避策を紹介します。
スタンドアロンのFlutterアプリと比べると、次の制約を抱えることになります。
- 起動時のレイテンシ - 最初のFlutter画面ではエンジンを立ち上げる必要があり、ネイティブ画面にはないコストがかかります。
- メモリとアプリサイズ - エンジンごとに独自のisolateとヒープを持ち、Flutterランタイムによってバイナリが数MB増えます。
- 画面間でDartのメモリを共有できない - 別々のエンジンで動く画面同士は、互いの状態を参照できません。
- 開発者体験の低下 - 2つのツールチェーン、2つのデバッガ、より長いネイティブビルド。
- プロダクト基盤の二重化 - 移行が終わるまで、デザインシステム、翻訳、分析、クラッシュレポートが両側に存在します。
どれも単独では致命的ではありません。しかし積み重なると負担になります。これこそが、add-to-appを留まる場所ではなく移行の手段として推奨している理由です。
add-to-appにおけるFlutterエンジンの起動レイテンシ
ネイティブ画面には起動すべきランタイムがありません。しかし最初のFlutter画面にはあります。エンジンが必要で、そのエンジンがAOTスナップショットを読み込み、Dart isolateを起動し、エントリポイントを実行して、ようやく最初のフレームを描画します。ユーザーがボタンをタップした時点でエンジンを生成すると、一瞬だけ空白または白い画面が表示されます。並べて比較するデモでは、ステークホルダーが最初に気づくのがこの点です。
標準的な対策はプリウォームです。ユーザーがFlutterの画面に到達するまでに準備が整うよう、エンジンを早めに(たとえばスプラッシュ画面や、アプリ内の適切な画面で)生成しておきます。Inlayを使えば、エンジンのプリウォームは自動で行われます。 Inlayはアプリの起動時にエンジングループを生成してエンジンをプリウォームするため、最初のFlutter画面がエンジンを待つことはなく、ホストアプリ側に追加のコードも必要ありません。
Flutter add-to-appにおけるメモリ使用量とアプリサイズへの影響
コストが大きいのは最初のエンジンです。同じFlutterEngineGroupから生成されたエンジンは、GPUコンテキスト、フォントメトリクス、isolateグループのスナップショットを共有します。そのためFlutterの公式ドキュメントによれば、グループ内のエンジンが少なくとも1つ生きている限り、追加のエンジン1つあたりのコストは約180 kBです。ただし、エンジン間で共有されないのがDartのヒープです。各エンジンは、起動コード、キャッシュ、プラグインのインスタンスをそれぞれ独自に持ちます。
バイナリも大きくなります。Flutterのドキュメントでは、エンジンのベースラインは圧縮後で数MBとされており、残りの大部分はアセット、フォント、パッケージです。add-to-appについて決まった数値はないため、モジュールあり・なしの両方で自分たちのアプリで計測してください。計測はリリースビルドのみで行い、アップロード用パッケージではなくストアでのダウンロードサイズを比較します。
Flutter画面間でDartのメモリを共有できない
ネイティブ画面を挟んで2つのFlutter画面が同時に存在しうる場合、エンジンが2つ必要になります。エンジンが2つあるということは、メモリを共有しない2つのisolateがあるということです。ある画面で値を入れたシングルトンやインメモリキャッシュは、もう一方の画面には存在しません。純粋なFlutterの経験しかない開発者は、たいていバグ報告でこれに気づきます。ユーザーがあるFlutter画面でログインしたのに、次の画面ではまだログアウト状態だと認識されている、というものです。
Flutter画面間で共有する状態は、すべてネイティブ側に置き、各エンジンに同期させることができます。その仕組みはコアコンセプトで解説しており、Inlayの状態管理が私たちの解決策です。
開発者体験:ビルド、ホットリロード、デバッグ
add-to-appでは、GradleとXcodeが日々の開発サイクルに加わります。ネイティブビルドはDartもビルドするようになるため遅くなり、ネイティブ開発者のマシンにもFlutter SDKが必要になります。境界をまたいだデバッグでは2つのデバッガを同時に使うことになり、動作が遅く、不安定なこともあります。ホットリロードはflutter attach経由で引き続き使えますが、全体としての体験はそれほどスムーズではありません。
私たちにとって最も効果があったのは、ネイティブ依存をフェイクに差し替えた状態でFlutterモジュールをスタンドアロンのアプリとして実行できるようにしておくことです。これにより、UI作業の大部分を通常のFlutterの開発サイクルで行えます。E2Eテストにも課題があります。Flutterのintegration_testはネイティブ画面を操作できないため、境界をまたぐテストにはPatrolのように、それが可能なツールが必要です。
ネイティブとFlutterのハイブリッドアプリにおける基盤の二重化
この点は最初のうちはほとんど話題になりませんが、隠れたコストの多くがここに潜んでいます。アプリの一部がネイティブ、一部がFlutterである間は、いくつかのものが二重に存在します。
- デザインシステム - Flutterで実装し、ネイティブ版と揃え続ける必要があります。そうしないと、移行した画面がネイティブの画面と並んだときに微妙に違って見えます。
- 翻訳 - ネイティブの文字列とARBファイルには、信頼できる唯一の情報源が必要です。そうしないと、数回のリリースで内容がずれていきます。
- 分析 - 画面名やイベントの命名規則を一致させる必要があります。そうしないと、移行した画面がビジネス側の依存するファネルを壊してしまいます。
- クラッシュレポート - Dartのエラーは明示的に報告し、シンボルをアップロードする必要があります。そうしないと、ダッシュボードに表示されません。
- テーマとロケール - ユーザーがネイティブの設定でダークモードや言語を切り替えたとき、稼働中のすべてのFlutterエンジンがそれに追従する必要があります。
どれも技術的に難しいものではありませんが、いずれもオーナーが必要です。移行の最初の数週間で、これらの項目を明示的にリストアップすることをおすすめします。ここでConvergeが真価を発揮します。 Convergeは両方のコードベースを読み取るため、3つ目の実装を追加する前に、iOS版とAndroid版がすでにどこで異なっているかを示します。さらに、すべての画面と画面間のナビゲーションをマッピングし、それを画面ごとの見積もり付きのウェーブ単位の計画にまとめます。移行の進行に合わせて再実行すれば出口までの距離がわかるため、すべてを二重に抱える期間をできる限り短くできます。そして移行が終われば、このセクションの内容はもう当てはまりません。
自分たちのアプリで重要になるadd-to-appの制約は?
| 制約 | 特に重要になるケース | 私たちの対策 |
|---|---|---|
| 起動時のレイテンシ | 最初のFlutter画面がよく通る経路上にある | エンジンをプリウォームする(Inlayが支援)、ローエンド端末で計測する |
| メモリとアプリサイズ | ローエンドAndroidのユーザー、厳しいサイズ制限 | エンジングループ、CIでのリリースビルドのサイズチェック |
| Dartのメモリ共有なし | ネイティブとFlutterの画面が交互に並ぶ | ネイティブ側に共有状態を置き、各エンジンに同期する |
| 開発者体験 | Flutterに不慣れなネイティブチーム | 日々の作業はスタンドアロンのモジュールで行う、境界をまたぐE2Eテスト |
| 基盤の二重化 | 長期にわたる移行 | 項目ごとに唯一の情報源、明確なオーナー、終了日のある計画(Converge) |
これらの制約の多くは、アプリのFlutter化が進むにつれて小さくなり、Flutterがシェルを引き継いだ時点で消えます。だからこそ私たちは、初日から出口戦略を計画しています。