understanding-stability-inference について
Compose開発者向けのスキルです。コンパイラーによる12段階の安定性推論を説明し、特にジェネリクス、モジュール間の型、インターフェース、再帰型の意外な分類を調べます。
主な機能
- 0112段階の安定性推論を解説
- 02ジェネリック引数と型置換を分析
- 03モジュール間のruntime stableを説明
- 04インターフェース、Java、再帰型を解説
- 05アノテーションと既知型レジストリーを説明
活用シーン
- 01runtime stableやunknownの分類理由の説明
- 02ジェネリック型の安定性結果の調査
- 03依存モジュールの型分類の分析
- 04再帰型がunstableになる理由の理解
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ skydoves/ compose-performance-skills --skill understanding-stability-inferenceインストール後、Claude Code で/understanding-stability-inferenceを入力して読み込まれたか確認します。
同梱ファイルreferences/bitmask-encoding.mdreferences/twelve-phase-algorithm.md
詳細
Stability Inference を理解する — コンパイラーの思考を読み解く
安定性は、Compose コンパイラーに組み込まれた12フェーズのアルゴリズムによって決まります。このスキルでは、レポートにそのような判定が表示される理由を説明し、レポートが生成される前に分類を予測できるよう、アルゴリズムが型をどのようにたどるかを Claude に教えます。これを ../diagnosing-compose-stability/SKILL.md(レポートを生成)および ../stabilizing-compose-types/SKILL.md(明らかに不安定な型を修正)と組み合わせてください。よりシンプルなスキルの判定に開発者が驚いたときは、このスキルを使ってください。
このスキルを使う場面
- 開発者が「
Fooがstableではなくruntime stableに分類されるのはなぜですか?」と質問した場合。 - 「問題なさそうな」クラスについて、レポートに
runtimeまたはunknownと表示されている場合。 - ジェネリクスが関係する場合:
Box<T>、Wrapper<A, B>、Pair<String, Int>、ImmutableList<User>。 - クラスが別のモジュールにあり、
.class/.kotlin_metadataアーティファクトとして提供されている場合。 - 開発者が
$stable: Intフィールド、@StabilityInferred、またはモジュール間の分類について質問した場合。 - 自己参照型(
class Node(val children: List<Node>))が、明らかでない理由で不安定になっている場合。 - Java の型、インターフェース、または抽象基底クラスがパラメーターリストに現れ、開発者の予想と異なる場合。
このスキルを使わない場面
- 修正方法が機械的な場合(
var→val、List→ImmutableList、Flowパラメーターの削除)。../stabilizing-compose-types/SKILL.mdを使ってください。 - まだレポートが存在しない場合。まず
../diagnosing-compose-stability/SKILL.mdを実行してください。 - 開発者が安定性を CI で強制したい場合。
../enforcing-stability-in-ci/SKILL.mdを使ってください。
前提条件
- Compose Compiler のレポートがすでに生成されているか、少なくとも1つの
<module>-classes.txtと<module>-composables.txtがbuild/compose_compiler/にあること。 - 開発者が安定性の基本用語である
stable、unstable、skippable、restartable、@Stable、@Immutableを理解していること。 - Compose コンパイラープラグインを使用する Kotlin 2.0 以降(Strong Skipping はデフォルトで ON)。
ワークフロー — 診断用の質問と回答ツリー
型を、コンパイラーと同じ12のフェーズに沿ってたどります。各呼び出し箇所で、質問を順番に確認してください。最初に当てはまったフェーズが適用されます。
正規のフェーズ順(このスキル全体および references/twelve-phase-algorithm.md で使用):
- フェーズ1 — プリミティブ型/
String/関数/Unitの高速パス →Stable。 コンパイラーは直ちに結果を返します。フィールド解析は実行されません。ほかに何も調べていないことが開発者に伝わるよう、高速パスであることを説明してください。 - フェーズ2 — 型パラメーターの置換。
型変数
Tのみの場合はStability.Parameter(T)になります。解決は、それを置換する呼び出し箇所まで保留されます。 - フェーズ3 — null 許容型のアンラップ(
Int?→Intを解析)。 null 許容かどうかで安定性は変わりません。アルゴリズムは?を取り除いて再帰的に解析します。 - フェーズ4 — インラインクラス — 基底の型を確認。
value class Wrapper(val raw: T)の安定性は、Tとまったく同じです。 - フェーズ5 — 循環の検出(再帰的なツリー → 保守的に UNSTABLE)。
アルゴリズムは終了を保証するため、循環があると処理を中断します。回避策は、再帰クラスに
@Stable/@Immutableを付けることです。フェーズ5に到達する前のフェーズ6で適用されます。 - フェーズ6 — アノテーションの確認(
@Stable、@Immutable、@StableMarker)。 該当する場合 →Stability.Certain(安定)。@Immutableは、プロパティの読み取りを静的式に昇格し、等価性チェックを省略できるため、@Stableを超える追加の最適化を有効にします。@Stableが保証するのは、変更通知のみです。 - フェーズ7 — 既知の安定な構造体のレジストリに一致(
Pair/Triple/Result/ImmutableList/dagger.Lazy/ClosedRangeなど)。 レジストリのビットマスクを含むStability.Parameterを返します。レジストリ全体についてはreferences/bitmask-encoding.mdを参照してください。 - フェーズ8 — 外部設定に一致(
stability_config.conf)。 設定ファイルで宣言されたビットマスクを含むStability.Parameterを返します。 - フェーズ9 — 外部モジュール(別コンパイルで生成された
@StabilityInferredアノテーション)。Stability.Runtimeを返します。コンパイラーは JVM では$stable: Intフィールドを、Native/JS ではマングルされたトップレベルプロパティを出力します。これは ランタイム がComposer.changedを介して参照します。これは バグではありません と開発者に伝えてください。runtimeとは、コンパイラーが「コンパイル時には証明できないため、ランタイムに確認します」と述べているということです。 - フェーズ10 — Java 型(デフォルトは UNSTABLE — 設定ファイルで修正)。
アルゴリズムは読み取れる Kotlin メタデータを持たないため、推論では Java の
finalフィールドもvarのように扱われます。Java ソースを編集するのではなく、stabilityConfigurationFilesで修正してください。 - フェーズ11 — インターフェース(UNKNOWN。ランタイムでは
===を使用)。 コンパイラーは単一の呼び出し箇所から実装を列挙できません。等価性の判定では、ランタイムはフォールバックとして同一性(===)を使用します。 - フェーズ12 — フィールドごとの解析(低速パス):
- 線形化されたクラス階層をたどるため、継承されたフィールドも対象になります。独立した「継承」フェーズはありません。継承はここで扱います。
varプロパティが1つでもある →Unstable(Snapshot 統合なしで変更が検出されます)。- 型が
Unstableであるプロパティが1つでもある →Unstable(Combined では不安定性が優先されます)。 - それ以外の場合、クラスは
Stableです(すべてのフィールドが安定している場合、Combined の結果は Stable になります)。
12のフェーズすべてとフィールドごとのループの完全な擬似コードについては、references/twelve-phase-algorithm.md を参照してください。
パターン
パターン:「なぜ Box<String> は runtime stable と表示されるのですか?」
// Source — the developer's class, in a library module
class Box<T>(val value: T)
// Call site, in app module
@Composable fun BoxRow(box: Box<String>) { Text(box.value) }
コンパイラーは Box<T> を次のように処理します。
- フェーズ 12(フィールドごとの処理)で、定義元モジュール内にあるフィールド
value: Tを見つけます。Tを再帰的に処理すると、フェーズ 2(型パラメーター)に到達し、Stability.Parameterになります。これを統合すると、ビットマスク0b1を持つStability.Parameterにまとめられます(単一の型パラメーターが安定性に影響します)。 Boxが別のモジュールから使用されるため、コンパイラーはBoxに@StabilityInferred(parameters = 0b1)を出力し、実行時にTの安定性から初期化される$stable: Intフィールドを生成します。下流の呼び出し箇所では、フェーズ 9 を通じてこの情報を取得します。- 呼び出し箇所で置換 →
T = String→ String は Certain Stable → ビット 0 が満たされます。 - 最終レポートの行は
runtime stable class Box<T>となり、呼び出し箇所ではBoxRowがskippableになります。
// WRONG mental model
// "runtime stable means there is a runtime cost on every recomposition" — partly true but misleading
// WRONG because: the cost is one Int field load and a bitwise AND, performed once when the runtime
// computes the call-site stability. It is far cheaper than the unskipped recomposition it prevents.
// RIGHT mental model
// runtime stable = "the compiler proved stability conditional on the type arguments, and emitted
// a $stable: Int field whose bits the runtime ANDs against the substituted argument stabilities".
// The skip decision is still made; it is just made at runtime instead of compile time.
パターン:「なぜ @Immutable data class Person(val name: String) は @Stable より多くの最適化を可能にするのですか?」
@Stable は「変更を Compose に通知します」という契約です。@Immutable は「決して変更しません」という、より強い契約です。@Immutable では、コンパイラーは Person.name の読み取りを静的な式に昇格させ、ネストされた使用箇所の等価性チェックを省略できます。一方、@Stable では、等価性チェックを引き続き出力する必要があります。どちらも Stability.Certain に分類されますが、下流のオプティマイザーは @Immutable をより積極的に扱います。
// WRONG
@Stable data class Coordinates(val lat: Double, val lng: Double)
// WRONG because: Coordinates never mutates after construction. @Stable understates the contract
// and forfeits static-expression promotion at every read site.
// RIGHT
@Immutable data class Coordinates(val lat: Double, val lng: Double)
パターン:「見たところ問題ないのに、なぜ再帰ツリーが不安定なのですか?」
data class Node(val id: String, val children: List<Node>)
フェーズ 5(循環検出)で処理が中断されます。答えを証明するために答えを仮定する必要があるため、コンパイラーは不動点解析を試みません。保守的な判定は Unstable です。すべてのフィールドがそれ以外の点で安定していても、children を経由する再帰によって、親の呼び出しに Unstable が返されます。
// WRONG — adds @Stable to "force" stability
@Stable data class Node(val id: String, val children: List<Node>)
// WRONG because: List<Node> is a mutable interface backed by ArrayList in practice. The @Stable
// annotation tells the compiler to trust the contract, but the actual List instance can mutate
// between recompositions without notifying Compose, producing silent missed recompositions.
// RIGHT
import kotlinx.collections.immutable.ImmutableList
@Immutable data class Node(val id: String, val children: ImmutableList<Node>)
ImmutableList は、ビットマスク 0b1 を持つ既知の安定構造のレジストリ(フェーズ 7)に登録されているため、children を経由する再帰が許可されます。フェーズ 5 では循環検出が引き続き実行されますが、レジストリに該当するため、保守的な判定は短絡されます。
パターン:「なぜ Set<String> ではスキップが妨げられるのに、ImmutableSet<String> では妨げられないのですか?」
kotlin.collections.Set はインターフェース(フェーズ 11 → Unknown)で、実際には変更可能な LinkedHashSet によって実装されています。kotlinx.collections.immutable.ImmutableSet は、ビットマスク 0b1 を持つ既知の安定構造のレジストリに登録されているため、Stability.Parameter となり、要素の型が安定していれば安定と判定されます。
// WRONG
@Composable fun TagRow(tags: Set<String>) { /* ... */ }
// WRONG because: Set is an interface — phase 11 returns Unknown, the call site is non-skippable.
// RIGHT
@Composable fun TagRow(tags: ImmutableSet<String>) { /* ... */ }
パターン:「val しか持たないのに、なぜ別モジュールのクラスが runtime stable になるのですか?」
これは個別コンパイルによるものです。呼び出し箇所をコンパイルするとき、コンパイラーが参照できるのは、依存関係の完全なソース AST ではなく、.class ファイルと @StabilityInferred(parameters = ...) のメタデータだけです。フェーズ 9 ではそのアノテーションを読み取ります。実行時には、生成された $stable: Int フィールドを使用して、ビットマスクを実際の型引数に照らし合わせて解決します。分類自体は正しく、追加の処理は必要ありません。ただし、ソースがなければモジュールをまたいだコンパイル時解析はできないため、実行時まで判定を保留する必要があります。
コンパイラーレベルの 5 つの安定性の型
「なぜ」という質問への回答では、これらの名前を使ってください。コンパイラーは、安定性を次のいずれかとして保持します。
- Stability.Certain — プリミティブ型、String、Unit、関数型、列挙型、
@Stable/@Immutableが付いたクラス。判定は確定済みで、コンパイル時に行われます。 - Stability.Runtime — 個別にコンパイルされたクラス。コンパイル時に
$stable: Intフィールドと@StabilityInferredが出力され、実行時に実際の型引数に対してビットの論理積が取られます。 - Stability.Unknown — インターフェース、具体的な解析が行われていない抽象クラス、またはコンパイラーが判定を確定できない対象です。実行時の等価性チェックでは、フォールバックとして
===による同一性判定が使われます。 - Stability.Parameter — ジェネリック型です。安定性はビットマスクを介した型引数の関数です。
- Stability.Combined — 複数の要素をまとめたものです(クラスのフィールド、または複数の型引数など)。Unstable が優先されます — 1 つでも Unstable の要素があると、全体が不安定になります。
ビットマスクのエンコード(プレビュー)
Container<T1, T2, T3> は Int ビットマスクを使用します。ビット i がセットされている場合、Ti は安定性に関与します。
| 型 | ビットマスク | 意味 |
|---|---|---|
kotlin.Pair<A, B> | 0b11 | A と B の両方が安定性に影響します |
kotlin.Triple<A, B, C> | 0b111 | 3 つすべてが安定性に影響します |
kotlinx.collections.immutable.ImmutableList<E> | 0b1 | E のみが安定性に影響します |
java.math.BigInteger | 0b0 | 型パラメーターがないため、型引数が消去されていても安定と分類されます |
別々にコンパイルされる型に対して @StabilityInferred(parameters = ...) がどのように生成されるか、また JVM での $stable: Int フィールドの配置と、Kotlin/Native および Kotlin/JS で使用されるマングルされたトップレベルプロパティの違いを含む完全なルールについては、references/bitmask-encoding.md を参照してください。
必須ルール
- 必須: 開発者に、
runtime stableはバグでも不安定という判定でもないことを説明してください。これはコンパイラーが「安定性は型引数に依存し、実行時に$stable: Intフィールドを介して確認される」と伝える方法です。 - 必須: 現在の構造が不安定である理由を説明する前に、構造の変更(
varをvalに変える、コレクション型を入れ替えるなど)を提案してはいけません。対処の前に原因を診断してください。 - 必須: 回答では
Stability.UnknownとStability.Unstableを区別してください。Unknownは「判断できない」という意味で、同一性による等価性判定にフォールバックします。Unstableは「不安定であることが証明されている」という意味で、スキップを無効にします。 - 必須: 開発者が契約を保証できない型に
@Stableを追加するよう指示してはいけません。安定性アノテーションは契約です。これに違反すると、気づかないまま再コンポジションが欠落するため、スキップできないコンポーザブルよりも深刻です。 - 推奨: 詳細な説明に役立つ場合は、Kotlin コンパイラーのソースを引用してください。具体的なファイルは、代数的データ型の
Stability.kt、レジストリーのKnownStableConstructs.kt、$stableフィールドを出力するClassStabilityTransformer.kt、設定ファイルの読み取りを行うStabilityConfigParser.ktです。 - 推奨: ジェネリックを説明する際は、ビットマスクを具体的にたどってください。「
Pairのビットマスクではビット 0 がセットされており、A=String は Certain Stable なので条件を満たします。ビット 1 もセットされていますが、B=List は Unstable なので条件を満たしません。その結果、Combined は Unstable に集約されます」のように説明してください。
検証
- Claude はレポートを実行する前に、対象の型が
Certain、Runtime、Unknown、Parameter、Combinedのどれに分類されるかを予測できます。 - Claude は判定を生成した 12 個のフェーズのうち、どのフェーズかを特定できます。
-
runtime stableクラスについて、コンパイラーがクラス宣言に@StabilityInferred(parameters = ...)を出力し、実行時に置換後の型引数の安定性との AND 演算を行う$stable: Intフィールドを生成したことを説明できます。 - 変更可能性に関する契約が保証されていない型に
@Stableまたは@Immutableを付けるよう推奨しません。 - スキップを妨げる理由を説明する際、
Set<T>、List<T>、Map<K, V>をUnstableではなくUnknownインターフェースとして正しく識別します。
参考資料
- Compose の安定性の概要 — https://developer.android.com/develop/ui/compose/performance/stability
- 安定性 — 診断 — https://developer.android.com/develop/ui/compose/performance/stability/diagnose
- 強いスキップ — https://developer.android.com/develop/ui/compose/performance/stability/strongskipping
- Ben Trengrove、「Jetpack Compose Stability Explained」 — https://medium.com/androiddevelopers/jetpack-compose-stability-explained-79c10db270c8
- Ben Trengrove、「New ways of optimizing stability」 — https://medium.com/androiddevelopers/new-ways-of-optimizing-stability-in-jetpack-compose-038106c283cc
- Chris Banes、「Composable metrics」 — https://chrisbanes.me/posts/composable-metrics/
- skydoves、「Optimize App Performance by Mastering Stability」 — https://medium.com/proandroiddev/optimize-app-performance-by-mastering-stability-in-jetpack-compose-69f40a8c785d
- skydoves/compose-stability-inference — https://github.com/skydoves/compose-stability-inference
- skydoves/compose-stable-marker — https://github.com/skydoves/compose-stable-marker
参考ファイル
references/twelve-phase-algorithm.md— 12 のフェーズすべてと、フィールドごとの分析の疑似コードを段階的に説明します。references/bitmask-encoding.md— 汎用的な安定性ビットマスクのルール、Known Stable Constructs レジストリ、コンパイラーが生成する@StabilityInferred(parameters = 0b1)アノテーション、JVM 上の実行時$stable: Intフィールド、Native および JS におけるマングルされたトップレベルプロパティの方式について説明します。