A comprehensive study of how the Compose compiler determines type stability for recomposition optimization. All details that Optimize App Performance By Mastering Stability in Jetpack Compose and compose-performance couldn't take in.
Jetpack Compose Mechanisms takes you from "how to use Compose" into "how Compose actually works," tracing the AOSP source line by line through the compiler, runtime, and UI layers beneath every Composable, with practical, production-ready examples from the author's own Compose tooling and libraries. It then ties all three layers together into deep, real-world performance tuning, from stability inference to the skip decision. Fully updated for Kotlin 2.4.0 and Compose Compiler 2.4.0.
- Compose Compiler Stability Inference System
- ๐ Manifest Android Interview
- ๐๏ธ Dove Letter
- Table of Contents
- Chapter 1: Foundations
- Chapter 2: Stability Type System
- Chapter 3: The Inference Algorithm
- Chapter 4: Implementation Mechanisms
- Chapter 5: Case Studies
- Chapter 6: Configuration and Tooling
- Chapter 7: Advanced Topics
- Chapter 8: Compiler Analysis System
- Conclusion
- Find this repository useful? โค๏ธ
- License
The Compose compiler implements a stability inference system to enable recomposition optimization. This system analyzes types at compile time to determine whether their values can be safely compared for equality during recomposition.
The inference process involves analyzing type declarations, examining field properties, and tracking stability through generic type parameters. The results inform the runtime whether to skip recomposition when parameter values remain unchanged.
A type is considered stable when it satisfies three conditions:
- Immutability: The observable state of an instance does not change after construction
- Equality semantics: Two instances with equal observable state are equal via
equals() - Change notification: If the type contains observable mutable state, all state changes trigger composition invalidation
These properties allow the runtime to make optimization decisions based on value comparison.
When a composable function receives parameters, the runtime determines whether to execute the function body:
@Composable
fun UserProfile(user: User) {
// Function body
}The decision process:
- Compare the new
uservalue with the previous value - If equal and the type is stable, skip recomposition
- If different or unstable, execute the function body
Without stability information, the runtime must conservatively recompose on every invocation, regardless of whether parameters changed.
Stability inference affects recomposition in three ways:
Smart Skipping: Composable functions with stable parameters can be skipped when parameter values remain unchanged. This reduces the number of function executions during recomposition.
Comparison Propagation: The compiler passes stability information to child composable calls, enabling nested optimizations throughout the composition tree.
Comparison Strategy: The runtime selects between structural equality (equals()) for stable types and referential equality (===) for unstable types, affecting change detection behavior.
Consider this example:
// Unstable parameter type - interface with unknown stability
@Composable
fun ExpensiveList(items: List<String>) {
// List is an interface - has Unknown stability
// Falls back to instance comparison
}
// Stable parameter type - using immutable collection
@Composable
fun ExpensiveList(items: ImmutableList<String>) {
// ImmutableList is in KnownStableConstructs
// Can skip recomposition when unchanged
}
// Alternative: Using listOf() result
@Composable
fun ExpensiveList(items: List<String>) {
// If items comes from listOf(), the expression is stable
// But the List type itself is still an interface with Unknown stability
}The key insight: List and MutableList are both interfaces with Unknown stability. To achieve stable parameters, use:
ImmutableListfrom kotlinx.collections.immutable (in KnownStableConstructs)- Add
kotlin.collections.Listto your stability configuration file - Use
@Stableannotation on your data classes containing List
The compiler represents stability through a sealed class hierarchy defined in Stability.kt:
sealed class Stability {
class Certain(val stable: Boolean) : Stability()
class Runtime(val declaration: IrClass) : Stability()
class Unknown(val declaration: IrClass) : Stability()
class Parameter(val parameter: IrTypeParameter) : Stability()
class Combined(val elements: List<Stability>) : Stability()
}Each subtype represents a different category of stability information available to the compiler.
This type represents stability that can be determined completely at compile time.
Structure:
class Certain(val stable: Boolean) : Stability()The stable field indicates whether the type is definitely stable (true) or definitely unstable (false).
Examples:
// Certain(stable = true)
class Point(val x: Int, val y: Int)
// Certain(stable = false)
class Counter(var count: Int)Usage Conditions:
- Primitive types (
Int,Long,Boolean, etc.) StringandUnit- Function types (
FunctionN,KFunctionN) - Classes with only stable
valproperties - Classes with any
varproperty (immediately unstable) - Classes marked with
@Stableor@Immutableannotations
Implementation: See Stability.kt for the knownStable() extension function.
This type indicates that stability must be checked at runtime by reading a generated $stable field.
Structure:
class Runtime(val declaration: IrClass) : Stability()The declaration references the class whose stability requires runtime determination.
Generated Code Example:
// Source code
class Box<T>(val value: T)
// Compiler-generated code
@StabilityInferred(parameters = 0b1)
class Box<T>(val value: T) {
companion object {
@JvmField
val $stable: Int = /* computed based on type parameters */
}
}When Applied:
- Classes from external modules (separately compiled)
- Generic classes where type parameters affect stability
- Classes with
@StabilityInferredannotation
Runtime Behavior:
At instantiation sites, the runtime computes the $stable field value:
Box<Int> // $stable = STABLE (0b000)
Box<MutableList> // $stable = UNSTABLE (0b100)Implementation: See Stability.kt (the Runtime handling in StabilityInferencer.stabilityOf) and ClassStabilityTransformer.kt (the generated $stable field).
This type represents cases where the compiler cannot determine stability.
Structure:
class Unknown(val declaration: IrClass) : Stability()Examples:
interface Repository {
fun getData(): String
}
class Screen(val source: Repository)
// Repository has Unknown stabilityUsage Conditions:
- Interface types (unknown implementations)
- Abstract classes without concrete analysis
- Types in incremental compilation scenarios
Runtime Behavior:
When encountering Unknown stability, the runtime falls back to instance comparison (===) for change detection. This conservative approach ensures correctness but prevents skipping optimizations.
Implementation: See Stability.kt (the Stability.Unknown branch in StabilityInferencer.stabilityOf).
This type represents stability that depends on a generic type parameter.
Structure:
class Parameter(val parameter: IrTypeParameter) : Stability()Example:
class Wrapper<T>(val value: T)
// ^^^^^^^^^^^^
// Stability depends on T
// Instantiation examples:
Wrapper<Int> // Stable (Int is stable)
Wrapper<Counter> // Unstable (Counter from 2.2 is unstable)Resolution Process:
When analyzing Wrapper<Int>:
- Identify
value: ThasStability.Parameter(T) - Substitute
TwithInt - Evaluate
stabilityOf(Int)=Stable - Result:
Wrapper<Int>is stable
Implementation: See Stability.kt for type parameter handling (the isTypeParameter() branch and applyTypeParameterMask).
This type aggregates multiple stability factors from different sources.
Structure:
class Combined(val elements: List<Stability>) : Stability()Examples:
class Complex<T, U>(
val primitive: Int, // Certain(stable = true)
val param1: T, // Parameter(T)
val param2: U // Parameter(U)
)
// Combined([Certain(true), Parameter(T), Parameter(U)])Combination Rules:
The compiler combines stabilities using the plus operator (defined in Stability.kt):
operator fun plus(other: Stability): Stability = when {
other is Certain -> if (other.stable) this else other
this is Certain -> if (stable) other else this
else -> Combined(listOf(this, other))
}Worked examples:
Stable + Stable = Stable
Stable + Unstable = Unstable
Unstable + Stable = Unstable
Stable + Parameter = Parameter // a stable Certain is absorbed
Parameter + Parameter = Combined([Parameter, Parameter])
Runtime + Parameter = Combined([Runtime, Parameter])Key insight: Adding a stable Certain returns the other operand unchanged, so only genuinely uncertain factors (Parameter, Runtime, Unknown) accumulate into a Combined. A Combined is only produced when neither operand is Certain.
Key Property: Unstable stability dominates all combinations. A single unstable component makes the entire result unstable.
The Compose compiler follows a systematic decision tree when determining stability. This tree represents the actual logic flow implemented in the compiler.
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Start: Analyze Type/Class โ
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Is it a primitive type? โโโโYesโโโ [STABLE]
โ (Int, Boolean, Float, etc.) โ
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ No
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Is it String or Unit? โโโโYesโโโ [STABLE]
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ No
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Is it a function type? โโโโYesโโโ [STABLE]
โ (Function<*>, KFunction<*>) โ
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ No
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Has @Stable or @Immutable? โโโโYesโโโ [STABLE]
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ No
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Has @StableMarker descendant? โโโโYesโโโ [STABLE]
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ No
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Is it an Enum class/entry? โโโโYesโโโ [STABLE]
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ No
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Is it an object (singleton)? โโโโYesโโโ [STABLE]
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ No
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Is it a Protobuf type? โโโโYesโโโ [STABLE]
โ (GeneratedMessage/Lite) โ
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ No
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Is it in KnownStableConstructs?โโโโYesโโโ [STABLE/RUNTIME]
โ (Pair, Triple, etc.) โ (check type params)
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ No
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Matches external config? โโโโYesโโโ [STABLE/RUNTIME]
โ (stability-config.conf) โ (check type params)
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ No
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Is it an interface? โโโโYesโโโ [UNKNOWN]
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ No
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Is it from external Java? โโโโYesโโโ [UNSTABLE]
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ No
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Public/internal & declared in โโโโYesโโโ [RUNTIME]
โ a different file? (JVM, for โ (read $stable,
โ incremental compilation) โ apply type-param mask)
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ No
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ External stub with a stability โโโโYesโโโ [RUNTIME] / [UNSTABLE]
โ bitmask? (@StabilityInferred) โ (no bitmask โ UNSTABLE)
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ No
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Seed stability: โ
โ final class โ start STABLE โ
โ non-final โ start UNKNOWN โ
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Analyze class members: โ
โ - Check all properties โ
โ - Check backing fields โ
โ - Check superclass โ
โ (ignored if Unknown) โ
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Any non-delegated var โโโโYesโโโ [UNSTABLE]
โ (mutable) property? โ
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ No
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ All members stable? โโโโYesโโโ [STABLE]
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ No
โผ
[UNSTABLE/COMBINED/UNKNOWN]
When analyzing generic types, the compiler follows an additional decision path:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Generic Type: Class<T1, T2> โ
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Base class stable? โโโโNoโโโโ [UNSTABLE]
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ Yes
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Has stability bitmask? โโโโNoโโโโ Analyze each
โ (from KnownStableConstructs โ type parameter
โ or external config) โ individually
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ Yes
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ For each type parameter Ti: โ
โ Is bit i set in bitmask? โโโโNoโโโโ Ti doesn't affect
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ stability
โ Yes
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Check stability of actual โ
โ type argument for Ti โโโโโ Combine all results
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
For expressions (used in default parameters and composable bodies):
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Expression to analyze โ
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Is expression type stable? โโโโYesโโโ [STABLE]
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ No
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Is it a constant (IrConst)? โโโโYesโโโ [STABLE]
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ No
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Is it a stable function call? โโโโYesโโโ Check function
โ (listOf, mapOf, etc.) โ type parameters
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ No
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Is it a val reference? โโโโYesโโโ Check initializer
โ (non-mutable variable) โ stability
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ No
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Is it a composite with all โโโโYesโโโ [STABLE]
โ stable sub-expressions? โ
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ No
โผ
[UNSTABLE]
1. Early Exit Conditions:
- Primitives, String, Unit, and function types are immediately stable
- Stability annotations override all other checks
- Enums (classes and entries) are always stable (finite, immutable values)
- Objects (singletons) are always stable
2. Interface Handling:
- Interfaces return
Unknownstability because implementations can vary - Exception: Interfaces with
@Stablemarker are trusted
3. External Types:
- Java types default to unstable (mutable by default in Java)
- Protobuf types are special-cased as stable (immutable messages)
- External Kotlin modules use
@StabilityInferredbitmasks
4. Member Analysis:
- The seed stability is
Stableforfinalclasses andUnknown(declaration)for non-final (open/abstract) classes - Any non-delegated
varproperty makes the entire class unstable - Delegated properties are analyzed based on their delegate type
- Superclass stability is combined into the result, but an
Unknownsuperclass result is ignored (so an open superclass doesn't poison the subclass)
5. Generic Type Resolution:
- Bitmask encodes which type parameters affect stability
- Maximum 32 type parameters supported (32-bit bitmask)
- Type arguments are substituted and analyzed recursively
This decision tree is implemented across several key functions in the compiler, with the main entry point being StabilityInferencer.stabilityOf().
The stability inference algorithm operates following the decision tree shown above, with multiple optimization paths for early termination. The implementation spans several key components working together.
Algorithm Structure:
The algorithm short-circuits when it can determine stability definitively, avoiding unnecessary analysis.
The compiler first checks for common stable types:
when {
type is IrErrorType -> Stability.Unstable
type is IrDynamicType -> Stability.Unstable
type.isUnit() ||
type.isPrimitiveType() ||
type.isFunctionOrKFunction() ||
type.isSyntheticComposableFunction() ||
type.isString() -> Stability.Stable
}Primitive Types:
- Numeric:
Byte,Short,Int,Long,Float,Double - Boolean:
Boolean - Character:
Char
Function Types:
- Standard functions:
Function0,Function1, ...,FunctionN - Kotlin functions:
KFunction0,KFunction1, ...,KFunctionN - Composable functions:
ComposableFunction0, etc.
For generic type parameters:
type.isTypeParameter() -> {
val classifier = type.classifierOrFail
val arg = substitutions[classifier]
val symbol = SymbolForAnalysis(classifier, emptyList(), analysisEntryFile)
if (arg != null && symbol !in currentlyAnalyzing) {
stabilityOf(arg, substitutions, currentlyAnalyzing + symbol, analysisEntryFile)
} else {
Stability.Parameter(classifier.owner as IrTypeParameter)
}
}Substitution Example:
class Container<T>(val item: T)
// Analyzing Container<Int>
// T is IrTypeParameter
// substitutions map: {T: Int}
// Result: stabilityOf(Int) = StableNullable types defer to their non-null counterpart:
type.isNullable() ->
stabilityOf(type.makeNotNull(), substitutions, currentlyAnalyzing)Examples:
Int?โ analyzeIntโ StableUser?โ analyzeUserโ depends on User structure
Inline classes (value classes) have special handling:
type.isInlineClassType() -> {
val inlineClassDeclaration = type.getClass()
if (inlineClassDeclaration.hasStableMarker()) {
Stability.Stable
} else {
stabilityOf(
type = getInlineClassUnderlyingType(inlineClassDeclaration),
substitutions = substitutions,
currentlyAnalyzing = currentlyAnalyzing
)
}
}Examples:
@JvmInline
value class UserId(val value: Int)
// Checks: stabilityOf(Int) = Stable
@JvmInline
value class Token(val value: String)
// Checks: stabilityOf(String) = Stable
@JvmInline
@Stable
value class SpecialId(val list: MutableList<Int>)
// @Stable marker overrides underlying type analysis
// Result: Stable (by annotation)To prevent infinite recursion with recursive types:
if (currentlyAnalyzing.contains(fullSymbol))
return Stability.UnstableExample:
class Node(val value: Int, val next: Node?)
// Analysis trace:
// 1. stabilityOf(Node) โ add to currentlyAnalyzing
// 2. Check field: value: Int โ Stable
// 3. Check field: next: Node? โ unwrap nullable
// 4. Check field: next: Node โ CYCLE DETECTED
// 5. Return UnstableThis conservative approach ensures termination while potentially marking some stable recursive types as unstable.
Quick checks for annotated or special types:
if (declaration.hasStableMarkedDescendant()) return Stability.Stable
if (declaration.isEnumClass || declaration.isEnumEntry) return Stability.Stable
if (declaration.isObject) return Stability.Stable
if (declaration.defaultType.isPrimitiveType()) return Stability.Stable
if (declaration.isProtobufType()) return Stability.StableStable Markers:
@Stableannotation@Immutableannotation- Annotations marked with
@StableMarker
Enum Handling:
All enum classes and enum entries are considered stable because:
- Enum instances are singletons (referential equality works)
- Enum state is immutable after initialization
- Enum equality is based on identity
Object Handling:
object declarations (singletons) are always stable. There is exactly one instance, so identity comparison is always valid regardless of the object's members.
Protobuf Detection:
private fun IrClass.isProtobufType(): Boolean {
if (!isFinalClass) return false
val directParentClassName = superTypes
.lastOrNull { !it.isInterface() }
?.classOrNull?.owner?.fqNameWhenAvailable?.toString()
return directParentClassName == "com.google.protobuf.GeneratedMessageLite" ||
directParentClassName == "com.google.protobuf.GeneratedMessage"
}Generated protobuf classes are marked stable despite potentially containing mutable implementation details.
The compiler maintains a registry of known stable types:
val stableTypes = mapOf(
Pair::class.qualifiedName!! to 0b11,
Triple::class.qualifiedName!! to 0b111,
Comparator::class.qualifiedName!! to 0b1,
Result::class.qualifiedName!! to 0b1,
ClosedRange::class.qualifiedName!! to 0b1,
ClosedFloatingPointRange::class.qualifiedName!! to 0b1,
// Guava
"com.google.common.collect.ImmutableList" to 0b1,
"com.google.common.collect.ImmutableEnumMap" to 0b11,
"com.google.common.collect.ImmutableMap" to 0b11,
"com.google.common.collect.ImmutableEnumSet" to 0b1,
"com.google.common.collect.ImmutableSet" to 0b1,
// Kotlinx immutable
"kotlinx.collections.immutable.ImmutableCollection" to 0b1,
"kotlinx.collections.immutable.ImmutableList" to 0b1,
"kotlinx.collections.immutable.ImmutableSet" to 0b1,
"kotlinx.collections.immutable.ImmutableMap" to 0b11,
"kotlinx.collections.immutable.PersistentCollection" to 0b1,
"kotlinx.collections.immutable.PersistentList" to 0b1,
"kotlinx.collections.immutable.PersistentSet" to 0b1,
"kotlinx.collections.immutable.PersistentMap" to 0b11,
// Dagger
"dagger.Lazy" to 0b1,
// Coroutines
EmptyCoroutineContext::class.qualifiedName!! to 0,
// Java types
BigInteger::class.qualifiedName!! to 0,
BigDecimal::class.qualifiedName!! to 0,
Locale::class.qualifiedName!! to 0,
)The integer value represents a bitmask indicating which type parameters affect stability (covered in Chapter 4). A mask of 0 means the type is stable regardless of its type arguments (e.g. BigInteger, Locale).
Users can provide configuration files declaring types as stable:
if (declaration.isExternalStableType()) {
val baseStability = Stability.Stable
return baseStability.applyTypeParameterMask(
mask = externalTypeMatcherCollection
.maskForName(declaration.fqNameWhenAvailable) ?: 0,
typeParameters = typeParameters,
substitutions,
analyzing,
analysisEntryFile,
)
}The same applyTypeParameterMask helper used for KnownStableConstructs (Phase 7) combines the base stability with the stability of the type arguments selected by the configured bitmask. Configuration file format is covered in Chapter 6.
Stability inference must be stable across incremental compilation, which is separated by file. If the compiler inferred concrete stability for a class declared in another file, a later edit to that file could silently invalidate the result without recompiling the dependents. To avoid this, classes that are part of the public/internal API and are declared in a different file than the one that started the analysis are forced to use runtime stability โ i.e. the value of their generated $stable field is read at runtime instead of being inferred at compile time.
// `analysisEntryFile` is the file containing the element that started this
// stabilityOf() call tree; `fileContainingDeclaration` is where `declaration` lives.
val forcedToUseRuntimeStability = isTargetJvm &&
(declaration.visibility.isPublicAPI ||
declaration.visibility == DescriptorVisibilities.INTERNAL) &&
(fileContainingDeclaration == null || fileContainingDeclaration != analysisEntryFile)
if (forcedToUseRuntimeStability) {
val baseStability = Stability.Runtime(declaration)
return baseStability.applyTypeParameterMask(
mask = null, // null = consider every type parameter
typeParameters = typeParameters,
substitutions,
analyzing,
analysisEntryFile,
)
}
// Classes that come from a separately-compiled module arrive as external stubs.
// Their stability is encoded in the @StabilityInferred bitmask.
if (declaration.origin == IrDeclarationOrigin.IR_EXTERNAL_DECLARATION_STUB) {
val mask = declaration.stabilityParamBitmask() ?: return Stability.Unstable
val baseStability = Stability.Runtime(declaration)
return baseStability.applyTypeParameterMask(
mask,
typeParameters = typeParameters,
substitutions,
analyzing,
analysisEntryFile,
)
}Key points:
- The decision is driven by the file the declaration lives in (via
analysisEntryFile), not by a "current module" check. This is what makes the result safe under incremental compilation. Stability.Runtime(declaration)means "emit a read ofdeclaration.$stableat runtime"; it is combined with the stability of the relevant type arguments viaapplyTypeParameterMask.- When
forcedToUseRuntimeStability,maskisnull, so all type parameters are taken into account. For genuine external stubs the precise@StabilityInferredbitmask is used instead. - An external stub with no
@StabilityInferredbitmask (e.g. a third-party class compiled without the Compose compiler) is treated asUnstable.
Note: the order of checks in the source is
Java stub โ interface โ external-stub-without-bitmask โ forcedToUseRuntimeStability โ external stub. Phases 10 and 11 below (Java and interface handling) actually execute before this runtime-stability logic.
if (declaration.origin == IrDeclarationOrigin.IR_EXTERNAL_JAVA_DECLARATION_STUB) {
return Stability.Unstable
}Java types default to unstable because:
- Java allows unrestricted mutability
- No equivalent of Kotlin's
valguarantee - No stability annotations in Java standard library
if (declaration.isInterface) {
return Stability.Unknown(declaration)
}Without concrete implementation details, interfaces have unknown stability.
For concrete classes in the current module:
var stability = if (declaration.modality == Modality.FINAL) {
Stability.Stable
} else {
Stability.Unknown(declaration)
}
for (member in declaration.declarations) {
when (member) {
is IrProperty -> {
member.backingField?.let {
if (member.isVar && !member.isDelegated) return Stability.Unstable
stability += stabilityOf(it.type, substitutions, analyzing, analysisEntryFile)
}
}
is IrField -> {
stability += stabilityOf(member.type, substitutions, analyzing, analysisEntryFile)
}
}
}
declaration.superClass?.let {
val superClassStability = stabilityOf(it, substitutions, analyzing, analysisEntryFile)
if (superClassStability !is Stability.Unknown) {
stability += superClassStability
}
}
return stabilityKey Points:
- The seed is
Stableonly forfinalclasses; non-final (open/abstract) classes seed asUnknown(declaration), since an unknown subclass could add unstable state - Any non-delegated
varproperty immediately returnsUnstable - Combine stability of all backing-field (
val) property types - Include superclass stability โ but only when it is not
Unknown(anUnknownsuperclass result is dropped rather than propagated) - Use the
+operator for combination (see 2.6)
Beyond type stability, the compiler analyzes expression stability:
fun stabilityOf(expr: IrExpression): Stability {
val stability = stabilityOf(expr.type)
if (stability.knownStable()) return stability
return when (expr) {
is IrConst -> Stability.Stable
is IrCall -> stabilityOf(expr, stability)
is IrGetValue -> /* analyze variable */
is IrLocalDelegatedPropertyReference -> Stability.Stable
is IrComposite -> /* analyze all statements */
else -> stability
}
}Literal constants are always stable:
val x = 42 // IrConst(42) โ Stable
val s = "text" // IrConst("text") โ Stable
val b = true // IrConst(true) โ StableThe compiler checks known stable functions:
private fun stabilityOf(expr: IrCall, baseStability: Stability): Stability {
val function = expr.symbol.owner
val fqName = function.kotlinFqName
return when (val mask = KnownStableConstructs.stableFunctions[fqName.asString()]) {
null -> baseStability
0 -> Stability.Stable
else -> Stability.Combined(/* check type arguments */)
}
}Known Stable Functions:
val stableFunctions = mapOf(
"kotlin.collections.emptyList" to 0,
"kotlin.collections.listOf" to 0b1,
"kotlin.collections.listOfNotNull" to 0b1,
"kotlin.collections.mapOf" to 0b11,
"kotlin.collections.emptyMap" to 0,
"kotlin.collections.setOf" to 0b1,
"kotlin.collections.emptySet" to 0,
"kotlin.to" to 0b11,
// Kotlinx immutable
"kotlinx.collections.immutable.immutableListOf" to 0b1,
"kotlinx.collections.immutable.immutableSetOf" to 0b1,
"kotlinx.collections.immutable.immutableMapOf" to 0b11,
"kotlinx.collections.immutable.persistentListOf" to 0b1,
"kotlinx.collections.immutable.persistentSetOf" to 0b1,
"kotlinx.collections.immutable.persistentMapOf" to 0b11,
)For val variables, the compiler checks initializer stability:
val x = 42 // Stable initializer
val y = x // IrGetValue(x) โ Stable
var z = 42 // Mutable variable
val w = z // IrGetValue(z) โ use type stabilityGeneric types use bitmasks to encode type parameter dependencies.
Each type parameter is represented by a single bit:
- Bit N = 1: Type parameter N affects stability
- Bit N = 0: Type parameter N does not affect stability
Maximum Limit: 32 type parameters (Int size constraint)
Examples:
// Pair<A, B>
@StabilityInferred(parameters = 0b11)
// Binary: 00000000000000000000000000000011
// Bit 0: A affects stability
// Bit 1: B affects stability
// Triple<A, B, C>
@StabilityInferred(parameters = 0b111)
// Binary: 00000000000000000000000000000111
// Bit 0: A affects stability
// Bit 1: B affects stability
// Bit 2: C affects stability
// Result<T>
@StabilityInferred(parameters = 0b1)
// Binary: 00000000000000000000000000000001
// Bit 0: T affects stabilityIf bit position typeParams.size is set, the class is known stable:
@StabilityInferred(parameters = 0b101)
class Container<T, U>
// Binary: 00000000000000000000000000000101
// Bit 0: T affects stability
// Bit 1: U does not affect stability
// Bit 2: Known stable bit (1 shl 2 where typeParams.size = 2)This indicates the class is stable regardless of type parameter instantiation.
This is implemented by the Stability.applyTypeParameterMask extension function. Note that mask is nullable: a null mask means "consider every type parameter" (used for the incremental-compilation Runtime path), while a concrete mask selects parameters bit-by-bit.
private fun Stability.applyTypeParameterMask(
mask: Int?,
typeParameters: List<IrTypeParameter>,
substitutions: Map<IrTypeParameterSymbol, IrTypeArgument>,
currentlyAnalyzing: Set<SymbolForAnalysis>,
analysisEntryFile: IrFile?,
): Stability {
return when {
mask == 0 || typeParameters.isEmpty() -> this
else -> this + Stability.Combined(
typeParameters.mapIndexedNotNull { index, irTypeParameter ->
if (index >= 32) return@mapIndexedNotNull null
if (mask == null || mask and (0b1 shl index) != 0) {
val sub = substitutions[irTypeParameter.symbol]
if (sub != null)
stabilityOf(sub, substitutions, currentlyAnalyzing, analysisEntryFile)
else
Stability.Parameter(irTypeParameter)
} else null
}
)
}
}Process:
- For each type parameter at index I (capped at 32)
- If
maskisnull, or bit I is set inmask, include that parameter - When included, add the stability of the substituted type argument (or
Parameterif not yet substituted) - Otherwise, ignore that parameter
For JVM targets, the compiler generates a static field:
// Source
class Box<T>(val value: T)
// Generated
@StabilityInferred(parameters = 0b1)
class Box<T>(val value: T) {
companion object {
@JvmField
public static final int $stable = 0
}
}The field name is always $stable, and it is initialized with a computed stability value.
Stability Values:
enum class StabilityBits(val bits: Int) {
UNSTABLE(0b100),
STABLE(0b000);
fun bitsForSlot(slot: Int): Int = bits shl (1 + slot * 3)
}bitsForSlot positions the stability bits for a given parameter slot; the $stable field stored on a class uses slot 0 (i.e. bits shl 1).
For Native and JS targets, the compiler generates, at the package (top) level:
- A private backing field with a mangled, FQN-derived name (
<fqName>$stableprop_fieldstyle) - A property wrapping that field
- A separate getter function with a mangled name, registered as metadata-visible
// Generated for Native/JS (names are derived from the class FQN)
private val `com_example_Box$stableprop_field`: Int = /* computed */
// Registered via metadataDeclarationRegistrar.registerFunctionAsMetadataVisible(...)
@Deprecated(
level = DeprecationLevel.HIDDEN,
message = "Synthetic declaration generated by the Compose compiler. Please do not use."
)
// @HiddenFromObjC is added only on Native targets
fun `com_example_Box$stableprop_getter`(): Int =
`com_example_Box$stableprop_field`Rationale:
A separate getter function is used (instead of a plain field getter) because registerFunctionAsMetadataVisible does not work for a field getter and there is no API to register properties as metadata-visible. Making the getter metadata-visible is what enables cross-module stability reads. On Native, the getter is additionally annotated with @HiddenFromObjC. Dependencies compiled with an older plugin that lack this getter are treated as Unstable, and a configuration warning is emitted advising an upgrade (to avoid extra recompositions on non-JVM targets).
private fun IrAnnotationContainer.stabilityParamBitmask(): Int? =
(annotations.findAnnotation(ComposeFqNames.StabilityInferred)?.arguments[0] as? IrConst)
?.value as? IntThe annotation carries a single integer parameter representing the bitmask.
val annotation = IrAnnotationImpl(
UNDEFINED_OFFSET,
UNDEFINED_OFFSET,
StabilityInferredClass.defaultType,
StabilityInferredClass.constructors.first(),
typeArgumentsCount = 0,
constructorTypeArgumentsCount = 0,
origin = null
).also {
it.arguments[0] = irConst(parameterMask)
}
if (useK2 && cls.hasFirDeclaration()) {
context.metadataDeclarationRegistrar.addMetadataVisibleAnnotationsToElement(
cls,
annotation,
)
} else {
cls.annotations += annotation
classStabilityInferredCollection?.addClass(cls, parameterMask)
}The annotation is created as an IrAnnotationImpl (earlier compiler versions used IrConstructorCallImpl). Under K2 it is attached as a metadata-visible annotation; otherwise it is added directly to the class and also recorded in the classStabilityInferredCollection.
Before using stability for code generation, the compiler normalizes it:
fun Stability.normalize(): Stability {
when (this) {
is Stability.Certain,
is Stability.Parameter,
is Stability.Runtime,
is Stability.Unknown,
-> return this
is Stability.Combined -> { /* normalize */ }
}
val parameters = mutableSetOf<IrTypeParameterSymbol>()
val parts = mutableListOf<Stability>()
val stack = mutableListOf<Stability>(this)
while (stack.isNotEmpty()) {
when (val stability: Stability = stack.removeAt(stack.size - 1)) {
is Stability.Combined -> {
stack.addAll(stability.elements)
}
is Stability.Certain -> {
if (!stability.stable)
return Stability.Unstable
}
is Stability.Parameter -> {
if (stability.parameter.symbol !in parameters) {
parameters.add(stability.parameter.symbol)
parts.add(stability)
}
}
is Stability.Runtime -> parts.add(stability)
is Stability.Unknown -> { /* ignore */ }
}
}
return Stability.Combined(parts)
}Normalization Operations:
- Flatten Combined: Recursively expand nested
Combinedinstances - Remove Unknown:
Unknownelements are discarded (treated as uncertain) - Deduplicate Parameters: Keep only unique type parameters
- Short-circuit on Unstable: Return immediately if any
Certain(false)found - Collect Runtime and Parameter: Preserve these for runtime checks
Result Types:
Stability.Unstableif any component is unstableStability.Combined([...])with deduplicated elements otherwise
val x: Int = 42
// Analysis: type.isPrimitiveType() โ true
// Result: Stability.Certain(stable = true)All primitive numeric types follow the same pattern.
val s: String = "text"
// Analysis: type.isString() โ true
// Result: Stability.Certain(stable = true)String receives special treatment due to its immutability guarantees.
val f: (Int) -> String = { it.toString() }
// Analysis: type.isFunctionOrKFunction() โ true
// Result: Stability.Certain(stable = true)Function types are stable because:
- Function references are immutable
- Capturing lambdas capture immutable values (or create new closures)
- Function equality is well-defined
data class User(
val id: Int,
val name: String
)
// Analysis:
// 1. Not in fast path
// 2. No annotations
// 3. Not in known constructs
// 4. Field analysis:
// - id: Int โ Stable
// - name: String โ Stable
// 5. Combine: Stable + Stable = Stable
// Result: Stability.Certain(stable = true)class Counter(
var count: Int
)
// Analysis:
// 1. Field analysis:
// - count is var โ immediate return
// Result: Stability.Certain(stable = false)class Mixed(
val stable: String,
var unstable: Int
)
// Analysis:
// 1. Field analysis:
// - stable: String โ Stable
// - unstable is var โ immediate return
// Result: Stability.Certain(stable = false)The presence of any var property makes the entire class unstable.
class Box<T>(val value: T)
// Analysis:
// 1. Field analysis:
// - value: T โ Stability.Parameter(T)
// 2. Generate annotation: @StabilityInferred(parameters = 0b1)
// Result: Stability.Combined([Stability.Parameter(T)])
// Instantiation:
val intBox: Box<Int>
// Substitute T โ Int
// stabilityOf(Int) = Stable
// Result: Stable
val counterBox: Box<Counter>
// Substitute T โ Counter
// stabilityOf(Counter) = Unstable (from 5.2)
// Result: Unstableclass Pair<A, B>(val first: A, val second: B)
// Analysis:
// 1. Field analysis:
// - first: A โ Parameter(A)
// - second: B โ Parameter(B)
// 2. Generate annotation: @StabilityInferred(parameters = 0b11)
// Result: Combined([Parameter(A), Parameter(B)])
// Instantiation:
val pair: Pair<Int, String>
// Substitute A โ Int, B โ String
// stabilityOf(Int) = Stable
// stabilityOf(String) = Stable
// Result: Stableclass Container<T>(val items: List<T>)
// Analysis:
// 1. Field analysis:
// - items: List<T>
// - List is known stable construct (mask = 0b1)
// - Check type arg T โ Parameter(T)
// 2. Result: Combined([Parameter(T)])
// Instantiation:
val container: Container<String>
// items: List<String>
// List stability depends on String
// stabilityOf(String) = Stable
// Result: Stable// Library module (compiled separately)
@StabilityInferred(parameters = 0b1)
class LibraryBox<T>(val value: T) {
companion object {
@JvmField
val $stable: Int = 0
}
}
// Your module
fun useLibraryBox(box: LibraryBox<Int>) {
// Analysis:
// 1. box: LibraryBox<Int>
// 2. LibraryBox is external
// 3. Has @StabilityInferred(0b1)
// 4. Create Stability.Runtime(LibraryBox)
// 5. Check bit 0 (T parameter)
// 6. Add stabilityOf(Int) = Stable
// Result: Combined([Runtime(LibraryBox), Stable])
// At runtime: check LibraryBox.$stable field
}// Third-party library (no Compose compiler)
class ThirdPartyType(val data: String)
// Your module
fun useThirdParty(obj: ThirdPartyType) {
// Analysis:
// 1. ThirdPartyType is external
// 2. No @StabilityInferred annotation
// 3. stabilityParamBitmask() returns null
// Result: Stability.Unstable
}interface Repository {
fun getData(): String
}
class Screen(val repo: Repository)
// Analysis of Repository:
// 1. Repository is interface
// 2. Unknown implementations
// Result: Stability.Unknown(Repository)
// Analysis of Screen:
// 1. Field repo: Repository โ Unknown
// Result: Combined([Unknown(Repository)])
// Runtime: use instance comparison for repoabstract class BaseViewModel {
abstract val state: String
}
class Screen(val viewModel: BaseViewModel)
// Analysis:
// 1. BaseViewModel is abstract (not an interface), so it reaches member analysis
// 2. modality != FINAL -> seed stability is Unknown(BaseViewModel)
// 3. `abstract val state` has no backing field, so no member contributes
// Result: Stability.Unknown(BaseViewModel)A non-final class seeds as Unknown. If it had concrete val properties with backing fields, those would still be combined in โ but the Unknown seed keeps the overall result uncertain unless something resolves it.
@Stable
interface StableRepository {
fun getData(): String
}
class Screen(val repo: StableRepository)
// Analysis of StableRepository:
// 1. Check annotations
// 2. Has @Stable marker
// Result: Stability.Certain(stable = true)
// Analysis of Screen:
// 1. Field repo: StableRepository โ Stable
// Result: Stableopen class Base(val id: Int)
class Derived(val name: String) : Base(0)
// Analysis of Derived:
// 1. Field analysis:
// - name: String โ Stable
// 2. Check superclass: Base
// - Field id: Int โ Stable
// 3. Combine: Stable + Stable = Stable
// Result: Stability.Certain(stable = true)open class Base(var state: Int)
class Derived(val data: String) : Base(0)
// Analysis of Base:
// 1. Field state is var
// Result: Unstable
// Analysis of Derived:
// 1. Field data: String โ Stable
// 2. Check superclass: Base โ Unstable
// 3. Combine: Stable + Unstable = Unstable
// Result: Stability.Certain(stable = false)The unstable superclass makes all derived classes unstable.
Declares that a type's public API is stable:
@Stable
class MutableCounter(private var count: Int) {
fun increment() {
count++
// Must trigger recomposition
}
override fun equals(other: Any?): Boolean {
return other is MutableCounter && count == other.count
}
}Contract:
- Public API appears immutable (private var is internal)
equals()implements structural equality- State changes trigger composition invalidation
Warning: Incorrect usage violates runtime assumptions.
Stronger guarantee than @Stable:
@Immutable
class ImmutableData(val value: String)Contract:
- All observable state is truly immutable
- No mutable fields (even private)
equals()implements structural equality
While both annotations mark types as stable for recomposition skipping, there is one significant compiler-level difference.
Stability Inference Treatment
Both annotations are processed identically through hasStableMarker():
fun IrAnnotationContainer.hasStableMarker(): Boolean =
annotations.any { it.isStableMarker() }Both result in:
- Same stability inference (types marked as
Stability.Certain(stable = true)) - Same
@StabilityInferredannotation generation - Same
$stableruntime field generation - Same recomposition skipping behavior
Static Expression Optimization (Key Difference)
@Immutable has special treatment for static expression detection.
private fun IrConstructorCall.isStatic(): Boolean {
// special case for inline classes
if (type.isInlineClassType()) {
return stabilityInferencer.stabilityOf(type.unboxInlineClass()).knownStable() &&
arguments[0]?.isStatic() == true
}
// @Immutable constructors with static args are static
if (symbol.owner.parentAsClass.hasAnnotationSafe(ComposeFqNames.Immutable)) {
return areAllArgumentsStatic()
}
// @Stable constructors are NOT considered static
return false
}Practical Impact:
@Immutable
data class ImmutablePoint(val x: Int, val y: Int)
@Stable
data class StablePoint(val x: Int, val y: Int)
@Composable
fun Example() {
// Static expression - enables additional optimizations
val immutable = ImmutablePoint(10, 20)
// NOT a static expression - standard optimizations only
val stable = StablePoint(10, 20)
// Lambda with immutable capture can be more aggressively memoized
val lambda1 = { immutable.x } // Better optimization
// Lambda with stable capture has standard memoization
val lambda2 = { stable.x } // Standard optimization
}Optimization Benefits of Static Expressions:
- Lambda Memoization: Static captures don't prevent lambda singleton optimization
- Default Parameters: Static defaults can be computed at compile time
- Remember Optimization: Compiler may skip unnecessary remember calls for static values
Summary Table:
| Aspect | @Stable | @Immutable |
|---|---|---|
| Stability inference | Stable | Stable |
| Recomposition skipping | Enabled | Enabled |
| Constructor staticness | No | Yes (with static args) |
| Lambda capture optimization | Standard | Enhanced |
| Semantic contract | Allows private mutability | Truly immutable |
The compiler treats @Immutable as a stronger guarantee that enables additional compile-time optimizations, particularly for static expression evaluation and lambda memoization.
Create custom stability markers:
@StableMarker
annotation class MyStable
@MyStable
class CustomType(val data: String)
// Treated as @StableCreate a Stability configuration file, stability_config.conf:
// Single class
com.example.ExternalType
// Package wildcard
com.example.models.**
// Single-segment wildcard
com.example.*.data
// Generic parameter inclusion
com.example.Container<*>
// Generic parameter exclusion
com.example.Wrapper<*,_>
// Mixed generic parameters
com.example.Complex<*,_,*>
Wildcard Rules:
*: Matches single package segment**: Matches multiple package segments<*>: Generic parameter affects stability<_>: Generic parameter ignored for stability
Generic Parameter Encoding:
Container<*,_,*>
โ โ โ
โ โ โโ Bit 2 set (third param matters)
โ โโโโ Bit 1 clear (second param ignored)
โโโโโโ Bit 0 set (first param matters)
// Bitmask: 0b101 = 5
To enable this feature, pass the path of the configuration file to the composeCompiler options block of the Compose compiler Gradle plugin configuration.
composeCompiler {
stabilityConfigurationFile = rootProject.layout.projectDirectory.file("stability_config.conf")
}composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_reports")
metricsDestination = layout.buildDirectory.dir("compose_metrics")
}<module>-classes.txt: Class stability analysis
stable class User {
stable val id: Int
stable val name: String
}
unstable class Counter {
unstable var count: Int
}
runtime stable class Box {
stable val value: T
}
<module>-composables.txt: Composable function analysis
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun UserProfile(
stable user: User
)
restartable scheme("[androidx.compose.ui.UiComposable]") fun Counter(
unstable counter: Counter
)
<module>-module.json: Metrics summary
{
"skippableComposables": 45,
"restartableComposables": 50,
"readonlyComposables": 5,
"totalComposables": 100,
"restartGroups": 50,
"totalGroups": 75
}Problem:
data class UserState(var loading: Boolean)
// Unstable due to varSolution:
data class UserState(val loading: Boolean)
// StableProblem:
class ViewModel(val items: MutableList<String>)
// MutableList is unstableSolution:
import kotlinx.collections.immutable.ImmutableList
class ViewModel(val items: ImmutableList<String>)
// ImmutableList is stable (in KnownStableConstructs)Alternative (requires configuration):
class ViewModel(val items: List<String>)
// List is an interface with Unknown stability by default
// To make it stable, add to stability-config.txt:
// kotlin.collections.ListProblem:
interface DataSource { }
@Composable
fun Screen(source: DataSource) {
// DataSource has Unknown stability
// Falls back to instance comparison
}Solution A: Add @Stable
@Stable
interface DataSource { }Solution B: Use concrete type
@Composable
fun Screen(source: ConcreteDataSource) {
// Concrete class can have known stability
}Problem:
// Third-party library without Compose support
class LibraryClass(val data: String)
@Composable
fun Display(obj: LibraryClass) {
// LibraryClass marked unstable
}Solution:
Add to stability-config.txt:
com.thirdparty.LibraryClass
Problem:
open class MutableBase(var state: Int)
class DerivedData(val name: String) : MutableBase(0)
// Unstable due to base classSolution:
Restructure to avoid mutable inheritance:
open class Base(val id: Int)
class DerivedData(val name: String, val state: Int) : Base(0)
// Stable if all fields are stableprivate fun IrSimpleType.substitutionMap(): Map<IrTypeParameterSymbol, IrTypeArgument> {
val cls = classOrNull ?: return emptyMap()
val params = cls.owner.typeParameters.map { it.symbol }
val args = arguments
return params.zip(args).filter { (param, arg) ->
param != (arg as? IrSimpleType)?.classifier
}.toMap()
}Example:
class Container<T, U>(val first: T, val second: U)
// Analyzing Container<Int, String>
// typeParameters = [T, U]
// arguments = [Int, String]
// substitutionMap = {T: Int, U: String}When analyzing Container<Int, String>:
// Field: first: T
stabilityOf(T, substitutions = {T: Int, U: String})
// Lookup T in substitutions โ Int
// Result: stabilityOf(Int) = Stable
// Field: second: U
stabilityOf(U, substitutions = {T: Int, U: String})
// Lookup U in substitutions โ String
// Result: stabilityOf(String) = Stableclass Outer<T>(val inner: Inner<T>)
class Inner<U>(val value: U)
// Analyzing Outer<Int>
// 1. Field inner: Inner<T>
// 2. Inner has type parameter U
// 3. U is substituted with T
// 4. T is substituted with Int
// 5. Result: stabilityOf(Int) = Stabledata class SymbolForAnalysis(
val symbol: IrClassifierSymbol,
val typeParameters: List<IrTypeArgument?>,
// The file containing the element that initiated this stabilityOf request tree.
// Two identical symbols analyzed from different entry files are distinct keys,
// which is what keeps the per-file caching/runtime-stability behavior correct.
val analysisEntryFile: IrFile?,
)
// In stabilityOf(declaration: IrClass)
val fullSymbol = SymbolForAnalysis(symbol, typeArguments, analysisEntryFile)
if (currentlyAnalyzing.contains(fullSymbol))
return Stability.UnstableThe currentlyAnalyzing set tracks the analysis stack to detect cycles. The analysisEntryFile is part of the key because the same type can resolve to different stability depending on which file initiated the analysis (see Phase 9).
class Node(val value: Int, val next: Node?)
// Analysis trace:
// stabilityOf(Node, currentlyAnalyzing = {})
// analyzing = {Node}
// field: value: Int โ Stable
// field: next: Node?
// unwrap nullable
// stabilityOf(Node, currentlyAnalyzing = {Node})
// CYCLE DETECTED: Node in currentlyAnalyzing
// return UnstableSome recursive types that could be stable are marked unstable:
class TreeNode(val value: Int, val left: TreeNode?, val right: TreeNode?)
// Could be stable (immutable structure)
// Marked unstable due to cycle detectionThis conservative approach ensures algorithm termination.
Detection:
private fun IrClass.isProtobufType(): Boolean {
if (!isFinalClass) return false
val directParentClassName = superTypes
.lastOrNull { !it.isInterface() }
?.classOrNull?.owner?.fqNameWhenAvailable?.toString()
return directParentClassName == "com.google.protobuf.GeneratedMessageLite" ||
directParentClassName == "com.google.protobuf.GeneratedMessage"
}Rationale:
Generated protobuf classes use internal mutability for builder patterns but present an immutable API. The compiler treats them as stable based on their parent class.
if (member.isVar && !member.isDelegated)
return Stability.UnstableDelegated var properties are not automatically unstable. The stability depends on the delegate implementation.
Example:
class WithDelegate {
var value: String by mutableStateOf("")
// Delegated to MutableState
// Compose tracks state changes
// Can be stable with proper delegate
}if (inlineClassDeclaration.hasStableMarker()) {
Stability.Stable
}Inline classes can override underlying type stability with annotations:
@JvmInline
@Stable
value class Wrapper(val list: MutableList<Int>)
// Underlying type (MutableList) is unstable
// But @Stable annotation overrides
// Result: Stable (developer responsibility)The Compose compiler stores per-element metadata during compilation and reads it back in later phases. There are two distinct mechanisms: IR attributes for the IR (backend/lowering) phase, and WritableSlices on the K1 frontend.
Note: most of Chapter 8 describes the K1 (descriptor/PSI-based) frontend, which is now the legacy path. K2/FIR is the default frontend and lives under the
k2/package. The concepts below are still useful, but the exact APIs shown are mostly K1.
lower/ComposePluginAttributes.kt
In the IR phase the compiler no longer uses a BindingContext. Instead, metadata is attached directly to IR nodes via delegated IR attribute / flag properties (irAttribute(...) / irFlag(...)):
// lower/ComposePluginAttributes.kt (illustrative)
var IrExpression.isStaticExpression: Boolean by irFlag(/* ... */)
var IrExpression.isStaticFunctionExpression: Boolean by irFlag(/* ... */)
var IrElement.isComposableSingleton: Boolean by irFlag(/* ... */)
var IrElement.isComposableSingletonClass: Boolean by irFlag(/* ... */)
var IrElement.durableFunctionKey: KeyInfo? by irAttribute(/* ... */)
var IrElement.hasTransformedLambda: Boolean by irFlag(/* ... */)These attributes carry critical metadata:
- isStaticExpression: Marks expressions that can be evaluated at compile time
- durableFunctionKey: Stores unique keys for functions to enable hot reload
- isComposableSingleton / isComposableSingletonClass: Marks hoisted composable lambda singletons
They are written and read as plain properties on the IR node, e.g. expr.isStaticExpression = true during analysis and if (expr.isStaticExpression) { ... } during lowering โ no trace/context lookup is involved.
k1/FrontendWritableSlices.kt
On the K1 frontend, analysis results are recorded in a BindingTrace via WritableSlice keys:
object FrontendWritableSlices {
val INFERRED_COMPOSABLE_DESCRIPTOR = WritableSlice<FunctionDescriptor, Boolean>()
val LAMBDA_CAPABLE_OF_COMPOSER_CAPTURE = WritableSlice<FunctionDescriptor, Boolean>()
val INFERRED_COMPOSABLE_LITERAL = WritableSlice<KtLambdaExpression, Boolean>()
val COMPOSE_LAZY_SCHEME = WritableSlice<Any, LazyScheme>()
}These slices are stored in the BindingContext/BindingTrace that persists throughout K1 frontend analysis:
// During analysis
trace.record(FrontendWritableSlices.INFERRED_COMPOSABLE_DESCRIPTOR, descriptor, true)
// Reading back
val inferred = trace.bindingContext[
FrontendWritableSlices.INFERRED_COMPOSABLE_DESCRIPTOR, descriptor
]k1/ComposableCallChecker.kt
The composable call checker ensures composable functions are only called from valid contexts.
The checker walks up the PSI tree from each composable call site:
override fun check(resolvedCall: ResolvedCall<*>, reportOn: PsiElement, context: CallCheckerContext) {
// Walk up PSI tree to find composable context
var node: PsiElement? = reportOn
while (node != null) {
when (node) {
is KtLambdaExpression -> {
val descriptor = bindingContext[BindingContext.FUNCTION, node.functionLiteral]
if (descriptor?.isComposableCallable() == true) {
return // Valid: inside composable lambda
}
}
is KtFunction -> {
val descriptor = bindingContext[BindingContext.FUNCTION, node]
if (descriptor?.isComposableCallable() == true) {
return // Valid: inside composable function
}
}
is KtPropertyAccessor -> {
val descriptor = bindingContext[BindingContext.PROPERTY_ACCESSOR, node]
if (descriptor?.isComposableCallable() == true) {
return // Valid: inside composable property
}
}
is KtTryExpression -> {
// Check if composable call is inside try/catch
if (node.tryBlock.isAncestor(reportOn)) {
context.trace.report(ILLEGAL_TRY_CATCH_AROUND_COMPOSABLE.on(reportOn))
return
}
}
is KtClass, is KtFile -> {
// Reached non-composable boundary
context.trace.report(COMPOSABLE_INVOCATION.on(reportOn))
return
}
}
node = node.parent
}
}Special handling for inline functions with composable lambda parameters:
private fun checkInlineLambdaCall(
resolvedCall: ResolvedCall<*>,
reportOn: PsiElement,
context: CallCheckerContext
) {
val descriptor = resolvedCall.resultingDescriptor
for ((parameter, argument) in resolvedCall.valueArguments) {
if (!parameter.type.isComposableType()) continue
if (parameter.hasDisallowComposableCalls()) {
// This lambda cannot contain composable calls
val lambda = argument.getLambdaExpression()
lambda?.forEachDescendantOfType<KtCallExpression> { call ->
if (call.isComposableCall()) {
context.trace.report(
CAPTURED_COMPOSABLE_INVOCATION.on(call, parameter, descriptor)
)
}
}
}
}
}Example:
inline fun runWithoutComposables(
@DisallowComposableCalls block: () -> Unit
) = block()
@Composable
fun MyComposable() {
runWithoutComposables {
Text("Error") // ERROR: Composable calls not allowed
}
}Validates that composable and non-composable function types match expected signatures:
override fun checkType(
expression: KtExpression,
expressionType: KotlinType,
expectedType: KotlinType,
context: CallCheckerContext
) {
val isComposableExpression = expressionType.isComposableType()
val isComposableExpected = expectedType.isComposableType()
when {
isComposableExpression && !isComposableExpected -> {
// Trying to pass composable lambda where non-composable expected
if (!isInlineConversion(expression)) {
context.trace.report(
TYPE_MISMATCH.on(expression, expectedType, expressionType)
)
}
}
!isComposableExpression && isComposableExpected -> {
// Trying to pass non-composable where composable expected
context.trace.report(
TYPE_MISMATCH.on(expression, expectedType, expressionType)
)
}
}
}Validates composable function and property declarations follow Compose rules.
Key validation rules enforced:
class ComposableDeclarationChecker : DeclarationChecker {
override fun check(declaration: KtDeclaration, descriptor: DeclarationDescriptor, context: DeclarationCheckerContext) {
when (descriptor) {
is FunctionDescriptor -> checkFunction(descriptor, declaration, context)
is PropertyDescriptor -> checkProperty(descriptor, declaration, context)
}
}
private fun checkFunction(descriptor: FunctionDescriptor, declaration: KtDeclaration, context: DeclarationCheckerContext) {
// Rule 1: Composable functions cannot be suspend
if (descriptor.isComposableCallable() && descriptor.isSuspend) {
context.trace.report(COMPOSABLE_SUSPEND_FUN.on(declaration))
}
// Rule 2: Main function cannot be composable
if (descriptor.isComposableCallable() && descriptor.name.asString() == "main") {
context.trace.report(COMPOSABLE_FUN_MAIN.on(declaration))
}
// Rule 3: Composability must be consistent in overrides
descriptor.overriddenDescriptors.forEach { overridden ->
if (descriptor.isComposableCallable() != overridden.isComposableCallable()) {
context.trace.report(
CONFLICTING_OVERLOADS.on(declaration, listOf(descriptor, overridden))
)
}
}
}
}Composable properties have specific limitations:
private fun checkProperty(descriptor: PropertyDescriptor, declaration: KtDeclaration, context: DeclarationCheckerContext) {
if (!descriptor.isComposableCallable()) return
// Rule 1: Composable properties cannot have backing fields
if (descriptor.hasBackingField()) {
context.trace.report(COMPOSABLE_PROPERTY_BACKING_FIELD.on(declaration))
}
// Rule 2: Composable properties cannot be var (have setters)
if (descriptor.isVar) {
context.trace.report(COMPOSABLE_VAR.on(declaration))
}
// Rule 3: Composable delegates restricted to getValue only
if (declaration is KtProperty && declaration.hasDelegate()) {
if (descriptor.isVar) {
// setValue on composable delegate not allowed
context.trace.report(COMPOSE_INVALID_DELEGATE.on(declaration))
}
}
}Ensures override hierarchies maintain consistent composability:
private fun checkOverrideConsistency(
descriptor: CallableDescriptor,
context: DeclarationCheckerContext
) {
val isComposable = descriptor.isComposableCallable()
for (overridden in descriptor.overriddenDescriptors) {
val overriddenComposable = overridden.isComposableCallable()
if (isComposable != overriddenComposable) {
context.trace.report(
CONFLICTING_OVERLOADS.on(
descriptor.source.getPsi()!!,
listOf(descriptor, overridden)
)
)
}
// Check applier compatibility (scheme-based): the overridden declaration's
// scheme must be able to override this declaration's scheme.
if (!overridden.toScheme().canOverride(descriptor.toScheme())) {
context.trace.report(
COMPOSE_APPLIER_DECLARATION_MISMATCH.on(
descriptor.source.getPsi()!!
)
)
}
}
}The applier target system ensures composable functions target compatible UI frameworks.
Applier schemes encode which UI framework a composable targets:
sealed class Item
// Concrete applier, e.g. "androidx.compose.ui.UiComposable"
class Token(val value: String) : Item()
// Generic/unknown applier, identified by an index that unification can bind
class Open(val index: Int, override val isUnspecified: Boolean = false) : Item()
class Scheme(
val target: Item,
val parameters: List<Scheme> = emptyList(),
val result: Scheme? = null,
val anyParameters: Boolean = false,
) {
// Whether this scheme can legally override `other`
// (alpha-renames open targets, then compares structurally).
fun canOverride(other: Scheme): Boolean = /* ... */
}Note that Token and Open are top-level subclasses of Item (not nested as Item.Token/Item.Open), and Scheme exposes a canOverride check used for override validation rather than isOpen()/isConcrete() helpers.
Example Schemes:
// UI Composable targeting Android UI
Scheme(Token("androidx.compose.ui.UiComposable"))
// Generic composable (works with any applier)
Scheme(Open(-1))
// Composable with lambda expecting UI target
Scheme(
target = Token("androidx.compose.ui.UiComposable"),
parameters = listOf(
Scheme(Token("androidx.compose.ui.UiComposable"))
)
)The ApplierInferencer class performs unification to resolve applier targets. It is generic and adapter-driven โ it is not tied to descriptors or a ModuleDescriptor. Instead, callers supply adapters that teach it how to read schemes from their own node/type representation (so the same engine works for both K1 and K2):
class ApplierInferencer<Type, Node>(
private val typeAdapter: TypeAdapter<Type>,
private val nodeAdapter: NodeAdapter<Type, Node>,
private val lazySchemeStorage: LazySchemeStorage<Node>,
private val errorReporter: ErrorReporter<Node>,
) {
// Infers/visits a node, unifying the schemes of a call with its callees.
}Conceptually, inference proceeds by:
- Reading the declared scheme from
@ComposableTarget/@ComposableInferredTargetannotations (or a fully-open scheme when none is present). - Walking the call graph via the adapters and unifying the scheme of each call site with the scheme of its callee.
- Resolving open targets through a
Bindingstable: anOpen(index)target gets bound to a concreteTokenwhen it meets one, and two different concreteTokens that meet are a conflict (reported viaerrorReporter, not by throwing). - Merging results with
Scheme.mergeWith/bindings.unifyto produce the final, possibly-still-open scheme.
There are no Constraint/Substitution/IncompatibleApplierException types; unification state lives in Bindings and LazyScheme.
Validates that composable calls use compatible appliers:
private fun checkApplierCompatibility(
caller: CallableDescriptor,
callee: CallableDescriptor,
context: CallCheckerContext
) {
val callerScheme = inferredScheme(caller)
val calleeScheme = inferredScheme(callee)
if (!areCompatible(callerScheme, calleeScheme)) {
context.trace.report(
COMPOSE_APPLIER_CALL_MISMATCH.on(
context.element,
calleeScheme.target.toString(),
callerScheme.target.toString()
)
)
}
}Example Error:
@Composable
@ComposableTarget("androidx.compose.ui.UiComposable")
fun UiButton(text: String) { /* ... */ }
@Composable
@ComposableTarget("com.example.CustomApplier")
fun CustomWidget() { /* ... */ }
@Composable
fun Screen() {
UiButton("Click") {
CustomWidget() // COMPOSE_APPLIER_CALL_MISMATCH
}
}Note: under K1 applier-target mismatches are reported as errors, but under K2 (the default frontend)
COMPOSE_APPLIER_CALL_MISMATCH,COMPOSE_APPLIER_PARAMETER_MISMATCH, andCOMPOSE_APPLIER_DECLARATION_MISMATCHare reported as warnings.
Automatically infers @Composable annotation on lambda expressions based on expected type.
Note:
ComposeTypeResolutionInterceptorExtensionshown below is the K1-only mechanism (package...kotlin.k1). Under K2 (the default), composable-lambda inference is performed in FIR, not via aTypeResolutionInterceptor.
class ComposeTypeResolutionInterceptorExtension : TypeResolutionInterceptorExtension {
override fun interceptFunctionLiteralDescriptor(
expression: KtLambdaExpression,
context: TypeResolutionContext,
descriptor: FunctionDescriptor
): FunctionDescriptor {
val expectedType = context.expectedType ?: return descriptor
if (!expectedType.isComposableType()) return descriptor
if (descriptor.isComposableCallable()) return descriptor
// Infer @Composable on lambda
val composableDescriptor = descriptor.copy(
annotations = descriptor.annotations + ComposableAnnotation
)
// Record inference
context.trace.record(
FrontendWritableSlices.INFERRED_COMPOSABLE_DESCRIPTOR,
composableDescriptor,
true
)
context.trace.record(
FrontendWritableSlices.INFERRED_COMPOSABLE_LITERAL,
expression.functionLiteral,
true
)
return composableDescriptor
}
}The system automatically adapts lambda types to match composable expectations:
// Expected: @Composable () -> Unit
val content: @Composable () -> Unit = {
// Lambda automatically becomes @Composable
Text("Hello") // Composable call allowed
}
// Without inference, this would be an error
Column(
content = { // Automatically @Composable
Text("Item 1")
Text("Item 2")
}
)The analysis system operates through distinct compilation phases:
โโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ 1. PARSING โ
โ PSI Tree Construction โ
โโโโโโโโโโโโโฌโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโผโโโโโโโโโโโโโโ
โ 2. RESOLUTION (K1/K2) โ
โ Type & Symbol Binding โ
โโโโโโโโโโโโโฌโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโผโโโโโโโโโโโโโโ
โ 3. DIAGNOSTIC PHASE โ
โ Checkers & Validators โ
โ โโโโโโโโโโโโโโโโโโโโ โ
โ โ Annotation Check โ โ
โ โ Declaration Checkโ โ
โ โ Call Check โ โ
โ โ Target Check โ โ
โ โโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโฌโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโผโโโโโโโโโโโโโโ
โ 4. IR GENERATION โ
โ Convert PSI to IR Tree โ
โโโโโโโโโโโโโฌโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโผโโโโโโโโโโโโโโ
โ 5. IR ANALYSIS โ
โ Stability Inference โ
โ Static Detection โ
โโโโโโโโโโโโโฌโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโผโโโโโโโโโโโโโโ
โ 6. IR LOWERING โ
โ Transform Composables โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโ
Frontend Analysis โ WritableSlices:
// During type resolution
ComposeTypeResolutionInterceptor {
record(INFERRED_COMPOSABLE_DESCRIPTOR, descriptor, true)
}
// During call checking
ComposableCallChecker {
record(LAMBDA_CAPABLE_OF_COMPOSER_CAPTURE, lambda, true)
}
// During target checking
ComposableTargetChecker {
record(COMPOSE_LAZY_SCHEME, function, scheme)
}IR Analysis โ IR Attributes:
// During static-expression analysis
expression.isStaticExpression = isStatic
// During key generation
DurableFunctionKeyTransformer {
function.durableFunctionKey = keyInfo
}IR Lowering โ Read Attributes:
// During composable transformation
ComposableFunctionBodyTransformer {
val isStatic = expr.isStaticExpression
val key = function.durableFunctionKey
if (isStatic) {
// Generate optimized code
} else {
// Generate standard code
}
}// Source code
class MainActivity {
fun onCreate() {
Text("Hello") // ERROR: Not in composable context
}
@Composable
fun Content() {
Text("Hello") // OK: In composable function
runBlocking {
Text("Error") // ERROR: In non-composable lambda
}
LaunchedEffect(Unit) {
Text("Error") // ERROR: LaunchedEffect block is suspend, not composable
}
}
}Analysis Flow:
ComposableCallCheckerseesText("Hello")call- Walks up PSI tree from call site
- In
onCreate: ReachesKtFunctionwithout@Composableโ ReportsCOMPOSABLE_INVOCATION - In
Content: Finds@Composablefunction โ Valid - In
runBlocking: Lambda not composable โ ReportsCOMPOSABLE_INVOCATION - In
LaunchedEffect: Suspend lambda context โ ReportsCOMPOSABLE_INVOCATION
inline fun <T> withoutComposables(
@DisallowComposableCalls noinline block: () -> T
): T = block()
@Composable
fun Screen() {
withoutComposables {
val data = loadData() // OK
Text(data) // ERROR: CAPTURED_COMPOSABLE_INVOCATION
}
}Analysis Flow:
ComposableCallChecker.checkInlineLambdaCall()examineswithoutComposablescall- Finds parameter
blockhas@DisallowComposableCalls - Traverses lambda body AST
- Finds composable call to
Text() - Reports
CAPTURED_COMPOSABLE_INVOCATIONwith parameter name and function
// Source
data class StableUser(val name: String, val age: Int)
data class UnstableUser(var name: String, var age: Int)
@Composable
fun StableUserCard(user: StableUser) {
Text(user.name)
}
@Composable
fun UnstableUserCard(user: UnstableUser) {
Text(user.name)
}Analysis and Generation:
-
Stability Analysis:
StableUser: Allvalproperties of stable types โCertain(stable = true)UnstableUser: Hasvarproperties โCertain(stable = false)
-
IR Attribute Recording:
stableUserParam.isStaticExpression = false unstableUserParam.isStaticExpression = false
-
Code Generation Difference:
StableUserCard (can skip):
fun StableUserCard(user: StableUser, $composer: Composer, $changed: Int) { $composer.startRestartGroup(key) if ($changed and 0x1 == 0 && $composer.skipping) { $composer.skipToGroupEnd() // Skip if user unchanged } else { Text(user.name, $composer, 0) } $composer.endRestartGroup()?.updateScope { StableUserCard(user, it, $changed or 0x1) } }
UnstableUserCard (always executes):
fun UnstableUserCard(user: UnstableUser, $composer: Composer, $changed: Int) { $composer.startRestartGroup(key) // No skipping logic - always executes Text(user.name, $composer, 0) $composer.endRestartGroup()?.updateScope { UnstableUserCard(user, it, 0x1) } }
The analysis system's decisions directly impact runtime performance through intelligent code generation based on stability inference and validation results.
The Compose compiler's stability inference system operates through a multi-phase analysis process that examines types, classes, and expressions. The system balances compile-time analysis with runtime checks, enabling optimization while maintaining correctness guarantees.
Key principles:
- Stability enables recomposition skipping through value comparison
- The algorithm proceeds from fast paths to detailed analysis
- Bitmasks encode generic type parameter dependencies
- External modules require runtime stability fields
- Configuration files extend stability to external types
- Conservative handling ensures correctness over optimization
Understanding this system allows developers to structure code for optimal Compose performance while maintaining type safety and correctness. If you want to get more information about the Compose performance, check out compose-performance repository.
Manifest Android Interview is a comprehensive guide designed to enhance your Android development expertise through 108 interview questions with detailed answers, 162 additional practical questions, and 50+ "Pro Tips for Mastery" sections. The interview questions primarily focus on Android developmentโincluding the Framework, UI, Jetpack Libraries, and Business Logicโas well as Jetpack Compose, covering Fundamentals, Runtime, and UI.
If you're eager to dive deeper into Kotlin and Android, explore Dove Letter, a private subscription repository where you can learn, discuss, and share knowledge. To get more details about this unique opportunity, check out the Learn Kotlin and Android With Dove Letter article.
Support it by joining stargazers for this repository. โญ
Also, follow me on GitHub for my next creations! ๐คฉ
Designed and developed by 2025 skydoves (Jaewoong Eum)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
