Agent Skillsskydoves/compose-performance-skills › stabilizing-compose-types

stabilizing-compose-types

GitHub

修复 Jetpack Compose 中不稳定的类型,按三层策略:重构为不可变、添加注解或配置外部类型。

stability/stabilizing-compose-types/SKILL.md skydoves/compose-performance-skills

Trigger Scenarios

编译器报告参数不稳定 询问如何稳定 User 类或 List 参数 提及 @Immutable 或 stabilityConfigurationFiles

Install

npx skills add skydoves/compose-performance-skills --skill stabilizing-compose-types -g -y
More Options

Non-standard path

npx skills add https://github.com/skydoves/compose-performance-skills/tree/main/stability/stabilizing-compose-types -g -y

Use without installing

npx skills use skydoves/compose-performance-skills@stabilizing-compose-types

指定 Agent (Claude Code)

npx skills add skydoves/compose-performance-skills --skill stabilizing-compose-types -a claude-code -g -y

安装 repo 全部 skill

npx skills add skydoves/compose-performance-skills --all -g -y

预览 repo 内 skill

npx skills add skydoves/compose-performance-skills --list

SKILL.md

Frontmatter
{
    "name": "stabilizing-compose-types",
    "license": "Apache-2.0. See LICENSE for complete terms.",
    "metadata": {
        "author": "Jaewoong Eum (skydoves)",
        "keywords": [
            "jetpack-compose",
            "performance",
            "stability",
            "immutable",
            "stable-marker",
            "kotlinx-collections-immutable",
            "stability-configuration-file",
            "strong-skipping"
        ]
    },
    "description": "Use this skill to fix unstable Jetpack Compose types once a stability diagnosis has identified them. Covers the three-tier strategy — make the type truly stable with val plus immutable fields, mark with @Immutable or @Stable when the source is owned, and use stabilityConfigurationFiles for third-party or Java types. Explains the compiler-level difference between @Immutable and @Stable (static expression promotion), kotlinx.collections.immutable for List\/Set\/Map parameters, and the StableHolder wrapper escape hatch. Use when the developer asks how to stabilize a User class, a List parameter, java.time.LocalDateTime, a Flow parameter, or when the compiler report shows unstable params and the developer wants the fix. The diagnostic step lives in a sibling skill."
}

Stabilizing Compose Types — Three Tiers, In Order

Once a diagnosis (see ../diagnosing-compose-stability/SKILL.md) has named the unstable types, this skill walks Claude through fixing them. The strategy is a strict three-tier waterfall: (1) make the type truly stable by structural rewrite (val + immutable fields) — no annotation needed; (2) annotate with @Immutable or @Stable when the source is owned and the contract is honored; (3) use stabilityConfigurationFile for third-party or Java types. DO NOT invert this order — annotations are a contract, not a magic spell.

When to use this skill

  • The compiler report (composables.txt / classes.txt) shows one or more unstable parameters.
  • The developer asks how to stabilize a domain class, a List<Foo> parameter, java.time.LocalDateTime, or a third-party type.
  • The developer mentions @Immutable, @Stable, compose-stable-marker, compose-runtime-annotation, kotlinx.collections.immutable, stabilityConfigurationFiles, or stability_config.conf.
  • A @TraceRecomposition log shows recomposition happening because an argument is allocated fresh every recomposition.

When NOT to use this skill

  • The unstable types are not yet identified — run ../diagnosing-compose-stability/SKILL.md first.
  • The symptom is a wrong-phase state read (Modifier.alpha(state.value)); use ../../recomposition/deferring-state-reads/SKILL.md.
  • The symptom is a derivedStateOf problem; use ../../recomposition/choosing-derivedstateof/SKILL.md.
  • A Flow<T> parameter is the cause; the fix is to collect upstream — see ../../side-effects/collecting-flows-safely/SKILL.md rather than annotating Flow as stable.

