Androidアプリ開発においてなぜDI Containerライブラリが必要になるのか
Androidアプリ開発において、 Hilt のようなDI Containerのライブラリの使用は公式から推奨されている手段です。
本記事では、DIの存在しない実装を例に、段階的にDI Containerを導入していきながら、DI Containerライブラリの必要性について説明していきます。
Dependency Injection (DI)
まずは、Androidアプリにおける一般的なログインフローの実装を考えてみます。
例えば、すべてのログインに関するコードを1つの Activity
または Fragment
に記述するのはよろしくありません。
これらのUIベースのクラスには、UIやOSとのやり取りを処理するロジックのみを含めるようにし、できる限りシンプルに保つことで、コンポーネントのライフサイクルに関連する多くの問題を回避し、クラスのテストのしやすさを向上させることが重要になります。
このことから、Androidアプリ開発では少なくとも以下の2つのレイヤに分割して実装することが推奨されています。
- 画面にアプリデータを表示する UIレイヤ
- アプリのビジネス ロジックを含み、アプリデータを公開するデータレイヤ
さらにドメインレイヤというレイヤを追加することで、UI レイヤとデータレイヤの間のやり取りを簡素化でき、再利用できます。
この設計に則って、下図のようにログインフローの実装に必要なクラスを定義します。
LoginActivity
は LoginViewModel
に依存し、さらに UserRepository
に依存しています。
そして、 UserRepository
は UserLocalDataSource
と UserRemoteDataSource
に依存し、 RemoteDataSource
はさらに Retrofit
のサービスに依存します。
これらの各クラスに必要な依存関係を、それぞれのクラス内部でインスタンス化するとしましょう。
class UserLocalDataSource { ... }
class UserRemoteDataSource {
fun login(email: String, password: String): LoginResponse {
val retrofit = Retrofit.Builder()
.baseUrl("https://example.com")
.build()
.create(LoginService::class.java)
...
}
}
class UserRepository {
fun login(email: String, password: String) {
val remoteDataSource = UserRemoteDataSource()
val result = remoteDataSource.login(email, password)
val localDataSource = UserLocalDataSource()
localDataSource.store(result)
}
}
class LoginViewModel {
fun login() {
val userRepository = UserRepository()
userRepository.login(email, password)
}
}
class LoginActivity: Activity() {
private lateinit var loginViewModel: LoginViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
loginViewModel = LoginViewModel()
}
...
fun onLoginClick() {
loginViewModel.login()
}
}
このようにクラス内部で依存関係を解決してしまう書き方だと、次のような問題があります。
- コードのさまざまな箇所で依存関係が初期化されているため、どのクラスに依存しているかがわかりにくく、リファクタリングが難しくなる。
- テスト時に依存関係を置き換えることが困難になるため、テストを書きづらい。
これらの問題を解決するために、 依存関係をクラスの内部で構築するのではなく、 クラスのコンストラクタで外からパラメータとして受け取るようにします。
class UserLocalDataSource { ... }
class UserRemoteDataSource(
private val loginService: LoginService
) { ... }
class UserRepository(
private val localDataSource: UserLocalDataSource,
private val remoteDataSource: UserRemoteDataSource
) { ... }
class LoginViewModel(
private val userRepository: UserRepository
): ViewModel() { ... }
これにより、各クラスの依存関係が明確になり、またテストも書きやすくなりました。
このように、クラス内部で依存関係を解決するのではなく、外側から注入してもらうことを Dependency Injection ( DI )と呼びます。
依存関係の管理手法
各クラスをDI可能な状態にしたところで、ログインフローへのエントリーポイントとなる LoginActivity
にて、すべての依存関係が含まれる LoginViewModel
を作成する必要があります。
class LoginActivity: Activity() {
private lateinit var loginViewModel: LoginViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val retrofit = Retrofit.Builder()
.baseUrl("https://example.com")
.build()
.create(LoginService::class.java)
val remoteDataSource = UserRemoteDataSource(retrofit)
val localDataSource = UserLocalDataSource()
val userRepository = UserRepository(localDataSource, remoteDataSource)
loginViewModel = LoginViewModel(userRepository)
}
}
しかし、この依存関係の解決方法には次のような問題があります。
-
ボイラープレートコードが多い
- コードの複数箇所で
LoginViewModel
の別のインスタンスを作成する場合は、コードが重複してしまいます。 - 複数人で開発する際に、依存が増えるたびに修正する必要のあるファイル数が増え、コンフリクトの発生率が高まります。
- 依存関係は順番に宣言する必要があります。作成するには、
LoginViewModel
の前にUserRepository
をインスタンス化する必要があります。
- コードの複数箇所で
-
オブジェクトの再利用が困難
- 複数の機能で
UserRepository
を再利用する場合は、シングルトンパターンに従う必要があります。 すべてのテストが同じシングルトンインスタンスを共有するため、シングルトンパターンによりテストはより困難になります。
- 複数の機能で
それぞれの問題はどのように解決できるか考えていきます。
Factory
まずボイラープレートコードが多いという問題については、単純にインスタンスの生成処理を別なクラスへ移譲すれば解決できそうです。
それぞれの依存関係に対応する Factory クラスを用意します。
class RetrofitFactory {
fun create(): Retrofit {
return Retrofit.Builder()
.baseUrl("https://example.com")
.build()
}
}
class LoginServiceFactory(
retrofit: Retrofit = RetrofitFactory().create()
) {
fun create(): LoginService {
return retrofit.create(LoginService::class.java)
}
}
class UserRemoteDataSourceFactory(
loginService: LoginService = LoginServiceFactory().create()
) {
fun create(): UserRemoteDataSource {
return UserRemoteDataSource(
loginService = loginService
)
}
}
class UserLocalDataSourceFactory {
fun create(): UserLocalDataSource {
return UserLocalDataSource()
}
}
class UserRepositoryFactory(
localDataSource: UserLocalDataSource = UserLocalDataSourceFactory().create(),
remoteDataSource: UserRemoteDataSource = UserRemoteDataSourceFactory().create()
) {
fun create(): UserRepository {
return UserRepository(
localDataSource = localDataSource,
remoteDataSource = remoteDataSource
)
}
}
class LoginViewModelFactory(
userRepository: UserRepository = UserRepositoryFactory().create()
) {
fun create() {
return LoginViewModel(
userRepository = userRepository
)
}
}
これらの Factory
を使用して Activity
にて依存関係を解決します。
class LoginActivity: Activity() {
private lateinit var loginViewModel: LoginViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
loginViewModel = LoginViewModel(
userRepository: UserRepositoryFactory().create()
)
}
}
これにより、コードの別の部分で LoginViewModel
の別のインスタンスを作成する場合は、ボイラープレートを複製せずに LoginViewModelFactory
からインスタンスを生成すれば良いことになります。
DI Container
二つ目の問題である、オブジェクトを再利用したいケースとしては以下のようなものが挙げられます。
- オブジェクトを作成するコストが高く、依存関係として宣言されるたびに新しいインスタンスを作成したくない(JSONパーサーなど)。
- アプリケーション全体で同じインスタンスを共有させたい。
これを解決する方法のアンチパターンとしてシングルトンが存在します。
class UserRepository private constructor(
...
) {
...
companion object {
val shared = UserRepository(...)
}
}
しかしこれでは、コンストラクタをprivateにしてしまっているため、テストを書くのが難しくなります。
なのでシングルトンではなく、依存関係の取得に使用するコンテナクラスを用意します。
class AppContainer {
val userRepository = UserRepositoryFactory().create()
}
このコンテナで管理する依存関係はアプリ全体で使用されるため、すべての Activity
が使用できる共通の場所、つまり Application
クラスに配置する必要があります。
class MyApplication : Application() {
val appContainer = AppContainer()
}
これで、アプリから AppContainer
のインスタンスを取得し、 UserRepository
インスタンスの共有を取得できます。
class LoginActivity: Activity() {
private lateinit var loginViewModel: LoginViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val appContainer = (application as MyApplication).appContainer
loginViewModel = LoginViewModel(appContainer.userRepository)
}
}
これにより、シングルトンの UserRepository
を用意することなく、代わりに依存オブジェクトを含むすべての Activity
で AppContainer
を共有することで、オブジェクトのインスタンスを再利用することが可能になりました。
このようなコンテナクラスを DI Container と呼びます。
DI Containerにおけるスコープ
アプリケーション全体でオブジェクトを再利用するケースを取り上げましたが、より限定的なスコープで再利用したいケースも存在します。
例えば、1つのアクティビティ( LoginActivity
)と複数のフラグメント( LoginUsernameFragment
と LoginPasswordFragment
)で構成されるログインフローがあるとします。
これらのビューでは、次のことができます。
- ログインフローが終了するまで、共有する必要がある同じ
LoginUserData
インスタンスにアクセスします。 - フローが再開されると、
LoginUserData
の新しいインスタンスを作成します。
これを行うには、ログインフロー用のコンテナ、 LoginContainer
を作成します。
このコンテナは、ログインフローの開始時に作成し、フローの終了時にメモリから削除する必要があります。
LoginContainer
のインスタンスは、シングルトンにするのではなく、 AppContainer
内でインスタンス化し、ログインフローに必要な依存関係を持たせます。
class LoginContainer(val userRepository: UserRepository) {
val loginData = LoginUserData()
val loginViewModelFactory = LoginViewModelFactory(userRepository)
}
class AppContainer {
...
val userRepository = UserRepositoryFactory.create()
var loginContainer: LoginContainer? = null
}
フロー固有のコンテナを作成したら、コンテナインスタンスを作成して削除するタイミングを決定する必要があります。
ログインフローはアクティビティ( LoginActivity
)のライフタイムに等しくなるため、 LoginActivity.onCreate()
にて LoginContainer
インスタンスを作成し、 onDestroy()
で削除します。
class LoginActivity: Activity() {
private lateinit var loginViewModel: LoginViewModel
private lateinit var loginData: LoginUserData
private lateinit var appContainer: AppContainer
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
appContainer = (application as MyApplication).appContainer
appContainer.loginContainer = LoginContainer(appContainer.userRepository)
loginViewModel = appContainer.loginContainer.loginViewModelFactory.create()
loginData = appContainer.loginContainer.loginData
}
override fun onDestroy() {
appContainer.loginContainer = null
super.onDestroy()
}
}
これで、ログインフローで登場する1つのアクティビティ( LoginActivity
)と複数のフラグメント( LoginUsernameFragment
と LoginPasswordFragment
)にて、 LoginData
を共有することが可能になりました。
DI Containerライブラリ
アプリの規模が大きくなると、大量のボイラープレートコード( Factory
など)を作成することになり、エラーが発生しやすくなります。
また、コンテナのスコープとライフサイクルを自分で管理し、メモリを解放するためにコンテナを最適化して、不要になったコンテナを破棄していく必要もあります。
この処理を誤ると、アプリで小さなバグやメモリリークが発生する可能性があります。
これらをすべて手で書いて管理していくのはなかなかコストの高い作業です。
そこで、 DI Containerライブラリ が必要となってきます。
DI Containerライブラリの Hilt では、 @Inject
などのアノテーションを適切に記述することで、ここまで説明してきた Factory
や DI Container
を自動生成してくれます。
2019年のAndroid Dev Summitにおける An opinionated guide to Dependency Injection on Android というセッションでは、以下のような表を用いてDI Containerライブラリ導入によってかかるコストとアプリのサイズの関係を示しています (緑のグラフ)。
この頃はDagger 2しかありませんでしたが、現在はその後継であるHiltがあり、かなり書き方も簡略化されたので、このグラフ以上にDI Containerライブラリの導入コストは低いように感じます。
まとめ
本記事では、ログインフローを具体例に、まずはDIのないところから始め、依存関係を外から渡せるようにし、Factory、そしてDI Containerを導入するところまでをコード例とともに順序立てて説明することで、Androidアプリ開発におけるDI Containerライブラリの必要性について説きました。
本記事がDI Containerに対する理解を深める上での一助になれば幸いです。
参考
Discussion