ツールとパフォーマンス

Compose でパフォーマンスを向上させる

スピーカー

概要

講演について

Jetpack Compose を使用すると、高品質のユーザー インターフェースを簡単に設計できますが、UI のパフォーマンスに影響する問題を追跡するのは困難な場合があります。このような問題は、起動レイテンシや、スクロールのジャンクやアニメーションの途切れとして現れるフレーム落ちなど、ユーザーが気づく問題につながる可能性があります。

この講演では、Perfetto と(再)コンポジション トレースを中心に、UI のパフォーマンスに影響を与えている要因を明らかにするツールを紹介します。次に、知らず知らずのうちにコストを発生させている可能性のある特定のアンチパターン、コード内でそれらを見つける方法、それらを置き換えることができるポジティブなパターンについて説明します。この講演を最後までご覧いただくと、パフォーマンスを意識した Compose の考え方と、アプリのパフォーマンス最適化をプロファイリング、分析、検証する方法を理解できます。

概要

  • Perfetto トレースは、デバイス上のアクティビティを記録し、メインスレッドに沿ってコンポジションやレイアウトなどの Compose スライスを表示します。これらを使用して、フレームのタイムラインを検査し、フェーズの所要時間を追跡します。
  • コルーチンの起動やキャンセルなど、コンポーザブル内のすべてのオペレーションにはコストがかかります。副作用 API を監査し、同じキーを持つエフェクトを統合してコンポジションのオーバーヘッドを削減することを検討してください。
  • Composition トレースはコンポーズ可能な関数にアノテーションを付け、再コンポーズ トレース(1.13 以降)は再コンポーズの原因となる状態の読み取りと書き込みを追跡します。Perfetto トレース内で予期しない作業を直接診断するのに役立ちます。
  • ComputedState(Compose 1.13 以降)は、不要な無効化を回避し、通常は書き込み後の読み取りの無効化中に DerivedState よりも高速になります。DerivedState は、コストの高い計算や割り当てがキャッシュからメリットを得る場合に引き続き役立ちます。
  • コンポーザブルをスキップすると、維持コストとパラメータ比較のオーバーヘッドが発生します。安定性を強制するのではなく、強力なスキップとオブジェクト ID の等価性に依存するか、無効化を完全に回避します。

スキル

リソース

Gemini Notebook

トピック

  • jetpack-compose
  • android-performance
  • perfetto
  • ベンチマーク
  • コルーチン
  • composition-tracing
  • android-cli

文字起こし

はじめに

  • こんにちは。

  • 皆さん、こんにちは。

  • 十分に休憩できましたか?

  • カフェインを摂取して、水分補給もして、準備は万端でしょうか。

  • 本日は、もう少し詳しく見ていきましょう。

  • はい、こちらです。

  • 今日は少し詳しく見ていきましょう。

  • Andrei Shikov と申します。本日は Andrew Bailey とともに登壇します。

  • 2 人とも Compose コンパイラ ランタイム チームに所属していますが、パフォーマンスなど、他の多くのことも担当しています。

  • 今日のトークのテーマは、パフォーマンスに関する質問に答えることです。Jetpack Compose は、Android デベロッパー向けのデフォルトの UI ツールキットとして確立されています。

  • そして今、多くの人が Compose UI のパフォーマンスを可能な限り高める方法を尋ねています。

  • そこで本日は、先ほど申し上げたとおり、詳細な調査を行います。

  • また、Compose でのパフォーマンスを向上させるためのツールもいくつか紹介します。

  • まず、パフォーマンスがなぜ重要なのかについてお話しします。

  • 品質や品質の高いアプリの構築について考えるとき、通常はアニメーションや豊富な機能セットを思い浮かべ、パフォーマンスは忘れられがちですが、ユーザーの認識はアプリのパフォーマンスに大きく左右されます。

  • アニメーションが途切れると、認識はすぐに低下します。

  • つまり、本日ご紹介するすべての指標と数値は、実際には、ユーザーがアプリ、アニメーション、画面上で起こっているすべてのことについてどのように感じているかを表すために選択したプロキシ指標です。

  • つまり、アプリの最適化とは、根本的にはエクスペリエンスを向上させるための最適化です。

  • これは、本日さまざまなトピックを深く掘り下げるにあたって、皆様に持っていただきたい心構えのようなものです。

  • アプリのパフォーマンスを確認するにあたって、まず 3 つの簡単なことを行ってください。

  • まず、パフォーマンスを測定する場合は、リリースモードで測定します。デバッグ可能な情報が少なく、アプリの速度が低下するためです。

  • 2 つ目は、R8 などの圧縮ツールを有効にすることです。

  • 今年、コルーチンのパフォーマンスを 2 倍に向上させる最適化を導入しました。他にもさまざまな取り組みを行っています。

  • Compose を最適化する際は、R8 に大きく依存しています。

  • 最後に、特に起動指標については、ベースライン プロファイルを使用していることを確認してください。

  • そのため、起動時に読み込むクラスは、アプリの起動時にプリコンパイルされ、プリロードされます。

  • ご覧のとおり、通常のデバッグ可能なアプリのビルドとは 6 倍ほどの差があります。

  • これらすべてを測定することで、コード内で行っている他の改善と相乗効果が生まれます。

  • これらすべてを行っても、アプリがまだ少し遅い場合や、パフォーマンスに他の問題がある場合は、このパフォーマンス ループに入ります。

  • 特定のシナリオでいくつかの指標を確認したいと考えています。

  • たとえば、アプリの読み込みに時間がかかりすぎるため、アプリをリセットして通常のランチャーから起動し、最初のデータ画面が表示されるまでの時間を測定するとします。

  • アプリが少しカクつく場合は、スクロールして、このスクロールが再現可能で安定していることを確認します。

  • このスライドの安定した環境は非常に重要です。後で最適化を行う際に、指標の改善を確認し、最適化が実際に意図したとおりに機能していることを確認できるからです。

  • ベンチマークは、すでに多くのデータをキャプチャしており、デバイスのノイズを抑えるために多くのイテレーションを行っているため、この目的に適しています。また、デバイスのセットアップも多く行っているため、役立ちます。

  • また、ベンチマークはデフォルトで繰り返し可能なシナリオでもあり、これがここで目指していることです。

  • しかし、必要に応じて、一連のスクリプトを使用して行うこともできます。

  • 最後に、プロファイリングを行い、アプリを改善し、データを確認して、このプロセスを繰り返します。アプリが苦戦しているシナリオ指標があれば、それについても繰り返します。

