一般的なモジュール化パターン

すべてのプロジェクトに適合する単独のモジュール化戦略はありません。Gradle は柔軟性が高いため、プロジェクトをまとめる方法について制約はほとんどありません。このページでは、マルチモジュール Android アプリを開発する際に使用できる一般的なルールとパターンの概要について説明します。

高凝集度と低結合度の原則

モジュラー コードベースの特徴として、結合度凝集度のプロパティの使用があげられます。結合度は、モジュールが相互に依存している度合いを示します。凝集度は、この文脈では、1 つのモジュールの要素が機能面でどのように関連しているかを示します。原則として、結合度は低く、凝集度は高くなるようにしてください。

  • 結合度が低いとは、モジュール間の依存度を可能な限り低くすることを指します。これにより、1 つのモジュールに対する変更が他のモジュールに及ぼす影響をゼロまたは最小限にできます。モジュールが他のモジュールの内部動作を認識しないようにする必要があります
  • 凝集度が高いとは、モジュールを構成するコードの集合が 1 つのシステムとして機能することを指します。モジュールには明確に定義された役割が必要で、また特定のドメイン知識の範囲内にある必要があります。サンプルの電子書籍アプリケーションについて考えてみましょう。書籍関連のコードと支払い関連のコードは異なる 2 つの機能ドメインなので、同じモジュール内で併用するのは不適切な場合があります。

モジュールの種類

モジュールを整理する方法は、主にアプリのアーキテクチャによって決まります。推奨されるアプリ アーキテクチャに沿ってアプリに導入できる一般的なモジュールには、次の種類があります。

データ モジュール

データ モジュールには通常、リポジトリ、データソース、モデルクラスが含まれています。データ モジュールの主な役割は次の 3 つです。

  1. 特定のドメインのすべてのデータとビジネス ロジックをカプセル化する: 各データ モジュールは、特定のドメインを表すデータの処理を行います。関連しているものであれば、数多くの種類のデータを処理できます。
  2. リポジトリを外部 API として公開する: データ モジュールの公開 API はアプリの他の部分にデータを公開する役割を担うため、リポジトリでなければなりません。
  3. すべての実装の詳細とデータソースを外部から認識できないようにする: データソースには、同じモジュールのリポジトリしかアクセスできないようにする必要があります。外部からは認識できなくなります。これを実現するには、Kotlin の公開設定キーワード private または internal を使用します。
図 1. サンプルデータ モジュールとその内容

機能モジュール

機能とは、アプリの機能の独立した部分のことで、通常は 1 つの画面または一連の密接に関連する画面(登録フローや決済フローなど)に対応します。アプリにボトムバー ナビゲーションがある場合、それぞれのリンク先は機能であることが多いです。

図 2. このアプリの各タブは機能として定義可能

機能はアプリの画面やリンク先に関連付けられます。そのため、通常ロジックと状態を処理するために関連付けられた UI と ViewModel があります。どの機能も、1 つのビューまたはナビゲーションのリンク先には限定されません。機能モジュールはデータ モジュールに依存します。

図 3. サンプル機能モジュールとその内容

アプリ モジュール

アプリ モジュールは、アプリケーションのエントリ ポイントです。これらは機能モジュールに依存し、通常はルート ナビゲーションに使用されます。ビルド バリアントにより、1 つのアプリ モジュールをさまざまなバイナリにコンパイルできます。

図 4. プロダクト フレーバー モジュールの「デモ用」および「完全」依存関係グラフ

アプリが複数のデバイスタイプ(自動車、Wear、テレビなど)をターゲットとする場合は、それぞれについてアプリ モジュールを定義します。これにより、プラットフォーム固有の依存関係を分離できます。

図 5. Wear アプリの依存関係グラフ

共通モジュール