Prerequisites

  • ../diagnosing-compose-stability/SKILL.md has been run; the developer has a concrete list of unstable types.
  • Kotlin 2.0.0+ with org.jetbrains.kotlin.plugin.compose applied. Strong Skipping is on by default.
  • For pure-Kotlin / data modules without compose-runtime: either androidx.compose.runtime:runtime-annotation (official) or com.github.skydoves:compose-stable-marker (legacy) on the classpath. Both expose @Stable / @Immutable / @StableMarker without dragging in the full runtime.
  • For tier-3 fixes: Compose Compiler 1.5.5+ for stabilityConfigurationFiles DSL support (plural; the older singular stabilityConfigurationFile property is deprecated, see footnote below).

Workflow — decision tree

  • 1. Do we own the source of the unstable type? If yes, use tier 1 or tier 2. If no (Java stdlib, third-party SDK), jump to tier 3.

  • 2. Tier 1 — restructure to truly stable. Can every property be a val of an already-stable type? If yes, MUST make that change first. No annotation is needed and no contract is implied. The compiler will infer stability automatically.

// 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. Is the unstable property a collection? Replace kotlin.collections.List, Set, Map with kotlinx.collections.immutable.ImmutableList, ImmutableSet, ImmutableMap. Build with persistentListOf(), persistentSetOf(), persistentMapOf() factories or .toImmutableList() adapters. The kotlinx-collections-immutable artifact ships a known-stable bitmask 0b1 recognized by the Compose Compiler. PREFERRED: prefer this over whitelisting kotlin.collections.* in stability_config.conf because the type system enforces immutability.

  • 4. Is the source a pure-Kotlin / data module that does not depend on compose-runtime? Use androidx.compose.runtime:runtime-annotation (official, recommended) or com.github.skydoves:compose-stable-marker (legacy). Both let the data module annotate types without pulling in the full Compose runtime. Skydoves hot take #6: pure-Kotlin / data modules can use compose-stable-marker (or newer official compose-runtime-annotation) without pulling in full compose-runtime.

// 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 — choose @Immutable or @Stable. If every property is val AND every nested value is itself immutable AND equals() is structural, use @Immutable. Otherwise — for types whose values can change but whose mutations are observed by Compose (e.g. holders containing MutableState) — use @Stable.

The compiler treats @Immutable more aggressively than @Stable. For @Immutable types, when every constructor argument at a call site is a compile-time constant (e.g. literal numbers, top-level vals of stable types, or other @Immutable-with-constant-args), the compiler performs static expression promotion: the constructed instance is hoisted into a singleton, the lambda capture sites are de-duplicated, and the @Immutable parameter is marked @static in composables.txt. Most articles describe @Stable and @Immutable as interchangeable — they are not.

// 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 — third-party or Java types. Use stabilityConfigurationFiles (plural). Create stability_config.conf at the project root listing exact-class or wildcard patterns; wire it into the composeCompiler { } block via stabilityConfigurationFiles.add(...). Full grammar lives in references/stability-config-syntax.md.
// build.gradle.kts (root or composable module)
composeCompiler {
    stabilityConfigurationFiles.add(
        rootProject.layout.projectDirectory.file("stability_config.conf")
    )
}

Footnote — legacy form. Older projects may still wire the file via the singular property stabilityConfigurationFile = .... That property is @Deprecated("Use the stabilityConfigurationFiles option instead") in modern Compose Compiler Gradle plugin releases — prefer the plural stabilityConfigurationFiles.add(...) shown above.

# 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. Nothing in tiers 1-3 fits? Wrap in a @Stable class StableHolder<T>(val item: T). This is the escape hatch — the holder's identity is stable, equality delegates to item, and Compose can skip on it.
@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> parameters — DO NOT annotate as stable. Skydoves hot take #4: Flow parameters are unstable. Don't pass flows to composables; collect them in a ViewModel or with 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. Re-run the diagnostic skill to verify the previously unstable params are now stable or runtime.

Patterns

Pattern: data class with var and Set field

// 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(),
)

Pattern: List<Item> parameter on a composable

// 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>) { /* ... */ }

Producer side:

val items: ImmutableList<Item> = repository.items().toImmutableList()

Pattern: java.time.LocalDateTime flagged unstable

The Java time types are separately compiled, no annotation, so the inference falls through to "unknown". Tier 3 is the right answer.

# 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)

Pattern: Flow<T> parameter on a composable

// 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?) { /* ... */ }

Cross-reference: ../../side-effects/collecting-flows-safely/SKILL.md.

Pattern: inline composable wrap as a "fix"

