Compose Compiler 深入解析:从参数注入到重组执行的 IR 变换全流程 lxx 2025年12月28日 在 Compose 编译阶段,针对 @Composable 函数,编译器主要做两件事:
一是修改方法签名(参数注入),二是修改方法实现(函数体 Lowering)。
这两个步骤是分离实现、顺序执行的,分别对应两个核心 IR Transform。
方法签名变换发生在 ComposerParamTransformer 中,负责向 Composable 函数注入 $composer、$changed、$default 等参数;
方法实现变换发生在 ComposableFunctionBodyTransformer 中,负责生成 Group、处理默认参数、实现跳过与重组逻辑。
这两个 Transform 的入口方法形式上都叫 visitFunction,但它们承担的职责完全不同。
源码位置如下:
修改方法签名:
https://cs.android.com/android-studio/kotlin/+/master:plugins/compose/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/lower/ComposerParamTransformer.kt
修改方法实现:
https://cs.android.com/android-studio/kotlin/+/master:plugins/compose/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/lower/ComposableFunctionBodyTransformer.kt
在进入函数体变换之前,有必要先理解签名变换阶段到底做了什么。
ComposerParamTransformer 的核心目标很明确:
把一个“普通的 Kotlin 函数”,改造成一个“可以被 Compose Runtime 驱动的函数”。
以一个最简单的 Composable 为例:
1
2
3
4
@Composable
fun A ( x : Int ) {
f ( x )
}
在签名层面,编译器会把它改写为(示意):
1
2
3
4
5
fun A (
x : Int ,
$ composer : Composer <*>,
$ changed : Int
)
如果存在默认参数,还会注入 $default 掩码参数。
在 ComposerParamTransformer 中,真正完成这件事的是 copyWithComposerParam 这个扩展方法。它并不是“在原函数上就地修改”,而是深拷贝一个新的 IrSimpleFunction,并将新旧函数建立映射关系,防止递归调用和符号错乱。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private fun IrSimpleFunction . copyWithComposerParam (): IrSimpleFunction {
assert ( parameters . lastOrNull () ?. name != ComposeNames . ComposerParameter )
return deepCopyWithSymbolsAndMetadata ( parent ). also { fn ->
val oldFn = this
transformedFunctionSet . add ( fn )
transformedFunctions [ oldFn ] = fn
fn . metadata = oldFn . metadata
fn . overriddenSymbols = oldFn . overriddenSymbols . map {
it . owner . withComposerParamIfNeeded (). symbol
}
.. .
}
}
这里一个容易被忽略但非常关键的点是 overriddenSymbols 的处理。
如果一个 Composable 函数重写了父类或接口方法(例如 setContent 中的 Content()),那么父类方法必须被同步注入 composer 参数,否则虚方法分发表会直接失效。
接下来是参数注入本身。
首先注入 $composer:
1
2
3
4
5
6
val composerParam = fn . addValueParameter {
name = ComposeNames . ComposerParameter
type = composerType . makeNullable ()
origin = IrDeclarationOrigin . DEFINED
isAssignable = true
}
然后注入 $changed,用于参数变化追踪与跳过优化:
1
2
3
4
5
6
for ( i in 0 until changedParamCount ( realParams , fn . thisParamCount )) {
fn . addValueParameter (
if ( i == 0 ) " $changed " else " $changed$i " ,
context . irBuiltIns . intType
)
}
这里的 $changed 并不是一个简单的 boolean,而是一个 bitmask。
每个参数占用固定数量的 bit,用来编码该参数在调用点已知的状态(是否稳定、是否变化、是否未知)。
如果函数存在默认参数,还会额外注入 $default 掩码参数,用来指示哪些参数在调用点被省略:
1
2
3
4
5
6
7
8
9
if ( fn . requiresDefaultParameter ()) {
for ( i in 0 until defaultParamCount ( currentParams )) {
fn . addValueParameter (
if ( i == 0 ) " $default " else " $default$i " ,
context . irBuiltIns . intType ,
IrDeclarationOrigin . MASK_FOR_DEFAULT_FUNCTION
)
}
}
需要注意的是:Compose 并不依赖 Kotlin 原生的 $default 桥接函数来处理默认参数,而是选择在 Composable 函数体内部显式展开默认值逻辑。这也是为什么默认参数的处理必须放在函数体变换阶段。
在签名变换阶段,还有一段看起来有些“边角料”的逻辑:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private fun IrSimpleFunction . makeStubsForDefaultValueClassIfNeeded (): List < IrSimpleFunction > {
if ( !is PublicComposableFunction ()) return emptyList ()
val stubs = mutableListOf < IrSimpleFunction >()
makeValueClassNonPrimitiveStub () ?. let { stubs . add ( it ) }
if (! context . platform . isJvm ()) {
makeValueClassInaccessibleConstructorDefaultStub { isPrimaryConstructorPrivate () }
?. let { stubs . add ( it ) }
makeValueClassInaccessibleConstructorDefaultStub {
! constructorVisibilityIsAtLeastAsAccessibleAsType ()
} ?. let { stubs . add ( it ) }
}
return stubs
}
这段逻辑只在 public(或 published API)的 Composable 函数上触发,核心目的是处理 value class 作为默认参数时,在跨模块、构造器不可访问等场景下的 ABI 兼容问题。
这些 stub 的生成完全发生在参数变换阶段,它们的存在只是为了保证调用侧参数 patch 的一致性,本身并不会参与 Group 生成或重组逻辑。
完成签名注入后,编译器才会进入第二阶段:函数体变换。
ComposableFunctionBodyTransformer 的入口同样是 visitFunction,但这里的重点已经不再是“函数长什么样”,而是“函数执行时如何与 Composer 交互”。
1
2
3
4
5
6
7
8
9
override fun visitFunction ( declaration : IrFunction ): IrStatement {
val scope = Scope . FunctionScope ( declaration , this )
return inScope ( scope ) {
visitFunctionInScope ( declaration )
}. also {
metrics . recordFunction ( scope . metrics )
declaration . functionMetrics = scope . metrics
}
}
FunctionScope 是整个函数体 Lowering 的上下文核心,它在构造时会扫描函数参数,识别:
是否存在 $composer $changed 参数集合$default 参数集合实际业务参数数量 Slot 数量 其中一个最重要的派生属性是:
1
val isComposable = composerParameter != null
这里的“Composable”并不是指是否有 @Composable 注解,而是指:
这个函数是否已经完成了 composer 参数注入,是否是一个真正可被 Runtime 驱动的 Composable 入口。
接下来进入 visitFunctionInScope。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private fun visitFunctionInScope ( declaration : IrFunction ): IrStatement {
val scope = currentFunctionScope
if (! scope . isComposable ) {
return super . visitFunction ( declaration )
}
if ( declaration . isDefaultParamStub ) {
return visitComposableFunctionStub ( declaration )
}
if ( declaration . origin == ADAPTER_FOR_CALLABLE_REFERENCE ) {
return visitComposableReferenceAdapter ( declaration , scope )
}
.. .
}
这里可以看到,函数体变换阶段一开始就会过滤掉几类“不是 Composable 执行体”的函数:
默认参数 stub、函数引用适配器,这些函数要么只是转发入口,要么只是桥接逻辑,本身不应该生成 Group,也不参与重组。
接下来才是 Composable 函数的核心分流逻辑。
编译器会基于函数特性,决定它属于哪一类:
Composable Lambda 可重启(Restartable)的普通 Composable 不可重启(Non-restartable)的 Composable 是否可重启,取决于多个条件的综合判断,而不仅仅是“是否 inline”。
其中一个关键限制是:只有返回 Unit 的函数,才可能拥有独立的重组作用域(Restart Scope)。
不可重启并不等于“不生成 Group”。
返回值的 Composable 函数依然需要 Group 来维护 Slot Table 状态,只是它们无法生成 startRestartGroup,通常会生成 startReplaceableGroup。
在可重启函数中,编译器会生成典型的结构:
1
2
3
4
5
6
7
$ composer . startRestartGroup ( key )
// 参数变化检测、默认参数展开、跳过逻辑
$ composer . endRestartGroup () ?. updateScope { composer , force ->
A ( x , composer , $ changed or 1 , $ default )
}
这里的 updateScope Lambda,是 Runtime 在重组时重新执行该函数的入口。
$changed or 1 的目的是强制设置最低位,避免在重组回调中再次被跳过。
在函数体内部,$changed 位掩码的传播起到了核心作用。
当一个 Composable 调用另一个 Composable 时,编译器会尽可能把当前已知的参数状态编码进被调用方的 $changed 参数中,这一过程被称为 Comparison Propagation。
1
2
3
4
5
6
B (
x ,
123 ,
$ composer ,
( maskFromParent and dirty ) or staticMask
)
通过这种方式,Runtime 可以在更深的调用层级上提前判断是否需要执行函数体,从而避免无意义的重组。
至此可以看到,Compose Compiler 的整体设计非常清晰:
第一阶段通过签名变换,把 Composable 函数改造成 Runtime 可调度的形态;
第二阶段通过函数体变换,构建 Group 结构、展开默认参数、实现跳过与重组逻辑。
理解这一点之后,再回头看 remember、mutableStateOf、derivedStateOf 等 API,就会发现它们并不是“魔法”,而只是对 Slot Table 与 Group 机制的不同使用方式。
上篇:2025年终总结
下篇:为什么设置 clipChildren = false 可以突破父 View 边界?