Perfetto トレースを使用したプロファイリング

  • データを確認する際に、本日ご紹介する最初のツールであり、ぜひ使いこなしていただきたいのが Perfetto トレースです。

  • Perfetto トレースは、トレースの記録中にデバイスで発生したすべてのことを記録します。たとえば、マクロベンチマークの実行中や、記録中のスクリプトなどです。

  • これは、Android のパフォーマンスに取り組んでいるすべてのエンジニアにとって大きな助けとなります。

  • 私の場合、最近は 1 日の仕事のほとんどを Perfetto トレースの確認に費やしています。

  • たとえば、次のようなものです。

  • これは大量のデータです。

  • 前述のとおり、デバイスで発生するすべての事象がキャプチャされます。

  • そのため、最初はアプリがどこにあるのかを把握するのに苦労するかもしれません。この便利な検索バーを使用することをおすすめします。

  • Compose を検索したり、アプリ パッケージを検索したりします。

  • 身近なものに意識を向けて。

  • アプリのプロセスを見つけて、フレーム時間のラインと、アプリのさまざまな処理にかかる時間を確認します。

  • そのうちの 1 つを分離すると、次のようになります。

  • 1 つ目はメインスレッドの処理です。

  • Compose の処理のほとんどはここで行われます。

  • 次に、RenderThread です。

  • ここでキャンバス コマンドを処理し、GPU にエンコードします。

  • 次に GPU が処理を行い、システムがそれを合成します。

  • 今回はメインスレッドのみに注目します。

  • これは、Compose がここで処理を行うためです。

  • Compose について説明しているので、Compose のみに焦点を当てるのが妥当です。

  • メインスレッドでその 1 つのフレームを分離すると、Compose スライスが表示されるようになります。

  • いくつか選んでみました。

  • コンポジション、測定、レイアウト、描画のようなものです。

  • Compose のフェーズについて聞いたことがある場合、以前にパフォーマンスを確認したことがある場合は、おそらくそれらを目にすることになるでしょう。

  • Perfetto トレースには個々のスライスがあり、各フェーズにかかる時間や、特定のフレームで実行されるフェーズを簡単に確認できます。

  • ここまで、この作業の一部を最適化することについて詳しく説明してきました。適切なフェーズで適切な作業を行うことが重要です。

  • たとえば、ボックスの色などを更新するアニメーションがある場合、無効化する必要があるのは描画フェーズのみで、コンポジションやレイアウトなどは無効化する必要がありません。

  • これはパフォーマンスの基本です。今回は、さらに詳しく掘り下げて、特にコンポジションに焦点を当てて説明します。

  • では、Andrew にバトンタッチして、その点について説明してもらいましょう。

  • ありがとう、Andrei。

  • Andrei が言ったように、以前はパフォーマンスのコンテキストで、最適化について多くの時間を費やしていました。たとえば、後方読み取りを回避すること、状態の読み取りをコンポジションのライフサイクルの適切なフェーズまで遅らせること、最適化すること、コンポーザブルが期待どおりに無効化されるようにして、すべてのフレームで過剰な処理を行わないようにすることなどです。

  • これは非常に良い出発点ですが、これに関する資料はすでにたくさんあります。私たちは、この会話を少し前に進めたいと考えています。

  • そこで、本日は、これらの最適化を行った後にコンポジションに残るものと、コンポーザブル自体が行う処理について説明します。

  • Compose コード内のすべての要素、そしてパフォーマンスに関する限り、アプリに搭載するすべての要素には、残念ながらコストが伴います。

  • アプリに組み込むコードは、どれも無料ではありません。状態の読み取りや、コンポジション内での副作用のようなものの作成など、些細なことでも、それに関連する具体的なコストが発生します。特に、コンポーザブルが実際に何を行っているかを分析し、アプリ自体のパフォーマンスを向上させようとする場合は、こうしたコストに注意することが重要です。

  • 最も微視的なスケールで実際にどのようなことが起こるかを示すために、コード内のどこでも、特にコンポーザブル内で実行できる最小のオペレーションをいくつか紹介します。この解像度では、これらのオペレーションにかかるおおよそのコストをナノ秒スケールで考えることになります。

  • パフォーマンスを向上させるには、2 つの方法があります。

  • 安価なオペレーションを大量に削除するか、高価なオペレーションを少量削除するかを選択できます。

  • このグラフでは、コルーチンはこれらのオペレーションの中でコストが高い方に属しています。これは、どこから調査を始めるべきかを示す良い例です。これらのインスタンスをいくつか削除すれば、アプリケーションでより具体的なメリットをより早く確認できるようになります。

  • コルーチンについて特に留意すべきもう 1 つの点は、スライドに記載されているように、コルーチンの起動だけでなく、コルーチンのキャンセルにもコストがかかるということです。

  • キャンセル費用は、コンテンツを破棄してアプリ内の他の領域に移動する可能性がある場合に主に発生するため、特に注意が必要です。

  • ナビゲーションは、多くのアプリケーションにおいて非常にコストのかかるケースです。フレーム時間を最大限に活用し、利用可能なリソースを最大限に活用する必要があります。

  • そのため、これらの費用の両方に注意することが重要です。

  • これらすべては、何を見ているかにかかわらず、約 1,600 万ナノ秒のスケールで処理されます。

  • この予算を少しずつ削っていく必要があります。コルーチンは、そのための良い例です。

