アプリ開発備忘録

PlayStationMobile、Android、UWPの開発備忘録

【差分計算の仕組み】@Composableの引数にListを取ると差分計算がされない。 【Stable, Immutable】

このような動的に変更される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)
    }
}

おわりに

もっと良い知見があれば是非教えて下さい。