2024年11月17日日曜日

Component が任意のタイミングで更新される構成になっている Dagger を Hilt に移行する

Hilt が入っていない Dagger のみの構成があります。 @Singleton @Component( modules = [ AppModule::class, ] ) interface AppComponent { fun inject(app: MainActivity) } @Module class AppModule { @Singleton @Provides fun provideNeedResetRepository(myApi: MyApi): NeedResetRepository { return NeedResetRepository(myApi) } @Singleton @Provides fun provideMyApi(): MyApi { return MyApi() } } class NeedResetRepository( private val myApi: MyApi ) { // キャッシュなどのデータを持っている } class MyApi { // 状態を持たない } この構成では MyApplication で Dagger の Component である AppComponent のインスタンスを保持しています。

ログアウト時に NeedResetRepository で保持しているキャッシュを削除したいので、MyApplication で保持している AppComponent のインスタンスを null にしてから MainActivity を作り直しています(reset() メソッドのところ)。

(Hilt がない時代の標準的なやり方だと AppComponent のインスタンスは lateinit var にして onCreate() で代入し、作り直すことがないようにすることが多いのですが、それだと Hilt への移行で困ることがないので、今回は困るパターンということでこのような構成例になっています) class MyApplication : Application() { private var appComponent: AppComponent? = null fun getAppComponent(): AppComponent { return appComponent ?: DaggerAppComponent.builder() .build() .also { appComponent = it } } fun reset() { appComponent = null TaskStackBuilder.create(this) .addNextIntent( Intent(this, MainActivity::class.java) ) .startActivities() } } 以下のコードで MainActivity の reset ボタンを押すと、AppComponent のインスタンスが新しくなるので AppComponent および NeedResetRepository のインスタンスが新しくなるのがわかります。

AppComponent と NeedResetRepository には @Singleton がついているので、AppComponent が作り直されるまで NeedResetRepository のインスタンスは変わりません。そのため例えば画面回転時には NeedResetRepository のインスタンスは変わりません。 class MainActivity : ComponentActivity() { @Inject lateinit var needResetRepository: NeedResetRepository override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val appComponent = (application as MyApplication).getAppComponent() appComponent.inject(this) enableEdgeToEdge() setContent { MaterialTheme { Scaffold { innerPadding -> Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier .padding(innerPadding) .padding(16.dp), ) { Text( text = "$needResetRepository", ) Button( onClick = { (application as MyApplication).reset() } ) { Text("reset") } } } } } } } さて、このような構成に対し、新しく作成するクラスや ViewModel では Hilt を使いたいとします。


ステップ1 : Hilt ライブラリの設定


libs.versions.toml [libraries] ... hilt = { module = "com.google.dagger:hilt-android", version.ref = "dagger" } hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "dagger" } [plugins] ... hilt = { id = "com.google.dagger.hilt.android", version.ref = "dagger" } build.gradle plugins { ... alias(libs.plugins.hilt) apply false } app/build.gradle plugins { ... + alias(libs.plugins.hilt) } ... dependencies { - implementation(libs.dagger) - ksp(libs.dagger.compiler) + implementation(libs.hilt) + ksp(libs.hilt.compiler) ... }
hilt の plugin を build.gradle に設定し、dependencies の依存ライブラリを dagger のものから hilt のものに置き換えてビルドすると、次のようなエラーがでます。

[ksp] /.../AppComponent.kt:21: [Hilt] com.example.sample.AppModule is missing an @InstallIn annotation. If this was intentional, see https://dagger.dev/hilt/flags#disable-install-in-check for how to disable this check.

@Module アノテーションがついている AppModule に @InstallIn がついていないと怒られていますが、AppModule は AppComponent 用なので @DisableInstallInCheck をつけてエラーがでないようにします。 @DisableInstallInCheck @Module class AppModule { ... } 最後に MyApplication に @HiltAndroidApp アノテーションをつけます。 @HiltAndroidApp class MyApplication : Application() { ... } ここまでで、ビルドして以前と同じ動作になっているのが確認できます(MainActivity にはまだ @AndroidEntryPoint はつけません!)。


ステップ2 : SubComponent 化


AppModule を分割して、リセットする必要のない MyApi は @InstallIn(SingletonComponent::class) をつけた Module で管理するようにしたいのですが、 @InstallIn(SingletonComponent::class) @Module object AppModule2 { @Singleton @Provides fun provideMyApi(): MyApi { return MyApi() } } こうすると、NeedResetRepository を @Provides しているところで MyApi が見えなくなって