副作用の管理

  • Compose では、コルーチンはエフェクト API で最もよく使用されます。

  • SideEffects で使用できる API には、高機能なものから低レイテンシのものまで、さまざまなものがあります。

  • このスペクトルの最も機能的な側には LaunchedEffect があります。これには、アニメーションなど、コルーチン スコープが必要な非同期作業に必要なものがすべて含まれています。

  • ただし、そのような機能が必要なく、より安価な API でタスクを完了できる場合は、ユースケースのニーズに合っていれば、代わりにその API の使用を検討する価値があるかもしれません。

  • 実際にどのように動作するかを示すために、ここではメディア プレーヤーのコンポーザブルを使用して、さまざまな SideEffect と、それらが表示されるタイミングを示します。

    @Composable
    fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
        val mediaPlayer = rememberMediaPlayer()
    
        // Asynchronous work and animations
        LaunchedEffect(contentSource) { mediaPlayer.load(contentSource) }
    }
  • LaunchedEffect から始めることができます。

  • この例では、メディア プレーヤー内でデータを非同期で読み込むために使用されています。

  • コルーチン スコープが必要な場合、またはコルーチン全般が必要な場合、または一時停止呼び出しを行う場合は、LaunchedEffect を使用します。

  • 依然としてコストは高めで、コルーチンに関連するコストも発生しますが、コルーチンを活用して正当化できる限り、ジョブに適した API を使用してください。

  • ただし、LaunchedEffect を使用しているものの、コルーチンを活用していない場合は、同じことをより低コストで実現できる他の API を検討することをおすすめします。

  • そのため、スペクトルの下位に移動すると、DisposableEffect などの副作用の例が確認できます。

    @Composable
    fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
        val activity = LocalActivity.current
    
        // Setup and teardown
        DisposableEffect(Unit) {
            activity.requestedOrientation = SCREEN_ORIENTATION_LANDSCAPE
            onDispose {
                activity.requestedOrientation = SCREEN_ORIENTATION_UNSPECIFIED
            }
        }
    }
  • DisposableEffect は、エフェクトに関連付けられたセットアップとティアダウンのプロセスがある場合に最適です。

  • 同じメディア プレーヤーの例で、コンポーズ可能な関数が存在するときにアクティビティの画面の向きを横向きにロックし、その後、コンポーズ可能な関数がなくなるとその画面の向きのロックを解除する例を示します。

  • LaunchedEffect と DisposableEffect の 1 回限りの効果が必要で、コルーチンは必要なく、破棄効果もない場合は、Compose 1.12 で導入された新しい SideEffect with keys API も使用できます。

    @Composable
    fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
        val mediaPlayer = rememberMediaPlayer()
    
        // One-shot events
        SideEffect(Unit) { mediaPlayer.play() }
    }
  • これは、他の 2 つの API の代替として最適です。利便性のために LaunchedEffect を使用している場合や、ワンショット効果のために DisposableEffect を使用している場合、キー API を使用して SideEffect に移動または移行すると、オーバーヘッドが少なくなり、パフォーマンスが向上します。特に、アプリ全体で複数回移行する場合は、同じことを実現できます。

  • 最後に、最も安価な API は、バニラの SideEffect API です。

    @Composable
    fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
        val mediaPlayer = rememberMediaPlayer()
    
        // Synchronize the outside world to the state of composition
        SideEffect { mediaPlayer.playbackRate = playbackRate }
    }
  • これは、コンポジションの外部の状態をコンポジション内の処理と同期する場合に最適です。

  • メディア プレーヤーの例に戻りましょう。この例では、コンポジションによって受信されるパラメータと再生状態を同期しています。

  • 1 つ注意すべき点として、副作用の使用状況を監査し、よりコストの低いものに移行する場合は、これまで説明した他の動作やユースケースに加えて、副作用による順序保証の違いにも注意する必要があります。

  • まず、すべての DisposableEffect が実行され、次に通常の SideEffect(キー付きかどうかを問わない)が実行されます。フレームが完了すると、ディスパッチが関与するため、すべての LaunchedEffect が実行されます。

  • 起動されたエフェクトでこのタイミングに依存している場合は、移行を検討する際にその点に注意してください。

  • また、移行の際は、コードをテストして、これらのタイミング保証に依存していないこと、移行後もコードが引き続き動作することを確認してください。

  • 副作用自体の複雑さを軽減できない場合は、副作用を統合することで、副作用の数を減らすこともできます。

    @Composable
    fun ShoppingCartScreen(
        viewModel: CartViewModel
    ) {
        LaunchedEffect(Unit) { viewModel.loadCart() }
        LaunchedEffect(Unit) { viewModel.loadPaymentMethods() }
        LaunchedEffect(Unit) { viewModel.loadDeliveryAddresses() }
    
        CartContent(...)
    }
  • これは、同じキーを使用する複数のエフェクトがある場合に特に考慮すべき点です。

  • これらは同時に実行できるため、統合を検討するのに適しています。

  • この例では、複数の LaunchedEffect があり、それぞれがビューモデルに対して非同期呼び出しを行っています。

  • これらを 1 つの LaunchedEffect に置き換え、その中で他の launch 呼び出しを行うと、実行されるコルーチンの数は同じでも、コンポジションからコルーチンに処理が移行されるため、オーバーヘッドが少なくなります。

    @Composable
    fun ShoppingCartScreen(
        viewModel: CartViewModel
    ) {
        LaunchedEffect(Unit) {
            launch { viewModel.loadCart() }
            launch { viewModel.loadPaymentMethods() }
            launch { viewModel.loadDeliveryAddresses() }
        }
    
        CartContent(...)
    }
  • そのため、このような状況では、可能な限りエフェクトの数を減らすために、移行を検討することをおすすめします。

  • エフェクトとコルーチンは、アプリケーション内で監査できる場所の一例にすぎません。コンポーズ可能な関数やアプリケーションはそれぞれ異なり、内部にさまざまな最適化案があります。

  • アプリケーションに関連するものを理解するには、アンドレイが次に紹介するツール、コンポジション トレースについて説明する必要があります。