共通モジュール(コアモジュールとも呼ばれます)には、他のモジュールが頻繁に使用するコードが含まれています。これにより冗長性が低くなります。また、これらのモジュールがアプリのアーキテクチャの特定のレイヤを表すことはありません。共通モジュールの例を以下に示します。

  • UI モジュール: アプリ内でカスタム UI 要素や精密なブランディングを使用する場合は、ウィジェット コレクションをカプセル化して、すべての機能を再利用できるようにモジュール化することを検討してください。これにより、複数の機能間で UI の一貫性を保つことができます。たとえば、テーマが一元管理されている場合、リブランディング時に手間のかかるリファクタリングを回避できます。
  • アナリティクス モジュール: トラッキングは、ビジネス要件によって決まルことが多く、ソフトウェア アーキテクチャについて考慮する必要はほぼありません。関連のない多くのコンポーネントでは、多くの場合、アナリティクス トラッカーが使用されます。そのような場合は、専用のアナリティクス モジュールを用意することをおすすめします。
  • ネットワーク モジュール: 多くのモジュールでネットワーク接続を必要とする場合は、HTTP クライアントの提供に特化したモジュールの用意を検討してください。これは、クライアントがカスタム設定を必要とする場合に特に便利です。
  • ユーティリティ モジュール: ユーティリティ(ヘルパーとも呼ばれます)は通常、アプリ全体で再利用される小規模のコードです。ユーティリティの例としては、テストヘルパー、通貨フォーマット関数、メール検証ツール、カスタム演算子などがあります。

テスト モジュール

テスト モジュールはテスト専用の Android モジュールです。このモジュールには、テストの実行にのみ必要で、アプリの実行時には不要なテストコード、テストリソース、テスト依存関係が含まれます。テスト モジュールはテスト固有のコードをメインアプリと分離するために作られており、モジュール コードの管理と保守が容易になります。

テスト モジュールのユースケース

次の例は、テスト モジュールの実装が特に役に立つケースを表しています。

  • 共有テストコード: プロジェクトに複数のモジュールがあり、一部のテストコードが複数のモジュールに適用される場合は、テスト モジュールを作成してコードを共有できます。これにより、重複が排除され、テストコードを保守しやすくなります。共有テストコードには、カスタム アサーションやマッチャーなどのユーティリティ クラスや関数に加え、シミュレートされた JSON レスポンスなどのテストデータを含められます。

  • すっきりとしたビルド構成: テスト モジュールによってテスト用の build.gradle ファイルを保持でき、すっきりとしたビルド構成にできます。テストにのみ関連する構成のせいでアプリ モジュールの build.gradle ファイルが雑然となることを防げます。

  • 統合テスト: テスト モジュールでは、ユーザー インターフェース、ビジネス ロジック、ネットワーク リクエスト、データベース クエリなど、アプリのさまざまな部分のインタラクションをテストする統合テストを保存できます。

  • 大規模アプリ: テスト モジュールは複雑なコードベースと複数のモジュールを持つ大規模アプリで特に役に立ちます。このようなケースでは、テスト モジュールによってコードを整理でき、保守性が改善します。

図 6. テスト モジュールを使用すると他の方法では依存するモジュールを分離可能

モジュール間の通信

モジュールが完全に分離されていることはまれで、多くの場合、他のモジュールに依存し、他のモジュールと通信しています。モジュールが連携して頻繁に情報を交換する場合でも、結合度を低く抑えることが重要です。2 つのモジュール間の直接通信は、アーキテクチャの制約の場合のように、望ましくない場合があります。また、循環依存関係の場合など、それが不可能な場合もあります。

図 7. モジュール間の直接双方向通信が循環依存関係のために不可能な場合、他の 2 つの独立したモジュール間のデータフローを調整するためにメディエーション モジュールが必要

この問題を解決するには、3 つ目のモジュールで他の 2 つのモジュールをメディエーションします。メディエータ モジュールは、両方のモジュールからのメッセージをリッスンし、必要に応じて転送できます。このサンプルアプリでは、購入手続き画面で購入対象の書籍を認識する必要があります。これは別の機能に含まれる別の画面でイベントが発生した場合でも同様です。この場合、メディエータは、ナビゲーション グラフを所有するモジュール(通常はアプリ モジュール)です。この例では、ナビゲーションを使用し、Navigation コンポーネントによりホーム機能から決済機能にデータを渡します。

navController.navigate("checkout/$bookId")

決済先は、書籍 ID を引数として受け取り、これを使用して書籍に関する情報を取得します。デスティネーション機能の ViewModel 内のナビゲーション引数を取得するには、保存済み状態ハンドルを使用します。

class CheckoutViewModel(savedStateHandle: SavedStateHandle, …) : ViewModel() {

   val uiState: StateFlow<CheckoutUiState> =
      savedStateHandle.getStateFlow<String>("bookId", "").map { bookId ->
          // produce UI state calling bookRepository.getBook(bookId)
      }
      …
}

ナビゲーション引数としてオブジェクトを渡さないようにしてください。代わりに、データレイヤーから目的のリソースにアクセスして読み込むために機能で使用されるシンプルな ID を使用します。これにより、結合度を低く抑えることができ、「信頼できる唯一の情報源」の原則を遵守できます。

