add-to-appのプロジェクト構成

モノレポ、ポリレポ、git submodule。Flutterモジュール、ネイティブのホストアプリ、共有コードをどこに置くか。

add-to-appプロジェクトには、置き場所が必要な構成要素が少なくとも3つあります。Flutterモジュール(Dartコードと、そこから生成される境界コード)と、それを組み込むネイティブのホストアプリ(通常はiOSアプリ1つとAndroidアプリ1つ)です。これらをリポジトリにどう配置するかは、最初に下す構造上の決定の1つです。以後の境界をまたぐあらゆる変更のコストを左右するため、意識的に決める価値があります。

この決定を何よりも左右する問いが2つあります。

  • ネイティブアプリのオーナーはすでに誰か? エンタープライズのadd-to-appプロジェクトの多くでは、ネイティブアプリはFlutterより前から存在しています。確立されたリポジトリにあり、独自のチーム、CI、リリースプロセスを持っています。こうした既存の現実が、どんな好みよりも構成を制約することがよくあります。
  • 境界はどのくらいの頻度で変わるか? ネイティブとFlutterの間のコントラクト(ネイティブとFlutterの通信を参照)は、どれも両側にまたがるコードです。境界がまだ頻繁に変わっているなら、両側の変更を同時に反映したいはずです。安定しているなら、独立性のコストは小さくなります。

一般的な構成は3つあります。大きな組織全体では互いに排他的ではありませんが、1つのプロダクトについては通常どれか1つを選びます。

モノレポ

Flutterモジュールと両方のネイティブホストが1つのリポジトリにあります。1回のチェックアウトに、アプリとして出荷されるものがすべて含まれます。

app-repo/
  android/          # native Android host
  ios/              # native iOS host
  flutter_module/   # the Flutter module (Dart + generated boundary)
  packages/         # shared Dart packages (optional)

これは、このフレームワーク自身のサンプルが採用している構成です。inlayパッケージ、example_module、そしてexample_android / example_iosのホストが、すべて1つのツリーに置かれています。

この構成が適しているのは、1つの組織がネイティブ側とFlutter側の両方を所有している場合や、移行と並行してホストアプリを新しく作る場合です。境界がまだ動いている間は最も有力な選択肢です。

得られるのは、境界をまたぐ変更(ルートの追加、共有コントラクトのフィールド名の変更、3プラットフォームすべての型の再生成)が1つのアトミックなコミットになることです。1つの単位としてレビューでき、中途半端にマージされることはありません。信頼できる唯一の情報源があり、境界のバージョンも1つだけです。これは1つのチームが境界コードを所有する場合に望ましい形です。

コストは、リポジトリが3つのツールチェーン(Gradle、Xcode、Flutter)を抱えることです。そのため、pushのたびにすべてをビルドするのではなく、変更された部分だけをビルドするようにCIを設定する必要があります。リポジトリは大きくなり、ネイティブチームとFlutterチームは望むと望まざるとにかかわらず同じリポジトリを共有することになります。

ポリレポ

Flutterモジュールは独自のリポジトリに置かれ、各ネイティブホストもそれぞれのリポジトリに残ります。ホストはモジュールを依存関係として利用します。ビルド済みの成果物として利用するか、ソースのリビジョンに固定して利用します(ビルド統合で解説)。

flutter-module-repo/      # the Flutter module, released independently
android-app-repo/         # consumes the module as a dependency
ios-app-repo/             # consumes the module as a dependency

この構成が適しているのは、ネイティブアプリがすでに別々のチームによる別々のリポジトリに存在する場合で、これはエンタープライズでよくある出発点です。ポリレポなら、ネイティブチームがすでに依存しているリポジトリを再編成せずにFlutterを追加できます。

得られるのは、明確なオーナーシップの境界と、独立したリリースサイクルです。Flutterチームのリポジトリはツールチェーンが1つだけで、CIは速く、焦点が絞られています。ネイティブチームは自分たちのリポジトリをほぼそのままにして、1つの依存関係を通じてFlutterを利用できます。

