Dagger アプリの Hilt への移行

この Codelab では、Android アプリへの依存関係注入(DI)のために Dagger を Hilt に移行する方法について説明します。この Codelab では、Android アプリで Dagger を使用する方法に関する Codelab を Hilt に移行します。この Codelab の目的は、移行の計画を立てる方法と、アプリの機能を維持したまま、Dagger と Hilt を共存させた状態で、各 Dagger コンポーネントを Hilt に移行してゆく方法を説明することです。

依存関係注入により、コードの再利用、リファクタリング、テストが簡単になります。Hilt は、よく知られた DI ライブラリである Dagger の上に構築されているため、コンパイル時の正確性、実行時のパフォーマンス、スケーラビリティ、Android Studio のサポートといった Dagger の恩恵を受けられます。

多くの Android フレームワーク クラスは OS 自体によってインスタンス化されるため、Android アプリで Dagger を使用する際には、関連するボイラープレートがあります。Hilt では、以下が自動的に生成、提供されることで、このボイラープレートの多くが除去されています。

  • Dagger を使用して Android フレームワークのクラスを統合するためのコンポーネント(手動で作成する必要がなくなります)。
  • Hilt が自動的に生成するコンポーネントのためのスコープ アノテーション
  • 事前定義済みのバインディングと修飾子

なによりも、Dagger と Hilt は共存できるため、必要になったときに随時アプリの移行ができます。

この Codelab で問題(コードのバグ、文法的な誤り、不明確な表現など)が見つかった場合は、Codelab の左下隅にある [誤りを報告] から問題を報告してください。

前提条件

  • Kotlin の構文に関する理解
  • Dagger の利用経験

学習内容

  • Android アプリに Hilt を追加する方法。
  • 移行戦略を計画する方法。
  • 既存の Dagger コードの動作を維持したまま、コンポーネントを Hilt に移行する方法。
  • スコープ設定されたコンポーネントを移行する方法。
  • Hilt を使用してアプリをテストする方法。

必要なもの

  • Android Studio 4.0 以降。

コードを取得する

次のコマンドで、GitHub から Codelab のコードを取得します。

$ git clone https://github.com/googlecodelabs/android-dagger-to-hilt

次のボタンで、リポジトリを ZIP ファイルとしてダウンロードすることもできます。

ZIP をダウンロード

Android Studio を開く

Android Studio をダウンロードする必要がある場合は、こちらからダウンロードします。

プロジェクトの設定

このプロジェクトには複数の GitHub ブランチがあります。

  • master は、チェックアウトまたはダウンロードしたブランチです。この Codelab の出発点となります。
  • interop は、Dagger と Hilt の相互運用ブランチです。
  • solution には、この Codelab の解答に加え、テストと ViewModel もあります。

master ブランチから Codelab の手順に沿って一歩一歩マイペースで進めることをおすすめします。

Codelab の途中には、プロジェクトに追加する必要があるコード スニペットを記載しています。また、コード スニペットのコメントで明示的に記載されたコードを削除する必要がある場合もあります。

チェックポイントとして、特定のステップで助けが必要になった場合に利用できる中間ブランチがあります。

git を使用して solution ブランチを取得するには、次のコマンドを使用します。

$ git clone -b solution https://github.com/googlecodelabs/android-dagger-to-hilt

または、次の場所から解答コードをダウンロードします。

最終版のコードをダウンロードする

よくある質問

サンプルアプリの実行

まず、サンプルアプリがどんなものか見てみましょう。次の手順に沿って、Android Studio でサンプルアプリを開いてください。

  • zip アーカイブをダウンロードした場合は、ファイルをローカルに展開します。
  • Android Studio でプロジェクトを開きます。
  • execute.png [Run] ボタンをクリックして、エミュレータを選択するか、Android デバイスを接続します。すると、登録画面が表示されます。

54d4e2a9bf8177c1.gif

このアプリには、Dagger を使用するフローが 4 個あります(フローはアクティビティとして実装されています)。

  • Registration: ユーザー名とパスワードを入力し、利用規約に同意して登録します。
  • Login: Registration フローで追加された認証情報でログインします。登録の解除もできます。
  • Home: ホーム画面です。未読通知の数が表示されます。
  • Settings: ログアウトと、未読通知数の更新ができます(通知数には乱数が生成されます)。

プロジェクトは一般的な MVVM パターンに従っているため、ビューの複雑な部分はすべて ViewModel が請け負います。このプロジェクトの構造をよく理解しておいてください。

8ecf1f9088eb2bb6.png

矢印はオブジェクト同士の依存関係を表しています。これは、アプリケーション グラフと呼ばれるもので、アプリのすべてのクラスと、それらの間の依存関係を表しています。

master ブランチ内のコードでは、Dagger を使用して依存関係を注入しています。これから、アプリをリファクタリングして、Component を手動で作成する代わりに、Hilt が Component と他の Dagger 関連のコードを生成するようにします。

このアプリ内での Dagger の設定は、次の図のようになっています。型に付いた丸印は、その型がその型を提供する Component にスコープ設定されていることを表します。

a1b8656d7fc17b7d.png

簡単にするために、最初にダウンロードした master ブランチのこのプロジェクトには、Hilt の依存関係を追加してあります。そのため、以下のコードを追加する必要はありません。それでは、Android アプリで Hilt を使用する場合に必要となるものを説明します。

Hilt では、ライブラリの依存関係とは別に、プロジェクトに設定されている Gradle プラグインを使用します。ルート(プロジェクト レベル)の build.gradle ファイルを開き、クラスパスで次の Hilt の依存関係を見つけます

