ネイティブアプリにおけるFlutterモジュールのバージョニング

モジュール、ネイティブのホストアプリ、フレームワークを、リリースのたびに足並みをそろえて保つ方法。

add-to-appプロジェクトには、それぞれのペースでリリースできる部分がいくつかあります。Flutterモジュール、ネイティブのホストアプリ、モジュールの土台となるフレームワーク(inlay)、そしてそれらすべての下にあるFlutter SDKそのものです。バージョニングとは結局のところ、これらのうちどれが独立して動いてよく、どれが一緒に動かなければならないかを決め、それらが出会う地点を偶然に任せず明示的にすることです。

これについてどこまで考える必要があるかは、ビルド統合のモードに直接左右されます。ソースレベルの統合では、その大部分が不要になります。1回のチェックアウトがすべての部分を一度に固定し、コミットがそのままバージョンになるからです。ビルド済み成果物による統合では、公開されたバージョン番号がモジュールと各ホストの間のコントラクトになり、このページの残りは主にそのコントラクトの管理についての話になります。

本当にバージョニングが必要なもの

動く部分は4つあり、それぞれが抱えるリスクは同じではありません。

  • Flutter SDK。 これは固定すべき最も重要なものです。開発者のマシン、モジュールのCI、ネイティブのビルドエージェントの間でSDKのバージョンがずれると、再現性のないビルドと、最悪の類の「自分のマシンでは動く」バグが生まれます。CIの設定と、チームで合意したチェックイン済みのファイル(fvmの.fvmrc、mise/asdfの.tool-versionsのエントリ、最低でもドキュメント化されたバージョン)で明示的に固定し、バージョンの引き上げは意図的でレビューを経た変更として扱ってください。
  • フレームワーク(inlay)。モジュールはinlayに依存し、生成される境界コードはinlay_genによって作られます。この2つは一致していなければならず、ビルド済みのモジュールをリンクするホストは、そのモジュールのビルドに使われたinlayのバージョンをそのまま引き継ぎます。両者は一緒に引き上げてください。
  • Flutterモジュール。 成果物として利用する場合、これはホストが固定するバージョンです。ソースから利用する場合は、独自のバージョン番号を持たず、コミットがバージョンになります。
  • 境界コントラクト。 Pigeonで生成されたプラットフォームチャネルのAPIと、inlay_genで生成されたルート/ストアが、Dartとネイティブの間の実際のコントラクトです。つまり、アプリのプロセス内でチャネルを越えるすべてのメッセージの形です。不一致がどこで表面化するかは、生成されたネイティブコードがどのようにホストに届くかによって決まります。ホストがDart側と同じスキーマから生成されたKotlin/Swiftに対してコンパイルされる場合(そのコードがモジュールの中に含まれて出荷される場合がこれにあたります。Inlayのサンプルでは、flutter build aarがバンドルできるよう、付属のプラグインがそのコードを保持しています。また、両側が1つのチェックアウトにある場合もこれにあたります)、破壊的変更はネイティブ側のコンパイルエラーになります。ホストが生成コードの独自のコピーを持っている場合や、別のスキーマリビジョンから生成されたビルド済みモジュールをリンクしている場合、不一致は実行時エラーとしてしか現れません。これこそ最も注意を払うべきケースです。ネイティブとFlutterの通信を参照してください。

境界コントラクトは忘れられがちです。どこにも入力するバージョン番号がなく、モジュールのバージョンによって暗黙的に決まるからです。実際には、モジュールのバージョンが境界コントラクトを一緒に運びます。両方のネイティブ側を再生成・再リンクせずにモジュールのバージョンを上げると、モジュールにもう存在しないメソッドシグネチャを呼び出すホストを出荷してしまうことがあります。

Inlayは境界コントラクトのバージョンを明示的にし、さらにそれをチェックしてくれます。inlay_genは Dart、Kotlin、Swiftの出力にスキーマのフィンガープリントを埋め込みます。すべてのルートが それを持って境界を越え、生成されたデコーダーが到着時にそれを検証します。ずれたホストとモジュールの組み合わせは、 本番環境でデータを破損させる代わりに、最初の遷移で両方のフィンガープリントを示すわかりやすいエラーとともに 失敗します。アプリ側での設定は不要です。

lockstepとdivergence

中心となる決定は、モジュールとホストが独立してリリースすることを許すかどうかです。

モジュールとネイティブホストは一緒に動きます。あるアプリのビルドは既知のモジュールバージョンを組み込み、組み込むモジュールに対して再検証することなくホストを出荷することはありません。ソース統合を行うモノレポでは、これは自然に実現されます。バージョンは1つ、つまりチェックアウトだけです。境界コントラクトが構造的に検証されるため、これが最も安全なモデルです。境界を呼び出すコードと境界を実装するコードが、同じソースから同時にビルドされるからです。

管理されたdivergence

モジュールは独自のサイクルでリリースし、ホストは準備ができたときに新しいバージョンを採用します。これは成果物による統合を行うポリレポで得られるものであり、本当に必要になる場合もあります。独自のリリーストレインを持つ2つのネイティブチームが、常に足並みをそろえてバージョンを上げられるとは限りません。コストは、どの時点でも、異なるホストが異なるモジュールバージョンを組み込んでいる可能性があり、それぞれの組み合わせが実際に動作することを確認しなければならない点です。

リスクは、歯止めのないdivergenceです。誰もバージョンの引き上げを担当していないために、モジュールの3バージョン前で止まってしまったホストなどがその例です。divergenceがリリースのタイミングをずらすのは、数日、長くても1スプリントまでにすべきです。あるホストが日常的に大きく遅れているなら、それは通常、境界の変化が速すぎるか、誰もアップグレードを担当していないかのどちらかを意味します。そしてほとんどの場合、モノレポに戻る方向へ進むべき理由になります。

