🕋

Androidアプリ開発においてなぜDI Containerライブラリが必要になるのか

2022/05/26に公開

Androidアプリ開発において、 Hilt のようなDI Containerのライブラリの使用は公式から推奨されている手段です。

本記事では、DIの存在しない実装を例に、段階的にDI Containerを導入していきながら、DI Containerライブラリの必要性について説明していきます。

Dependency Injection (DI)

まずは、Androidアプリにおける一般的なログインフローの実装を考えてみます。

例えば、すべてのログインに関するコードを1つの Activity または Fragment に記述するのはよろしくありません。
これらのUIベースのクラスには、UIやOSとのやり取りを処理するロジックのみを含めるようにし、できる限りシンプルに保つことで、コンポーネントのライフサイクルに関連する多くの問題を回避し、クラスのテストのしやすさを向上させることが重要になります。

このことから、Androidアプリ開発では少なくとも以下の2つのレイヤに分割して実装することが推奨されています。

  • 画面にアプリデータを表示する UIレイヤ
  • アプリのビジネス ロジックを含み、アプリデータを公開するデータレイヤ

さらにドメインレイヤというレイヤを追加することで、UI レイヤとデータレイヤの間のやり取りを簡素化でき、再利用できます。

この設計に則って、下図のようにログインフローの実装に必要なクラスを定義します。

LoginActivityLoginViewModel に依存し、さらに UserRepository に依存しています。
そして、 UserRepositoryUserLocalDataSourceUserRemoteDataSource に依存し、 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)
        }
    }

しかし、この依存関係の解決方法には次のような問題があります。

  1. ボイラープレートコードが多い

    • コードの複数箇所で LoginViewModel の別のインスタンスを作成する場合は、コードが重複してしまいます。
    • 複数人で開発する際に、依存が増えるたびに修正する必要のあるファイル数が増え、コンフリクトの発生率が高まります。
    • 依存関係は順番に宣言する必要があります。作成するには、 LoginViewModel の前に UserRepository をインスタンス化する必要があります。
  2. オブジェクトの再利用が困難

    • 複数の機能で 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 を用意することなく、代わりに依存オブジェクトを含むすべての ActivityAppContainer を共有することで、オブジェクトのインスタンスを再利用することが可能になりました。

このようなコンテナクラスを DI Container と呼びます。

DI Containerにおけるスコープ

アプリケーション全体でオブジェクトを再利用するケースを取り上げましたが、より限定的なスコープで再利用したいケースも存在します。

例えば、1つのアクティビティ( LoginActivity )と複数のフラグメント( LoginUsernameFragmentLoginPasswordFragment )で構成されるログインフローがあるとします。
これらのビューでは、次のことができます。

  1. ログインフローが終了するまで、共有する必要がある同じ LoginUserData インスタンスにアクセスします。
  2. フローが再開されると、 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 )と複数のフラグメント( LoginUsernameFragmentLoginPasswordFragment )にて、 LoginData を共有することが可能になりました。

DI Containerライブラリ

アプリの規模が大きくなると、大量のボイラープレートコード( Factory など)を作成することになり、エラーが発生しやすくなります。

また、コンテナのスコープとライフサイクルを自分で管理し、メモリを解放するためにコンテナを最適化して、不要になったコンテナを破棄していく必要もあります。
この処理を誤ると、アプリで小さなバグやメモリリークが発生する可能性があります。

これらをすべて手で書いて管理していくのはなかなかコストの高い作業です。

そこで、 DI Containerライブラリ が必要となってきます。

DI Containerライブラリの Hilt では、 @Inject などのアノテーションを適切に記述することで、ここまで説明してきた FactoryDI 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に対する理解を深める上での一助になれば幸いです。

参考

https://developer.android.com/training/dependency-injection
https://developer.android.com/training/dependency-injection/manual
https://developer.android.com/training/dependency-injection/dagger-basics
https://qiita.com/takahirom/items/46053e031041405e2a9e
https://blog.mindorks.com/android-viewmodels-under-the-hood

Discussion