buildscript {
    ...
    ext.hilt_version = '2.28-alpha'
    dependencies {
        ...
        classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version"
    }
}

app/build.gradle を開き、上の kotlin-kapt プラグインのすぐ下にある Hilt Gradle プラグインの宣言を確認してください。

...
apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'

android {
    ...
}

Hilt の依存関係とアノテーション プロセッサは、次のように、同じ app/build.gradle ファイル内のプロジェクトに含まれています。

...
dependencies {
    implementation "com.google.dagger:hilt-android:$hilt_version"
    kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
}

すべてのライブラリは、Hilt を含めて、プロジェクトのビルドと同期の際にダウンロードされます。では、Hilt を使ってみましょう。

一度にすべてを Hilt に移行することも頭に浮かぶかもしれませんが、現実のプロジェクトでは、少しずつ Hilt に移行しながら、その都度アプリのビルドと実行を行ってエラーがないことを確認したくなるでしょう。

Hilt に移行する際には、作業を複数のステップに分けることをおすすめします。おすすめは、Application または @Singleton コンポーネントの移行から始めて、その後でアクティビティとフラグメントを移行する方法です。

この Codelab では、まず AppComponent を移行してから、Registration、Login、最後に Main と Settings の順で各フローを移行します。

移行する中で、すべての @Component インターフェースと @Subcomponent インターフェースを削除し、すべてのモジュールに @InstallIn のアノテーションを付けます。

移行後は、すべての Application/Activity/Fragment/View/Service/BroadcastReceiver クラスに @AndroidEntryPoint のアノテーションを付け、コンポーネントのインスタンス化または伝播を行うすべてのコードを削除する必要があります。

移行を計画するため、まず AppComponent.kt から始めて、コンポーネントの階層を確認しましょう。

@Singleton
// Definition of a Dagger component that adds info from the different modules to the graph
@Component(modules = [StorageModule::class, AppSubcomponents::class])
interface AppComponent {

    // Factory to create instances of the AppComponent
    @Component.Factory
    interface Factory {
        // With @BindsInstance, the Context passed in will be available in the graph
        fun create(@BindsInstance context: Context): AppComponent
    }

    // Types that can be retrieved from the graph
    fun registrationComponent(): RegistrationComponent.Factory
    fun loginComponent(): LoginComponent.Factory
    fun userManager(): UserManager
}

AppComponent には、@Component のアノテーションが付けられていて、StorageModuleAppSubcomponents の 2 つのモジュールがあります。

AppSubcomponents には、RegistrationComponentLoginComponentUserComponent の 3 つのコンポーネントがあります。

  • LoginComponentLoginActivity に注入されています。
  • RegistrationComponentRegistrationActivityEnterDetailsFragmentTermsAndConditionsFragment に注入されています。また、このコンポーネントは RegistrationActivity にスコープが設定されています。

UserComponentMainActivitySettingsActivity に注入されています。

ApplicationComponent への参照は、アプリに移行する Component にマッピングされる Hilt が生成した Component(生成されたすべてのコンポーネントへのリンク)に置き換えることができます。

このセクションでは、AppComponent を移行します。以降のステップでは各コンポーネントを Hilt に移行しますが、その間も既存の Dagger コードの動作を維持するには、下準備が必要となります。

Hilt を初期化してコード生成を開始するには、Application クラスに Hilt のアノテーションを付ける必要があります。

MyApplication.kt を開き、@HiltAndroidApp アノテーションをクラスに追加してください。これらのアノテーションは、Dagger が検出してアノテーション プロセッサで使用するコード生成をトリガーするよう Hilt に指示します。

MyApplication.kt

package com.example.android.dagger

import android.app.Application
import dagger.hilt.android.HiltAndroidApp

@HiltAndroidApp
open class MyApplication : Application() {

    // Instance of the AppComponent that will be used by all the Activities in the project
    val appComponent: AppComponent by lazy {
        initializeComponent()
    }

    open fun initializeComponent(): AppComponent {
        // Creates an instance of AppComponent using its Factory constructor
        // We pass the applicationContext that will be used as Context in the graph
        return DaggerAppComponent.factory().create(applicationContext)
    }
}

1. Component モジュールを移行する

まず、AppComponent.kt を開いてください。AppComponent には、@Component アノテーションで追加された 2 つのモジュール(StorageModuleAppSubcomponents)があります。まず、これらの 2 つのモジュールを移行して、Hilt がそれらのモジュールを生成される ApplicationComponent に追加するようにします。

そのために、AppSubcomponents.kt を開き、このクラスに @InstallIn アノテーションを付けます。@InstallIn アノテーションでは、正しいコンポーネントにモジュールが追加されるようにパラメータを指定します。今回は、アプリケーション レベルのコンポーネントを移行しようとしているので、ApplicationComponent にバインディングが生成されるようにします。

AppSubcomponents.kt

// This module tells a Component which are its subcomponents
// Install this module in Hilt-generated ApplicationComponent
@InstallIn(ApplicationComponent::class)
@Module(
    subcomponents = [
        RegistrationComponent::class,
        LoginComponent::class,
        UserComponent::class
    ]
)
class AppSubcomponents

StorageModule でも同じ変更を加える必要があります。StorageModule.kt を開き、前の手順と同じように @InstallIn アノテーションを追加してください。

StorageModule.kt

// Tells Dagger this is a Dagger module
// Install this module in Hilt-generated ApplicationComponent
@InstallIn(ApplicationComponent::class)
@Module
abstract class StorageModule {