// 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 { /* ... */ }
}

Pattern: @Immutable versus @Stable — pick the stronger one

// 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,
)

In composables.txt, calls like ThemeColors(Color(0xFFFFFFFF), Color(0xFF000000)) will then be reported as stable @static themeColors: ThemeColors — the constructed instance is hoisted to a singleton. MUST prefer @Immutable whenever the type qualifies.

Mandatory rules

  • MUST NOT annotate a type as @Stable or @Immutable unless the contract is honored. Skydoves hot take #2: stability config is a contract, not a magic spell — break it and recompositions are silently missed.
  • MUST prefer @Immutable over @Stable whenever every property is a val of an already-immutable type. The compiler emits stronger optimizations (static expression promotion, lambda-singleton, compile-time default eval) for @Immutable.
  • MUST prefer kotlinx.collections.immutable over annotating a mutable collection as stable. The compiler ships a known-stable bitmask for these types.
  • MUST NOT wrap inline composables (Row/Column/Box) to "force" skippability. Inline composables are not restartable in the first place; wrapping changes scoping without addressing the root cause.
  • MUST NOT pass Flow<T> as a composable parameter. Collect upstream with collectAsStateWithLifecycle.
  • MUST re-run ../diagnosing-compose-stability/SKILL.md after applying fixes; the previously unstable params MUST now report stable or runtime.
  • PREFERRED: stabilityConfigurationFiles (plural; stabilityConfigurationFiles.add(...)) over scattering annotations across modules the team does not own.
  • PREFERRED: in pure-Kotlin / data modules, use androidx.compose.runtime:runtime-annotation (official) or com.github.skydoves:compose-stable-marker (legacy) so the annotation is available without a full compose-runtime dependency.

Verification

  • Re-run release-mode build with composeCompiler { reportsDestination = ... } enabled.
  • In composables.txt, every previously unstable parameter is now stable or runtime.
  • In classes.txt, every fixed class is now stable class … or runtime stable class … — no unstable remains for the targeted types.
  • If stabilityConfigurationFiles was used, every entry has been verified to honor the contract (no hidden mutation, structural equals, no observable that bypasses Snapshot).
  • @TraceRecomposition (skydoves/compose-stability-analyzer) on the previously hot composable shows recomposition counts dropping to the expected number per state change.
  • No @Stable / @Immutable annotation has been added to a type that still contains a var or an unstable nested type.

References

Version History

  • 1b32f81 Current 2026-07-24 16:58

Same Skill Collection

audit/auditing-compose-performance/SKILL.md
hot-reload/iterating-with-ai-and-mcp/SKILL.md
hot-reload/preserving-state-across-reloads/SKILL.md
hot-reload/setting-up-compose-hotswan/SKILL.md
hot-reload/understanding-hot-reload-limits/SKILL.md
lists/configuring-lazy-prefetch/SKILL.md
lists/optimizing-lazy-layouts/SKILL.md
measurement/generating-baseline-profiles/SKILL.md
measurement/testing-compose-in-release-mode/SKILL.md
measurement/tracing-recompositions-at-runtime/SKILL.md
modifiers/migrating-to-modifier-node/SKILL.md
modifiers/ordering-modifier-chains/SKILL.md
recomposition/avoiding-subcomposition-pitfalls/SKILL.md
recomposition/choosing-derivedstateof/SKILL.md
recomposition/debugging-recompositions/SKILL.md
recomposition/deferring-state-reads/SKILL.md
recomposition/using-strong-skipping-correctly/SKILL.md
side-effects/collecting-flows-safely/SKILL.md
side-effects/using-efficient-effects/SKILL.md
stability/diagnosing-compose-stability/SKILL.md
stability/enforcing-stability-in-ci/SKILL.md
stability/understanding-stability-inference/SKILL.md
stability/using-stability-analyzer-ide-plugin/SKILL.md
stability/visualizing-recomposition-cascades/SKILL.md

Metadata

Files
0
Version
1b32f81
Hash
795555df
Indexed
2026-07-24 16:58

inicio - Wiki
Copyright © 2011-2026 iteam. Current version is 2.155.2. UTC+08:00, 2026-08-16 22:13
浙ICP备14020137号-1 $mapa de visitantes$