大規模Androidアプリのモジュール分割Tips
これはEureka Advent Calendar 2020の5日目の記事です。
はじめに
こんにちは!Androidエンジニアのyuyakaidoです。
Android開発では複数のモジュールを組み合わせて1つのアプリを組み上げるアプローチが採用されることが増えてきています。このアプローチはマルチモジュールとも呼ばれ、Pairsも2019年からモジュールの分割に取り組んでいます。元々は単一で巨大なAppモジュールのみで構成されていましたが、Feature単位でモジュールを分割するアプローチをベースとして、状況によってはアーキテクチャのLayer単位でのモジュール分割も組み合わせる形でモジュール分割を進めています。
Pairsでのモジュール分割方法については、私が2019年のEureka Advent Calendarで記事を書いており、本記事ではその続編としてPairsでモジュール分割を進める中で遭遇したTipsを3つ紹介します。
前回のおさらい
本題に入る前に前回のおさらいとして、モジュール分割についての簡単な振り返りと、Pairsにおけるモジュール分割のアプローチを再度紹介します。
モジュールとは
Android開発においてのモジュールは複数の意味をもっていますが、本記事ではLibrary Moduleを指します。
- Application Module
- Library Module
- Dynamic Feature Module
- Dagger Module
モジュール分割は大まかに以下の利点があります。
- 並列コンパイルによるビルドの高速化
- アーキテクチャ構造の強制化
Pairsでのモジュール分割方法
モジュールは循環参照にならない限りはどのような形で分割しても問題ありませんが、主に「Feature単位での分割」と「Layer単位での分割」を組み合わせる形でモジュール分割を行っています。
- Feature単位での分割
それなりに大きなアプリであれば複数のFeatureが組み合わさって1つのアプリとして動作する場合がほとんどなはずで、これらのFeature単位でモジュールを分割するアプローチです。
- Layer単位での分割
Android開発ではアプリ内で大まかにPresentation/Domain/InfrastructureといったLayerに責務を分割するレイヤードアーキテクチャが採用されることが多く、これらのLayer単位でモジュールを分割するアプローチです。もちろんレイヤードアーキテクチャ以外のアーキテクチャにおけるLayerで分割した場合もこの分割方法に当てはまります。
サンプルアプリ
本記事で紹介するコードは以下のリポジトリで公開しているので、実際のコードが気になる方は記事と合わせてご覧ください。解説で利用するのはRedditというサービスのクライアントアプリで、Redditのアカウントさえあれば誰でも利用できるようになっています。
サンプルアプリは以下の図のようにFeature単位でのモジュール分割(青)とLayer単位でのモジュール分割(オレンジ)を組み合わせています。
大規模アプリのモジュール分割Tips
ここからが本題です。
Pairsのような大規模なアプリをFeature単位でモジュールに分割する場合、同じことを実現するにもいくつか実装のアプローチが存在したり、少し応用的なアプローチが必要になったりすることがあり、本記事では規模が大きなアプリでモジュール分割をする際に遭遇する可能性の高いTipsを3つ紹介します。
- Navigationを用いた画面遷移
- Daggerとの連携方法
- ライブラリのバージョン管理
Navigationを用いた画面遷移
Android開発ではNavigationを用いて画面遷移を実装することが増えていますが、モジュールで分割しつつNavigationを利用するには少し工夫が必要です。
NavigationではXMLで画面遷移を定義しますが、サンプルアプリのようにFeature単位でモジュール分割をする場合、循環参照を避けるためにFeatureモジュール同士は原則的には参照することができず、XMLで画面遷移を定義することができません。また、ActionやArgumentをどこで定義するのかも同時に考える必要があります。
このようなシチュエーションにおいて画面遷移を定義するには「Coreモジュールでの定義」と「Appモジュールでの定義」という2つの方法があります。
- Coreモジュールでの定義
モジュール分割を行う場合、Coreモジュールといった全てのモジュールから参照できるモジュールを用意することが多いと思います。逆にCoreモジュールからFeatureモジュールは参照不可能です。つまり、Coreモジュールでは画面遷移を定義不可能と考える方が多いかと思いますが、実は定義可能です。
例えば、DroidKaigi 2020の公式アプリはCoreモジュールで画面遷移を定義するアプローチを採用しています。Android Studioでコードを見てみると参照エラーが発生しますが、コンパイルは成功し、挙動も問題ありません。また、ActionやArgumentはCoreモジュールに依存する全てのモジュールから参照することができます。
これは静的解析が働かないという性質を利用した実装で、一度正しく定義してしまえば問題なく動作しますが、クラス名を変更したりパッケージを移動したりといったリファクタリングに自動では追従してくれません。つまり、リファクタリング後にパスを手動で書き換えないとランタイムエラーが発生してしまいます。
Coreモジュールで定義する方法は、画面遷移に関わるコードはCoreモジュールで一元管理できるメリットがありますが、参照エラーが発生してしまうことでランタイムエラーを引き起こすリスクを抱えるというデメリットが存在します。
- Appモジュールで定義する
もう1つの方法は、Appモジュールのように全てのFeatureモジュールを参照できるモジュールで画面遷移を定義する方法があります。全てのFeatureモジュールを参照可能ということは、Coreモジュールで定義する場合に発生していた参照エラーは発生しません。
しかし、この方法の場合、ActionやArgumentをFeatureモジュールから参照することができないため、それらの定義をFeatureモジュールにも配置しておく必要があります。
Appモジュールで定義する方法は、参照エラーが発生しない状態で画面遷移を一元管理できるメリットがありますが、ActionやArgumentをAppモジュールとFeatureモジュールで2重管理しないといけないというデメリットが存在します。
Daggerとの連携方法
Android開発でDependency Injectionを行う際はDaggerがデファクトスタンダードとなっており、モジュール分割を駆使して責務の分離を行いつつDIする方法を紹介します。
サンプルアプリのようにFeature単位でモジュール分割を行う場合、各Featureの具体的な実装はモジュール内部に隠蔽し、モジュールの外部には抽象化されたインターフェースのみを公開するといったアプローチがいいでしょう。ここではユーザーデータを扱うUserモジュールを例に具体的な実装を紹介します。
モジュールの外部に公開するインターフェースは他のモジュールからも参照できるようになっている必要があるため、Coreモジュールのような全てのモジュールから参照できる場所に配置します。
interface UserRepositoryType {
suspend fun detail(user: User): User.Detail
suspend fun me(): User.Detail.Me
}
UserRepositoryTypeの実装クラスはユーザーデータに関する具体的な実装を扱うことになるため、Userモジュールに配置します。ここではデータをAPIから取得したりDBに保存したりといった典型的な要件を実装しています。
class UserRepository(
private val api: UserApi,
private val database: MeDatabase
) : UserRepositoryType {
override suspend fun detail(user: User): User.Detail {
return api.user(user = user.name).toEntity()
}
override suspend fun me(): User.Detail.Me {
val schema = api.me().toSchema()
database.meDao().insert(me = schema)
return schema.toEntity()
}
}
Daggerの設定でUserRepositoryをUserRepositoryTypeの実装クラスとして登録し、Userモジュールの外部でUserRepositoryTypeが取得できるようにします。
@Module
class UserModule {
@Provides
fun provideUserRepositoryType(
api: UserApi,
database: MeDatabase
): UserRepositoryType {
return UserRepository(
api = api,
database = database
)
}
}
サンプルアプリではAppモジュールのみが全てのFeatureモジュールにアクセス可能なため、ComponentとModuleの紐付けはAppモジュールにて行います。
@Component(modules = [
UserModule::class
])
interface AppComponent : AndroidInjector<Gaia>
以上でDaggerの設定は終了です。あとはAppComponentにアクセス可能であれば任意の箇所でUserRepositoryTypeをInjectすることができます。
ライブラリのバージョン管理
モジュール分割を行う場合、複数のモジュールで同じライブラリを利用する場面があると思います。Gradleでライブラリを定義する場合は以下のような記述が一般的ですが、この記述方法だと複数のモジュールで具体的なライブラリ名やバージョンを記述する必要があり、長期的にはメンテナンス性が下がってしまいます。
dependencies {
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.4.20'
}
Gradleには任意のプロパティを定義する機能があり、これを利用することで複数のモジュールでの利用が想定されるライブラリ定義を一元管理することが出来ます。具体的には、トップレベルモジュールにてExtraプロパティを利用すると良いでしょう。
buildscript {
ext.versions = [
kotlin: [
core: '1.4.20',
]
]
ext.libs = [
kotlin: [
stdlib: "org.jetbrains.kotlin:kotlin-stdlib:$versions.kotlin.core"
]
]
}
上記コードではExtraプロパティの中にVersionsプロパティとLibsプロパティを追加しています。Versionsプロパティはライブラリのバージョン定義、Libsプロパティはライブラリのアーティファクト定義です。これらのカスタムプロパティを利用すると、モジュールからは以下のようにライブラリを参照することができます。
dependencies {
implementation libs.kotlin.stdlib
}
このように実装することでアプリ内で利用するライブラリはトップレベルモジュールにて一元管理されることになるので、ライブラリ定義のメンテナンスコストを低い状態に保つ効果が期待できるでしょう。
まとめ
本記事ではPairsでのモジュール分割を題材として、大規模なアプリでモジュール分割を行う際のTipsを3つ紹介しました。
モジュール分割とは
モジュール分割とは、1つのアプリを複数のモジュールに分解しながら開発することを指しており、大まかに以下の利点があります。
- 並列コンパイルによるビルドの高速化
- アーキテクチャ構造の強制化
Pairsでのモジュール分割
Pairsでは以下2つのアプローチを組み合わせる形でモジュール分割を行っています。
- Feature単位での分割
- Layer単位での分割
大規模アプリでのモジュール分割Tips
- Navigationを用いた画面遷移
Coreモジュールで定義する方法とAppモジュールで定義する方法を紹介しました。
Coreモジュールで定義する方法は、画面遷移に関わるコードはCoreモジュールで一元管理できるメリットがありますが、参照エラーが発生してしまうことでランタイムエラーを引き起こすリスクを抱えるというデメリットが存在します。
Appモジュールで定義する方法は、参照エラーが発生しない状態で画面遷移を一元管理できるメリットがありますが、ActionやArgumentをAppモジュールとFeatureモジュールで2重管理しないといけないというデメリットが存在します。
- Daggerとの連携方法
Feature単位でモジュールを分割する場合、各Featureの具体的な実装はモジュールの内部に隠蔽し、モジュールの外部には抽象化されたインターフェースのみを公開することが良くあります。
このような場合、Coreモジュールにインターフェースを配置し、それに対する実装クラスはFeatureモジュールで実装します。インターフェースと実装クラスを紐付けるためのDaggerのModuleはFeatureモジュールに配置し、全てのFeatureモジュールにアクセス可能なAppモジュールにDaggerのAppComponentを配置することで依存解決を行います。
- ライブラリのバージョン管理
モジュール分割を行う場合は、複数のモジュールから同じライブラリを参照することが多くあるため、GradleのExtraプロパティを用いてライブラリ定義を一元管理するといいでしょう。プロパティは階層化することもできるため、ライブラリのカテゴリーごとに分けるといった対応を行うとよりメンテナンスがしやすくなるはずです。
6日目はPairsエンゲージのAndroid開発を担当するDavidさんによる「Android unidirectional architecture with StateFlow」です。お楽しみに!