はじめに
Jetpack Composeは変更があった部分だけ、再計算(ReComposition) されます。どのようなときにReCompositionされて、どのようなときにCompositionがSkipされるのかを知っていると、最適なパフォーマンスを得られる手がかりになります。
Jetpack Composeでは以下の3つのフェーズでUIが表示されます。
表示する要素が一部だけしか変わっていない時に、いかに表示するUIを決定するReCompositionをSkipするか。表示するUIの内容は同じだが、Layoutの配置だけ変えたい時にReCompositionをいかに行わないかの観点で最適化していきます。
https://developer.android.com/jetpack/compose/phases
StableとSkippable
ReComposeされずにSkipされる条件
Composable関数の引数が全て「Stable」であり、equalsでの比較がtrueになればReComposeされずにSkipされます。この状態のComposable関数は Skippable Composable
と言います。
前置き
以下のComposeコンパイラの文献では、Composable関数が skippable
で無いのは、必ずしも悪い事では無いと記述されています。更に公式のドキュメントで紹介されている例でも、全てがskippableという訳ではありません。
しかし、どの程度気にすればいいかも明確ではないので、自分は基本的に全てskippableにしています。
https://github.com/androidx/androidx/blob/403b6d0032c289ed6e65a1c75f137c07aedd220f/compose/compiler/design/compiler-metrics.md
コンパイラ
Composable関数の引数が「Stable」なのかはComposeコンパイラによって決定され、その結果はコンパイラに引数を与える事で出力できます。
https://github.com/androidx/androidx/blob/403b6d0032c289ed6e65a1c75f137c07aedd220f/compose/compiler/design/compiler-metrics.md
TopLevelの build.gradle
に以下の引数を設定します。モジュールごとに結果が出力されます。
allprojects { gradle.projectsEvaluated { tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { kotlinOptions { def baseDir = "path/to/dir" def outPath = "${baseDir}${project.path.replace(":", "/")}" freeCompilerArgs += [ "-P", "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=${outPath}", "-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=${outPath}" ] } } } }
出力結果の読み方
*-module.json
まずは出力されたJsonを見ていきます。
{ "skippableComposables": 81, "restartableComposables": 86, "readonlyComposables": 0, "totalComposables": 86, "restartGroups": 86, "totalGroups": 129, "staticArguments": 129, "certainArguments": 52, "knownStableArguments": 961, "knownUnstableArguments": 20, "unknownStableArguments": 10, "totalArguments": 991, "markedStableClasses": 0, "inferredStableClasses": 16, "inferredUnstableClasses": 4, "inferredUncertainClasses": 1, "effectivelyStableClasses": 16, "totalClasses": 21, "memoizedLambdas": 68, "singletonLambdas": 32, "singletonComposableLambdas": 3, "composableLambdas": 12, "totalLambdas": 71 }
今回注目する部分だけ抜粋します。
86のComposableがあり、Restart可能なのが86で、Skip可能であるSkippableなComposableは81個です。
なので5つのスキップ不可なComposableがあることがわかります。
{ "skippableComposables": 81, "restartableComposables": 86, "totalComposables": 86 }
*-composables.txt
*-composables.txt
を抜粋して見ていきます。
skippableがついている関数とついていない関数があります。
ついていない方の引数にはunstableな引数があります。
restartable skippable fun Label( stable drawableRes: Int stable colorRes: Int stable contentDescription: String ) restartable fun ListItem( stable modifier: Modifier unstable uiState: ListItemUiState )
*-classes.txt
次にクラスの方のStabilityを一部抜粋して見ていきます。
先程unstableだった ListItemUiState
です。
Listがunstableなのでclassもunstableになっています。なお、Lambdaはstableです。
unstable class ListItemUiState{ stable val iconPath: String? stable val userName: String unstable val labels: List<String> stable val isDraft: Boolean stable val onClick: Function0<Unit> <runtime stability> = Unstable }
StableにしてSkip可能にする
TypeをStableにするには、 @Immutable
と @Stable
の2つのStableMarkerアノテーションを使用します。
2つには以下のような使い分けがあります。
- Immutable
- publicな値は変更されない
- MutableStateを通しても値が変更されない
- Stable
- publicな値は変更されない
- MutableStateを通して値が変更される
Listは変更される可能性があるため、変更の可能性が無いListを作成して、Immutableを付けます。
このようにして、クラスをStableにしていきます。
@Immutable public class ImmutableList<E>(list: List<E>) : List<E> by list.toList()
SkippableなComposableを作る
ここまででStableなクラスを作成する方法がわかったと思うので、ここからは自分が実際に使用しているCompose周りの設計の一部を紹介します。
データ
Composable関数に渡すViewのデータは、data classにして、比較周りはdata classに任せます。また、適宜 @Immutable
か @Stable
を付けています。クリックイベントも引数として持たせています。しかし、Lambdaを毎回作成していると、比較するときにfalseになってしまうので、常に比較がtrueになるラッパーを作成して使用しています。
@Immutable public data class NamesScreenUiState( public val names: ImmutableList<NameUiState>, public val onClickAddButton: ComposeEventWrapper<() -> Unit>, ) @Immutable public data class NameUiState( public val name: String, public val onClick: ComposeEventWrapper<() -> Unit>, ) @Stable public class ComposeEventWrapper<T>(public val event: T) { override fun hashCode(): Int { return hash } override fun equals(other: Any?): Boolean { return true } public companion object { private val hash = hashCode() } }
List周り
@Composable public fun NamesScreen( uiState: NamesScreenUiState, ) { Column(modifier = Modifier.fillMaxWidth()) { Button(onClick = { uiState.onClickAddButton.event() }) { Text(text = "Add") } LazyColumn(modifier = Modifier.fillMaxWidth()) { items(uiState.names) { item -> // Listアイテムの直下はすぐに関数にしてSkipされるようにしている Name( modifier = Modifier.fillMaxWidth(), uiState = item ) } } } } @Composable public fun Name( modifier: Modifier, uiState: NameUiState, ) { Column( modifier = modifier.clickable { uiState.onClick.event() } ) { Text(text = uiState.name) } }
参考までにFragment側のコードも置いておきます。
コードを展開して見る
val namesFlow = MutableStateFlow<List<String>>(listOf()) val namesScreenUiStateFlow = MutableStateFlow( NamesScreenUiState( names = ImmutableList(listOf()), onClickAddButton = ComposeEventWrapper { namesFlow.update { it.plus(it.size.toString()) } } ) ) lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { namesFlow.collect { names -> namesScreenUiStateFlow.update { it.copy( names = ImmutableList( names.map { name -> NameUiState( name = name, onClick = ComposeEventWrapper { Toast.makeText(requireContext(), name, Toast.LENGTH_SHORT).show() } ) } ) ) } } } } binding.composeView.setContent { NamesScreen( uiState = namesScreenUiStateFlow.collectAsState().value ) }
読み取りを遅延する
次に、Layoutだけ変更したい時にCompositionをSkipする方法です。
以下の内容に基づいています。
https://developer.android.com/jetpack/compose/performance
このようなComposableを作ったことがありました。
https://matsudamper.hatenablog.com/entry/2022/06/09/131151
改善前
scrollOffsetによってLayoutの配置位置を変えています。
しかし、これではscrollOffsetが変わるたびにReCompositionが実行されてしまいます。
@Composable public fun CollapsingLayout( modifier: Modifier = Modifier, scrollOffset: Int, content: @Composable () -> Unit ) { Layout( modifier = modifier.clip(RectangleShape), content = content ) { measurables, constraints -> if (measurables.size > 1) throw IllegalArgumentException("content item must have only.") val viewWidth = constraints.maxWidth val viewHeight = constraints.maxHeight val view = measurables[0].measure(constraints.copy(maxHeight = Int.MAX_VALUE)) val putY = (viewHeight - view.height) * 0.5 layout(viewWidth, viewHeight) { view.place( x = 0, y = ( min(0, putY.toInt()) + (scrollOffset * 0.5) ).toInt(), ) } } }
改善後
しかし、以下のように変更することで、scrollOffsetが変わっても引数は変更されず、ReCompositionが実行される事はなくなります。そして、値の参照はLayoutフェーズの必要な時だけに行われます。
@Composable public fun CollapsingLayout( modifier: Modifier = Modifier, scrollOffset: () -> Int, content: @Composable () -> Unit ) { /* ~~ */ }
公式例
Modifierのoffsetには、Lambdaバージョンとそうでないバージョンがあります。変化する値をoffsetに使用したい場合はLambdaの方を使うと良いです。
P.S.
この内容で登壇しました。
https://andpad.connpass.com/event/249842/