🛼

StrongSkippingの罠

に公開

最近ではStrongSkippingがデフォルトで有効になり、Composeでstabilityを意識する機会は減ったように思えますが、それでもなお見落としがちなパターンに気づいたので紹介します。

Stabilityのおさらい(このセクションはSkippableです)

https://developer.android.com/develop/ui/compose/performance/stability?hl=ja

例えば、次のようなプリミティブ型で構成されたデータクラス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として処理します。

https://developer.android.com/develop/ui/compose/performance/stability/strongskipping?hl=ja

結果、多くのケースで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()

https://developer.android.com/develop/ui/compose/performance/stability/strongskipping?hl=ja#when-skip

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である必要はありません。

https://developer.android.com/develop/ui/compose/performance/stability/fix?hl=ja#not-composable

今回の例では作為的にuserRepository.getSomeData()で繰り返しemitしましたが、実際には何度もemitするRepositoryの実装はあまり多くないかもしれません。
仮にあったとしても、パフォーマンスに深刻な影響を及ぼすのはそのうちの何割でしょうか?また、全く影響が出ない些細なパフォーマンスを追求するあまり、コードの可読性が失われてしまっては本末転倒です。

そう考えると過剰に対策するのではなく、知識として頭の片隅に置いておく程度が良いのかもしれません。

NOT A HOTEL

Discussion