StrongSkippingの罠
最近ではStrongSkippingがデフォルトで有効になり、Composeでstabilityを意識する機会は減ったように思えますが、それでもなお見落としがちなパターンに気づいたので紹介します。
Stabilityのおさらい(このセクションはSkippableです)
例えば、次のようなプリミティブ型で構成されたデータクラスUserがあったとき、
valを使ったデータクラスはstableとみなし、varを使ったクラスではunstableとみなします。
// stable
data class User(val name: String, val age: Int)
// unstable
data class User(var name: String, var age: Int)
コンポーザブル関数では、パラメータがstableであった場合、関数をskippableとしてマークします。
@Composable
fun UserCard(user: User) { /* 略 */ } // これはskippable
skippableな関数では、引数の値が変わらない場合に再コンポーズをスキップすることができます。
つまり、関数がなるべくskippableになっていると、パフォーマンス上嬉しい、ということになります。
さて、次の場合はどうでしょうか。
@Composable
fun UserList(userNames: List<String>) { /* 略 */ } // これはskippableではない
Composeコンパイラは Listのようなコレクションをunstableとみなします。
Listの実態はMutableList かもしれないので、内部状態が変わりうるクラスをstableと扱うべきではない、としているのでしょう。これはKotlin的には正しそうですが、現実的にはコンポーザブル関数にListを使うことは多々あるでしょうし、それらが全てunstableになってしまうのは不便に思えます。
こうした背景もあってか、Kotlin 2.0.20 からはStrongSkippingがデフォルトでtrueになりました。
StrongSkippingが有効な場合、ComposeコンパイラはListのようなunstableなクラスが存在してもskippableとして処理します。
結果、多くのケースでskippableとして処理できるようになりましたが、StrongSkippingは全てを解決するわけではありません。
実際にあった例
例えば、以下のようなViewModelとRepositoryがあったとします。
class SomeViewModel(userRepository: UserRepository) : ViewModel() {
val uiState: StateFlow<SomeUiState> = combine(
userRepository.getUsers(),
userRepository.getSomeData(),
) { users, someData ->
SomeUiState(
userNames = users.map { it.name },
someData = someData,
)
}.stateIn(/* 略 */)
}
class UserRepository {
fun getUsers(): Flow<List<User>> = flow {
delay(500)
emit(listOf(User("Alice"), User("Bob"), User("Charlie")))
}
fun getSomeData(): Flow<String> = flow {
var i = 0
while (true) {
delay(500)
emit("Some data $i")
i++
}
}
}
data class User(
val name: String,
/* 略 */
)
一度きりの取得 getUsers() と、何度もemitとする getSomeData() を実行し、それらを単一のuiStateにまとめています。
これに対して以下のようなUIを書いたとします。
@Composable
fun StrongSkippingExample(viewModel: SomeViewModel, modifier: Modifier = Modifier) {
val uiState by viewModel.uiState.collectAsState()
Column(modifier) {
UserList(uiState.userNames, Modifier.fillMaxWidth())
Text("Some data: ${uiState.someData}", modifier.padding(16.dp))
}
}
@Composable
fun UserList(userNames: List<String>, modifier: Modifier = Modifier) {
Column(modifier) {
userNames.forEach { userName ->
Text(text = userName, modifier = Modifier.padding(16.dp))
}
}
}
これをレイアウトインスペクタで確認すると、UserListの内容は変化していないにも関わらず、何度も再コンポーズされていました。

原因は何か
UserListにはパラメータuserNames: List<String>がありますが、StrongSkippingによりUserListはskippableになります。ここは問題ありません。
問題はuiStateを生成するときに.map{}により、リストのインスタンスを作り直している点です。
SomeUiState(
userNames = users.map { it.name },
someData = someData,
)
冒頭で、skippableな関数では引数の値が変わらない場合に再コンポーズをスキップすることができる、と述べました。
この「引数の値が変わらない」のチェック方法がポイントで、具体的には次のように処理されます。
- unstableなパラメータの場合、インスタンスの比較(
===)を行う - stableなパラメータの場合、オブジェクトの構造を見て比較を行う(
Object.equals())
UserListはunstableなパラメータを持っていて、実際に渡す値もusers.map { it.name }で毎回インスタンスを作り直しています。
よって、引数の中身が変わっているとみなされ、skipできない(再コンポーズされる)、という状況に陥っていました。
対処
インスタンスを作り直すことが問題なので、キャッシュすれば問題を回避できます。
今回の例で言えば、mapしたあとのものをemitすればいいでしょう。
val uiState: StateFlow<SomeUiState> = combine(
+ userRepository.getUsers().map { users -> users.map { it.name } },
- userRepository.getUsers(),
userRepository.getSomeData(),
) { usersNames, someData ->
SomeUiState(
+ userNames = userNames,
- userNames = users.map { it.name },
someData = someData,
)
}.stateIn(/* 略 */)
}
そもそも、修正する必要はあるのか
公式でも言及していますが、全てのComposableがskippableである必要はありません。
今回の例では作為的にuserRepository.getSomeData()で繰り返しemitしましたが、実際には何度もemitするRepositoryの実装はあまり多くないかもしれません。
仮にあったとしても、パフォーマンスに深刻な影響を及ぼすのはそのうちの何割でしょうか?また、全く影響が出ない些細なパフォーマンスを追求するあまり、コードの可読性が失われてしまっては本末転倒です。
そう考えると過剰に対策するのではなく、知識として頭の片隅に置いておく程度が良いのかもしれません。
Discussion