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.
- 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
- Appendix: Source Code References
- 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 :
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 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 and .
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 .
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 for type parameter handling.
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 ():
Stable + Stable = Stable
Stable + Unstable = Unstable
Unstable + Stable = Unstable
Stable + Parameter = Combined([Parameter])
Parameter + Parameter = Combined([Parameter, Parameter])
Runtime + Parameter = Combined([Runtime, Parameter])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 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
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Has @StabilityInferred? โโโโYesโโโ [RUNTIME]
โ (from separate compilation) โ (use bitmask)
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ No
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Analyze class members: โ
โ - Check all properties โ
โ - Check backing fields โ
โ - Check superclass โ
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Any var (mutable) property? โโโโYesโโโ [UNSTABLE]
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ No
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ All members stable? โโโโYesโโโ [STABLE]
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ No
โผ
[UNSTABLE/COMBINED]
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 are always stable (finite, immutable values)
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:
- Any
varproperty makes the entire class unstable - Delegated properties are analyzed based on their delegate type
- Superclass stability affects subclass stability
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]
if (arg != null && symbol !in currentlyAnalyzing) {
stabilityOf(arg, substitutions, currentlyAnalyzing + symbol)
} 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.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
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(
"kotlin.Pair" to 0b11,
"kotlin.Triple" to 0b111,
"kotlin.Result" to 0b1,
"kotlin.Comparator" to 0b1,
"kotlin.ranges.ClosedRange" to 0b1,
"com.google.common.collect.ImmutableList" to 0b1,
"kotlinx.collections.immutable.ImmutableList" to 0b1,
"dagger.Lazy" to 0b1,
"java.math.BigInteger" to 0,
"java.math.BigDecimal" to 0,
// ... more entries
)The integer value represents a bitmask indicating which type parameters affect stability (covered in Chapter 4).
Users can provide configuration files declaring types as stable:
if (declaration.isExternalStableType()) {
mask = externalTypeMatcherCollection
.maskForName(declaration.fqNameWhenAvailable) ?: 0
stability = Stability.Stable
}Configuration file format (covered in Chapter 6).
For classes from external modules:
if (declaration.isInterface && declaration.isInCurrentModule()) {
return Stability.Unknown(declaration)
} else {
val bitmask = declaration.stabilityParamBitmask() ?: return Stability.Unstable
val knownStableMask = if (typeParameters.size < 32) 0b1 shl typeParameters.size else 0
val isKnownStable = bitmask and knownStableMask != 0
mask = bitmask and knownStableMask.inv()
stability = if (isKnownStable && declaration.isInCurrentModule()) {
Stability.Stable
} else {
Stability.Runtime(declaration)
}
}Rationale for Interface Handling:
Interfaces in the current module return Unknown to support incremental compilation. Since implementations may change across compilation units, the compiler cannot safely infer interface stability.
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 = Stability.Stable
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)
}
}
is IrField -> {
stability += stabilityOf(member.type, substitutions, analyzing)
}
}
}
declaration.superClass?.let {
stability += stabilityOf(it, substitutions, analyzing)
}
return stabilityKey Points:
- Start with
Stableassumption - Any
varproperty immediately returnsUnstable - Combine stability of all
valproperty types - Include superclass stability
- Use
+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.mapOf" to 0b11,
"kotlin.collections.emptyMap" to 0,
"kotlin.collections.setOf" to 0b1,
"kotlinx.collections.immutable.immutableListOf" to 0b1,
// ... more functions
)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.
return when {
mask == 0 || typeParameters.isEmpty() -> stability
else -> stability + Stability.Combined(
typeParameters.mapIndexedNotNull { index, irTypeParameter ->
if (index >= 32) return@mapIndexedNotNull null
if (mask and (0b1 shl index) != 0) {
val sub = substitutions[irTypeParameter.symbol]
if (sub != null)
stabilityOf(sub, substitutions, analyzing)
else
Stability.Parameter(irTypeParameter)
} else null
}
)
}Process:
- For each type parameter at index I
- Check if bit I is set in mask
- If set, add stability of that parameter
- If not set, 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)
}For Native and JS targets, the compiler generates:
- A top-level property with a mangled name
- A getter function for metadata visibility
// Generated for Native/JS
private val `com_example_Box$stabilityflag`: Int = /* ... */
@HiddenFromObjC
@Deprecated(level = DeprecationLevel.HIDDEN, message = "...")
internal fun `com_example_Box$stabilityflag_getter`(): Int =
`com_example_Box$stabilityflag`Rationale:
Non-JVM platforms require getter functions for metadata visibility. The getter is registered as metadata-visible to enable cross-module stability checks.
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 = IrConstructorCallImpl(
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
}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
// 2. isInterface check fails
// 3. Field analysis cannot be performed (abstract)
// Result: Stability.Unknown(BaseViewModel)@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) = Stableprivate data class SymbolForAnalysis(
val symbol: IrClassifierSymbol,
val typeParameters: List<IrTypeArgument?>,
)
// In stabilityOf(declaration: IrClass)
val fullSymbol = SymbolForAnalysis(symbol, typeArguments)
if (currentlyAnalyzing.contains(fullSymbol))
return Stability.UnstableThe currentlyAnalyzing set tracks the analysis stack to detect cycles.
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 uses Kotlin's binding context and custom writable slices to store metadata during compilation. This data flows through the compilation pipeline, influencing code generation decisions.
analysis/ComposeWritableSlices.kt, k1/FrontendWritableSlices.kt
WritableSlices act as key-value stores where the compiler records analysis results:
IR-Level Slices
object ComposeWritableSlices {
val IS_SYNTHETIC_COMPOSABLE_CALL = WritableSlice<IrFunctionAccessExpression, Boolean>()
val IS_STATIC_FUNCTION_EXPRESSION = WritableSlice<IrFunctionExpression, Boolean>()
val IS_STATIC_EXPRESSION = WritableSlice<IrExpression, Boolean>()
val IS_COMPOSABLE_SINGLETON = WritableSlice<IrAttributeContainer, Boolean>()
val IS_COMPOSABLE_SINGLETON_CLASS = WritableSlice<IrClass, Boolean>()
val DURABLE_FUNCTION_KEY = WritableSlice<IrSimpleFunction, KeyInfo>()
val HAS_TRANSFORMED_LAMBDA = WritableSlice<IrSimpleFunction, Boolean>()
}Frontend Slices
object ComposeFrontEndWritableSlices {
val INFERRED_COMPOSABLE_DESCRIPTOR = WritableSlice<FunctionDescriptor, Boolean>()
val LAMBDA_CAPABLE_OF_COMPOSER_CAPTURE = WritableSlice<IrFunction, Boolean>()
val INFERRED_COMPOSABLE_LITERAL = WritableSlice<IrFunctionExpression, Boolean>()
val COMPOSE_LAZY_SCHEME = WritableSlice<IrAttributeContainer, Scheme>()
}These slices store critical metadata:
- IS_STATIC_EXPRESSION: Marks expressions that can be evaluated at compile time
- DURABLE_FUNCTION_KEY: Stores unique keys for functions to enable hot reload
- INFERRED_COMPOSABLE_DESCRIPTOR: Tracks lambdas automatically inferred as composable
The slices are stored in a BindingContext that persists throughout compilation:
// During analysis
bindingTrace.record(ComposeWritableSlices.IS_STATIC_EXPRESSION, expression, true)
// During lowering
val isStatic = context.bindingContext[ComposeWritableSlices.IS_STATIC_EXPRESSION, expression]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))
)
}
}
// Rule 4: Open composable functions with default params (pre-Kotlin 2.0)
if (descriptor.isComposableCallable() &&
descriptor.modality != Modality.FINAL &&
descriptor.valueParameters.any { it.hasDefaultValue() }) {
if (!context.languageVersionSettings.supportsFeature(LanguageFeature.ComposeOpenFunctions)) {
context.trace.report(OPEN_FUNCTION_WITH_DEFAULT_PARAMETERS.on(declaration))
}
}
}
}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_VAR.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(COMPOSABLE_PROPERTY_CANNOT_BE_VAR.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
val applierInferencer = ApplierInferencer(descriptor.module)
if (!applierInferencer.areCompatible(descriptor, overridden)) {
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 {
class Token(val value: String) : Item() // Concrete applier
class Open(val index: Int) : Item() // Generic/unknown applier
}
data class Scheme(
val target: Item,
val parameters: List<Scheme> = emptyList(),
val result: Scheme? = null
) {
fun isOpen() = target is Item.Open
fun isConcrete() = target is Item.Token
}Example Schemes:
// UI Composable targeting Android UI
Scheme(Item.Token("androidx.compose.ui.UiComposable"))
// Generic composable (works with any applier)
Scheme(Item.Open(-1))
// Composable with lambda expecting UI target
Scheme(
target = Item.Token("androidx.compose.ui.UiComposable"),
parameters = listOf(
Scheme(Item.Token("androidx.compose.ui.UiComposable"))
)
)The ApplierInferencer class performs unification to resolve applier targets:
class ApplierInferencer(private val module: ModuleDescriptor) {
fun inferredScheme(descriptor: CallableDescriptor): Scheme {
// 1. Extract scheme from annotations
val annotationScheme = descriptor.getComposableTargetAnnotation()?.let { annotation ->
schemeFromAnnotation(annotation)
}
// 2. Build call graph
val callGraph = buildCallGraph(descriptor)
// 3. Create constraint system
val constraints = mutableListOf<Constraint>()
for ((caller, callee) in callGraph) {
constraints.add(UnifyConstraint(caller.scheme, callee.scheme))
}
// 4. Solve constraints via unification
val substitution = unify(constraints)
// 5. Apply substitution to get concrete scheme
return annotationScheme?.apply(substitution) ?: Scheme(Item.Open(-1))
}
private fun unify(constraints: List<Constraint>): Substitution {
val subst = mutableMapOf<Int, Item>()
for (constraint in constraints) {
when (constraint) {
is UnifyConstraint -> {
when {
constraint.left.isOpen() && constraint.right.isConcrete() -> {
subst[(constraint.left.target as Item.Open).index] = constraint.right.target
}
constraint.left.isConcrete() && constraint.right.isOpen() -> {
subst[(constraint.right.target as Item.Open).index] = constraint.left.target
}
constraint.left.isConcrete() && constraint.right.isConcrete() -> {
if (constraint.left.target != constraint.right.target) {
throw IncompatibleApplierException(constraint.left, constraint.right)
}
}
}
}
}
}
return Substitution(subst)
}
}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() // ERROR: Incompatible applier targets
}
}Automatically infers @Composable annotation on lambda expressions based on expected type.
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(
ComposeFrontEndWritableSlices.INFERRED_COMPOSABLE_DESCRIPTOR,
composableDescriptor,
true
)
context.trace.record(
ComposeFrontEndWritableSlices.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 โ WritableSlices:
// During stability analysis
StabilityInferencer {
irTrace.record(IS_STATIC_EXPRESSION, expression, isStatic)
}
// During key generation
DurableFunctionKeyTransformer {
irTrace.record(DURABLE_FUNCTION_KEY, function, keyInfo)
}IR Lowering โ Read Slices:
// During composable transformation
ComposableFunctionBodyTransformer {
val isStatic = irTrace[IS_STATIC_EXPRESSION, expr]
val key = irTrace[DURABLE_FUNCTION_KEY, function]
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)
-
WritableSlice Recording:
irTrace.record(IS_STATIC_EXPRESSION, stableUserParam, false) irTrace.record(IS_STATIC_EXPRESSION, unstableUserParam, 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 source code is available as part of the Kotlin compiler project:
Repository: https://github.com/JetBrains/kotlin/tree/master/plugins/compose
-
Stability.kt(Line count: 479)- Location:
compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/analysis/Stability.kt - Contains: Stability type definitions, inference algorithm, helper functions
- Location:
-
KnownStableConstructs.kt(Line count: 107)- Location:
compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/analysis/KnownStableConstructs.kt - Contains: Registry of known stable types and functions
- Location:
-
ClassStabilityTransformer.kt(Line count: 219+)- Location:
compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/lower/ClassStabilityTransformer.kt - Contains: Stability field generation, annotation application
- Location:
-
StabilityConfigParser.kt(Line count: 81)- Location:
compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/analysis/StabilityConfigParser.kt - Contains: Configuration file parsing
- Location:
-
StabilityExternalClassNameMatching.kt(Line count: 237)- Location:
compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/analysis/StabilityExternalClassNameMatching.kt - Contains: Pattern matching for external type configuration
- Location:
-
AbstractComposeLowering.kt(Line count: 1785)- Location:
compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/lower/AbstractComposeLowering.kt - Contains: Runtime field generation utilities (lines 820-950)
- Location:
-
ComposableCallChecker.kt(Line count: 673)- Location:
compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/k1/ComposableCallChecker.kt - Contains: Composable call validation logic
- Location:
-
ComposableDeclarationChecker.kt(Line count: 271)- Location:
compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/k1/ComposableDeclarationChecker.kt - Contains: Declaration validation rules
- Location:
-
ComposableTargetChecker.kt(Line count: 452)- Location:
compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/k1/ComposableTargetChecker.kt - Contains: Applier target inference and validation
- Location:
-
ComposeWritableSlices.kt(Line count: 31)- Location:
compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/analysis/ComposeWritableSlices.kt - Contains: IR-level data storage definitions
- Location:
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.
