安定性に関する問題を解決する

パフォーマンスの問題を引き起こす不安定なクラスに直面した場合は、Compose がそのクラスを比較またはインスタンス化する方法を最適化する必要があります。このドキュメントでは、そのために使用できるいくつかの手法について説明します。

強いスキップを有効にする

強いスキップモードは、Kotlin 2.0.20 以降ではデフォルトで有効になっています。強いスキップモードでは、インスタンスの等価性(===)を使用して不安定なパラメータを比較することで、不安定なパラメータを持つコンポーザブルをスキップできます。一方、安定したパラメータは、引き続き構造の等価性(Object.equals())を使用して比較されます。

2.0.20 より前の Kotlin バージョンを使用している場合は、まず強力なスキップモードを有効にします。強力なスキップモードがすでに有効になっていて、コンポーズ可能な関数が不必要に再コンポーズされている場合は、再コンポーズ パスごとに不安定なパラメータの新しいインスタンスが割り当てられているかどうかを確認します。

詳細については、強力なスキップをご覧ください。

クラスを不変にする

不安定なクラスを完全に不変にして、コンパイラがその安定性を自動的に推測できるようにすることもできます。

  • 不変: その型のインスタンスが構築された後にプロパティの値を変更できず、すべてのメソッドが参照透過的である型を示します。
    • クラスのすべてのプロパティが var ではなく val であり、不変型であることを確認します。
    • String、Int、Float などのプリミティブ型は常に不変です。
    • Compose コンパイラでビルドされたモジュール内の val プリミティブまたは String プロパティを持つフラットな data class タイプは、すでに安定していると推測され、.equals() と自動的に比較されます。@Immutable でアノテーションを付ける必要はありません。
    • 不変性が不可能な場合は、可変プロパティに Compose 状態を使用する必要があります。
  • Stable: 変更可能な型を示します。Compose ランタイムは、型のパブリック プロパティまたはメソッドの動作が以前の呼び出しとは異なる結果をもたらすかどうか、またいつ異なる結果をもたらすかを認識しません。

コレクションと安定性

Compose でクラスが不安定と見なされる一般的な理由は、コレクションです。安定性の問題を診断するページに記載されているように、Compose コンパイラは、List、Map、Set などのコレクション インターフェースが本当に不変であるかどうかを完全に確認できないため、不安定としてマークします。

安定性の問題を診断するガイドの次のクラスについて考えてみましょう。

unstable class Snack {
  …
  unstable val tags: Set<String>
  …
}

強いスキップモードが有効になっている場合(Kotlin 2.0.20 以降のデフォルト)、不安定なコレクションとコレクションを含むクラスは、同じ参照が渡されるたびにインスタンスの等価性(===)を使用して再コンポーズをスキップします。

kotlin.collections.List、Set、Map を Kotlinx Immutable Collections(ImmutableList、ImmutableSet、ImmutableMap)に置き換える場合や、@Immutable、@Stable、stability_config.conf ルールをコレクション保持モデルに追加する場合は、注意が必要です。強力なスキップモードでは、標準の List は参照の等価性(===)を使用してスキップしますが、ImmutableList とアノテーション付きコレクション モデルは、再コンポーズ パスごとに要素単位の .equals() 比較を実行します。

大規模なコレクションや遅延レイアウト(LazyColumn、LazyRow、LazyGrid)の場合、すべてのアイテムで .equals() をチェックすると、再コンポーズよりも時間がかかることがあります。ImmutableList プロパティを data class から子コンポーザブルに渡すと、このコストは 2 倍になります。

data class SnackCategory(
    val name: String,
    val snacks: ImmutableList<Snack>
)

@Composable
fun CategorySection(category: SnackCategory) {
    Text(category.name)
    // Compose compares category.snacks twice: once in SnackCategory.equals()
    // for CategorySection, and again for HighlightedSnacks.
    HighlightedSnacks(snacks = category.snacks)
}

コレクションに構造の等価性を強制するのではなく、エミッション全体でコレクションのインスタンス参照を保持します(たとえば、ViewModel または remember で変換をキャッシュに保存します)。これにより、Compose はインスタンスの等価性(===)の使用をスキップできます。コレクションを保持するモデルで ImmutableList または安定性アノテーションを検討するのは、コレクションのインスタンス参照を保持できず、構造の等価性チェックが再コンポーズよりも高速であることがベンチマークで確認されている場合に限ります。2.0.20 より前の Kotlin バージョンを使用している場合は、まず強力なスキップモードを有効にします。