境界をまたいだ固定

ホストがモジュールをどう固定するかは、統合のモードによって異なります。

  • ソースレベル(モノレポ):チェックアウトがすべてを固定します。両側が常に同じコミットからビルドされるため、管理すべきバージョン番号はありません。
  • ソースレベル(submodule):ホストはモジュールをコミットSHAに固定します。正確で再現可能ですが、セマンティックではありません。ポインターだけでは、あるバージョンが新しいのか互換性があるのかはわからず、正確にこのコミットであることしかわかりません。更新には新しいポインターのコミットが必要です。
  • 成果物(ポリレポ):ホストは依存関係の座標(AndroidではMavenの座標、iOSではPod/SPMのバージョン)によってセマンティックバージョンを固定します。境界をまたいでSemVerが実質的な意味を持つのはこのモードだけなので、バージョニングの規約が本当に必要になるのもこのモードです。

成果物のバージョニング

モジュールをAAR / XCFrameworkとして公開する際は、SemVerを付け、何を破壊的変更とみなすかは境界コントラクトに決めさせてください。ホストが実際に結合しているのは境界だからです。

  • メジャー - 境界が互換性のない形で変わった場合:チャネルのメソッドが削除された、またはそのシグネチャが変わった。生成されたルートやストアのフィールドの名前が変わった、または削除された。ホストはネイティブ側のコードを変更しない限りこれを採用できません。
  • マイナー - 境界への追加的な変更:新しいルート、新しいストアのキー、新しいメソッド。既存のホストのコードはそのまま動き続けます。
  • パッチ - 境界に影響しないDart内部の変更:画面内のバグ修正やUIの微調整。ホストは中身を確認せずに取り込めます。

実践的なルールとして、Pigeonやinlay_genの出力を再生成した結果、ホストが参照しているシグネチャが変わるなら、それは少なくともマイナーで、おそらくメジャーです。バージョンの引き上げは、変更の大きさの感覚ではなく、生成された境界コードのdiffに結びつけてください。

モード別のバージョニングワークフロー

ソース統合を行うモノレポには、公開してバージョンを上げるというステップがありません。境界の変更は、スキーマを更新し、3プラットフォームすべてを再生成し、両側を一緒に対応させる1つのアトミックなコミットです。バージョンはコミットであり、CIが全体を一度に検証します。モノレポでのソース統合が最もオーバーヘッドの小さいモデルなのはこのためで、そこではバージョニングはほとんど問題になりません。

成果物による統合を行うポリレポでは、バージョニングこそが両側をつなぎとめるものであり、受け渡しは次の手順になります。

  1. モジュールのDartを変更し、境界を再生成する。
  2. Flutter側を単独で検証する(dart analyze、dart test、再生成チェック)。
  3. 成果物(ホストが必要とするすべてのflavorとビルドモード。ビルド統合を参照)をビルドし、新しいSemVerで公開する。
  4. 各ホストを新しいバージョンに引き上げ、コントラクトの変更に合わせてネイティブのコードを対応させる。
  5. 各ホストを、新たに組み込んだバージョンに対して検証する。

これは自動化すべきです。 バージョンの公開とホストのバージョンを上げるPRの作成は機械的な受け渡しであり、手作業で行うと、数週間もすれば確実には行われなくなりがちです。ステップ3からステップ5までの間、つまり公開されたモジュールがホストより先行している期間が、意図的に受け入れているdivergenceです。この期間は短く保ってください。

成果物を固定する際はビルドモードを一致させてください。 バージョン番号はビルドモードを 表しません。releaseのホストはそのバージョンのreleaseビルドをリンクしなければならず、 debug/profile/releaseの成果物は互いに置き換えられません。多くの 「バージョン不一致」の障害は、実際にはモードの不一致です。

Flutter SDKとフレームワークの固定

この2つは上記すべての土台であり、個別に取り上げる価値があります。

Flutter SDKは、XcodeやAndroid SDKを固定するのと同じ方法で固定すべきです。ソース統合ではネイティブのビルドエージェント自身がFlutterを実行するため、固定はネイティブのCIにも置く必要があります。成果物による統合では、それが必要なのはモジュールのパイプラインだけです。これは、成果物がネイティブチームを切り離す理由の1つです。いずれにしても、モジュールのCIとすべての開発者のマシンは1つのバージョンで一致しているべきであり、それは慣習ではなくチェックイン済みの設定によって強制すべきです。

inlayとinlay_genは、同じバージョンに保ち、1つの単位として引き上げるべきです。あるinlayバージョンでビルドされたモジュールをホストが利用するのは問題ありません。ホストはモジュールが出荷したものをそのままリンクするからです。しかし、inlayとinlay_genが一致していないモジュールは、ランタイムと合わない境界を生成する可能性があります。フレームワークの引き上げはSDKの引き上げと同様に扱ってください。意図的に、レビューを経て、出荷前に境界をまたいで検証します。

経験則

  • Flutter SDKは、どこでも明示的かつ同一に固定する。 コストはわずかで、最悪の再現性のバグを防げます。
  • モジュールのバージョンを境界コントラクトのバージョンとして扱い、SemVerは生成された境界コードのdiffに基づいて決める。
  • lockstepを優先する。 divergenceを許すなら、歯止めと担当者を設けてください。大きく遅れているホストは、たいてい何かがおかしいことを意味します。
  • 公開とバージョン引き上げの受け渡しを自動化する。 手作業でのバージョン管理は、ポリレポのプロジェクトが破綻しがちな部分です。
  • 成果物をリンクする際はビルドモードを一致させる。 成果物による統合における「バージョン」のバグの多くは、実際にはモードの不一致です。

プライバシーポリシー

このページの内容