Jetpack Composeプロジェクトのパフォーマンス改善施策を9つ語ってみた

※ChatGPTで生成

はじめに

エクストーン Androidエンジニアの市橋です。 今回はJetpack Composeを使ったプロジェクトにおけるパフォーマンス改善について整理したいと思います。

パフォーマンスはUX(ユーザー体験)に直結する重要な要素です。例えば、アプリの起動が遅かったり、スクロールしてカクついたりすると、ユーザーはアプリに対する満足度が低下し、最悪の場合はアプリを使用しなくなってしまいます。

逆に、スムーズで高速な動作を実現することで、ユーザーの満足度を高め、アプリの評価を向上させることができます。そのため、Androidエンジニアにとってパフォーマンス改善は非常に重要な課題です。

弊社の案件で実際に行ったことをベースに、Composeのパフォーマンスに直接関わる部分と、アプリ全体の大きく2つに分けて書いていきます。

パフォーマンス改善で大切な考え方

具体的な施策に入る前に、弊社の案件でパフォーマンス改善において私が意識していたことを紹介します。

ボトルネックの特定

パフォーマンスを改善するにあたり、ボトルネックの特定が重要だと考えています。問題の発生箇所を正確に特定することで、効率的にパフォーマンス改善を進めることができます。

時間との勝負の中で、一から全てパフォーマンス改善をやっていては非効率になりかねません。なので、ボトルネックになっている箇所を優先して取り組むことが大切です。

データ駆動型アプローチ

感覚や推測ではなく、具体的なデータに基づいてパフォーマンス問題を取り組むことが重要だと思ってます。公式が発表するデータ、もしくは自ら収集したデータに基づいて意思決定を行うことで、ある程度根拠のある対策を講じることができます。

実際に行ったパフォーマンス改善の施策

弊社プロジェクトでは沢山の施策に取り組んできましたが、今回は以下の9つに絞って書いていきたいと思います。

Compose

Composeのバージョンを上げる

Jetpack Composeの新しいバージョンにはパフォーマンスの改善が含まれることがあります。

よって、バージョンによっては開発者がただ更新することでのみ、Composeのパフォーマンス向上が自然に期待できます。

執筆時点で最新安定版の1.6系でも、公式で共有してくれています↓

android-developers.googleblog.com

2023年8月のリリース時と比較して、スクロール性能がに更に約20%、起動時間が約12%改善されたとのことです。

プロジェクト次第ではありますが、弊社でも動作確認はしつつ、積極的に最新バージョンへの更新は検討するようにしています。

Layout InspectorでComposeのボトルネックを特定する

Composeでは、UI更新の際に必要な時以外は再構成せずに、スキップさせることがパフォーマンス向上の肝になります。

Android Studioに組み込まれているLayout Inspectorを使用すれば、Composableの再構成の数とスキップの数をカウントしてくれます。

不必要な場面でもスキップされず、頻繁に再構成されるComposableは、後述するrememberderivedStateOfを使用して最適化していきます。

弊社でもLayout Inspectorを使用したことで、スクロールと関係ないある箇所で1スクロールにつき、20回以上も再構成されることが判明しました。

前述した「ボトルネックの特定」や「データ駆動型アプローチ」の考え方に即して、頻繁に再構成されるComposableから優先して取り組むことが大切だと思ってます。

rememberやderivedStateOfを使用する

Composeでは必要な時以外は再構成の回数をいかに減らすかがパフォーマンス向上に繋がることは、前の項目「Layout InspectorでComposeのボトルネックを特定する」で触れました。

そこで、rememberderivedStateOfを使用することで、不要な再構成を避け、必要な時だけ状態を再計算するようにします。

rememberの使用

rememberは、Composable関数内で計算された値を記憶するために使用されます。これにより、再コンポーズが発生しても再計算を避けることができます。

@Composable
fun MyComponent(data: List<String>) {
    val filteredData = remember(data) { data.filter { it.isNotEmpty() } }

    Column {
        filteredData.forEach { item ->
            Text(text = item)
        }
    }
}

上記の例ではrememberを使うことで、dataの変更がない限りはfilteredDataの計算を再度行わないようにしています。

derivedStateOfの使用

derivedStateOfは、他の状態に依存する派生状態を作成し、依存する状態が変更されたときにのみ再計算されるようにします。これにより、関連する状態が変わった場合にのみ派生状態が更新されます。

@Composable
fun TextLengthCounter(inputText: String) {
    // inputTextが変更されたときにのみtextLengthが再計算されます
    val textLength by remember { derivedStateOf { inputText.length } }

    Text(text = "Text length: $textLength")
}

