opoojkk

Compose Compiler 深入解析:从参数注入到重组执行的 IR 变换全流程

lxx

在 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 (!isPublicComposableFunction()) 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 的上下文核心,它在构造时会扫描函数参数,识别:

其中一个最重要的派生属性是:

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 函数的核心分流逻辑。

编译器会基于函数特性,决定它属于哪一类:

是否可重启,取决于多个条件的综合判断,而不仅仅是“是否 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 结构、展开默认参数、实现跳过与重组逻辑。

理解这一点之后,再回头看 remembermutableStateOfderivedStateOf 等 API,就会发现它们并不是“魔法”,而只是对 Slot Table 与 Group 机制的不同使用方式。

标签:
Categories: