手動による依存関係挿入

Android アプリの推奨アーキテクチャでは、コードを クラスに分割して、関心の分離を活用することを推奨しています。関心の分離とは、 階層の各クラスが定義された単一の責任を持つという原則です。これにより、相互の依存関係を満たすために接続する必要がある、より多くの小さなクラスが作成されます。

通常、Android アプリは多数のクラスで構成され、一部のクラスは互いに依存しています。
図 1. Android アプリのアプリケーション グラフ

クラス間の依存関係はグラフで表すことができ、各クラスは依存するクラスに接続されます。すべてのクラスとその依存関係をあらわすことでアプリグラフが構成されます。 図 1 に、アプリグラフの概要を示します。クラス A(ViewModel)がクラス B(Repository)に依存している場合、その依存関係を表す線が A から B に向かって引かれます。

依存関係挿入により、これらの接続を確立し、テストのために実装を入れ替えることが可能になります。たとえば、リポジトリに依存する ViewModel をテストする場合、フェイクまたはモックを使用して Repository のさまざまな実装を渡して、さまざまなケースをテストできます。

手動依存関係インジェクションの基本

このセクションでは、実際の Android アプリのシナリオで手動依存関係挿入を適用する方法について説明します。ここでは、アプリで依存関係挿入を開始する反復的アプローチを説明します。このアプローチは、Dagger が自動的に生成するものと非常によく似たポイントに達するまで改善されていきます。Dagger の詳細については、Dagger の基本をご覧ください。

フロー は、機能に対応するアプリ内の画面のグループと考えてください。ログイン、登録、決済はすべてフローの例です。

一般的な Android アプリのログインフローをカバーする場合、LoginActivityLoginViewModel に依存し、さらに UserRepository に依存します。次に、 UserRepositoryUserLocalDataSourceUserRemoteDataSource に依存し、さらに Retrofit サービスに依存します。

LoginActivity はログインフローへのエントリ ポイントであり、ユーザーはアクティビティを操作します。したがって、LoginActivity はすべての依存関係を持つ LoginViewModel を作成する必要があります。

フローの RepositoryDataSource のクラスは次のようになります。

class UserRepository(
    private val localDataSource: UserLocalDataSource,
    private val remoteDataSource: UserRemoteDataSource
) { ... }

class UserLocalDataSource { ... }
class UserRemoteDataSource(
    private val loginService: LoginRetrofitService
) { ... }

Compose では、ComponentActivity がエントリ ポイントです。依存関係の配線は onCreate で 1 回行われ、UI は setContent から呼び出されるコンポーザブルによって記述されます。

class ApiService {
    /* Your API implementation here */
}

class UserRepository(private val apiService: ApiService) {
    /* Your implementation here */
}

class LoginActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // Satisfy the dependencies of LoginViewModel recursively,
        // then pass what the UI needs into setContent.
        val apiService = ApiService()
        val userRepository = UserRepository(apiService)

        setContent {
            LoginScreen(userRepository)
        }
    }
}

@Composable
fun LoginScreen(userRepository: UserRepository) {
    val viewModel: LoginViewModel = viewModel(
        factory = LoginViewModelFactory(userRepository)
    )
    // ...
}

この方法には次のような問題があります。

  1. 依存関係は順番に宣言する必要があります。作成するには、LoginViewModel の前に UserRepository をインスタンス化する必要があります。
  2. オブジェクトを再利用するのは困難です。複数の機能で UserRepository を再利用する場合は、シングルトン パターンに従う必要があります。すべてのテストが同じシングルトン インスタンスを共有するため、シングルトン パターンによりテストはより困難になります。

コンテナを使用した依存関係の管理

オブジェクトの再利用に関する問題を解決するには、依存関係の取得に使用する独自の依存関係コンテナクラスを作成します。 このコンテナによって提供されるすべてのインスタンスは公開できます。この例では、UserRepository のインスタンスのみが必要なため、依存関係を非公開にできます。必要に応じて、後で公開することもできます。

// Container of objects shared across the whole app
class AppContainer {

    // apiService and userRepository aren't private and will be exposed
    val apiService = ApiService()
    val userRepository = UserRepository(apiService)
}

これらの依存関係はアプリケーション全体で使用されるため、すべてのアクティビティで使用できる共通の場所(Application クラス)に配置する必要があります。AppContainer インスタンスを含むカスタム Application クラスを作成します。

// Custom Application class that needs to be specified
// in the AndroidManifest.xml file
class MyApplication : Application() {

    // Instance of AppContainer that will be used by all the Activities of the app
    val appContainer = AppContainer()
}

Compose では、同じ AppContainerApplication サブクラスに作成されます。アクセスするには、アクティビティで setContent を呼び出す前、またはコンポーザブル内で LocalContext を使用します。

class LoginActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val appContainer = (application as MyApplication).appContainer

        setContent {
            LoginScreen(appContainer.userRepository)
        }
    }
}

// Alternatively, read AppContainer from inside a composable:
@Composable
fun LoginScreen() {
    val context = LocalContext.current
    val appContainer = (context.applicationContext as MyApplication).appContainer
    val viewModel: LoginViewModel = viewModel(
        factory = LoginViewModelFactory(appContainer.userRepository)
    )
    // ...
}

ツリーの奥深くから LocalContext にアクセスするよりも、コンポーザブル パラメータとして依存関係を渡すことをおすすめします。これにより、コンポーザブルのテストが可能になり、入力が明示的になります。画面のルートでコンテナを 1 回解決し、必要なものを下位に渡します。

このように、シングルトンの UserRepository は存在しません。代わりに、グラフのオブジェクトを含むすべてのアクティビティで AppContainer を共有し、他のクラスが使用できるオブジェクトのインスタンスを作成します。

アプリケーションの複数の場所で LoginViewModel が必要な場合は、LoginViewModel のインスタンスを作成する一元化された場所を用意することをおすすめします。 LoginViewModel の作成をコンテナに移動し、ファクトリを使用してそのタイプの新しいオブジェクトを提供できます。LoginViewModelFactory のコードは次のようになります。

// Definition of a Factory interface with a function to create objects of a type
interface Factory<T> {
    fun create(): T
}

// Factory for LoginViewModel.
// Since LoginViewModel depends on UserRepository, in order to create instances of
// LoginViewModel, you need an instance of UserRepository that you pass as a parameter.
class LoginViewModelFactory(private val userRepository: UserRepository) : Factory<LoginViewModel> {
    override fun create(): LoginViewModel {
        return LoginViewModel(userRepository)
    }
}

Compose では、AppContainer の更新でもファクトリが公開されます。ファクトリ は viewModel コンポーズ可能な関数によって使用されるため、ViewModel は最も近い ViewModelStoreOwner(通常はホスト Activity、または Navigation Compose の場合はナビゲーション エントリ)にスコープ設定されます。

// AppContainer exposing the factory (unchanged from the snippet above)
class AppContainer {
    // ...
    val userRepository = UserRepository(localDataSource, remoteDataSource)
    val loginViewModelFactory = LoginViewModelFactory(userRepository)
}

// Compose entry point + screen composable
class LoginActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val appContainer = (application as MyApplication).appContainer
        setContent {
            LoginScreen(appContainer.loginViewModelFactory)
        }
    }
}

@Composable
fun LoginScreen(factory: LoginViewModelFactory) {
    val viewModel: LoginViewModel = viewModel(factory = factory)
    // ...
}

この方法は前の方法よりも優れていますが、次の点も考慮してください。

  1. AppContainer を自分で管理し、すべての依存関係のインスタンスを手動で作成する必要があります。

  2. 他にも多くのボイラープレート コードがあります。オブジェクトを再利用するかどうかに応じて、ファクトリやパラメータを手動で作成する必要があります。

アプリフローでの依存関係の管理

AppContainer は、プロジェクトに機能を追加しようとすると複雑になります。アプリの規模が大きくなり、さまざまな機能フローを導入するようになると、さらに多くの問題が発生します。

  1. 異なるフローがある場合は、オブジェクトをそのフローの範囲内で動作させることができます。たとえば、LoginUserData(ログインフローでのみ使用されるユーザー名とパスワード)を作成する場合、別のユーザーの古いログインフローのデータは保持しないことも可能です。新しいフローごとに新しいインスタンスが必要です。次のコード例に示すように、AppContainer 内に FlowContainer オブジェクトを作成することで、これを実現できます。

  2. アプリグラフとフローコンテナの最適化も難しい場合があります。 現在のフローに応じて、不要なインスタンスを削除する必要があります。

サンプルコードに LoginContainer を追加しましょう。アプリで LoginContainer のインスタンスを複数作成できるようにする場合、シングルトンにするのではなく、AppContainer からのログインフローに必要な依存関係を持つクラスにします。

class LoginContainer(val userRepository: UserRepository) {

    val loginData = LoginUserData()

    val loginViewModelFactory = LoginViewModelFactory(userRepository)
}

// AppContainer contains LoginContainer now
class AppContainer {
    ...
    val userRepository = UserRepository(localDataSource, remoteDataSource)

    // LoginContainer will be null when the user is NOT in the login flow
    var loginContainer: LoginContainer? = null
}

Compose では、フローコンテナの有効期間はホスト Activity ではなくコンポジションに関連付けられています。コンポーザブルは依存関係をパラメータとして受け取るか、ホイストされた ViewModel から読み取るため、共有 AppContainer.loginContainer を変更する必要はありません。次のいずれかの方法でご対応ください。

  1. Navigation Compose ネストされたグラフ(複数画面のフローにおすすめ)。 ログインフローのすべての画面をネストされたナビゲーション グラフに配置し、コンテナをそのグラフの NavBackStackEntry にスコープ設定します。コンテナは、ユーザーがフローに入ったときに作成され、バックスタック エントリがポップされたときにクリアされます。手動でライフサイクルを呼び出す必要はありません。詳細については、ナビゲーション グラフを設計するをご覧ください。
  2. 画面のルートで remember(単一画面のフローの場合、または Navigation Compose を使用していない場合)。remember 内にコンテナを構築します。これにより、コンポジションへのエントリごとに 1 回作成され、コンポーザブルが終了するとガベージ コレクションされます。
@Composable
fun LoginFlow(appContainer: AppContainer) {
    val loginContainer = remember(appContainer) {
        LoginContainer(appContainer.userRepository)
    }
    val viewModel: LoginViewModel = viewModel(
        factory = loginContainer.loginViewModelFactory
    )
    // Render the login flow using loginContainer.loginData and viewModel.
}

まとめ

依存関係挿入は、スケーラブルでテスト可能な Android アプリを作成するための優れた手法です。コンテナは、アプリのさまざまな部分でクラスのインスタンスを共有する方法として、また、ファクトリを使用してクラスのインスタンスを作成する一元化された場所として使用します。

アプリの規模が大きくなると、大量のボイラープレート コード(ファクトリなど)を作成することになり、エラーが発生しやすくなります。また、コンテナのスコープとライフサイクルを自分で管理し、メモリを解放するためにコンテナを最適化して、不要になったコンテナを破棄する必要があります。 この処理を誤ると、アプリで小さなバグやメモリリークが発生する可能性があります。

Dagger セクションでは、Dagger を使用してこの プロセスを自動化し、手動で記述した場合と同じコードを生成する方法を説明します。

参考情報

Views コンテンツ