[ksp] /.../AppComponent.kt:17: [Dagger/MissingBinding] com.example.sample.MyApi cannot be provided without an @Inject constructor or an @Provides-annotated method.

このようなエラーが出てしまいます。

そこで、Module を分割する前に、既存の AppComponent を Hilt の SubComponent に変更します。


1. AppComponent のアノテーションを @Subcomponent に変更し、@Subcomponent.Builder をつけた Builder を用意します -@Singleton -@Component( - modules = [ - AppModule::class, - ] -) +@Subcomponent interface AppComponent { + @Subcomponent.Builder + interface Builder { + fun build(): AppComponent + } + fun inject(app: MainActivity) }

2. 任意のタイミングで AppComponent を作り直せるように、AppComponent の親 Component を用意します。ログアウト時にリセットすることを想定して、ここでは AuthComponent という名前にしています。

AuthComponent では SingletonComponent に InstallIn されている型も見えるようにしたいので、parent に SingletonComponent を指定します。

AuthComponent に対応する Scope も用意します。ここでは AuthScope という名前にしています。AuthComponent に @AuthScope をつけます。 @Scope @Retention(AnnotationRetention.RUNTIME) annotation class AuthScope @AuthScope @DefineComponent(parent = SingletonComponent::class) interface AuthComponent { @DefineComponent.Builder interface Builder { fun build(): AuthComponent } } AppComponent を AuthComponent に紐づけるための Module を用意します。@InstallIn(AuthComponent::class) をつけ、@Module の subcomponents で AppComponent を指定します。 @InstallIn(AuthComponent::class) @Module( subcomponents = [ AppComponent::class, ] ) interface AuthModule

3. AppModule の @DisableInstallInCheck を @InstallIn(AuthComponent::class) に変更し、@Singleton を @AuthScope に変更します。 -@DisableInstallInCheck +@InstallIn(AuthComponent::class) @Module object AppModule { - @Singleton + @AuthScope @Provides fun provideNeedResetRepository(myApi: MyApi): NeedResetRepository { return NeedResetRepository(myApi) } - @Singleton + @AuthScope @Provides fun provideMyApi(): MyApi { return MyApi() } }

4. 任意のタイミングで AppComponent を作り直すために、AppComponent の親である AuthComponent を管理する GeneratedComponentManager を用意します。 @Singleton class AuthComponentRegistry @Inject constructor( private val authComponentBuilder: AuthComponent.Builder, ) : GeneratedComponentManager<AuthComponent> { private var authComponent: AuthComponent init { authComponent = authComponentBuilder.build() } fun reset() { authComponent = authComponentBuilder.build() } override fun generatedComponent(): AuthComponent { return authComponent } fun getAppComponent(): AppComponent { return EntryPoints.get( this, AuthComponentEntryPoint::class.java ) .appComponentBuilder() .build() } @EntryPoint @InstallIn(AuthComponent::class) interface AuthComponentEntryPoint { fun appComponentBuilder(): AppComponent.Builder } } reset() メソッドを用意して、そこで authComponent のインスタンスを作り直すことによって、任意のタイミングで AppComponent を作り直せるようにしています。

AppComponent.Builder は AuthModule で @InstallIn(AuthComponent::class) されているので、同じように @InstallIn(AuthComponent::class) をつけた EntryPoint を用意することで取得できます。
この EntryPoint のインスタンスは、EntryPoints.get() に AuthComponentRegistry インスタンスを渡すことで取得できます。