コストは、境界の変更がもはやアトミックではなくなることです。コントラクトを変更するには、一連のプルリクエストを調整する必要があります。モジュールを更新し、新しいバージョンを公開し、各ホストでバージョンを上げて対応させる、という流れで、その間はリポジトリ同士の状態が食い違う期間が生じます。バージョンの固定は能動的に管理しなければならないものになり(バージョニングを参照)、すべてのコードを見渡せるチェックアウトが存在しないため、大規模な横断的リファクタリングも難しくなります。

git submodule

中間的な選択肢です。ネイティブホストのリポジトリは別々のままですが、それぞれがFlutterモジュールのリポジトリをgit submoduleとして取り込みます。これにより、2つのリポジトリを統合することなく、ホストがモジュールをソースからビルドできます。

この構成が適しているのは、ソースレベルの統合とほぼアトミックなチェックアウトを望みつつ、既存のネイティブリポジトリをモノレポにまとめることができない(またはまとめたくない)場合です。

得られるのは、ホストが常に既知のコミットにある実際のモジュールのソースに対してビルドされることです。そのため、境界をまたいでステップ実行やデバッグができ、特定のSHAにおけるホストのチェックアウトは再現可能です。リポジトリを分けたまま、モジュールを変更するたびに公開する往復作業を避けられます。

コストは、submoduleの日々の使い勝手です。ホストはモジュールをセマンティックバージョンではなくコミットSHAに固定するため、更新には新しいポインターのコミットが必要です。--recursiveを忘れたコントリビューターは空のディレクトリを手にすることになり、detached HEAD状態はsubmoduleに慣れていない人をつまずかせます。どれも単体では大したことはありませんが、そのオーバーヘッドはリポジトリをクローンするすべての人にかかります。

アンチパターン:Dartパッケージごとのリポジトリ

ポリレポには、私たちが強くおすすめしない、より極端な形があります。Dart側そのものを多数のリポジトリに分割する(機能パッケージごとに1リポジトリ、共有ライブラリごとに1リポジトリ)、それらをgitやpathの依存関係でつなぐ形です。これは上記のポリレポとは異なります。上記のポリレポはモジュールをひとまとまりに保ち、ネイティブホストから分離するだけでした。こちらでは、Flutterのコードベース自体でさえアトミックな変更ができなくなります。

Dartパッケージごとに1つのリポジトリを作るのは避けてください。 私たちはこの方法で 構築されたプロジェクトに携わったことがあり、コードベースが大きくなるにつれてオーバーヘッドが深刻に積み重なりました。

私たちが直面した問題を、痛みの大きい順に挙げます。

  • どこにもアトミックな変更がない。 2つのパッケージに触れる変更(自明でない変更のほとんどがそうです)は、リポジトリをまたぐプルリクエストの連鎖になります。次に進むには、それぞれで公開するか、固定した参照を上げる必要があります。単純なリファクタリングでも、プルリクエストの調整に半日かかることがあります。
  • 依存関係の管理が大半を占める。 数十ものパッケージ間のバージョン(またはgit SHA)を手作業で固定するのは、絶え間なく続くミスの起きやすい作業です。互換性のないバージョンが1つのアプリに解決されてしまうことも簡単に起こります。本当に望んでいる歩調の一致(lockstep)こそ、この構成が最も困難にするものです。
  • ツールが足を引っ張る。 コード生成(build_runner)、アナライザー、「コードベース全体での使用箇所の検索・名前変更」は、いずれもコードが見えることを前提にしています。リポジトリに分散していると、どれも機能しません。そのため、パッケージをまたぐリファクタリングはセーフティネットを失い、境界の再生成は複数リポジトリにまたがる作業になります。
  • オンボーディングとCIが倍増する。 新しいコントリビューターは皆、多数のリポジトリをクローンして配線しなければなりません。どのパッケージも独自のCI、リリースプロセス、レビューのオーバーヘッドを抱えます。リポジトリごとの固定コストを何度も払い続けることになります。