Composition と再コンポーズのトレース

  • それでは

  • コンポジション トレースは、以前に別のスライドで見た Perfetto フレームを忠実に再現したもので、さまざまなフェーズのタイミング情報を提供しますが、何が起こっているかについての詳細はあまり提供しません。

  • 何を?

  • はい。コンポーズ可能な関数が実行されるコンポジションの内部で何が起こっているかです。

  • コンポジション トレースは、そのために役立ちます。

  • そこで、Compose コンパイラにいくつかのマーカーを導入しました。これは基本的にすべての関数にアノテーションを付け、Perfetto トレースに表示して、各関数の所要時間を確認できるようにするものです。

  • これを使用して、再コンポーズされている関数を正確に特定できます。

  • ただし、これには多少のオーバーヘッドが伴うため、最も正確なパフォーマンス データをキャプチャする場合は、通常の Perfetto を使用するか、Perfetto キャプチャを使用せずに実行して他の指標を使用することをおすすめします。

  • しかし、コンポジション トレースは、個々のコンポーザブルにかかる時間を把握したい場合に非常に役立ちます。

  • また、再コンポーズが発生する理由を把握したい場合もあります。これは、Compose Tracing ランタイム トレースの次のリリース(1.13)で解決しようとしている課題です。

  • 現在、再コンポーズ トレースが導入されています。これは、再コンポーズにつながったすべての状態の読み取りと状態の書き込みにアノテーションを付けるものです。

  • Perfetto トレースでは、各状態の書き込みと、書き込みと読み取りが発生したコールスタックが表示されるため、それらの状態がアクセスまたは変更されたコードの実際の行に対応付けることができます。

  • 詳細については、こちらのリンクをご覧ください。

  • コンポジション トレースと再コンポジション トレースの両方に関するすべてのドキュメントが用意されています。

  • では、アンドリューにバトンタッチして、これをより効率的に使用して状態の書き込みを回避する方法について説明してもらいます。

