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 への移行完了です!


2024年11月14日木曜日

M3 の LinearProgressIndicator の progress は lambda になっているが、使い方に注意しないと recomposition が走ることがある

M2 の LinearProgressIndicator の progress 引数は Float でしたが、M3 では () -> Float になっています。 androidx.compose.material.LinearProgressIndicator( progress = 0.5f, ) androidx.compose.material3.LinearProgressIndicator( progress = { 0.5f }, ) いずれも内部の実装は Canvas composable を使って描画しています。
そのため、progress を lambda にすることで Composition と Layout phase をスキップして Drawing phase だけやり直せばよくなり、その分パフォーマンスが良くなります。
https://developer.android.com/develop/ui/compose/phases

実際以下のコードを実行して Layout Inspector で recomposition の回数を見ると、M2 の方は recompositoin されていますが M3 の方は skip されています。 Column( verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxSize().padding(16.dp), ) { var progress by remember { mutableFloatStateOf(0f) } androidx.compose.material3.Button( onClick = { progress = Random.nextFloat() }, ) { Text("update progress") } androidx.compose.material.LinearProgressIndicator( progress = progress, modifier = Modifier.fillMaxWidth(), ) androidx.compose.material3.LinearProgressIndicator( progress = { progress }, modifier = Modifier.fillMaxWidth(), ) }



M3 の LinearProgressIndicator を wrap するときは、wrap する component でも progress を lambda で取るように注意してください(より正確に言うと、lamda の中で state から読み出しを行うようにするということ)。そうしないと M3 の LinearProgressIndicator を使っていても recompose が走ります。 Column( verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxSize().padding(16.dp), ) { var progress by remember { mutableFloatStateOf(0f) } androidx.compose.material3.Button( onClick = { progress = Random.nextFloat() }, ) { Text("update progress") } LinearProgressIndicatorM2(progress) LinearProgressIndicatorM3_Bad(progress) LinearProgressIndicatorM3_Good({ progress }) } @Composable private fun LinearProgressIndicatorM2(progress: Float) { androidx.compose.material.LinearProgressIndicator( progress = progress, modifier = Modifier.fillMaxWidth(), ) } @Composable private fun LinearProgressIndicatorM3_Bad(progress: Float) { androidx.compose.material3.LinearProgressIndicator( progress = { progress }, modifier = Modifier.fillMaxWidth(), ) } @Composable private fun LinearProgressIndicatorM3_Good(progress: () -> Float) { androidx.compose.material3.LinearProgressIndicator( progress = progress, modifier = Modifier.fillMaxWidth(), ) }



そうは言っても、階層のどこかで progress が読み出されていることもあるでしょう(Text Composable で progress の値を表示しているとか)。その場合は rememberUpdatedState を使うことで LinearProgressIndicator の recomposition を skip させることができます。 @Composable private fun Wrap(progress: Float, onUpdate: () -> Unit) { val updatedProgress by rememberUpdatedState(progress) Column( verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxSize().padding(16.dp), ) { ... LinearProgressIndicatorM3_Good({ updatedProgress }) } }

2024年11月9日土曜日

LazyRow で snap させる

SnapLayoutInfoProvider での snap 位置の指定方法が変わっていた。

左端に snap する場合、以前は SnapLayoutInfoProvider( lazyListState = state, positionInLayout = { layoutSize, itemSize, beforeContentPadding, afterContentPadding, _-> 0 // 左端 }, ) だったが、SnapPosition という interface が用意され、 左端の場合は用意されている SnapPosition.Start を指定すれば良くなった。 Start の他に End と Center も用意されている。
任意の位置に snap したい場合は SnapPosition の実装を用意すればよい。 SnapLayoutInfoProvider( lazyListState = state, snapPosition = SnapPosition.Start, ) 全体のコードはこんな感じ。 @Composable fun LazyRowSnapSample() { val state = rememberLazyListState() val snappingLayout = remember(state) { SnapLayoutInfoProvider( lazyListState = state, snapPosition = SnapPosition.Start, ) } val flingBehavior = rememberSnapFlingBehavior(snappingLayout) LazyRow( modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically, state = state, flingBehavior = flingBehavior, ) { items(200) { Box( contentAlignment = Alignment.Center, modifier = Modifier .height(400.dp) .width(300.dp) .padding(8.dp) .background(Color.LightGray), ) { Text(text = it.toString(), fontSize = 32.sp) } } } }