stabilizing-compose-types について
不安定と判定された型の安定化を案内する、Android Compose 開発者向けスキルです。型の再構成、契約を守れる場合のアノテーション、第三者製または Java 型の設定という三段階で進めます。
主な機能
- 01三段階の安定化方針
- 02不変プロパティとコレクションの優先
- 03@Immutable と @Stable の適切な選択
- 04外部型の安定性設定
- 05コンポーザブル外での Flow 収集
活用シーン
- 01コンパイラレポートの不安定なパラメーターへの対処
- 02ドメインモデルやコレクション型の安定化
- 03第三者製または Java 型への対応
- 04Flow の収集をコンポーザブル外へ移動
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ skydoves/ compose-performance-skills --skill stabilizing-compose-typesインストール後、Claude Code で/stabilizing-compose-typesを入力して読み込まれたか確認します。
同梱ファイルreferences/stability-config-syntax.md
詳細
Compose の型を安定化する — 3 つの段階を順番に
診断によって不安定な型が特定されたら(../diagnosing-compose-stability/SKILL.md を参照)、このスキルでは Claude がそれらを修正する手順を説明します。方針は、厳密な 3 段階の順序で進めます。(1)構造を書き換えて(val +不変フィールド)真に安定した型にする — アノテーションは不要です。(2)ソースコードを管理していて、契約を守れる場合は @Immutable または @Stable を付ける。(3)サードパーティ製または Java の型には stabilityConfigurationFile を使う。この順序を絶対に逆にしないでください — アノテーションは契約であり、魔法の呪文ではありません。
このスキルを使用する場合
- コンパイラーレポート(composables.txt / classes.txt)に、1 つ以上の
unstableなパラメーターが示されている場合。 - 開発者が、ドメインクラス、
List<Foo>パラメーター、java.time.LocalDateTime、またはサードパーティ製の型を安定化する方法を尋ねている場合。 - 開発者が、
@Immutable、@Stable、compose-stable-marker、compose-runtime-annotation、kotlinx.collections.immutable、stabilityConfigurationFiles、またはstability_config.confに言及している場合。 @TraceRecompositionのログに、引数が再コンポジションのたびに新しく割り当てられることで再コンポジションが発生していると示されている場合。
このスキルを使用しない場合
- 不安定な型がまだ特定されていない場合 — 先に
../diagnosing-compose-stability/SKILL.mdを実行してください。 - 症状が誤ったフェーズでの状態読み取り(
Modifier.alpha(state.value))である場合は、../../recomposition/deferring-state-reads/SKILL.mdを使用してください。 - 症状が
derivedStateOfの問題である場合は、../../recomposition/choosing-derivedstateof/SKILL.mdを使用してください。 - 原因が
Flow<T>パラメーターである場合、修正方法は上流で収集することです。Flowを安定としてアノテーションするのではなく、../../side-effects/collecting-flows-safely/SKILL.mdを参照してください。
前提条件
../diagnosing-compose-stability/SKILL.mdを実行済みで、開発者が不安定な型の具体的なリストを把握していること。org.jetbrains.kotlin.plugin.composeが適用された Kotlin 2.0.0+。Strong Skipping はデフォルトで有効です。compose-runtimeを含まない純粋な Kotlin モジュール/データモジュールの場合、クラスパスにandroidx.compose.runtime:runtime-annotation(公式)またはcom.github.skydoves:compose-stable-marker(レガシー)があること。どちらも完全なランタイムを取り込まずに@Stable/@Immutable/@StableMarkerを提供します。- 第 3 段階の修正の場合、
stabilityConfigurationFilesDSL をサポートする Compose Compiler 1.5.5+ が必要です(複数形。古い単数形のstabilityConfigurationFileプロパティは非推奨です。後述の脚注を参照してください)。
ワークフロー — 判断フロー
-
1. 不安定な型のソースを所有していますか? はいの場合は、Tier 1 または Tier 2 を使用します。いいえの場合(Java 標準ライブラリ、サードパーティ SDK)は、Tier 3 に進みます。
-
2. Tier 1 — 真に安定するように再構成します。 すべてのプロパティを、すでに安定している型の
valにできますか? はいの場合は、まず必ずその変更を行います。アノテーションは不要で、契約も意味しません。コンパイラーが安定性を自動的に推論します。
// WRONG
data class Snack(
var name: String,
val tags: Set<String>,
)
// WRONG because: `var name` is an observable that does not notify Compose, and `Set<String>` is the standard library interface so its stability is Unknown — the data class is unstable on two axes.
// RIGHT
@Immutable
data class Snack(
val name: String,
val tags: ImmutableSet<String>,
)
-
3. 不安定なプロパティはコレクションですか?
kotlin.collections.List、Set、Mapをkotlinx.collections.immutable.ImmutableList、ImmutableSet、ImmutableMapに置き換えます。persistentListOf()、persistentSetOf()、persistentMapOf()ファクトリー、または.toImmutableList()アダプターを使って構築します。kotlinx-collections-immutable アーティファクトには、Compose Compiler が認識する既知の安定性ビットマスク0b1が含まれています。推奨: 型システムが不変性を強制するため、stability_config.confでkotlin.collections.*をホワイトリストに登録するより、こちらを優先してください。 -
4. ソースは
compose-runtimeに依存しない純粋な Kotlin/データモジュールですか?androidx.compose.runtime:runtime-annotation(公式、推奨)またはcom.github.skydoves:compose-stable-marker(レガシー)を使用します。どちらも、データモジュールが Compose ランタイム全体を取り込まずに型へアノテーションを付けられます。Skydoves の見解 #6:純粋な Kotlin/データモジュールでは、Compose ランタイム全体を取り込まずに compose-stable-marker(または新しい公式の compose-runtime-annotation)を使用できます。
// shared data module — build.gradle.kts
dependencies {
// Official, preferred:
compileOnly("androidx.compose.runtime:runtime-annotation:<version>")
// Or legacy skydoves:
// implementation("com.github.skydoves:compose-stable-marker:<version>")
}
- 5. Tier 2 —
@Immutableまたは@Stableを選びます。 すべてのプロパティがvalで、かつすべてのネストされた値がそれ自体不変であり、equals()が構造的な比較を行う場合は、@Immutableを使用します。それ以外の場合、値が変化し得るものの、その変更が Compose に監視される型(たとえば、MutableStateを含むホルダー)には@Stableを使用します。
コンパイラーは、@Immutable を @Stable より積極的に扱います。@Immutable 型では、呼び出し箇所のすべてのコンストラクター引数がコンパイル時定数(リテラルの数値、安定した型のトップレベル val、または定数引数を持つ別の @Immutable など)の場合、コンパイラーは静的式の昇格を実行します。構築されたインスタンスはシングルトンにホイストされ、ラムダのキャプチャ箇所は重複排除され、composables.txt では @Immutable パラメーターに @static が付けられます。多くの記事では @Stable と @Immutable は同じように使えると説明されていますが、そうではありません。
// RIGHT — @Immutable: every property val, every property type immutable
@Immutable
data class ThemeColors(
val primary: Color,
val onPrimary: Color,
val background: Color,
)
// RIGHT — @Stable: mutable but mutations notify Compose via Snapshot
@Stable
class CartState {
var total: Money by mutableStateOf(Money.ZERO)
val lines: SnapshotStateList<Line> = mutableStateListOf()
}
- 6. Tier 3 — サードパーティまたは Java の型です。
stabilityConfigurationFiles(複数形)を使用します。プロジェクトルートにstability_config.confを作成し、完全一致のクラス名またはワイルドカードパターンを記述します。stabilityConfigurationFiles.add(...)を使ってcomposeCompiler { }ブロックに設定します。文法の詳細はreferences/stability-config-syntax.mdにあります。
// build.gradle.kts (root or composable module)
composeCompiler {
stabilityConfigurationFiles.add(
rootProject.layout.projectDirectory.file("stability_config.conf")
)
}
脚注 — レガシー形式。 古いプロジェクトでは、単数形のプロパティ
stabilityConfigurationFile = ...を使ってファイルを設定している場合があります。最新の Compose Compiler Gradle プラグインのリリースでは、このプロパティは@Deprecated("Use the stabilityConfigurationFiles option instead")になっています。上記の複数形のstabilityConfigurationFiles.add(...)を優先してください。
# stability_config.conf — opt-in contract
java.time.LocalDateTime
java.time.LocalDate
kotlin.collections.List
kotlin.collections.Set
kotlin.collections.Map
com.example.thirdparty.**
- 7. Tier 1~3 のどれにも当てはまりませんか?
@Stable class StableHolder<T>(val item: T)でラップします。これは最後の手段です。ホルダーの同一性は安定しており、等価性はitemに委譲されるため、Compose はこのホルダーに対するスキップを判断できます。
@Stable
class StableHolder<T>(val item: T) {
override fun equals(other: Any?) = other is StableHolder<*> && other.item == item
override fun hashCode() = item?.hashCode() ?: 0
}
- 8.
Flow<T>パラメーター — 安定としてアノテーションを付けてはいけません。 Skydoves の見解 #4:Flowパラメーターは不安定です。フローをコンポーザブルに渡さないでください。ViewModel 内、またはcollectAsStateWithLifecycleを使って収集します。
// WRONG
@Composable
fun Feed(items: Flow<List<Item>>) { /* ... */ }
// WRONG because: Flow has no observable identity; the composable cannot skip on it, and a downstream `collectAsState` here detaches from lifecycle.
// RIGHT — collect upstream and pass the resolved value
@Composable
fun FeedRoute(viewModel: FeedViewModel = viewModel()) {
val items by viewModel.items.collectAsStateWithLifecycle()
Feed(items = items)
}
@Composable
fun Feed(items: ImmutableList<Item>) { /* ... */ }
- 9. 診断スキルを再実行し、以前は不安定だったパラメーターが
stableまたはruntimeになったことを確認します。
パターン
パターン:var と Set フィールドを持つデータクラス
// WRONG
data class Snack(
var name: String,
val tags: Set<String>,
)
// WRONG because: `var` blocks compile-time stability inference; `Set<String>` is an interface with unknown implementations.
// RIGHT
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
@Immutable
data class Snack(
val name: String,
val tags: ImmutableSet<String> = persistentSetOf(),
)
パターン:コンポーザブルの List<Item> パラメーター
// WRONG
@Composable
fun ItemList(items: List<Item>) { /* ... */ }
// WRONG because: `kotlin.collections.List` is an interface; the compiler cannot prove every implementation is immutable, so the parameter is `unstable` and skipping is disabled (or pinned to `===` under Strong Skipping, which still fails on every fresh allocation).
// RIGHT
import kotlinx.collections.immutable.ImmutableList
@Composable
fun ItemList(items: ImmutableList<Item>) { /* ... */ }
プロデューサー側:
val items: ImmutableList<Item> = repository.items().toImmutableList()
パターン: java.time.LocalDateTime が unstable と判定される
Java の time 型は個別にコンパイルされており、アノテーションもないため、推論は「unknown」にフォールスルーします。Tier 3 が正解です。
# stability_config.conf
java.time.LocalDateTime
java.time.LocalDate
java.time.Instant
java.time.ZonedDateTime
java.time.Duration
// WRONG — annotate a wrapper to "force" stability without honoring the contract
@Stable
class DateWrapper(var date: LocalDateTime)
// WRONG because: `var` field, no Snapshot notification — the @Stable contract is broken; recompositions will be silently missed.
// RIGHT — whitelist the immutable JDK type via stability config
// stability_config.conf: java.time.LocalDateTime
@Immutable
data class Order(val placedAt: LocalDateTime, val total: Money)
パターン: composable 上の Flow<T> パラメータ
// WRONG
@Composable
fun Detail(productFlow: Flow<Product>) {
val product by productFlow.collectAsState(initial = null)
/* ... */
}
// WRONG because: `Flow` is a cold producer with no observable identity; the composable can never skip on it, and `collectAsState` (no `WithLifecycle`) keeps collecting in the background.
// RIGHT — hoist collection to the route
@Composable
fun DetailRoute(viewModel: DetailViewModel = viewModel()) {
val product by viewModel.product.collectAsStateWithLifecycle()
Detail(product = product)
}
@Composable
fun Detail(product: Product?) { /* ... */ }
相互参照: ../../side-effects/collecting-flows-safely/SKILL.md。
パターン: 「修正」としてのインライン composable のラップ
// WRONG — wrapping a Row in an extracted composable to "force" skippability
@Composable
fun ItemRow(item: Item) {
Row { /* ... */ }
}
// WRONG because: `Row`/`Column`/`Box` are inline composables — they are NOT restartable/skippable to begin with (skydoves hot take #3). Wrapping them creates a new restart scope, which can change behavior unpredictably without addressing the root unstable parameter.
// RIGHT — make the parameter stable, leave the inline composable alone
@Immutable
data class Item(val id: Long, val name: String)
@Composable
fun ItemRow(item: Item) {
Row { /* ... */ }
}
パターン: @Immutable と @Stable — より強い方を選ぶ
// SUFFICIENT but suboptimal
@Stable
data class ThemeColors(
val primary: Color,
val onPrimary: Color,
)
// Sufficient because: the compiler still treats it as stable.
// Suboptimal because: @Immutable would additionally enable static expression promotion when ThemeColors is constructed with all-constant args.
// PREFERRED
@Immutable
data class ThemeColors(
val primary: Color,
val onPrimary: Color,
)
composables.txt では、ThemeColors(Color(0xFFFFFFFF), Color(0xFF000000)) のような呼び出しが stable @static themeColors: ThemeColors として報告されます — 構築されたインスタンスはシングルトンにホイストされます。型が該当する場合は常に @Immutable を MUST 優先してください。
必須ルール
- 契約が守られていない限り、型を
@Stableまたは@Immutableとしてアノテーションしてはなりません (MUST NOT)。Skydoves のホットテイク #2: stability 設定は契約であり、魔法の呪文ではありません — 破れば再コンポジションは黙って見逃されます。 - すべてのプロパティがすでに immutable な型の
valである場合は常に、@Stableよりも@Immutableを MUST 優先してください。コンパイラは@Immutableに対してより強力な最適化 (静的式の巻き上げ、ラムダシングルトン、コンパイル時のデフォルト評価) を出力します。 - mutable なコレクションに stable アノテーションを付けるよりも、
kotlinx.collections.immutableを MUST 優先してください。コンパイラはこれらの型に対して既知の stable ビットマスクを提供しています。 - スキップ可能性を「強制」するためにインライン composable (
Row/Column/Box) をラップしてはなりません (MUST NOT)。インライン composable はそもそも再起動可能ではありません。ラップすると根本原因に対処せずにスコープが変わるだけです。 Flow<T>を composable のパラメータとして渡してはなりません (MUST NOT)。上流でcollectAsStateWithLifecycleを使って収集してください。- 修正を適用した後は、
../diagnosing-compose-stability/SKILL.mdを MUST 再実行してください。以前 unstable だったパラメータは MUST 今度はstableまたはruntimeを報告する必要があります。 - PREFERRED: チームが所有していないモジュール全体にアノテーションを散らばせるよりも、
stabilityConfigurationFiles(複数形;stabilityConfigurationFiles.add(...)) を優先してください。 - PREFERRED: 純粋な Kotlin / データモジュールでは、
androidx.compose.runtime:runtime-annotation(公式) またはcom.github.skydoves:compose-stable-marker(レガシー) を使用して、完全なcompose-runtime依存関係なしでアノテーションを利用できるようにしてください。
検証
-
composeCompiler { reportsDestination = ... }を有効にしてリリースモードビルドを再実行します。 -
composables.txtでは、以前unstableだったすべてのパラメータが今度はstableまたはruntimeになっています。 -
classes.txtでは、修正されたすべてのクラスがstable class …またはruntime stable class …になっており、対象の型にunstableが残っていません。 -
stabilityConfigurationFilesを使用した場合、すべてのエントリが契約を守っていること (隠れたミューテーションがない、構造的なequals、Snapshot をバイパスする observable がない) を検証済みです。 - 以前ホットだった composable に
@TraceRecomposition(skydoves/compose-stability-analyzer) を適用すると、再コンポジション回数が状態変化ごとに期待される数まで減少していることが示されます。 -
varまたは unstable なネスト型を依然として含む型に@Stable/@Immutableアノテーションが追加されていません。
参考資料
- Android Developers — 安定性の修正:https://developer.android.com/develop/ui/compose/performance/stability/fix
- Android Developers — 安定性の概要:https://developer.android.com/develop/ui/compose/performance/stability
- Android Developers — 強力なスキップ:https://developer.android.com/develop/ui/compose/performance/stability/strongskipping
- Ben Trengrove — 安定性の解説:https://medium.com/androiddevelopers/jetpack-compose-stability-explained-79c10db270c8
- Ben Trengrove — 安定性を最適化する新しい方法:https://medium.com/androiddevelopers/new-ways-of-optimizing-stability-in-jetpack-compose-038106c283cc
- Ben Trengrove — 強力なスキップモードの解説:https://medium.com/androiddevelopers/jetpack-compose-strong-skipping-mode-explained-cbdb2aa4b900
- Manuel Vivo — Flow を安全に利用する:https://medium.com/androiddevelopers/consuming-flows-safely-in-jetpack-compose-cde014d0d5a3
- skydoves — 安定性を極めてアプリのパフォーマンスを最適化する:https://medium.com/proandroiddev/optimize-app-performance-by-mastering-stability-in-jetpack-compose-69f40a8c785d
- skydoves — Jetpack Compose の 6 つのガイドライン:https://medium.com/proandroiddev/6-jetpack-compose-guidelines-to-optimize-your-app-performance-be18533721f9
- skydoves — compose-stable-marker:https://github.com/skydoves/compose-stable-marker
- kotlinx.collections.immutable:https://github.com/Kotlin/kotlinx.collections.immutable
references/stability-config-syntax.md—stability_config.confの完全な文法と、例を使った解説。