5. MyApplication で保持している appComponent を削除して、getAppComponent() では AuthComponentRegistry から取得したインスタンスを返すようにします。reset() メソッドでは AuthComponentRegistry の reset() メソッドを呼ぶように変更します。 @HiltAndroidApp class MyApplication : Application() { - private var appComponent: AppComponent? = null + @Inject + lateinit var authComponentRegistry: AuthComponentRegistry fun getAppComponent(): AppComponent { - return appComponent ?: DaggerAppComponent.builder() - .build() - .also { - appComponent = it - } + return authComponentRegistry.getAppComponent() } fun reset() { - appComponent = null + authComponentRegistry.reset()

これで AppComponent が Hilt で管理されるようになりました。

reset ボタンが押されるまでは NeedResetRepository のインスタンスが保持され、reset ボタンを押すと NeedResetRepository のインスタンスが新しくなるという以前の挙動を保っています。



ステップ3 : Module 分割


ここまでくれば MyApi を別 Module に分割できます。 @InstallIn(AuthComponent::class) @Module object AppModule { @AuthScope @Provides fun provideNeedResetRepository(myApi: MyApi): NeedResetRepository { return NeedResetRepository(myApi) } +} - @AuthScope +@InstallIn(SingletonComponent::class) +@Module +object AppModule2 { + + @Singleton @Provides fun provideMyApi(): MyApi { return MyApi() } } AppModule2 には @InstallIn(SingletonComponent::class) をつけ、provideMyApi() の scope を @AuthScope から @Singleton に変更します。 reset ボタンが押されても MyApi のインスタンスが保持されるように変わります。



ステップ4 : @HiltViewModel


この段階で、AuthComponent に依存しないクラスだけを引数にとる ViewModel なら @HiltViewModel を使えるようになります。 @HiltViewModel class SomeViewModel @Inject constructor( prival val myApi: MyApi ) : ViewModel() @AndroidEntryPoint class SomeActivity : ComponentActivity() { private val viewModel by viewModels<SomeViewModel>() }
AuthComponent に依存するクラスを引数にとるときは AssistedInject を利用します。
https://dagger.dev/hilt/view-model#assisted-injection
@HiltViewModel(assistedFactory = SomeViewModel.Factory::class) class SomeViewModel @AssistedInject constructor( @Assisted private val needResetRepository: NeedResetRepository ) : ViewModel() { @AssistedFactory interface Factory { fun create( needResetRepository: NeedResetRepository ): SomeViewModel } } @AndroidEntryPoint class SomeActivity : ComponentActivity() { private val viewModel by viewModels<SomeViewModel>( extrasProducer = { defaultViewModelCreationExtras.withCreationCallback<SomeViewModel.Factory> { factory -> factory.create( authComponentEntryPoint().needResetRepository() ) } } ) } Context から AuthComponent に依存するクラスのインスタンスを取れるように便利メソッドを用意しておきます。 @EntryPoint @InstallIn(AuthComponent::class) interface AuthComponentEntryPoint { fun needResetRepository(): NeedResetRepository } @InstallIn(SingletonComponent::class) @EntryPoint interface SingletonComponentEntryPoint { fun authComponentRegistry(): AuthComponentRegistry } fun Context.authComponentEntryPoint(): AuthComponentEntryPoint { val authComponentRegistry = EntryPointAccessors .fromApplication<SingletonComponentEntryPoint>(this) .authComponentRegistry() return EntryPoints.get(authComponentRegistry, AuthComponentEntryPoint::class.java) }


ステップ4 : AppComponent の inject() メソッド廃止


上で用意した便利メソッドを使って MainActivity の needResetRepository にインスタンスをセットするように変えます。

MyApi は @InstallIn(SingletonComponent::class) なので MainActivity に @AndroidEntryPoint をつければ inject されます。 +@AndroidEntryPoint class MainActivity : ComponentActivity() { - @Inject - lateinit var needResetRepository: NeedResetRepository + private lateinit var needResetRepository: NeedResetRepository @Inject lateinit var myApi: MyApi override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val appComponent = (application as MyApplication).getAppComponent() - appComponent.inject(this) + needResetRepository = authComponentEntryPoint().needResetRepository() enableEdgeToEdge() interface AppComponent { interface Builder { fun build(): AppComponent } - fun inject(app: MainActivity) } このように AppComponent の inject() メソッドを段階的に廃止していきます。



ステップ5 : AppComponent の廃止


AppComponent に定義されているメソッドがなくなったら AppComponent 自体を廃止します。 -@Subcomponent -interface AppComponent { - - @Subcomponent.Builder - interface Builder { - fun build(): AppComponent - } -} -@InstallIn(AuthComponent::class) -@Module( - subcomponents = [ - AppComponent::class, - ] -) -interface AuthModule @Singleton class AuthComponentRegistry @Inject constructor( private val authComponentBuilder: AuthComponent.Builder, ) : GeneratedComponentManager<AuthComponent> { ... override fun generatedComponent(): AuthComponent { return authComponent } - - fun getAppComponent(): AppComponent { - return EntryPoints.get( - this, - AuthComponentEntryPoint::class.java - ) - .appComponentBuilder() - .build() - } - - @EntryPoint - @InstallIn(AuthComponent::class) - interface AuthComponentEntryPoint { - fun appComponentBuilder(): AppComponent.Builder - } } @HiltAndroidApp class MyApplication : Application() { @Inject lateinit var authComponentRegistry: AuthComponentRegistry - fun getAppComponent(): AppComponent { - return authComponentRegistry.getAppComponent() - } - fun reset() {


これで Hilt への移行完了です!


0 件のコメント:

コメントを投稿