上記の例では、derivedStateOfを使うことで、inputText(依存する状態)が変更されたときにのみtextLength(派生状態)が再計算されます。

これらは公式でも技術記事でも定番の施策なので、積極的に取り組みたいところです。

状態の読み取りを延期させる

Composeでは、状態の読み取りを適切なフェーズまで延期させることでパフォーマンスを改善できます。これにより、無駄なフェーズでの再実行を避けられます。

Composeでは基本的に、以下の状態のフェーズを順に経てUIが表示されます。

  1. Compositionフェーズ: 表示するUI要素の構造を決定
  2. Layoutフェーズ: UI要素の配置方法を決定
  3. Drawフェーズ: UI要素の描画方法を決定

例えば、以下のコードの実装ではBoxをタップする度に背景色が赤⇄青にアニメーションします。

var isSelected by remember { mutableStateOf(false) }
val color by animateColorAsState(if (isSelected) Color.Red else Color.Blue)

Box(
    Modifier
        .fillMaxSize()
        .background(color)
        .clickable { isSelected = !isSelected }
)

この場合はあくまで背景色を変えたいだけなので、Compositionフェーズ(表示するUIは決まっている)とLayoutフェーズ(UI要素の配置も決まっている)が再実行されてしまうのはパフォーマンスには避けたいです。

そこで次のように最適化します。

var isSelected by remember { mutableStateOf(false) }
val color by animateColorAsState(if (isSelected) Color.Red else Color.Blue)

Box(
    Modifier
        .fillMaxSize()
        .clickable { isSelected = !isSelected }
        .drawBehind {
            drawRect(color)
        }
)

Modifier関数のラムダ版にはあるフェーズまでスキップしてくれるものが存在します。その中でもdrawBehind{}はDrawフェーズまで遅延してくれます。これにより、無駄なCompositionフェーズやLayoutフェーズの再実行が避けられます。

他にもoffset{}graphicsLayer{}は特定のフェーズまでスキップします。

BoxWithConstraintsを置き換える

BoxWithConstraintsのレイアウトコンポーネントは便利ですが、実は通常のレイアウトと比べてオーバーヘッドが増えるので、パフォーマンスに影響を与えます。

例えば、以下のコードはBoxWithConstraintsを使用したレイアウトの実装です。

@Composable
fun ExampleWithBoxWithConstraints() {
    BoxWithConstraints {
        if (maxWidth > 600.dp) {
            Text("Large screen")
        } else {
            Text("Small screen")
        }
    }
}

こちらをLayoutを使用したカスタムレイアウトで置き換えることができます。

@Composable
fun ExampleWithCustomLayout() {
    Layout(content = {
        Text("Large screen", Modifier.layoutId("large"))
        Text("Small screen", Modifier.layoutId("small"))
    }) { measurables, constraints ->
        val largeMeasurable = measurables.first { it.layoutId == "large" }
        val smallMeasurable = measurables.first { it.layoutId == "small" }

        val largePlaceable = largeMeasurable.measure(constraints)
        val smallPlaceable = smallMeasurable.measure(constraints)
        
        layout(constraints.maxWidth, constraints.maxHeight) {
            if (constraints.maxWidth > 600.dp.toPx().toInt()) {
                largePlaceable.placeRelative(0, 0)
            } else {
                smallPlaceable.placeRelative(0, 0)
            }
        }
    }
}

必要に応じてBoxWithConstraintsのコンポーネントをカスタムレイアウト等に置き換えることで、パフォーマンスの改善に役立てることができます。

Layoutを使用したカスタムレイアウトにより、割と複雑なUIも組み立てられる印象です。しかし、慣れるまでに時間を要したり、コードの可読性が下がったりと、デメリットもあるので、状況に応じて使い分けるのが良さそうです。

Strong Skip Modeを試す

Compose 1.7.0のstable版ではおそらく、Strong Skip Mode(強力なスキップモード)がデフォルトで有効になると公式が発表しています。

medium.com

Strong Skip Modeが有効になることで、不安定なパラメータを持つComposableがスキップ可能になったり、不安定なキャプチャを持つラムダが記憶されたりします。

今までは不安定なComposableやラムダは不必要な時でも再実行されてしまい、パフォーマンスに影響を与えていたので、自ら手動で安定したComposableやラムダに最適化することで、パフォーマンス改善をしていました。