状態の最適化と無効化

  • ありがとう、Andrei。

  • この調査の結果から、コンポジションにアプリにとって有用ではなく、そもそも回避できたはずの無効化があることが判明する場合があります。

  • たとえば、最も一般的な例は、表示またはスクロールの進行状況に依存する「トップへ戻る」ボタンです。

    @Composable
    fun ScrollToTopButton(
        scrollState: ScrollState
    ) {
        val showScrollToTopButton = scrollState.value > 0
        val coroutineScope = rememberCoroutineScope()
    
        if (showScrollToTopButton) {
            FloatingActionButton(
                onClick = {
                    coroutineScope.launch { scrollState.animateScrollTo(0) }
                }
            ) {
                Icon(Icons.Default.ArrowUpward, "Scroll to Top")
            }
        }
    }
  • ここでは、コンポジション内でスクロール状態の値を読み取っているため、これは非常に頻繁に変化する状態です。1 フレームに 1 回変化する可能性があり、多くの再コンポーズが発生します。しかし、スクロール状態自体は変化していても、ここで考慮している実際の条件はそれほど頻繁に変化しないため、再コンポーズは回避できます。

  • これは、計算された状態などを使用することをおすすめする例です。これにより、アプリにとって重要でない状態変更の無効化を回避できます。

  • この例では、計算自体を計算された状態内にラップしています。この状態を読み取ると、スクロール位置自体に基づいて無効化するのではなく、条件全体に基づいてこのコンポーザブルを条件付きで無効化し、他の状態自体から派生させます。

    @Composable
    fun ScrollToTopButton(
        scrollState: ScrollState
    ) {
        val showScrollToTopButton by remember { computedStateOf { scrollState.value > 0 } }
        val coroutineScope = rememberCoroutineScope()
    
        if (showScrollToTopButton) {
            FloatingActionButton(
                onClick = {
                    coroutineScope.launch { scrollState.animateScrollTo(0) }
                }
            ) {
                Icon(Icons.Default.ArrowUpward, "Scroll to Top")
            }
        }
    }
  • このパターンを以前に見たことがある場合は、代わりに派生状態で使用されているのを見たことがあるかもしれません。

  • ComputedState は Compose 1.13 で新しく導入されたものです。ComputedState と DerivedState はどちらも、計算が変更されたときに条件付きで無効化するという同様の目的を持っていますが、その方法には非常に微妙な違いがあります。

  • Compose の初期から提供されている DerivedState は、計算した値をキャッシュに保存し、依存関係が変更されるまで再利用します。

  • ComputedState を使用すると、読み取るたびに値が計算されます。

  • キャッシュ保存は便利そうに聞こえますが、実際には、多くの状況で、キャッシュ保存は結果を読み取るよりもコストがかかります。

  • その仕組みを理解するために、これらの状態がアプリケーション内でどのように使用されるかの例を見て、それぞれが実際に何を行っているかを詳しく見ていきましょう。

  • ここでは、[トップへスクロール] ボタンで行っていた処理をほぼそのまま反映した同じ状態があり、他の状態に基づく条件が設定されています。また、DerivedState と ComputedState の両方として設定されています。

    var a by mutableStateOf(0)
    
    val derivedState = derivedStateOf { a > 0 }
    val computedState = computedStateOf { a > 0 }
    
    // First read
    derivedState.value
    computedState.value
    
    // Second read
    derivedState.value
    computedState.value
  • また、状態を 2 回読み取っており、どちらの場合も同じ値が返されますが、内部で行われている処理は 2 つの間で異なります。

  • Perfetto などで実際に表示されるトレースを簡略化するために、これら 2 つの状態を読み取ったときに実際に何が起こるかを調べてみましょう。

  • ここでは、まず computedState.value を呼び出します。

  • 計算ラムダが実行され、状態の値が読み取られます。また、読み取りが行われたことを通知するための、ちょっとした管理作業も行われます。

  • これは、Compose のすべてのスナップショット状態に当てはまります。

  • 2 回目の読み取りは 1 回目の読み取りとまったく同じように動作し、パフォーマンス特性も同じです。

  • 一方、DerivedState の最初の読み取りでは、同様の処理が行われます。計算ラムダを呼び出し、他の通知を行う必要がありますが、キャッシュ管理のオーバーヘッドが少し発生します。これは、チェックするキャッシュがないため、最初の段階で少しショートカットされます。

  • そのため、キャッシュ自体のメンテナンスが少し必要になりますが、わずかに時間がかかるだけで同じことを実現できます。

  • しかし、2 回目の読み取りでは、DerivedState が ComputedState に勝っています。どちらもかなり単純な状態であるにもかかわらずです。

  • 前のスライドで説明したように、スナップショットの読み取りコストは、場合によっては無視できないものになります。実際、キャッシュからの読み取りは、読み取り自体を行うよりも高速です。そのため、DerivedState の 2 回目の読み取り自体は、わずかに高速になっています。

  • しかし、実際に興味深いシナリオは、状態を無効にする必要がある場合に発生します。

  • これは、書き込み後の読み取りシナリオと呼ばれるもので、計算で使用されている状態の 1 つを更新すると、計算を再度実行する必要があります。

  • コードでは、a を変更(インクリメントなど)してから、値を再度読み取るか、再コンポーズします。

    var a by mutableStateOf(0)
    
    val derivedState = derivedStateOf { a > 0 }
    val computedState = computedStateOf { a > 0 }
    
    ...
    
    // Read-after-write
    a++
    derivedState.value
    computedState.value
  • 実際の図でこれがどのように見えるかは、ComputedState の場合とまったく同じです。

  • 管理するキャッシュがないため、1 回目、2 回目、3 回目など、どの読み取りでもまったく同じように見えます。

  • しかし、DerivedState にはやるべきことがたくさんあります。

  • キャッシュが無効であることを確認するために、非常にコストの高いパスを通過してから、更新して計算自体を呼び出します。その後、依存関係のトラッキングを行い、多くの依存関係を管理して、キャッシュ値を書き戻し、これらの値がすべて実際に変更されたことを理解し、維持管理自体を行う必要があります。

  • これらすべてが積み重なり、DerivedState の無効化にかかるコストは、ComputedState と比較して非常に高くなります。

  • これらの主張を数値で表すと、ComputedState は通常、最初の読み取りで約 200 ナノ秒高速です。

  • ComputedState は、特定のベンチマーク条件下で、Google Pixel 6 で約 350 ナノ秒遅くなります。

  • そのため、2 回目の読み取りでは、ComputedState は DerivedState に比べてわずかに劣ります。

  • しかし、実際に重要なケース、つまり無効化を行うケースや、フレームで実際に作業を行うケースでは、ComputedState の方が DerivedState よりも 2 倍以上高速です。

  • そのため、状態を何に使用するかによっては、ComputedState が非常に優れた最初の選択肢になる可能性があります。

  • 一般的に、DerivedState と ComputedState を使用する場合でも、決定を下す必要があります。

  • ComputedState は非推奨にはならず、どちらも引き続き使用されます。

  • どちらを選択すればよいかわからない場合は、通常、計算された状態をデフォルトとして選択することをおすすめします。

  • 派生状態が最も役立つのは、実際にキャッシュを活用する場合です。つまり、複雑なアルゴリズム、大規模な割り当て、キャッシュのメリットを実際に享受する多数の状態読み取りなど、重要な処理を行う場合や、オブジェクトの等価性やオブジェクト ID の等価性などに依存する重要な処理を行う場合です。このような場合、派生状態が提供するキャッシュのメリットを享受できます。