    // Makes Dagger provide SharedPreferencesStorage when a Storage type is requested
    @Binds
    abstract fun provideStorage(storage: SharedPreferencesStorage): Storage
}

@InstallIn アノテーションで、Hilt が生成した ApplicationComponent にモジュールを追加するよう Hilt に指示しました。

戻って AppComponent.kt を確認しましょう。AppComponent は、RegistrationComponentLoginComponentUserManager に依存関係を提供しています。次のステップでは、これらのコンポーネントを移行する準備をします。

2. 公開されている型を移行する

アプリをすべて Hilt に移行する間、Hilt ではエントリ ポイントを使って Dagger の依存関係を手動で要求する必要があります。エントリ ポイントを使用すると、各 Dagger コンポーネントを移行しながら、アプリの動作を維持することができます。このステップでは、各 Dagger コンポーネントを置き換え、Hilt が生成した ApplicationComponent 内で依存関係を手動でルックアップするようにします。

Hilt が生成した ApplicationComponent から RegistrationActivity.ktRegistrationComponent.Factory を取得するには、@InstallIn アノテーションを付けた EntryPoint インターフェースを新たに作成する必要があります。InstallIn アノテーションにより、バインディングの取得元を Hilt に指示します。エントリ ポイントにアクセスするには、EntryPointAccessors の適切な静的メソッドを使用します。パラメータは、コンポーネント インスタンスか、コンポーネント ホルダーとして機能する @AndroidEntryPoint オブジェクトのいずれかです。

RegistrationActivity.kt

class RegistrationActivity : AppCompatActivity() {

    @InstallIn(ApplicationComponent::class)
    @EntryPoint
    interface RegistrationEntryPoint {
        fun registrationComponent(): RegistrationComponent.Factory
    }

    ...
}

ここでは、Dagger 関連のコードを、RegistrationEntryPoint で置き換える必要があります。registrationComponent の初期化を変更して、RegistrationEntryPoint を使用します。移行して Hilt が使用されるようになるまでは、この変更により RegistrationActivity が Hilt の生成するコードから自身の依存関係にアクセスできます。

RegistrationActivity.kt

        // Creates an instance of Registration component by grabbing the factory from the app graph
        val entryPoint = EntryPointAccessors.fromApplication(applicationContext, RegistrationEntryPoint::class.java)
        registrationComponent = entryPoint.registrationComponent().create()

Component の他の公開されている型についても、同じ下準備が必要です。まず、LoginComponent.Factory から始めましょう。LoginActivity を開き、先程と同じように @InstallIn@EntryPoint のアノテーションが付いた LoginEntryPoint インターフェースを作成します。ただし、LoginActivity が Hilt コンポーネントで必要とするものを公開します。

LoginActivity.kt

    @InstallIn(ApplicationComponent::class)
    @EntryPoint
    interface LoginEntryPoint {
        fun loginComponent(): LoginComponent.Factory
    }

Hilt が LoginComponent の提供方法を認識するようになったので、古い inject() 呼び出しを EntryPoint の loginComponent() に置き換えます。

LoginActivity.kt

        val entryPoint = EntryPointAccessors.fromApplication(applicationContext, LoginEntryPoint::class.java)
        entryPoint.loginComponent().create().inject(this)

AppComponent から公開されている 3 つの型のうちの 2 つが、Hilt EntryPoint で処理されるように置き換えられました。次に、UserManager に同様の変更を加える必要があります。RegistrationComponentLoginComponent とは異なり、UserManagerMainActivitySettingsActivity の両方で使用されています。EntryPoint インターフェースを作成する必要があるのは 1 回だけです。アノテーションが付けられた EntryPoint インターフェースは両方の Activity で使用できます。これを簡単にするために、このインターフェースを MainActivity で宣言します。

UserManagerEntryPoint インターフェースを作成するために、MainActivity.kt を開き、@InstallIn@EntryPoint のアノテーションを付けてください。

MainActivity.kt

    @InstallIn(ApplicationComponent::class)
    @EntryPoint
    interface UserManagerEntryPoint {
        fun userManager(): UserManager
    }

ここでは、UserManagerEntryPoint を使用するように UserManager を変更します。

MainActivity.kt

        val entryPoint = EntryPointAccessors.fromApplication(applicationContext, UserManagerEntryPoint::class.java)
        val userManager = entryPoint.userManager()

SettingsActivity. にも同じ変更を加える必要があります。SettingsActivity.kt を開いて、UserManager の注入方法を置き換えます。

SettingsActivity.kt

    val entryPoint = EntryPointAccessors.fromApplication(applicationContext, MainActivity.UserManagerEntryPoint::class.java)
    val userManager = entryPoint.userManager()

3. コンポーネント ファクトリを削除する

@BindsInstance を使用して Dagger コンポーネントに Context を渡すのは一般的なパターンです。Hilt では、Context事前定義のバインディングとしてすでに使用可能になっているため、これは不要です。

通常、リソース、データベース、共有設定などにアクセスするには Context が必要です。Hilt では、修飾子 @ApplicationContext@ActivityContext を使用することでコンテキストへの注入が簡略化されています。

アプリを移行する間、どの型が依存関係として Context を必要としているかを確認し、Hilt が提供するものに置換します。

この場合、SharedPreferencesStorage には依存関係として Context が含まれています。Hilt にこのコンテキストを注入するよう指示するために、次を開いてください: SharedPreferencesStorage.kt. SharedPreferences にはアプリケーションの Context が必要なため、コンテキスト パラメータに @ApplicationContext アノテーションを追加してください。

