こんにちは!tkhsktです。
みなさん、Android書いてますか?Android、楽しいですよねぇ。
この記事では、オブジェクトのinterfaceと実装を別モジュールに配置した際の依存解決について、いくつかの方法を紹介したいと思います。
実現したいこと
この記事では、図のようなモジュールの依存関係を目指します。
なぜやるのか
interfaceと実装を別モジュールに分離し、実装の詳細へのアクセスを制限することで、アプリケーション固有の処理やI/O処理などをドメインレイヤーから隔離できます。これにより、ドメインモデルの理解が困難なったり複雑性が増加することを抑制できます。
Androidのプロジェクトでは複雑なドメインロジックを記述することはあまりないと思いますが、このような制約を設けることで多人数での開発でもある程度の秩序を保つことができます。
実現方法
Repositoryを例としてinterfaceと実装を別モジュールに分離する方法を紹介します。
この記事では、便宜上インターフェースを配置するモジュールを:interface
とし、実装を配置するモジュールを:impl
として記載しますが、実際のプロジェクトでは:data
や:infra
など適切な名前にするのが良いと思います。
各方法の説明中に特別に記載がない限りは、Repository, RepositoryImpl, ViewModel, Module(Hiltのモジュール)の実装は下記のようになります。
Repositoryはinterfaceモジュールに配置します。
// :interface
interface SampleRepository {
fun get(): Sample
}
class Sample
Repositoryの実装はimplモジュールに配置します。
// :impl
class SampleRepositoryImpl @Inject constructor(
private val source: DataSource
) : SampleRepository {
override suspend fun get(): Sample {
// do something
return sample
}
}
class DataSource @Inject constructor() {
...
}
ViewModelはuiモジュールに配置します。
@HiltViewModel
class SampleViewModel @Inject constructor(
private val repository: SampleRepository,
) : ViewModel() {
...
}
DIのモジュールは方法によって配置する場所が異なります。
@Module
@InstallIn(SingletonComponent::class)
internal abstract class SampleModule {
@Binds
abstract fun sampleRepository(impl: SampleRepositoryImpl): SampleRepository
}
方法1: appモジュールでDIする
1つ目の方法はappモジュールでDIする方法です。この方法ではappモジュールでDIの設定を行い、uiモジュールなどにInjectします。
モジュールの依存関係は下図のようになります。
appモジュール
dependencies {
...
implementation(project(":interface"))
implementation(project(":impl"))
implementation(project(":ui"))
}
DIのモジュールはappモジュールに配置します。
uiモジュール
dependencies {
...
// implモジュールへの依存は不要
implementation(project(":interface"))
}
- メリット
- 比較的容易に実装可能
- デメリット
- appモジュールからimplモジュールのオブジェクトを直接使用できてしまう
方法2: implモジュールでDIする
2つ目の方法はimplモジュールでDIする方法です。この方法では、implモジュールにあるオブジェクトの可視性をinternalにできるため、その他の方法よりも本来の目的に沿った構造になります。
依存関係はappモジュールでDIする場合と同様です。
appモジュール
dependencies {
...
implementation(project(":interface"))
implementation(project(":impl")) // DIのために依存を追加する必要がある
implementation(project(":ui"))
}
implモジュール
// internalにできる
internal class SampleRepositoryImpl @Inject constructor(
private val source: DataSource
) : SampleRepository {
override suspend fun get(): Sample {
return Sample(source.get())
}
}
DIのモジュールはimplモジュールに配置します。
- メリット
- implモジュールのオブジェクトの可視性をinternalにできるので、外部のモジュールからのアクセスを強く制限できる
- ただしuiモジュールやappモジュールで統合テストを書きたい時にimplモジュールのオブジェクトにアクセスできないので困る
- implモジュールのオブジェクトの可視性をinternalにできるので、外部のモジュールからのアクセスを強く制限できる
- デメリット
- DIのためだけにappモジュールに依存を追加することになる
方法3: DI用のモジュール作成する
3つ目の方法はDI用のモジュールでDIする方法です。この方法はappモジュールでDIする場合のデメリットである、appモジュールからimplモジュールのオブジェクトにアクセスできてしまう問題を解決できます。
appモジュール
dependencies {
...
implementation(project(":di")) // DIのために依存を追加する必要がある
implementation(project(":ui"))
}
diモジュール
dependencies {
...
implementation(project(":interface"))
implementation(project(":impl"))
}
@Module
@InstallIn(SingletonComponent::class)
internal abstract class SampleModule {
@Binds
abstract fun sampleRepository(impl: SampleRepositoryImpl): SampleRepository
}
DIのモジュールはdiモジュールに配置します。
- メリット
- implモジュールを直接依存追加しない限りは他のモジュールからimplモジュールのオブジェクトにアクセスできない
- デメリット
- DI専用のモジュールを作る必要があるので、モジュール構成が複雑になる
オプション: runtimeOnly
ここまで紹介した方法では、モジュールの依存追加にimplementation
を使用していましたが、依存追加にはruntimeOnly
を使用する方法もあります。これを使うことでインクリメンタルビルドの高速化が期待できます。
ただし、runtimeOnly
で依存追加したモジュールのオブジェクトにはコード上ではアクセスできないため注意が必要です。
例えばimplモジュールでDIする場合、appモジュールのbuild.gradle
で下記のようにimplの依存を追加することで、appモジュールからimplモジュールのオブジェクトをへのアクセスを防ぐことができ、インクリメンタルビルドの高速化が期待できます。
dependencies {
...
implementation(project(":interface"))
runtimeOnly(project(":impl"))
implementation(project(":ui"))
}