Flutterモジュールのスケーリング:パッケージとコード共有
Dart側をパッケージにモジュール化し、スタンドアロンのFlutterアプリと共有する方法。
数画面から始まったFlutterモジュールが、そのままの規模にとどまることはほとんどありません。機能が増え、関わる人が増えるにつれて、フラットな単一のlib/はボトルネックになります。どのファイルからでも何でもimportでき、ビルド時間は伸び、何を誰が担当しているのかが曖昧になっていきます。Dart側のスケーリングとは、つまるところ、境界がないことの痛みが出る前にモジュール内にパッケージの境界を引くことです。そして一度その境界ができれば、コードをコピーせずにスタンドアロンのFlutterアプリと共有するための土台にもなります。
内容の大半は、ごく一般的なFlutterのモジュール化です。これがadd-to-app特有の関心事になるのは、モジュールがその背後に置かれる境界があるからです。ネイティブのホストは厳選された一部の画面からFlutterに入り、Flutterはそれぞれが独立したisolateである複数のエンジン上で動くことがあり、さらにモジュールはネイティブのホストと、いずれはスタンドアロンのFlutterアプリの両方に組み込まれることを想定しています。この3つの事実によって、一部のパッケージの置き場所、各部分の通信方法、そしてバージョニングの対象となるパッケージが変わってきます。本ページでは、それらを該当箇所で都度取り上げます。ここでのガイドラインは特定のadd-to-appフレームワークに依存しません。inlay固有の内容は具体例として示すにとどめています。
本ページはプロジェクト構成の内部版にあたります。あちらはリポジトリをどこに置くかを扱い、こちらはリポジトリ構成に関係なく、モジュール内でDartコードをどう整理するかを扱います。
フォルダではなくパッケージ
lib/が大きくなったとき、まず思いつくのはサブフォルダを追加することです。しかしフォルダは名前を与えるだけで、強制力はありません。どのファイルも他のどのファイルでもimportできるため、フォルダツリーが整って見えていても、依存関係グラフは静かに劣化していきます。実際にグラフを制約する単位はパッケージです。パッケージは依存関係として宣言したものしか参照できないため、引いた境界はコードレビューではなくツールによってチェックされます。
これらのパッケージを置くべき場所は、Dart workspace(またはMelosモノレポ)として構成した1つのリポジトリです。パッケージごとにリポジトリを分けるべきではありません。このフレームワーク自体のコードもまさにこの構成で、inlay、inlay_gen、サンプルモジュールが1つのツリー内のworkspaceメンバーになっています。パッケージをきれいに分離しつつ、アトミックな変更、単一の依存関係解決、グラフ全体を把握できるツールが同時に手に入ります。パッケージごとにリポジトリを分けるアンチパターンは、パッケージの境界とリポジトリの境界を混同したときに起こるものです。
パッケージの整理
パッケージは2つの軸で分割され、通常その2つは一致します。ビジネスのサブドメインと、チームのオーナーシップです。各パッケージを所有するチームは必ず1つで、1つのチームが複数のパッケージを所有することもあります。パッケージ名には汎用的なfeature_ではなく、アプリ名のプレフィックスを付けます(app_transfers、app_design_system)。プレフィックスがあれば、どのパッケージがこのアプリのもので、どれがサードパーティの依存関係なのかが一目でわかります。
私たちの経験では、パッケージは主に3種類に分かれていきます。
- ビジネスパッケージ - ユーザー向けのサブドメインごとに1つ(
app_transfers、app_loans、app_accounts)。それぞれがそのサブドメインの画面、UI、ビジネスロジックを持ち、担当するビジネスチームが所有します。 - 技術・プラットフォームパッケージ - すべてのサブドメインが依存する横断的なインフラです。
app_networking、app_common、app_authorization、app_lint、そしてアプリを組み立てるapp_entrypointパッケージがこれにあたります。このコードは特定のサブドメインに属さないため、独自のバックログを持つ専任のプラットフォームチーム(通常は最もシニアなエンジニアたち)が所有します。add-to-appプロジェクトでは、生成されたネイティブとの境界(型付きのルートと状態のコントラクト)もここに置きます。 - デザインシステムパッケージ - 共通のビジュアル言語です。
app_design_system(共通ウィジェット、テーマ)とapp_widgetbookがこれにあたります。サブドメイン間でUIの一貫性を保つため、デザインシステムチームが所有します。
my_app/
apps/
app_entrypoint/ # assembles all packages into the runnable app(s)
packages/
app_transfers/ # business subdomain (business team)
app_loans/
app_accounts/
app_design_system/ # design system (design-system team)
app_widgetbook/
app_networking/ # platform / technical (platform team)
app_common/
app_authorization/
app_lint/各パッケージは内部実装をカプセル化し、lib/のバレルファイルを通じて、意図的に設計した公開インターフェースだけを公開します。外部に漏れてはならないもの(たとえば実装の詳細にあたるデザインシステムのウィジェット)には@internalを付け、外部から使われたらアナライザーが警告するようにします。これがないと、誰にも気づかれないまま境界が簡単にすり抜けられ、時間とともに崩れていきがちです。
小さなモジュールを初日から十数個のパッケージに分割しないでください。早すぎる モジュール化にはそれ自体のコストがあります。ビルド時間、オーナーシップの曖昧さ、 他の場所で再利用したいサブドメインなど、実際のプレッシャーが生じたときに分割しましょう。 上記の構成は出発点ではなく、目指すべき到達点として捉えてください。
ネイティブとの境界はそれ自体が独立したパッケージ
純粋なFlutterアプリでは、パッケージグラフがすべてです。add-to-appプロジェクトでは、スタンドアロンのアプリには存在しないパッケージがもう1つ加わります。それがネイティブとの境界です。ホストがどの画面を起動でき、ネイティブとFlutterの間でどんな状態がやり取りされるかを定義する型付きのコントラクトと、そこからDart、Kotlin、Swift向けに生成されたコードで構成されます。(inlayの場合、このコントラクトは@InlayNativeRoute / @InlayFlutterRoute / @InlayStoreスキーマとinlay_genの出力ですが、考え方は型付きのadd-to-app境界であれば何にでも当てはまります。)
このパッケージには2つの特別な理由があり、どちらもプラットフォームチームが所有する専用の技術パッケージとして保つべきだという結論につながります。
- ネイティブのホストがコンパイル時に依存する唯一のパッケージであること。生成されたKotlin/Swiftが、iOSチームとAndroidチームから見える表面のすべてです。境界の定義が複数のビジネスパッケージに散らばっていれば、ネイティブ向けのコントラクトも散らばります。1つの
app_boundaryパッケージに集約すれば、ネイティブ側はよく知られた単一の依存関係を持つだけで済みます。 - バージョニングの単位であること。モジュール成果物のバージョンは、実質的には境界コントラクトのバージョンです。再生成によって、ホストがリンクしているシグネチャが変わる可能性があります。境界を1つのパッケージにまとめておけば、「コントラクトは変わったか?」という問いは、コードベース全体を探し回るのではなく、単一パッケージの差分を見るだけで答えられます。
境界に含めるべきなのは、ネイティブから到達できる画面だけです。 サブドメインが持つ画面の大半は通常のFlutter内ナビゲーションで到達し、ネイティブ向けのコントラクトには一切現れません。それらはビジネスパッケージの内部にとどまります。境界に置くのはホストが起動するエントリ画面だけで、ほとんどの場合、意図的に厳選された少数のセットです。
パッケージ間の依存関係
よく見かけるルールに、フィーチャーパッケージ間の依存を一切禁止するというものがあります。私たちの経験では、これは厳しすぎます。正当なビジネス上の理由があれば、ビジネスパッケージは互いに依存するものです。送金にはアカウントのデータが必要なのでapp_transfersがapp_accountsに依存する、というのは正当な依存関係であり、これを禁止しようとしても、その関係がもっと扱いにくい場所に押しやられるだけです。また、どのビジネスパッケージも技術パッケージやデザインシステムパッケージには自由に依存します。
守るべきなのはグラフの形です。
- 非循環に保つ - 2つのパッケージが循環して互いに依存してはいけません。
app_transfersがapp_accountsを必要とし、その逆も成り立つなら、共通の概念を両者が依存する3つ目のパッケージに切り出すべきです。 - 内部ではなく、必ず公開インターフェースを経由する - 他のパッケージへの依存とは、そのプライベートなファイルではなく、エクスポートされたファサードへの依存を意味します。これを強制するのが
@internalとバレルファイルです。 - 新しい外部依存関係を関門で管理する - 大規模なプロジェクトでは、
pubspec.への追加はすべてプラットフォームチームを通すべきです(CIのyaml license_checkerステップがライセンスのずれを防ぎます)。依存関係グラフは、プラットフォームチームが積極的にキュレーションすべきものです。
パッケージをまたぐナビゲーション
モジュール化されたadd-to-appプロジェクトには、2つの異なるナビゲーションの面があり、両者を分けて考えると見通しがよくなります。
- ネイティブ → Flutter - ホストがエントリ画面を起動するケースです。ネイティブとの境界をまたぎ(マルチエンジン構成では、その画面をホストするエンジンを立ち上げ)、境界が公開する厳選されたルートに限定されます。この部分はパッケージグラフではなく、add-to-appフレームワークが担います。
- Flutter → Flutter - 1つのエンジン内で、異なるパッケージが所有する画面間を移動するケースです。これは以下で説明する通常のパッケージ間ルーティングで、ネイティブ側が関わることはありません。
Flutter内のナビゲーションでは、各パッケージが自身のルートを宣言します。他のパッケージが所有する画面に到達する方法は2つあります。
- 直接の依存 - 対象のパッケージに依存し、そのパッケージがエクスポートするルートやナビゲーションメソッドを使います。両者の間にすでに実際のビジネス上の依存がある場合には自然な方法です。
- 共有の
app_navigationパッケージ - すべてのルートを1つのナビゲーションパッケージで宣言し、すべてのパッケージがそれに依存します。これにより、直接依存することなく他のパッケージの画面に遷移できます。app_loansはapp_transfersに一切依存せずに送金画面をpushできます。ナビゲーションツリー全体の信頼できる唯一の情報源と、よりきれいなビジネス境界が得られる一方で、絶えず変更され、すべてのチームが共有するパッケージを抱えるというコストが生じます。
app_navigation ← all routes; everyone depends on it
↑ ↑
app_accounts app_loans ← loans reaches transfers via navigation,
↑ with no direct dependency on transfers
app_transfers ← needs accounts data (a real business dependency)パッケージ間の通信
単なるナビゲーションではなく、データを共有したり振る舞いをトリガーしたりする必要がある場合、一般的な仕組みは2つあります。
- ファサード - パッケージの明示的な公開インターフェースです。ファサードは内部データやAPI呼び出しへの読み取りアクセスを公開したり、アクションの実行を可能にしたり、データのストリームを公開したり、ナビゲーションメソッドを提供したりできます。他のパッケージは、このファサードを通じてのみそのパッケージとやり取りします。
- イベントバス - 複数のパッケージが互いを知らないまま同じイベントに反応したり、イベントを発行したりする必要がある場合に便利です。結合をうまく取り除けますが、使用は控えめにしてください。グローバルなイベントバスに大きく依存すると、原因と結果を追跡しにくいコードになります。
これらの仕組みはどちらも単一のエンジン内でしか機能しません。ファサードや
イベントバスでやり取りする2つのパッケージは、同じエンジンのisolateで動いている
必要があります。マルチエンジン構成では、あるエンジンの画面から別のエンジンに
あるファサードやバスは見えません(別々のisolateはDartのメモリを一切
共有しません)。エンジンをまたいで
参照する必要がある状態は別の問題であり、プラットフォーム側を介して同期する
仕組みが必要です(inlayの場合は
@InlayStore)。こうしたエンジン横断の共有定義は、
単一のビジネスパッケージ内ではなく、境界/技術レイヤーに置いてください。
モジュールインターフェース
すべてをエントリポイントに組み込むための実用的なパターンとして、すべてのパッケージが実装する共通のモジュールインターフェースがあります。
abstract interface class AppModule {
List<GoRoute> get routes;
List<SingleChildWidget> get providers;
List<ApiService> get apiServices;
}エントリポイントは各パッケージのAppModuleを集め、そのルート、プロバイダー、サービスを1か所で登録します。新しいパッケージを追加するときはこのインターフェースを実装するだけで、ナビゲーション、依存性注入、サービスが自動的に取り込まれます。パッケージごとの個別の配線は不要です。
マルチエンジンのadd-to-appプロジェクトでは、この組み立てはエントリポイントパッケージの単一のDartエントリポイント(inlayの場合はinlayMain)で実行されます。すべてのエンジンが同じエントリポイントを実行するため、モジュールのリストは各ネイティブコンテナが立ち上がるたびに、エンジンごとに1回組み立てられます。エンジンは独立していて短命なので、これ自体は問題ありません。ただし、モジュールの登録は軽量で、エンジンをまたぐ前提を持たないものにする必要があります。特に、「グローバルに一度だけ登録する」シングルトンは避けてください。そもそもisolateモデルではそれは実現できません。
複数のサブドメインにまたがる画面の置き場所
複数のサブドメインを組み合わせる画面(送金、ローン、アカウントからデータを集めるダッシュボードなど)は、エントリポイントに置くのではなく、利用するビジネスパッケージに依存する専用のパッケージにすべきです。エントリポイントは薄い組み立てレイヤーに保つべきです。ビジネスロジックが一部でも入り込むと、さらに多くのロジックが続きがちで、後から切り離すのが難しくなります。しっかりしたデザインシステムがあれば、ダッシュボードのパッケージは各サブドメインの奥深くに手を伸ばすのではなく、既存のウィジェットを組み合わせるだけで済むことがほとんどです。
スタンドアロンのFlutterアプリとの共有
アプリが薄いエントリポイントの背後にあるパッケージの集合になると、もう1つの利点が現れます。同じパッケージでスタンドアロンのFlutterアプリを動かせるのです。組み込みモジュールと完全なFlutterアプリは、同じビジネス・技術・デザインシステムパッケージ群の上に乗る2つのエントリポイントパッケージになります。各サブドメインは一度書くだけで、両方で動きます。add-to-appプロジェクトで、スタンドアロンのFlutterアプリとネイティブのホストの両方に組み込めるパッケージが有益なのはこのためです。一部の実装の詳細が異なるため、実行時にどちらで動いているかを判別する手段(注入された環境変数など)が欲しくなることもあります。
これが重要になるのは、よくある次の2つの状況です。
- サブドメインを単独で動かす場合。
app_transfersとその依存関係だけを組み込んだスタンドアロンアプリは、iOSやAndroidのホストのビルドを挟まずに、そのサブドメインを素早く開発・テストする手段になります。 - 移行の最終局面。 ほとんどのadd-to-appプロジェクトにおける出口戦略は、Flutterが所有するシェルへの完全な切り替えです。サブドメインがすでに共有パッケージに入っていれば、そのシェルはリライトではなく、同じパッケージの上に乗るもう1つのエントリポイントにすぎません。今日、単独開発のために作るスタンドアロンアプリは、最終的にリリースするアプリに近いものになります。
これをきれいに成立させる鍵は、エントリポイント固有の関心事を共有パッケージに持ち込まないことです。ビジネスパッケージは、組み込まれていることを前提にすべきではなく(ネイティブとの境界に直接手を伸ばすべきではありません)、スタンドアロンであることを前提にすべきでもありません。ナビゲーションのホスト方法、状態がネイティブ側に渡るかどうか、アプリレベルの配線など、2つの置き場所で異なるものはすべて、ホストごとに異なるエントリポイントパッケージに置きます。一方、そこで組み立てられるパッケージは同一のまま保ちます。
packages/app_transfers/ # identical in both
packages/app_*/ # identical in both
apps/app_entrypoint/ # entrypoint: embeds in native hosts via the boundary
apps/app_standalone/ # entrypoint: a normal MaterialApp shell特にadd-to-appプロジェクトでは、境界への依存が注意すべき継ぎ目になります。生成されたネイティブのルートやストアを利用するパッケージは、組み込まれた状態ではそのまま動きますが、スタンドアロンアプリではホスト側の代替実装かスタブが必要です。境界をすべてのサブドメインに漏らさず、技術パッケージ内にとどめておくことで、パッケージをどちらのエントリポイントにもきれいに組み込めるようになります。
経験則
- 境界を引くにはフォルダではなくパッケージを使う - 依存関係グラフを制約できるのはパッケージだけです。
- すべてのパッケージを1つのリポジトリにまとめ、Dart workspaceとして管理する。パッケージごとにリポジトリを分けてはいけません。
- パッケージにはアプリ名のプレフィックスを付け、それぞれに所有チームを1つだけ割り当てる。
- 実際のビジネス上の理由があればビジネスパッケージ同士の依存を許容する - ただしグラフは非循環に保ち、内部ではなく公開ファサードを経由すること。
- 共有のナビゲーションパッケージでナビゲーションを疎結合にし、依存することなく他のパッケージの画面に遷移できるようにする。
- 実際のプレッシャーが生じたときに分割する - 上記の構成は到達点であり、初日の姿ではありません。
- ネイティブとの境界はプラットフォームチームが所有する1つのパッケージにまとめる - ネイティブがコンパイル時に依存するコントラクトであり、バージョニングの単位でもあります。含めるのはネイティブからのエントリ画面だけです。
- エンジン間の状態共有にはプラットフォーム側で同期されるストアを使う(
inlayの場合は@InlayStore)。ファサードやイベントバスは1つのエンジンのisolate内にしか届きません。 - エントリポイント固有のコードはエントリポイントパッケージに置き、それ以外を組み込みモジュールとスタンドアロンアプリの間で同一に保つ。