以下の例では、両方の機能モジュールが同じデータ モジュールに依存しています。これにより、メディエータ モジュールが転送する必要があるデータ量を最小限に抑え、モジュール間の結合度を低く抑えることができます。モジュールはオブジェクトを渡す代わりに、プリミティブ ID を交換し、共有データ モジュールからリソースを読み込みます。

図 8. 共有データ モジュールに依存する 2 つの機能モジュール

依存関係の逆転

依存関係の逆転とは、抽象化が具体的な実装から分離するようにコードを整理することです。

  • 抽象化: アプリ内のコンポーネントまたはモジュールが相互にやり取りする方法を定義するコントラクト。抽象化モジュールには、システムの API が定義され、インターフェースとモデルが含まれています。
  • 具体的な実装: 抽象化モジュールに依存し、抽象化の動作を実装するモジュール。

抽象化モジュールで定義された動作に依存するモジュールは、特定の実装ではなく、抽象化自体にのみ依存する必要があります。

図 9. 上位レベルのモジュールが下位レベルのモジュールに直接依存するのではなく、上位モジュールと実装モジュールが抽象化モジュールに依存します。

動作のためデータベースが必要となる機能モジュールがあるとします。機能モジュールは、データベースの実装方法(ローカル Room データベースやリモート Firestore インスタンスなど)には関係ありません。必要なのは、アプリケーション データの保存と読み取りのみです。

これを実現するために、機能モジュールは特定のデータベース実装ではなく、抽象化モジュールに依存します。この抽象化によってアプリのデータベース API が定義されます。つまり、データベースの操作方法に関するルールが設定されます。これにより、機能モジュールは基盤となる実装の詳細を把握していなくても、任意のデータベースを使用できます。

具体的な実装モジュールは、抽象化モジュールで定義された API の実際の実装を提供します。そのために、この実装モジュールは抽象化モジュールにも依存します。

依存関係インジェクション

ここまでの説明について、機能モジュールと実装モジュールがどのように連携するのか疑問に思う人もいるでしょう。その答えは、依存関係インジェクションです。機能モジュールは、必要なデータベース インスタンスを直接作成せず、必要な依存関係を指定します。これらの依存関係は外部(通常はアプリ モジュール)から提供されます。

releaseImplementation(project(":database:impl:firestore"))

debugImplementation(project(":database:impl:room"))

androidTestImplementation(project(":database:impl:mock"))

メリット

API とその実装を分離すると、次のようなメリットがあります。

  • 互換性: API モジュールと実装モジュールを明確に分離することにより、API を使用するコードを変更することなく、同じ API に複数の実装を開発して切り替えることができます。これは、状況によって異なる機能や動作が必要なシナリオで特に役立ちます。たとえば、テスト用のモック実装と本番環境用の実際の実装を比較できます。
  • 分離: 分離とは、抽象化を使用するモジュールが特定のテクノロジーに依存しなくなることを指します。後でデータベースを Room から Firestore に変更する場合、変更はジョブを行う特定のモジュール(実装モジュール)でのみ行われ、データベースの API を使用する他のモジュールには影響しないため、変更は簡単にできます。
  • テストの容易性: API を実装から分離することで、テストが大幅に簡単になります。API コントラクトに対してテストケースを作成できます。また、さまざまな実装を使用して、モック実装などのさまざまなシナリオやエッジケースをテストできます。
  • ビルドのパフォーマンスの改善: API とその実装を別々のモジュールに分割する場合、実装モジュールを変更しても、ビルドシステムは API モジュールに応じてモジュールを再コンパイルする必要がありません。これにより、ビルド時間が短縮され、生産性が向上します。特にビルド時間が長くなりうる大規模なプロジェクトでこの傾向は顕著です。

分離すべき時点

次の場合は、API の実装から API を分離することをおすすめします。

  • 多様な機能: システムの一部を複数の方法で実装できる場合は、明確な API を使用することで、さまざまな実装の互換性を維持できます。たとえば、OpenGL や Vulkan を使用するレンダリング システムや、Play または自社の課金 API と連携できる課金システムを使用できます。
  • 複数のアプリケーション: プラットフォーム間で共有される機能を備えた複数のアプリを開発する場合は、共通の API を定義し、プラットフォームごとに特定の実装を開発できます。
  • 独立したチーム: 分離により、異なるデベロッパーやチームがコードベースの異なる部分について同時に作業できます。デベロッパーは API コントラクトを理解し、正しく使用する必要がありますが、他のモジュールの実装の詳細を気にする必要はありません。
  • 大規模なコードベース: コードベースが大規模な場合や複雑な場合、API を実装から分離することで、コードの管理が容易になります。そうすることで、コードベースをより細かく、理解しやすく、メンテナンスしやすい単位に分けることができます。