しかし、このStrong Skip Modeがデフォルトで有効になることで、自ら手動でパフォーマンスを改善する必要が減りました。弊社のチームでも細かな動作確認をしつつ、実験版で試している最中です。

一度試す価値はありそうです。

アプリ全体

Baseline Profiles生成

Baseline Profilesはアプリのパフォーマンスを最適化するための仕組みです。 アプリ内でよく使われる重要なクラスやメソッドを一覧化した情報(プロファイル)を事前に収集し、これを基にアプリのビルド時に事前コンパイルを行います。その結果、最適化したパスのみを通るので、パフォーマンスが改善できるという仕組みです。

このBaseline Profilesを導入することにより、アプリの起動や画面遷移、スクロールなどユーザー操作に対するパフォーマンスの改善が期待できます。

具体的な手順は省略しますが、Baseline Profilesを生成するジェネレータクラスでテストを実行し、成功することで、baseline-prof.txt(プロファイルの実態)が適切なディレクトリ位置に自動生成されます。

@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {

    @get:Rule
    val baselineProfileRule = BaselineProfileRule()

    @Test
    fun generateBaselineProfile() {
        baselineProfileRule.collectBaselineProfile("com.example.myapp") {
            
            pressHome()
            startActivityAndWait()
            // 他の重要な操作を追加
        }
    }
}

実際導入してみて、ジェネレータのテストに記述するユーザー操作の定義は優先順位を決めて行うのが良いと思います。baseline-prof.txtの容量制限があったり、テスト時間もその分長くなってしまうためです。

また、Google Play Storeからインストールしてきたアプリに対してのみBaseline Profilesの効力が適用されます。なので、Baseline Profilesを適用した模擬環境でテストの計測ができるMacrobenchmarkを使って改善されているかを見ます。

下記はアプリ起動に関して弊社で行った計測結果です。 上はBaseline Profiles適用時、下は未適用時のものです。

数値が小さいほど、起動時間も短くなることを意味するので、パフォーマンスが若干向上していることが読み取れると思います。

そして、弊社チームではプロジェクトの特性上行いませんでしたが、頻繁に改修リリースのあるプロジェクトの場合はCIと連携してジェネレータのテスト自体も自動化するのが良さそうです。

Firebase Performance Monitoringによる計測

Firebaseが提供するPerformance Monitoringは、アプリのパフォーマンスをリアルタイムで自動計測し、分析するツールです。これにより、パフォーマンスのボトルネックを特定し、改善点を見つけることができます。

アプリの起動時間や画面ごとのレンダリング状況をアプリのバージョンやOSのAPIレベル、端末ごとに細かく測定・トレースしてくれるのが個人的に便利だと感じました。

また、以下のようにカスタムトレースコードを追加することで、特定のコードの処理速度も計測できます。

val trace = FirebasePerformance.getInstance().newTrace("custom_trace")
trace.start()
// 計測したい処理
trace.stop()

Performance Monitoring自体簡単に導入できるので、アプリ全体におけるパフォーマンスのボトルネックを特定するのにおすすめです。

Kotlinコードのパフォーマンス改善

上記で示したPerformance Monitoring等でボトルネックとなるコードを見つけた場合、処理速度を考慮したKotlinコードになっているかも原因の一つに入ります。

例えば、弊社のあるプロジェクトではコレクション操作が多く使用されており、シーケンス(Sequences)を使うことでパフォーマンス向上に貢献してます。

// コレクション操作の例
val list = listOf(1, 2, 3, 4, 5)
val processedList = list
    .map { it * 2 }
    .filter { it > 5 }

// シーケンスを使用した例
val sequence = list.asSequence()
val processedSequence = sequence
    .map { it * 2 }
    .filter { it > 5 }
    .toList()

上記の例では、シーケンスを使用することで中間リストの生成を避け、全体の処理を効率化しています。大規模なデータである場合やコレクション操作が連続して行われる場合ほど効果を発揮します。

しかし、開発時間も限られていることが多いため、Kotlinコードのパフォーマンス改善は変に深追いしすぎない方が良いかと思います。

おわりに

今回の記事では、Jetpack Composeを使ったプロジェクトにおけるパフォーマンス改善について紹介しました。

正直まだまだ施策はありますが、優先順位を付けてパフォーマンス改善に取り組むことが大切であると考えています。また、データを裏付けとしてPDCAを回していく地道な日々の取り組みこそ、ユーザーに分かる形の成果として徐々に現れるのではないでしょうか。

自分もまた弊社で適切なパフォーマンス改善を更にできるように粛々と励んでいきたいと思います。ありがとうございました。