SharedPreferencesStorage.kt

class SharedPreferencesStorage @Inject constructor(
    @ApplicationContext context: Context
) : Storage {

//...

4. 注入メソッドを移行する

次に、コンポーネントのコードの inject() メソッドを確認し、対応するクラスに @AndroidEntryPoint アノテーションを付ける必要があります。この例では、AppComponentinject() メソッドがないため、何もする必要はありません。

5. AppComponent クラスを削除する

AppComponent.kt に列挙されているすべてのコンポーネントには EntryPoint をすでに追加しているため、AppComponent.kt は削除できます。

6. 移行する Component を使用しているコードを削除する

アプリケーション クラスでカスタムの AppComponent を初期化するコードは不要になり、代わりに、Application クラスは Hilt が生成した ApplicationComponent を使用します。クラス本体のコードをすべて削除してください。削除後のコードは次のようになります。

MyApplication.kt

package com.example.android.dagger

import android.app.Application
import dagger.hilt.android.HiltAndroidApp

@HiltAndroidApp
open class MyApplication : Application()

これにより、Hilt が Application に追加され、AppComponent が削除され、Hilt が生成した AppComponent に依存関係を注入するように Dagger コードが変更されました。デバイスやエミュレータでアプリをビルドして試すと、以前と同じように動作するはずです。以降のセクションで、各 Activity と各 Fragment を Hilt を使用するように移行します。

ここまでで、Application コンポーネントを移行し、下準備を行ったので、各 Component を 1 つずつ Hilt に移行できるようになりました。

まず、ログインフローの移行から始めましょう。LoginComponent を手動で作成して LoginActivity で使用するのではなく、それを Hilt が代わりに行うようにします。

前のセクションで使用した手順と同じですが、今回は Hilt が生成した ActivityComponent を使用します。これは、Activity が管理する Component を移行しようとしているためです。

最初は LoginComponent.kt ですが、LoginComponent にはモジュールがないため、何もする必要はありません。Hilt に LoginActivity のためのコンポーネントを生成して注入させるには、アクティビティに @AndroidEntryPoint アノテーションを付ける必要があります。

LoginActivity.kt

@AndroidEntryPoint
class LoginActivity : AppCompatActivity() {

    //...
}

LoginActivity を Hilt に移行するために必要なコードは以上ですが、Hilt が Dagger 関連のコードを生成するため、クリーンアップが必要になります。LoginEntryPoint インターフェースを削除してください。

LoginActivity.kt

    //Remove
    //@InstallIn(ApplicationComponent::class)
    //@EntryPoint
    //interface LoginEntryPoint {
    //    fun loginComponent(): LoginComponent.Factory
    //}

次に、onCreate() 内の EntryPoint コードを削除します。

LoginActivity.kt

override fun onCreate(savedInstanceState: Bundle?) {
   //Remove
   //val entryPoint = EntryPoints.get(applicationContext, LoginActivity.LoginEntryPoint::class.java)
   //entryPoint.loginComponent().create().inject(this)

    super.onCreate(savedInstanceState)

    ...
}

Hilt がコンポーネントを生成するので、LoginComponent.kt を見つけて削除してください。

現在、LoginComponent は AppSubcomponents.kt にサブコンポーネントとして列挙されています。Hilt がバインディングを生成してくれるため、サブコンポーネント リストから LoginComponent を削除しても問題ありません。

AppSubcomponents.kt

// This module tells a Component which are its subcomponents
@InstallIn(ApplicationComponent::class)
@Module(
    subcomponents = [
        RegistrationComponent::class,
        UserComponent::class
    ]
)
class AppSubcomponents

ここまでで、LoginActivity が移行されて Hilt を使用するようになります。このセクションでは、追加したコードよりも削除したコードの方がはるかに多くなっています。これは素晴らしいことです。Hilt の使用時に入力するコードが少ないだけでなく、保守が必要となるコードと、バグを生む可能性のあるコードが少なくなります。

このセクションでは、登録フローを移行してゆきます。移行の計画を行うために、RegistrationComponent を確認しましょう。RegistrationComponent.kt を開き、inject() 関数まで下にスクロールしてください。依存関係を RegistrationActivityEnterDetailsFragmentTermsAndConditionsFragment に注入するのは RegistrationComponent です。

まず、RegistrationActivity の移行から始めましょう。RegistrationActivity.kt を開き、クラスに @AndroidEntryPoint アノテーションを付けます。

RegistrationActivity.kt

@AndroidEntryPoint
class RegistrationActivity : AppCompatActivity() {
    //...
}

RegistrationActivity が Hilt に登録されたので、RegistrationEntryPoint インターフェースと EntryPoint 関連のコードを onCreate() 関数から削除します。

RegistrationActivity.kt

//Remove
//@InstallIn(ApplicationComponent::class)
//@EntryPoint
//interface RegistrationEntryPoint {
//    fun registrationComponent(): RegistrationComponent.Factory
//}

override fun onCreate(savedInstanceState: Bundle?) {
    //Remove
    //val entryPoint = EntryPoints.get(applicationContext, RegistrationEntryPoint::class.java)
    //registrationComponent = entryPoint.registrationComponent().create()

    registrationComponent.inject(this)
    super.onCreate(savedInstanceState)
    //..
}

コンポーネントの生成と依存関係の注入は Hilt が行うため、registrationComponent 変数と、削除された Dagger コンポーネントに対する inject 呼び出しを削除できます。

RegistrationActivity.kt

// Remove
// lateinit var registrationComponent: RegistrationComponent

override fun onCreate(savedInstanceState: Bundle?) {
    //Remove
    //registrationComponent.inject(this)
    super.onCreate(savedInstanceState)

    //..
}

次に、EnterDetailsFragment.kt を開いてください。RegistrationActivity と同じように、EnterDetailsFragment@AndroidEntryPoint のアノテーションを付けます。

EnterDetailsFragment.kt

@AndroidEntryPoint
class EnterDetailsFragment : Fragment() {

    //...
}

Hilt が依存関係を提供するので、削除された Dagger コンポーネントの inject() 呼び出しは不要です。onAttach() 関数を削除します。

次のステップは TermsAndConditionsFragment の移行です。TermsAndConditionsFragment.kt を開き、前のステップと同じように、クラスにアノテーションを付け、onAttach() 関数を削除します。最終的なコードは次のようになります。

TermsAndConditionsFragment.kt

@AndroidEntryPoint
class TermsAndConditionsFragment : Fragment() {

    @Inject
    lateinit var registrationViewModel: RegistrationViewModel

    //override fun onAttach(context: Context) {
    //    super.onAttach(context)
    //
    //    // Grabs the registrationComponent from the Activity and injects this Fragment
    //    (activity as RegistrationActivity).registrationComponent.inject(this)
    //}

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        val view = inflater.inflate(R.layout.fragment_terms_and_conditions, container, false)

        view.findViewById<Button>(R.id.next).setOnClickListener {
            registrationViewModel.acceptTCs()
            (activity as RegistrationActivity).onTermsAndConditionsAccepted()
        }

        return view
    }
}

この変更により、RegistrationComponent に列挙されているアクティビティとフラグメントはすべて移行されたので、RegistrationComponent.kt は削除できるようになりました。

RegistrationComponent を削除すると、AppSubcomponents のサブコンポーネント リストからの参照を削除する必要があります。

AppSubcomponents.kt

@InstallIn(ApplicationComponent::class)
// This module tells a Component which are its subcomponents
@Module(
    subcomponents = [
        UserComponent::class
    ]
)
class AppSubcomponents

登録フローの移行完了まで、あと一歩です。登録フローでは、独自のスコープ ActivityScope を宣言して使用します。スコープは、依存関係のライフサイクルを制御します。この場合、ActivityScope は、RegistrationActivity から始まるフローに RegistrationViewModel の同じインスタンスを注入するように指示します。Hilt には、これをサポートするためのライフサイクル スコープが組み込まれています。

RegistrationViewModel を開き、@ActivityScope アノテーションを、Hilt が提供する @ActivityScoped に置き換えてください。

RegistrationViewModel.kt

@ActivityScoped
class RegistrationViewModel @Inject constructor(val userManager: UserManager) {

    //...
}

ActivityScope は他で使用されていないので、ActivityScope.kt は削除しても問題ありません。

ここで、アプリを実行して、Registration フローを試してみましょう。現在のユーザー名とパスワードでログインしたり、登録を解除してから新しいアカウントで再登録したりして、このフローが以前と同じように機能することを確認します。

この時点で、Dagger と Hilt がアプリ内に共存しています。Hilt は、UserManager 以外の依存関係をすべて注入しています。次のセクションでは、UserManager を移行して、Dagger から Hilt に完全に移行します。

ここまでの Codelab で、1 つのコンポーネント UserComponent を除いて、サンプルアプリの大部分を Hilt に移行できました。UserComponent には、カスタム スコープである @LoggedUserScope アノテーションが付けられています。つまり、UserComponent が、UserManager の同じインスタンスを @LoggedUserScope アノテーションが付いたクラスに注入するということです。

UserComponent は、ライフサイクルを Android クラスで管理されていないため、使用可能な Hilt コンポーネントにマッピングされません。生成された Hilt 階層の途中にカスタム コンポーネントを追加することはできないため、次の 2 つのいずれかの方法で対処します。

  1. 現在のプロジェクトの状態のままで、Hilt と Dagger を共存させる。
  2. スコープ設定されたコンポーネントを、使用可能な最も近い Hilt コンポーネント(この場合は ApplicationComponent)に移行し、必要に応じて null 可能性を使用する。

1 つ目は、すでに前のステップで、その状態になっています。このステップでは、2 つ目の方法でアプリケーションを Hilt に完全に移行します。なお、実際のアプリでは、個々のユースケースに合わせて選択して構いません。

このステップでは、UserComponent が Hilt の ApplicationComponent の一部となるように移行されます。このコンポーネントにモジュールがある場合は、そのモジュールも ApplicationComponent にインストールする必要があります。

UserComponent にあるスコープ設定されている型は、@LoggedUserScope アノテーションが付いた UserDataRepository のみです。UserComponent は Hilt の ApplicationComponent に集約するので、UserDataRepository には @Singleton アノテーションが付けられ、ユーザーがログアウトしたときに null になるようにロジックを変更します。

UserManager はすでに @Singleton アノテーションが付いているため、アプリ全体で同じインスタンスを提供でき、変更を加えれば、Hilt で同じ機能を実現できます。最初に下準備が必要になるので、まず UserManagerUserDataRepository の動作を変更します。

UserManager.kt を開き、次のように変更してください。

  • UserComponent インスタンスを作成する必要がなくなったため、コンストラクタ内の UserComponent.Factory パラメータを UserDataRepository で置き換えます。代わりに、依存関係として UserDataRepository があります。
  • Hilt がコンポーネント コードを生成するため、UserComponent とそのセッターを削除します。
  • userComponent をチェックする代わりに、userRepository からのユーザー名をチェックするように isUserLoggedIn() 関数を変更します。
  • userJustLoggedIn() 関数にユーザー名をパラメータとして追加します。
  • userName を引数として userDataRepositoryinitData を呼び出すように userJustLoggedIn() 関数の本体を変更します。移行中に削除することになる userComponent の代わりです。
  • registerUser() 関数と loginUser() 関数の userJustLoggedIn() 呼び出しに username を追加します。
  • logout() 関数から userComponent を削除し、userDataRepository.cleanUp() への呼び出しに置き換えます。

以上の変更が終わると、UserManager.kt のコードは次のようになります。

UserManager.kt

@Singleton
class UserManager @Inject constructor(
    private val storage: Storage,
    // Since UserManager will be in charge of managing the UserComponent lifecycle,
    // it needs to know how to create instances of it
    private val userDataRepository: UserDataRepository
) {

    val username: String
        get() = storage.getString(REGISTERED_USER)

    fun isUserLoggedIn() = userDataRepository.username != null

    fun isUserRegistered() = storage.getString(REGISTERED_USER).isNotEmpty()

    fun registerUser(username: String, password: String) {
        storage.setString(REGISTERED_USER, username)
        storage.setString("$username$PASSWORD_SUFFIX", password)
        userJustLoggedIn(username)
    }

    fun loginUser(username: String, password: String): Boolean {
        val registeredUser = this.username
        if (registeredUser != username) return false

        val registeredPassword = storage.getString("$username$PASSWORD_SUFFIX")
        if (registeredPassword != password) return false

        userJustLoggedIn(username)
        return true
    }

    fun logout() {
        userDataRepository.cleanUp()
    }

    fun unregister() {
        val username = storage.getString(REGISTERED_USER)
        storage.setString(REGISTERED_USER, "")
        storage.setString("$username$PASSWORD_SUFFIX", "")
        logout()
    }

    private fun userJustLoggedIn(username: String) {
        // When the user logs in, we create populate data in UserComponent
        userDataRepository.initData(username)
    }
}

UserManager に関する作業は完了しました。次に、UserDataRepository にいくつか変更を行う必要があります。UserDataRepository.kt を開き、次のように変更してください。

  • @LoggedUserScope の依存関係は Hilt が管理するようになるため、これを削除します。
  • UserDataRepository はすでに UserManager に注入されているので、依存関係の循環を防ぐため、UserDataRepository のコンストラクタから UserManager パラメータを削除します。
  • unreadNotifications を null 許容に変更し、セッターを非公開にします。
  • 新しい null 許容変数 username を追加し、セッターを非公開にします。
  • usernameunreadNotifications を乱数に設定する新しい関数 initData() を追加します。
  • usernameunreadNotifications のカウントをリセットする新しい関数 cleanUp() を追加します。username を null に、unreadNotifications を -1 に設定します。
  • 最後に、クラス本体の randomInt() 関数を移動します。

以上の変更が終わると、コードは次のようになります。

UserDataRepository.kt

@Singleton
class UserDataRepository @Inject constructor() {

    var username: String? = null
        private set

    var unreadNotifications: Int? = null
        private set

    init {
        unreadNotifications = randomInt()
    }

    fun refreshUnreadNotifications() {
        unreadNotifications = randomInt()
    }
    fun initData(username: String) {
        this.username = username
        unreadNotifications = randomInt()
    }

    fun cleanUp() {
        username = null
        unreadNotifications = -1
    }

    private fun randomInt(): Int {
        return Random.nextInt(until = 100)
    }
}

UserComponent の移行を完了するために、UserComponent.kt を開き、inject() メソッドまでスクロールしてください。この依存関係は、MainActivitySettingsActivity で使用されます。まず、MainActivity から移行します。MainActivity.kt を開き、クラスに @AndroidEntryPoint アノテーションを付けます。

MainActivity.kt

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    //...
}

UserManagerEntryPoint インターフェースを削除し、onCreate() からエントリ ポイント関連のコードも削除します。

MainActivity.kt

//@InstallIn(ApplicationComponent::class)
//@EntryPoint
//interface UserManagerEntryPoint {
//    fun userManager(): UserManager
//}

override fun onCreate(savedInstanceState: Bundle?) {
    //val entryPoint = EntryPoints.get(applicationContext, UserManagerEntryPoint::class.java)
    //val userManager = entryPoint.userManager()
    super.onCreate(savedInstanceState)

    //...
}

UserManagerlateinit var を宣言し、Hilt が依存関係を注入できるように @Inject アノテーションを付けます。

MainActivity.kt

@Inject
lateinit var userManager: UserManager

UserManager は Hilt によって注入されるため、UserComponent に対する inject() の呼び出しを削除します。

MainActivity.kt

        //Remove
        //userManager.userComponent!!.inject(this)
        setupViews()
    }
}

MainActivity に必要な変更は以上です。同様の変更で、SettingsActivity を移行できます。SettingsActivity を開き、@AndroidEntryPoint アノテーションを付けます。

SettingsActivity.kt

@AndroidEntryPoint
class SettingsActivity : AppCompatActivity() {
    //...
}

UserManagerlateinit var を作成し、@Inject アノテーションを付けます。

SettingsActivity.kt

    @Inject
    lateinit var userManager: UserManager

エントリ ポイント コードと、userComponent() に対する注入呼び出しを削除します。これが終わると、onCreate() 関数は次のようになります。

SettingsActivity.kt

    override fun onCreate(savedInstanceState: Bundle?) {

        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_settings)

        setupViews()
    }

使用されないリソースをクリーンアップして、移行を完了します。LoggedUserScope.kt クラスと UserComponent.kt クラス、最後に AppSubcomponent.kt クラスを削除します。

再度、アプリを実行して試します。Dagger の場合と同じように動作するはずです。

Hilt へのアプリの移行を完了する前に、1 つの重要なステップが残っています。ここまでで、すべてのアプリコードを移行しましたが、テストは行っていません。Hilt は、アプリコードと同様に、テストにも依存関係を注入します。Hilt を使用したテストでは、テストごとに新しいコンポーネントのセットが自動的に生成されるため、メンテナンスは不要です。

単体テスト

単体テストを始めましょう。単体テストに Hilt を使用する必要はありません。コンストラクタにアノテーションがない場合と同様、対象のクラスのコンストラクタを直接呼び出して、疑似またはモックの依存関係を渡すことができるためです。

単体テストを実行すると、UserManagerTest でエラーになると思います。UserManager には、前のセクションで説明したコンストラクタのパラメータを含めて、多くの作業と変更を行っています。UserManagerTest.kt を開いてください。これはまだ UserComponentUserComponentFactory に依存しています。UserManager のパラメータはすでに変更されているので、UserComponent.Factory パラメータを UserDataRepository の新しいインスタンスに変更してください。

UserManagerTest.kt

    @Before
    fun setup() {
        storage = FakeStorage()
        userManager = UserManager(storage, UserDataRepository())
    }

このように変更します。テストを再度実行すると、すべての単体テストが合格になるはずです。

テスト用の依存関係の追加

作業に入る前に、app/build.gradle を開いて、次の Hilt 依存関係が存在することを確認してください。Hilt は、テスト専用のアノテーションのために hilt-android-testing を使用します。また、Hilt は androidTest フォルダにクラスのコードを生成する必要があるため、アノテーション プロセッサがそこで動作できる必要があります。

app/build.gradle

    // Hilt testing dependencies
    androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version"
    kaptAndroidTest "com.google.dagger:hilt-android-compiler:$hilt_version"

UI テスト

Hilt はテストごとに、テスト コンポーネントとテスト Application を自動的に生成します。まず、TestAppComponent.kt を開いて、移行の計画を行います。TestAppComponent には、TestStorageModuleAppSubcomponents という 2 つのモジュールがあります。すでに AppSubcomponents は移行して削除しているので、引き続き TestStorageModule を移行します。

TestStorageModule.kt を開き、クラスに @InstallIn アノテーションを付けてください。

TestStorageModule.kt

@InstallIn(ApplicationComponent::class)
@Module
abstract class TestStorageModule {
    //...

すべてのモジュールの移行が完了したので、TestAppComponent を削除します。

次に、Hilt を ApplicationTest に追加しましょう。Hilt を使用する UI テストには @HiltAndroidTest アノテーションを付ける必要があります。このアノテーションは、各テストに Hilt コンポーネントを生成させるものです。

ApplicationTest.kt を開き、次のアノテーションを追加してください。

  • @HiltAndroidTest: このテスト用のコンポーネントを生成するよう Hilt に指示します。
  • @UninstallModules(StorageModule::class): テスト中に TestStorageModule が注入されるように、アプリコードで宣言された StorageModule をアンインストールするよう Hilt に指示します。
  • HiltAndroidRuleApplicationTest に追加する必要もあります。このテストルールは、コンポーネントの状態を管理し、テストで注入を行うために使用されます。最終的なコードは次のようになります。

ApplicationTest.kt

@UninstallModules(StorageModule::class)
@HiltAndroidTest
class ApplicationTest {

    @get:Rule
    var hiltRule = HiltAndroidRule(this)

    //...

Hilt は、各インストルメンテーション テストに対して新しい Application を生成するため、UI テストの実行時に、Hilt が生成した Application を使用するように指定する必要があります。これを行うには、カスタムのテストランナーが必要です。

Codelab アプリには、すでにカスタムのテストランナーがあります。MyCustomTestRunner.kt を開きます。

Hilt には HiltTestApplication. という名前のテストに使用できる Application が用意されています。newApplication() 関数の本体で MyTestApplication::class.javaHiltTestApplication::class.java に変更する必要があります。

MyCustomTestRunner.kt

class MyCustomTestRunner : AndroidJUnitRunner() {

    override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {

        return super.newApplication(cl, HiltTestApplication::class.java.name, context)
    }
}

この変更により、MyTestApplication.kt ファイルは削除できるようになりました。では、テストを実行しましょう。テストはすべて合格になるはずです。

Hilt には、WorkManager や ViewModel など、他の Jetpack ライブラリのクラスを提供するための拡張があります。Codelab プロジェクト内の ViewModel はプレーンなクラスなので、アーキテクチャ コンポーネントの ViewModel を拡張していません。ViewModel の Hilt のサポートを追加する前に、アプリの ViewModel をアーキテクチャ コンポーネントの ViewModel に移行します。

ViewModel と統合するには、Gradle ファイルに次の依存関係を追加する必要があります。これらの依存関係はすでに追加してあります。このライブラリとは別に、Hilt アノテーション プロセッサ上で動作する別のアノテーション プロセッサを追加する必要があります。

// app/build.gradle file

...
dependencies {
  ...
  implementation "androidx.fragment:fragment-ktx:1.2.4"
  implementation 'androidx.hilt:hilt-lifecycle-viewmodel:$hilt_jetpack_version'
  kapt 'androidx.hilt:hilt-compiler:$hilt_jetpack_version'
  kaptAndroidTest 'androidx.hilt:hilt-compiler:$hilt_jetpack_version'
}

プレーンなクラスを ViewModel に移行するには、ViewModel() を拡張する必要があります。

MainViewModel.kt を開き、: ViewModel() を追加します。アーキテクチャ コンポーネントの ViewModel に移行するにはこれで十分ですが、まだ ViewModel のインスタンスを提供する方法を Hilt に指示する必要があります。そのためには、ViewModel のコンストラクタに @ViewModelInject アノテーションを追加します。@Inject アノテーションを @ViewModelInject に置き換えてください。

MainViewModel.kt

class MainViewModel @ViewModelInject constructor(
    private val userDataRepository: UserDataRepository
): ViewModel() {
//...
}

次に、LoginViewModel を開いて同じ変更を加えます。最終的なコードは次のようになります。

LoginViewModel.kt

class LoginViewModel @ViewModelInject constructor(
    private val userManager: UserManager
): ViewModel() {
//...
}

同様に、RegistrationViewModel.kt を開き、ViewModel() に移行し、Hilt アノテーションを追加します。拡張メソッド viewModels()activityViewModels() により ViewModel のスコープを制御できるため、@ActivityScoped アノテーションは必要ありません。

RegistrationViewModel.kt

class RegistrationViewModel @ViewModelInject constructor(
    val userManager: UserManager
) : ViewModel() {

同じ変更で EnterDetailsViewModelSettingViewModel を移行します。2 つのクラスの最終的なコードは次のようになります。

EnterDetailsViewModel.kt

class EnterDetailsViewModel @ViewModelInject constructor() : ViewModel() {

SettingViewModel.kt

class SettingsViewModel @ViewModelInject constructor(
     private val userDataRepository: UserDataRepository,
     private val userManager: UserManager
) : ViewModel() {

これで、すべての ViewModel がアーキテクチャ コンポーネントの ViewModel に移行され、Hilt アノテーションが付けられたので、その注入方法を移行できるようになりました。

次に、View レイヤで ViewModel を初期化する方法を変更する必要があります。ViewModel は OS によって作成され、by viewModels() 委譲関数を使用して取得できます。

MainActivity.kt を開き、@Inject アノテーションを Jetpack 拡張に置き換えます。また、lateinit を削除し、varval に変更し、そのフィールドを private とマークする必要があります。

MainActivity.kt

//    @Inject
//    lateinit var mainViewModel: MainViewModel
    private val mainViewModel: MainViewModel by viewModels()

同じように、LoginActivity.kt を開き、ViewModel の取得方法を変更します。

LoginActivity.kt

//    @Inject
//    lateinit var loginViewModel: LoginViewModel
    private val loginViewModel: LoginViewModel by viewModels()

次に、RegistrationActivity.kt を開き、同じように registrationViewModel の取得方法を変更します。

RegistrationActivity.kt

//    @Inject
//    lateinit var registrationViewModel: RegistrationViewModel
    private val registrationViewModel: RegistrationViewModel by viewModels()

EnterDetailsFragment.kt を開きます。EnterDetailsViewModel の取得方法を置き換えます。

EnterDetailsFragment.kt

    private val enterDetailsViewModel: EnterDetailsViewModel by viewModels()

同様に、registrationViewModel の取得方法を変更しますが、今回は viewModels(). ではなく activityViewModels() 委譲関数を使用します。registrationViewModel が注入されると、Hilt はアクティビティ レベルでスコープ設定された ViewModel を注入します。

EnterDetailsFragment.kt

    private val registrationViewModel: RegistrationViewModel by activityViewModels()

TermsAndConditionsFragment.kt を開き、再び viewModels() ではなく activityViewModels() 拡張関数を使用して registrationViewModel. を取得するようにします。

TermsAndConditionsFragment.kt

    private val registrationViewModel: RegistrationViewModel by activityViewModels()

最後に、SettingsActivity.kt を開き、settingsViewModel の取得方法を移行します。

SettingsActivity.kt

    private val settingsViewModel: SettingsViewModel by viewModels()

アプリを実行して、すべてが期待どおりに動作することを確認します。

これで、アプリが Hilt を使用するようにする移行が完了しました。単に移行を完了しただけでなく、Dagger コンポーネントを 1 つずつ移行しながら、アプリケーションの動作を維持することもできました。

この Codelab では、Application コンポーネントから始めて、Hilt を既存の Dagger コンポーネントと共存させるために必要な土台を構築しました。そこから、Activity と Fragment に Hilt アノテーションを使用し、Dagger 関連のコードを削除することで、各 Dagger コンポーネントを Hilt へ移行しました。1 つのコンポーネントの移行を完了するごとに、アプリが動作し正しく機能することが確認できました。また、Context 依存関係と ApplicationContext 依存関係を、Hilt が提供する @ActivityContext アノテーションと @ApplicationContext アノテーションを使用して移行しました。他の Android コンポーネントも移行しました。最後に、テストを移行して、Hilt への移行を完了しました。

参考資料

アプリを Hilt に移行する方法について詳しくは、Hilt への移行をご覧ください。Dagger から Hilt への移行の詳細だけでなく、dagger.android アプリの移行に関する情報もご覧ください。