How the Compose Compiler Rewrites Your `@Composable`

skydovesJaewoong Eum (skydoves)||16 min read

How the Compose Compiler Rewrites Your @Composable

The @Composable function you write is not the function that runs. Before your code reaches the runtime, the Compose compiler plugin rewrites every composable: it changes the parameter list, wraps the body in bookkeeping calls, and threads a hidden $composer through the whole call tree. That rewrite is the reason recomposition can skip a function whose inputs did not change, and restart a single function when one State it read is written. None of that behavior is in the source you wrote. It is generated.

In this article, you'll dive deep into exactly how that rewrite works, traced against the real compiler source (which now lives in the Kotlin repository under plugins/compose, not in AndroidX) and the compiler's own golden transform tests, the before and after snapshots it is verified against on every build. You'll watch the synthetic $composer, $changed, and $default parameters appear, follow the durable integer key that names your function, read the restart group and skip guard that wrap the body, and see how a class's stability decides whether any of it can be skipped. Every transformed snippet here is drawn from that verified test output, lightly trimmed: I drop the optional sourceInformation and trace breadcrumbs, then turn the dump's % back into the real $ that synthetic names carry in bytecode.

The function you wrote is not the function that runs

A plain function runs top to bottom every time you call it. A composable has to do more. The runtime needs to know where you are in the composition, so it can find the same state next time. It needs to know whether your arguments changed since the last pass, so it can decide to skip you. And it needs a way to re-run just you, and nothing else, when a value you read changes. A normal signature and a normal body have nowhere to put any of that.

So @Composable is not really a modifier on an ordinary function. It is a marker that tells the compiler to rewrite the function into one that speaks the runtime's protocol. The annotation stays on your declaration, but the shape underneath is replaced. Two kinds of change happen. The first is a calling convention: extra parameters that every composable receives and passes on to every composable it calls. The second is a body wrapper: group and comparison calls that the runtime reads as it walks your code. The rest of this article follows both, in the order the compiler does them.

Where the rewrite happens

The Compose compiler is a Kotlin compiler plugin, and it runs as a sequence of IR lowerings in a fixed order (ComposeIrGenerationExtension). Stability is inferred first. Then durable keys are computed and stored. Then default parameters and composable lambdas are lowered. Then ComposerParamTransformer adds the synthetic parameters, and finally ComposableFunctionBodyTransformer rewrites each body. The order matters, because the body transform consumes what earlier passes produced: the key and the stability.

You do not have to take any of this on faith, because the compiler leaves the evidence in the open. It stamps every rewritten composable with a @FunctionKeyMeta annotation, and it can print the transformed IR back out as readable Kotlin. That printout is what the plugin's ...TransformTests assert against, and it is where the snippets below come from. One notation detail before we read the snippets: the dumps render a $ inside a synthetic name as %, so %composer in a dump is the parameter whose real name is $composer. I use the $ form throughout, since that is also what you see if you decompile the bytecode.

Rewriting the signature: $composer, $changed, $default

ComposerParamTransformer rewrites the parameter list of every function that carries @Composable. After your real parameters, it appends a nullable $composer: Composer?, then one or more $changed: Int, and then, only when the function has default arguments, one or more $default: Int.

// you write
@Composable fun Greeting(name: String, modifier: Modifier = Modifier) { }

// the compiler emits (dumps print these names with %)
@Composable fun Greeting(
    name: String,
    modifier: Modifier,
    $composer: Composer?,
    $changed: Int,
    $default: Int,
) { }

The $composer is the thread the entire system pulls on. It is passed into every composable call inside the body, which is the real meaning of the rule that a composable can only be called from another composable: the caller is the one holding a $composer to hand down.

The $changed parameters carry what the caller already knows about each argument, so the callee can avoid recomparing. The count is ceil((valueParameters + receivers) / 10), with a floor of one, because each parameter is given a slot of three bits and a single Int holds ten such slots plus one reserved low bit. Those three bits are a tiny state per argument: Uncertain (nothing is known, compare it), Same, Different, or Static (a compile time constant that can never change). A function with thirty one value parameters crosses into a fourth $changed int, and the tests check exactly that boundary.

This article continues for subscribers

Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.

Become a Sponsor