Compose でのスキップと安定性

  • もう 1 つの重要な問題として、派生状態と計算状態はどちらも、コンポーザブル自体の無効化を回避しようとしていることが挙げられます。

  • これは、コンポーザブルのスキップとは異なります。コンポーザブルのスキップとは、条件付きで無効化すること、またはコンポーザブル内で処理を実行しないことを指します。

  • ここで明らかにしたいのは、Compose の安定性とスキップについては多くの議論があるものの、それがすべてではなく、最適化の対象として常に正しいとは限らないということです。

  • 前述のように、コンポジション内のすべての処理には、スキップも含めて、何らかのコストが伴います。

    // What you write
    @Composable fun ContactListItem(
        contact: Contact
    ) { ... }
    
    // What you get
    @Composable fun ContactListItem(
        contact: Contact,
        $composer: Composer?
        $changed: Int
    ) {
        val $dirty = $changed or
            $composer.changed(contact)
    
        if ($dirty) { /* Your function body */ }
        else $composer.skipToGroupEnd()
    }
  • スキップは必ずしも簡単ではありません。

  • そもそも、有効にするために多くのメンテナンスが必要です。

  • すべてのパラメータを保存し、初期コンポジションでそれらのパラメータ用のスペースを見つけて、再コンポーズ時にそのレコードを更新するなどの処理を行う必要があります。

  • また、コンポーザブルをスキップするかどうかを判断するために、値を以前の値と比較する必要もあります。

  • その後は、コードをスキップするかどうかを判断する if チェックの機能だけです。

  • ここで何が起こっているかによっては、費用が高くなる可能性があります。

  • コンパイラには、同じパラメータを渡すと等価性チェックを再利用できるという最適化が内部的に存在しますが、これは万能ではなく、簡単に無効化できます。

  • スキップが少し裏切る例として、スクロール リストの非常に古典的な例を見てみましょう。重要なのは、画面にコンポーズされるすべてのデータを含む巨大なオブジェクトがあることです。

    data class ContactsModel(
        val header: String,
        val contacts: List<Contact>
    )
    
    data class Contact(...)
    
    @Composable
    fun ContactsScreen(model: ContactsModel) {
        Column {
            TopAppBar(title = { Text(model.header) })
            ContactsList(model.contacts)
        }
    }
    
    @Composable
    fun ContactsList(contacts: List<Contact>) {
        LazyColumn {
            items(contacts) { ContactListItem(it) }
        }
    }
    
    @Composable
    fun ContactListItem(contact: Contact) { ... }
  • この画面モデルには、ヘッダー画面のようなものと、連絡先のリスト全体が含まれています。このリストは、いくつかのコンポーザブルを通過し、最終的に遅延リストを呼び出して、いくつかのアイテムセルとともに画面に表示されます。

  • このコードのコンポジション階層は次のようになります。ここでも、連絡先画面から始まり、少しファンアウトしてから、アイテムを呼び出して表示する他のコンポーザブルがあります。

  • データがどのように渡されるかを見てみましょう。まず、上部のモデルから始めます。

  • これを文字列に分解してからリストに分解し、そのリストを遅延リストの items ラムダに変換します。その後、遅延リストが画面に表示されるすべてのアイテムをコンポーズします。

  • このリストの最後の連絡先を変更するとどうなるかを見てみましょう。

  • リストの最後のアイテムだけを無効にしたいのですが、ここではそうはなりません。

  • この再コンポーズのコンポジション トレースを見ると、次のようになります。ContactScreen から始まり、最終的にこの ListItem に到達します。

  • これは、ここに来るにはかなり費用のかかる方法です。

  • また、トレース内で何も発生していないように見えるセクションがすべて表示されます。

  • ここでは明確ではありませんが、実際には、コンポーザブルがスキップするかどうかを決定するのはこの段階です。

  • この例では、遅延リストの再コンポーズ方法により、2 つのコンポーザブルのみがスキップされます。

  • 新しいコンテンツ ラムダを指定すると、保持しているすべてのアイテムが無効になります。

  • そのため、変更された 1 つの連絡先に加えて、変更されていない 2 つの連絡先も無効になります。この 2 つのアイテムのみがスキップされます。

  • それ以外はすべて、この等価比較をスキップして、実際に実行する必要があることを認識しています。

  • オブジェクトが安定している場合、この比較は実際にはかなりコストがかかる可能性があります。

  • すべてのデータ構造を強制的に処理すると、2 つのオブジェクトが等しいかどうかを判断するだけで、実際には無視できないコストが加算される可能性があります。

  • さらに悪いことに、リストを分解したため、リストに対して同じ比較を 2 回行う必要があります。

  • 代わりに、コンパイラをデフォルトの動作に任せて、強力なスキップを使用すると、スキップが可能になり、コンポーザブルを条件付きでスキップするチェックも可能になりますが、構造的等価性ではなくオブジェクト ID の等価性を使用する、より高速なルートが使用されます。

  • この方が計算がはるかに高速です。このフレームで同じアイテムを再コンポーズしますが、実際には高速に処理することが重要であり、これらのコンポーザブルを実際にスキップできなかったことを把握するのに時間を浪費することはありません。

  • また、再コンポーズの回数を直接確認するのではなく、ベンチマークやトレースなどのツールを使用してアプリの内部で何が起きているかを把握することを重視しているのも、このためです。

  • これらは有用なプロキシになる可能性がありますが、アプリケーションの最適化全体で必ずしも過度に注目したり、過度に重視したりする必要はありません。

  • コンポーザブルをスキップしようとするのではなく、そもそも無効化しないようにしてください。

