アプリ開発備忘録

PlayStationMobile、Android、UWPの開発備忘録

【Jetpack Compose】CompositionのSkippableとLayoutフェーズでのパフォーマンス最適化

はじめに

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/