このような動的に変更されるtimeがあります。
class TagViewModel(private val coroutineScope: CoroutineScope) { val time: MutableStateFlow<String> = MutableStateFlow("") init { coroutineScope.launch(Dispatchers.Default) { while (isActive) { time.value = System.currentTimeMillis().toString() delay(100) } } } }
問題のコード
時間を表示する以外にListを引数に取るComposableがあります。
@Composable fun Root( viewModel: TagViewModel ) { val tags = remember { MutableStateFlow((0 until 10).map { it.toString() }) } Row() { TagSection("title", tags.value) Text(text = viewModel.time.collectAsState().value) } } @Composable fun TagSection( title: String, tags: List<String>, ) { Column { Log.d("LOG", "TagSection Column") Box() { Log.d("LOG", "TagSection Box") Text(text = title) } Row { Log.d("LOG", "TagSection Row") tags.forEach { Text(text = it) } Spacer(modifier = Modifier.width(5.dp)) } } }
これだと無限に以下が呼ばれてしまいます(再コンポーズ)。簡単なものだとまだ良いのですが、パフォーマンスに影響が出てしまいます。
TagSection Column TagSection Box TagSection Row
原因の切り分け
Listが原因なのですが、試しに引数をStringに変えてみると呼ばれない事が確認できます。
@Composable fun Root( viewModel: TagViewModel ) { val tags = remember { MutableStateFlow((0 until 10).map { it.toString() }) } Row() { TagSection("title", tags.value.joinToString(" ") { it }) Text(text = viewModel.time.collectAsState().value) } } @Composable fun TagSection( title: String, tags: String, ) { Column { Log.d("LOG", "TagSection Column") Box() { Log.d("LOG", "TagSection Box") Text(text = title) } Row { Log.d("LOG", "TagSection Row") Text(text = tags) Spacer(modifier = Modifier.width(5.dp)) } } }
原因
ComposeにはStable(とImmutable)という概念があります。
https://developer.android.com/reference/kotlin/androidx/compose/runtime/Stable
https://developer.android.com/jetpack/compose/lifecycle#skipping
https://github.com/androidx/androidx/blob/androidx-main/compose/docs/compose-api-guidelines.md#stable-types
入力が変更されていない限りは再コンポーズは走るべきではありません。これを実現しているのがStableとImmutableです。
ラムダと文字列とInt、Float等のすべてのプリミティブ型はStableです。
引数が全てこれらを取る場合、変更が無ければ再コンポーズは走りません。
その他の型は推論されてStableかが決定されます。
interfaceはStableにならない為、明示的にStableにしてあげる必要があります(後述)
Stableでも再コンポーズを走らせたい(=表示されているものを変えたい)場合はMutableStateを使用します。MutableState.valueはCompositionにレイアウトを変更する事を通知してくれます。
// ex @Composable fun CustomTitle(title: String) { } @Composable fun Root() { val title = remember { mutableStateOf("1") } CustomTitle(title.value) Button(onClick = { title.value = "2" }) { } }
解決
ListはInterfaceであり、ComposeはStableとして認識しません。なのでStableにしあげます。
型に @Immutable
か @Stable
を付けるだけですが、注意があります。
- equalsで比較できなければいけません。同じインスタンスの場合はtrueを返します。
でないと差分更新できませんからね。 - publicな値は全てStableでないといけません。
- タイプのパブリックプロパティが変更された場合、Compositionに通知されます。
こちらはStableの場合はMutableState等を通じて変更を通知すれば良いです。Immutableはそれでの変更も許されていません。
ListはMutableListの実装もあり、この値をラップするだけではStableの条件を満たせません。なので自前でImmutableなListを作成してあげます。
なお、 StableなのにStableの条件を破った場合の動作は未定義です。
@Immutable class ImmutableList<E>(list: List<E>) : List<E> by list.toList() { companion object { fun <T : List<E>, E> T.toImmutableList() = ImmutableList(this) } }
これを使用して適切に再コンポーズを行うことができます。
@Composable fun Root( viewModel: TagViewModel ) { val tags = remember { MutableStateFlow((0 until 10).map { it.toString() }.toImmutableList()) } Row() { TagSection("title", tags.value) Text(text = viewModel.time.collectAsState().value) } }
おわりに
もっと良い知見があれば是非教えて下さい。