メソッド トレースを使用した実行の分析

  • この例で見たように、コンポジション トレースは、何が起きているかについて多くの情報を提供しますが、必ずしも全体像を示すわけではありません。

  • そのために、Andrei がメソッド トレースについて説明します。

  • それでは

  • Andrew がすでに述べたように、コンポジション トレースはコンポーズ可能な関数にのみアノテーションを付けるため、常にすべての状況を把握できるわけではありません。コンポジション トレースに空白スペースが表示されるだけで、Perfetto トレースはすでに拡張されているものの、そこで何が起こっているのかをすべて把握できない場合もあります。

  • Compose が原因の場合もあれば、コードに原因がある場合もあります。このコンポジション トレースを見るだけでは、原因を特定するのは非常に困難です。

  • ここで、メソッド トレースを取得します。

  • Android Studio のプロファイラに項目があるほか、ADB コマンドを使用してトレースを取得することもできます。これにより、Java 環境で行われるすべての関数呼び出しが提供されます。

  • Compose コンパイラがエンコードするすべてのもの、Compose ランタイムが行うすべてのこと、コードが行うすべてのことが含まれます。これは、実際に何が実行されているか、そのスペースでどの関数が呼び出されたかを追跡、調査するのに非常に役立ちます。

  • 1 つ、中央のこのアイテムを見てください。

  • メソッド トレースはオーバーヘッドが非常に高いことに注意してください。

  • 関数ツリーの複雑さに応じて、10 ~ 20 倍のオーバーヘッドが発生します。関数呼び出しごとに、少しずつオーバーヘッドが加算されるためです。

  • それを説明するために、スクリーンショットを見てみましょう。

  • これは、アプリの起動時のメソッド トレースの一般的な例です。これを見て、「どうすればこの内容を理解できるのか」と思われるかもしれません。

  • 通常は、コンポジション トレースを開始して、ここにある関数を確認します。

  • これは、簡略化されたコンポジション トレースのようなもので、ScreenContent、その中の Button、その他の関数が含まれています。

  • たとえば、ScreenContent で何が起きているかを把握したいとします。

  • Perfetto には検索バーがあります。

  • Perfetto ビューアにメソッド トレースを読み込み、ここで使用したのと同じ関数(ScreenContent)を検索すると、画面のメソッド トレースのようなものが表示されます。

  • ご覧のように、Composer.startGroup やグループ通話がいくつかあります。

  • これは、コンポジションが実際に魔法のように機能するように、ランタイムとコンパイラがバックグラウンドで行う処理です。ここで注目すべき呼び出しは Composer.changed です。

  • このようにして、コンポーザブルの前回の呼び出しと今回の呼び出しの間でパラメータが変更されたかどうかを確認します。

  • そして、これらの Composer.changed の大部分は、この ScreenModel.equals が占めています。

  • たとえば、ScreenModel が何らかのビッグデータ クラスで、再コンポーズの回数を減らすために安定版にしたとします。これは、コンパイラ レポートに表示される可能性があります。

  • この安定版アノテーションを削除すると、この特定のケースでのパフォーマンスが向上する可能性があります。

  • そのため、コンポジション メソッド トレースは、この問題を見つけるための最適なツールです。

  • それでは

  • 今日は多くのことを学びました。それでは、Andrew にバトンタッチして、まとめに入りましょう。

高速なコンポーザブル設計とパフォーマンス ツール

  • 最後に、本日お話しした内容の概要をまとめます。コンポーザブルを設計する際、および既存のアプリを最適化する際に、留意すべき点がいくつかあります。

  • まず、スキップするコンポーザブルよりも、負荷の低いコンポーザブルや無効化しないコンポーザブルの方が優れていることを覚えておいてください。

  • コンポーザブルを設計する際は、引数を小さくして比較しやすくし、他の関数呼び出しとの比較を再利用して最適化を活用することを検討することをおすすめします。

  • コンポーザブル内で API を使用する場合は、パフォーマンスまたはフレーム時間が、API 自体のコストと使用頻度の積になることに注意してください。

  • これらの費用がどのように加算されるかに注意してください。また、アプリケーションにとって最も不適切なタイミングで発生する可能性のある、解体費用についても注意してください。

  • また、アプリ自体の内部で最適化を行う際には、適切な市民であることが重要です。

  • まず、ベンチマークを取得して、情報に基づいた意思決定を行い、アプリをプロファイリングして、コードの適切な領域をターゲットにしていることを確認します。そして、ベンチマークとプロファイリングの結果として、変更を加えて、期待する結果が得られていることを確認します。

  • また、アプリケーションの保守性とパフォーマンスの間にはトレードオフの関係があることにも注意してください。

  • したがって、自分とチームにとって最適な方法を選択し、過剰な最適化を行って保守性の面で自分を追い込まないようにしてください。

  • また、さらにサポートが必要な場合は、もう 1 つのツールとして、すでに利用している AI エージェントを活用できます。

  • Android CLI の一部として、またそこで提供するスキルの一部として、Jetpack Compose のパフォーマンス スキルをリリースしました。このスキルには、本日お話しした情報の多くが含まれており、アプリケーションを監査して、このような最適化を提案できます。

  • Android CLI を使用して、既存のエージェントにインストールできます。

    android skills add jetpack-compose-performance
  • Android CLI について詳しくは、developer.android.com をご覧ください。

  • スキル自体の詳細については、GitHub リポジトリでも確認できます。

  • 安全なベンチマークと、快適な Compose 開発をお楽しみください。