Stable または Immutable アノテーションを付ける

.equals() チェックのコストが低い小さなクラスの安定性の問題を解決する方法として、不安定なクラスに @Stable または @Immutable のアノテーションを付けることが考えられます。

クラスにアノテーションを付けると、コンパイラがクラスについて推測する内容がオーバーライドされます。これは、Kotlin の !! 演算子に似ています。これらのアノテーションの使用方法には細心の注意を払う必要があります。コンパイラの動作をオーバーライドすると、想定どおりにコンポーザブルが再コンポーズされないなど、予期しないバグが発生する可能性があります。

強力なスキップモードを有効にした状態で、@Stable または @Immutable を追加するかどうかを判断する際は、次のガイドラインに沿ってください。

  • すでに安定していると推測されているクラスにアノテーションを付けない: Compose コンパイラでビルドされたモジュール内の安定した型の val プロパティを持つクラスは、すでに安定していると推測されています。
  • インスタンスが再作成されるときに、低コストの .equals() チェックを行うクラスにのみアノテーションを付ける: コンパイラが安定性を推測できない場合(Compose 以外のモジュールのインターフェースやクラスなど)かつデータソースが、変更されていないアイテムに対して、出力ごとに新しいオブジェクト インスタンスを割り当てる場合に、@Stable または @Immutable を追加します。ただし、クラスが小さく、低コストの .equals() チェックを行う場合に限ります。
  • コレクションを含むモデルに注意する: 大規模なコレクション(List、Set、Map)を保持するデータモデルに @Stable または @Immutable を追加しないでください。特に、遅延レイアウト(LazyColumn、LazyRow、LazyGrid、Pager)で使用されるモデルには追加しないでください。これらのモデルにアノテーションを付けないままにすると、Compose は、すべての要素に対して構造的な .equals() チェックを実行するのではなく、インスタンスの等価性(===)を使用してモデルを比較します。

次のスニペットは、コンパイラが安定性を推測できないモジュールで定義された場合に、不変としてアノテーションが付けられたフラットなデータクラス(コレクション プロパティなし)の最小限の例を示しています。

@Immutable
data class SnackSummary(
    val id: Long,
    val name: String,
    val price: Long
)

@Immutable アノテーションと @Stable アノテーションのどちらを使用しても、Compose コンパイラは SnackSummary クラスを安定版としてマークし、Object.equals() を使用して比較します。

コレクション内のアノテーション付きクラス

List<Snack> 型のパラメータを含むコンポーザブルについて考えてみましょう。

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun HighlightedSnacks(
  stable index: Int
  unstable snacks: List<Snack>
  stable onSnackClick: Function1<Long, Unit>
  stable modifier: Modifier? = @static Companion
)

Snack が安定しているか、@Immutable アノテーションが付いている場合でも、List はインターフェースであるため、Compose コンパイラは HighlightedSnacks の snacks パラメータを unstable としてマークします。

強いスキップモードを有効にすると、HighlightedSnacks は引き続き skippable とマークされ、同じ List インスタンスが渡されたときにインスタンスの等価性(===)を使用して再コンポーズをスキップします。snacks パラメータを stable にするには、List<Snack> を @Immutable ラッパークラスでラップするか、List<Snack> を ImmutableList<Snack> に置き換えるか、kotlin.collections.* を安定性の構成ファイルに追加します。

ただし、そうすると、Compose は再コンポーズ パスごとに要素単位の .equals() 比較を実行することになります。これらのアプローチは、リストのインスタンス参照を保持できず、ベンチマークでメリットが示されている場合にのみ使用してください。

安定性構成ファイル

Compose Compiler 1.5.5 以降では、安定していると見なすクラスの構成ファイルをコンパイル時に指定できます。これにより、LocalDateTime などの標準ライブラリ クラスなど、制御できないクラスを安定版として扱うことができます。

構成ファイルは、1 行に 1 つのクラスを含む書式なしテキスト ファイルです。コメント、単一ワイルドカード、二重ワイルドカードがサポートされています。