この構成の動機となっている目標、つまり明確なパッケージ境界を持つモジュール化されたDartコードベースは、正しい目標です。間違いは、パッケージの境界をリポジトリの境界として扱うことにあります。パッケージをDart workspace(またはMelosのモノレポ)として1つのリポジトリにまとめれば、こうした痛みなしにモジュール化のメリットをすべて得られます。パッケージはきれいに分離されたまま、変更はアトミックで、依存解決は1回、ツールはすべてを見渡せます。パッケージ境界の引き方については、スケーリングとコード共有で解説しています。

モノレポ vs ポリレポ vs submodule:add-to-appではどれを選ぶべきか

構成最適な状況境界をまたぐ変更主なコスト
モノレポ1つの組織が両側を所有している。境界がまだ動いているアトミック、1コミット複数ツールチェーンのリポジトリ + CIの複雑さ
ポリレポネイティブアプリが別々のチームによってすでに存在しているリポジトリをまたいでPRを調整境界の変更がアトミックでない。バージョンの固定
submoduleリポジトリを統合せずにソース統合したい更新ごとにポインターをコミットsubmoduleの使い勝手

最終的な到達点としてはモノレポを目指してください。 add-to-appプロジェクトは本質的に、境界で合意しなければならない2つのコードベースです。プロジェクトが大きくなるほど、リポジトリの垣根を越えてその合意を維持するコストは増えていきます。新しいコントラクト、共有パッケージ、両側に触れるリファクタリングのたびに、リポジトリ間の調整、バージョンの引き上げ、リポジトリ同士の状態が食い違う期間が生じます。モノレポはそのすべてをアトミックでレビュー可能なコミットにまとめます。そのメリットはプロジェクトの規模が大きくなるほど増し、一方でポリレポのオーバーヘッドもそれと同じだけ増えていきます。

モノレポは、いずれ来る出口に向けた準備にもなります。ほとんどのadd-to-appプロジェクトの最終局面はFlutterがシェルを持つ形への完全な切り替えであり、そのステップは定義上、両プラットフォームを1つのFlutterアプリに統合するものです。モノレポなら、そこへ段階的に到達できます。コードベースはすでに同じ場所にあり、境界はすでに唯一の情報源になっていて、「Flutterをホストにする」ことは、ただでさえ難しい移行の上にリポジトリの統合を重ねるのではなく、1つのリポジトリ内のリファクタリングになります。

とはいえ、ポリレポは出発点としてまったく問題ありません。多くの場合、唯一の現実的な選択肢でもあります。ネイティブアプリはすでにそれぞれのリポジトリにあり、リポジトリの再編成のために移行を止めるべきではありません。まずはそこから始め、バージョニングとフィーチャーフラグを頼りに境界を管理してください。そして、ポリレポのオーバーヘッドが移動に伴う混乱を上回り始めたとき(通常は境界が広くなり、頻繁に変わるようになったとき)のために、モノレポへの統合を意図的なマイルストーンとして位置づけてください。submoduleはその中間の橋渡しです。それ以外は分かれたネイティブリポジトリの中でモジュールをソースからビルドしたいときに使ってください。モノレポへの足がかりとして使うこともできますし、リポジトリの統合が本当に選択肢にない場合に使うこともできます。

リポジトリ構成は、ネイティブアプリがモジュールをどのように利用するか(ソースからビルドするか、 ビルド済みのAAR / XCFrameworkを使うか)と密接に関係していますが、別の問題です。 ソース統合はモノレポやsubmoduleの構成では自然な選択です。成果物による統合は、 ポリレポを実用的にするものです。その選択と、それに伴うCI/CDについては ビルド統合で解説しています。

プライバシーポリシー

このページの内容