コード

コード スニペット

「副作用の管理」の 9 分 34 秒の Kotlin スニペット
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
    val mediaPlayer = rememberMediaPlayer()

    // Asynchronous work and animations
    LaunchedEffect(contentSource) { mediaPlayer.load(contentSource) }
}
「副作用の管理」の 10:15 の Kotlin スニペット
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
    val activity = LocalActivity.current

    // Setup and teardown
    DisposableEffect(Unit) {
        activity.requestedOrientation = SCREEN_ORIENTATION_LANDSCAPE
        onDispose {
            activity.requestedOrientation = SCREEN_ORIENTATION_UNSPECIFIED
        }
    }
}
「副作用の管理」の 10:36 の Kotlin スニペット
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
    val mediaPlayer = rememberMediaPlayer()

    // One-shot events
    SideEffect(Unit) { mediaPlayer.play() }
}
「副作用の管理」の 11:12 にある Kotlin スニペット
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
    val mediaPlayer = rememberMediaPlayer()

    // Synchronize the outside world to the state of composition
    SideEffect { mediaPlayer.playbackRate = playbackRate }
}
「副作用の管理」の 12:12 の Kotlin スニペット
@Composable
fun ShoppingCartScreen(
    viewModel: CartViewModel
) {
    LaunchedEffect(Unit) { viewModel.loadCart() }
    LaunchedEffect(Unit) { viewModel.loadPaymentMethods() }
    LaunchedEffect(Unit) { viewModel.loadDeliveryAddresses() }

    CartContent(...)
}
「副作用の管理」の 12:36 の Kotlin スニペット
@Composable
fun ShoppingCartScreen(
    viewModel: CartViewModel
) {
    LaunchedEffect(Unit) {
        launch { viewModel.loadCart() }
        launch { viewModel.loadPaymentMethods() }
        launch { viewModel.loadDeliveryAddresses() }
    }

    CartContent(...)
}
「状態の最適化と無効化」の 15:12 の Kotlin スニペット
@Composable
fun ScrollToTopButton(
    scrollState: ScrollState
) {
    val showScrollToTopButton = scrollState.value > 0
    val coroutineScope = rememberCoroutineScope()

    if (showScrollToTopButton) {
        FloatingActionButton(
            onClick = {
                coroutineScope.launch { scrollState.animateScrollTo(0) }
            }
        ) {
            Icon(Icons.Default.ArrowUpward, "Scroll to Top")
        }
    }
}
「状態の最適化と無効化」の 15:51 の Kotlin スニペット
@Composable
fun ScrollToTopButton(
    scrollState: ScrollState
) {
    val showScrollToTopButton by remember { computedStateOf { scrollState.value > 0 } }
    val coroutineScope = rememberCoroutineScope()

    if (showScrollToTopButton) {
        FloatingActionButton(
            onClick = {
                coroutineScope.launch { scrollState.animateScrollTo(0) }
            }
        ) {
            Icon(Icons.Default.ArrowUpward, "Scroll to Top")
        }
    }
}
「状態の最適化と無効化」の 16:49 の Kotlin スニペット
var a by mutableStateOf(0)

val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }

// First read
derivedState.value
computedState.value

// Second read
derivedState.value
computedState.value
「状態の最適化と無効化」の 18:35 の Kotlin スニペット
var a by mutableStateOf(0)

val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }

...

// Read-after-write
a++
derivedState.value
computedState.value
「Compose でのスキップと安定性」の 21:07 の Kotlin スニペット
// What you write
@Composable fun ContactListItem(
    contact: Contact
) { ... }

// What you get
@Composable fun ContactListItem(
    contact: Contact,
    $composer: Composer?
    $changed: Int
) {
    val $dirty = $changed or
        $composer.changed(contact)

    if ($dirty) { /* Your function body */ }
    else $composer.skipToGroupEnd()
}
「Compose でのスキップと安定性」の 21:53 の Kotlin スニペット
data class ContactsModel(
    val header: String,
    val contacts: List<Contact>
)

data class Contact(...)

@Composable
fun ContactsScreen(model: ContactsModel) {
    Column {
        TopAppBar(title = { Text(model.header) })
        ContactsList(model.contacts)
    }
}

@Composable
fun ContactsList(contacts: List<Contact>) {
    LazyColumn {
        items(contacts) { ContactListItem(it) }
    }
}

@Composable
fun ContactListItem(contact: Contact) { ... }
「高速なコンポーズ可能デザインとパフォーマンス ツール」の 29:40 のシェル スニペット
android skills add jetpack-compose-performance