導入方法

依存関係の逆転を実装する手順は次のとおりです。

  1. 抽象化モジュールを作成する: このモジュールには、機能の動作を定義する API(インターフェースとモデル)が含まれている必要があります。
  2. 実装モジュールを作成する: 実装モジュールは API モジュールに依存し、抽象化の動作を実装する必要があります。
    上位レベルのモジュールが下位レベルのモジュールに直接依存するのではなく、上位モジュールと実装モジュールが抽象化モジュールに依存します。
    図 10. 実装モジュールは抽象化モジュールに依存します。
  3. 上位モジュールを抽象化モジュールに依存させる: モジュールを特定の実装に直接依存させるのではなく、抽象化モジュールに依存させます。上位モジュールは実装の詳細を知っている必要はなく、コントラクト(API)のみが必要です。
    上位モジュールは実装ではなく抽象化に依存します。
    図 11. 上位モジュールは実装ではなく抽象化に依存します。
  4. 実装モジュールを提供する: 最後に、依存関係の実際の実装を指定する必要があります。具体的な実装はプロジェクトの設定によって異なりますが、通常はアプリ モジュールを使用することをおすすめします。実装を提供するには、選択したビルド バリアントまたはテスト ソースセットへの依存関係として指定します。
    アプリ モジュールは実際の実装を提供します。
    図 12. アプリ モジュールは実際の実装を提供します。

一般的なベスト プラクティス

冒頭で述べたように、マルチモジュール アプリを開発する方法として唯一の正解はありません。多くのソフトウェア アーキテクチャと同様に、アプリをモジュール化する方法が数多く存在します。それでも、以下の推奨事項に従うことで、コードの可読性、保守性、テストの容易性が向上します。

設定の一貫性を維持する

どのモジュールでも、設定オーバーヘッドが発生します。モジュール数が一定のしきい値に達すると、一貫した設定の管理が難しくなります。たとえば、各モジュールで同じバージョンの依存関係を使用することが重要です。依存関係のバージョンを上げるためだけに多数のモジュールを更新する必要がある場合、作業量が増えるだけでなく、間違いが生じる可能性も高くなります。この問題を解決するには、Gradle のいずれかのツールを使用して設定を一元化します。

  • バージョン カタログ: 同期中に Gradle によって生成される依存関係の型安全なリストです。これは、すべての依存関係を 1 か所で宣言できるようにするための場所で、プロジェクト内のすべてのモジュールが使用できます。
  • コンベンション プラグイン: モジュール間でビルドロジックを共有します。

公開範囲を可能な限り限定する

モジュールの公開インターフェースは最小限にとどめ、必要なものしか公開しないようにする必要があります。実装の詳細が外部に漏洩しないようにしなければなりません。すべての内容について、可能な限り範囲を限定します。Kotlin の公開設定スコープ private または internal を使用して、宣言をモジュール プライベートにします。モジュールで依存関係を宣言する場合は、api よりも implementation を優先して使用してください。前者の場合、モジュールを使用する側に対して、依存関係の推移が公開されます。implementation を使用すると、再ビルドが必要なモジュールの数が少なくなるため、ビルド時間が短縮されます。

Kotlin モジュールと Java モジュールを優先する

Android Studio がサポートするモジュールには、次の 3 つの重要なタイプがあります。

  • アプリ モジュール: アプリケーションのエントリ ポイントです。ソースコード、リソース、アセット、AndroidManifest.xml を含めることができます。アプリ モジュールの出力は、Android App Bundle(AAB)または Android Application Package(APK)です。
  • ライブラリ モジュール: 内容はアプリ モジュールと同じです。これらは他の Android モジュールで依存関係として使用されています。ライブラリ モジュールの出力は Android Archive(AAR)で、構造的にはアプリ モジュールと同一になりますが、Android Archive(AAR)ファイルにコンパイルされ、後で他のモジュールで依存関係として使用できます。ライブラリ モジュールを使用すると、多くのアプリ モジュールで同じロジックとリソースをカプセル化して再利用できます。
  • Kotlin および Java のライブラリ: Android のリソース、アセット、マニフェスト ファイルは含まれていません。

Android モジュールにはオーバーヘッドが伴うため、できるだけ Kotlin または Java を使用することをおすすめします。