構成の例:

// Consider LocalDateTime stable
java.time.LocalDateTime
// Consider my datalayer stable
com.datalayer.*
// Consider my datalayer and all submodules stable
com.datalayer.**
// Consider my generic type stable based off it's first type parameter only
com.example.GenericClass<*,_>

この機能を有効にするには、Compose コンパイラ Gradle プラグイン構成の composeCompiler オプション ブロックに構成ファイルのパスを渡します。

composeCompiler {
  stabilityConfigurationFile = rootProject.layout.projectDirectory.file("stability_config.conf")
}

Compose コンパイラはプロジェクト内の各モジュールで個別に実行されるため、必要に応じて異なるモジュールに異なる構成を指定できます。または、プロジェクトのルートレベルに 1 つの構成を配置し、そのパスを各モジュールに渡します。

複数のモジュール

もう 1 つの一般的な問題は、マルチモジュール アーキテクチャに関連するものです。Compose コンパイラは、参照するすべての非プリミティブ型が明示的に安定版としてマークされているか、Compose コンパイラでビルドされたモジュールに含まれている場合にのみ、クラスが安定しているかどうかを推測できます。

データレイヤが UI レイヤとは別のモジュールにある場合(推奨されるアプローチ)、この問題が発生する可能性があります。強力なスキップモードでは、Compose 以外のモジュールのクラスは不安定として扱われ、同じインスタンスが渡された場合は、インスタンスの等価性(===)を使用して再コンポーズがスキップされます。ただし、データレイヤが、出力ごとに同じ値を持つ小さなフラットモデルの新しいインスタンスを頻繁に割り当てる場合は、構造的等価性(Object.equals())を有効にできます。

解決策

この問題を解決するには、次のいずれかの方法を使用します。

  1. .equals() チェックが低コストの、小さくフラットなクラスをコンパイラ構成ファイルに追加します。
  2. データレイヤ モジュールで Compose コンパイラを有効にするか、必要に応じてクラスに @Stable または @Immutable のタグを付けます。
    • これには、データレイヤに Compose 依存関係を追加する必要があります。ただし、これは Compose ランタイムの依存関係であり、Compose-UI の依存関係ではありません。
  3. UI モジュール内で、データレイヤ クラスを UI 固有のラッパークラスでラップします。

Compose コンパイラを使用していない外部ライブラリを使用する場合も、同じ問題が発生します。

すべてのコンポーザブルをスキップ可能にする必要はない

安定性の問題を修正する際に、すべてのコンポーザブルをスキップ可能にしようとしたり、すべてのパラメータに構造的等価性を強制したりしないでください。コンポーザブルのスキップは無料ではありません。スキップ可能なコンポーザブルのすべてのパラメータは、Compose ランタイムによって記憶されているかのように保存されます。Compose は、関数が再コンポーズされるたびに各パラメータを以前の値と比較して、スキップする必要があるかどうかを判断する必要があります。あらゆる場所で強制的にスキップしようとすると、時期尚早な最適化につながり、節約できるオーバーヘッドよりも多くのオーバーヘッドが発生する可能性があります。

構造の安定性を強制しても、実際のメリットはなく、パフォーマンスや保守性が低下する状況は数多くあります。次に例を示します。

  • 再コンポーズが頻繁に行われない、またはまったく行われないコンポーザブル。
  • それ自体がスキップ可能なコンポーザブルを呼び出すだけのコンポーザブル。
  • 多数のパラメータを持つコンポーザブル、またはコストの高い .equals() 実装を持つコレクション モデル。この場合、パラメータが変更されたかどうかを確認するコストが、低コストの再コンポジションのコストを上回る可能性があります。

再コンポーズの回数だけに注目しないでください。コンポーザブルを軽量にして実行できるようにすること、急速に変化する状態の読み取りをレイアウト フェーズまたは描画フェーズまで遅らせること、ベンチマークで変更を検証することに重点を置きます。コンポーザブルがスキップ可能である場合、小さなオーバーヘッドが追加されますが、それだけの価値がない可能性があります。再開可能またはスキップ可能にすると、それに見合わないオーバーヘッドが発生すると判断した場合は、コンポーザブルに non-restartable または non-skippable のアノテーションを付けることもできます。