深入浅出 Compose Compiler(4) 智能重组与 $changed 参数

13,464 阅读5分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

前言

重组是 Compose 的一个重要特征,重组过程中 Composable 函数会对参数进行比较,如果参数没有发生变化则会跳过重组,即所谓的“智能”的重组。但是这个参数的比较不全是 runtime 的事情,Compiler 也会参与其中。

@Composable
fun Foo(bar: String) {
    Text(bar)
}

我们拿上面这个非常简单的 Composable 作例子,它经过编译后变成下面这样(反编译后的伪代码,看着更清晰):

@Composable 
fun Foo(bar: String, $composer: Composer<*>, $changed: Int) {
    $composer.startRestartGroup(405544596)
    var $dirty = $changed
    if ($changed and 14 === 0) {
        $dirty = $dirty or if ($composer.changed(x)) 2 else 4
    }
    if ($dirty and 11 !== 2 || !$composer.skipping) {
        Text(bar)
    } else {
       $composer.skipToGroupEnd()
    }
    $composer.endRestartGroup().updateScope {
        Foo(bar, $changed)
    }
}

大多数的 Composable 函数编译后都会被包装在 startRestartGroup/endRestartGroup 中,让当前函数有了重组的能力,可以看到函数结尾处 updateScope 注册的 lambda 就是用于重组时的递归调用。

为实现“智能”的重组,函数执行允许 skip ,此时会直接执行下面的 else 分支,skipToGroupEnd() 将对 SlotTable 的遍历推进到最后:

 if ($dirty and 11 !== 2 || !$composer.skipping) {
        Text(bar)
    } else {
       $composer.skipToGroupEnd()
    }

这里 if 条件中依赖对 $dirty 的判断,而 $dirty 来自 $changed。 那这些变量代表什么呢?另外,代码中出现了好多魔数,诸如 14 ,11, 2, 4 之类的,这些又代表什么呢?本文就来讨论一下这些内容。

$changed 与 ParamState

Composable 经过编译后函数签名会发生变化。除了新增 $composer 以外,还会添加 changed参数。前缀changed 参数。前缀 ` 告诉我们这些都是 Compiler 的产物。$changed` 可以为参数提供额外的辅助信息,这些信息辅助运行时的参数比较,减少运行时的判断,提升性能。

$changed 是一个 Int 类型 (32bits),最低位是保留位,用来表示是否强制重组。然后从低到高,每3bits 代表一个参数的信息,这样 32 bit 至多可以承载 10 个参数的信息 ( 10 * 3 + 1 = 31 )。如果 Composable 参数超过 10 个,那么相应地会在签名中插入 $changed, $changed1 ... 以此类推。

这 3bits 信息被称为 ParamState,它是一个 Enum,有 5 个取值所以需要占用 3 个 bit

enum class ParamState(val bits: Int) {
    
    Uncertain(0b000),
    
    Same(0b001),
    
    Different(0b010),
    
    Static(0b011),
    
    Unknown(0b100)
}
  • Uncertain(0b000) :参数最新值与最后一次重组的值比较,是否有变化不确定
  • Same(0b001) :参数最新值与最后一次重组的值比较,没有发生变化
  • Different(0b010) :参数最新值与最后一次重组的值比较,发生了变化
  • Static(0b011): 参数是一个静态常量
  • Unknown(0b100): 3bits 的最高位表示参数类型是否稳定,1 表示不稳定

Composable 函数在调用处会根据编译期静态分析的结果,设置最适当的 $changed 值。 例如下面代码中, App 中传入 Foo 的是一个静态值,所以 $changedStatic(0b011)

@Composable
fun App() {
    Foo("Hello world!")
}

//编译后:
@Composable
fun MyApp($composer: Composer<*>) {
    Foo("Hello world!", $composer, 0b0110)  //static(0b011) shl 1 + 0b1
}

在例如下面代码中,App 中传入 Foo 是一个变量,所以编译期无法确定类型,传入 Uncertain(0b000)

var str = ""
@Composable
fun App() {
    Foo(str)
}

//编译后:
@Composable
fun MyApp($composer: Composer<*>) {
    Foo("Hello world!", $composer, 0b0000)  //Uncertain(0b000) shl 1 + 0b1
}

$dirty 与 ParamState.Uncertain

$changedUncertain 时,没法确定此次参数有没有变化,此时就需要运行时进行参数比较,经比较后的到一个确定结果 - 要么是 Same(0b001) 要么 Different(0b010),并更新到 $dirty 对应的字段。

var $dirty = $changed
if ($changed and 14 === 0) {
    $dirty = $dirty or if ($composer.changed(x)) 4 else 2
}

14 二进制是 0b1110,所以上面 if 语句中 $changed and 14 === 0 的条件,只有 $changedUncertain (0b000 左移一位) 时才成立。

当命中 Uncertain 时,调用 $composer.changed(x) 拿当前参数与 SlotTable 中的记录进行比较,如果有变化则则返回 false, 并将最新的参数存入 SlotTable。因此 4 就是 0b010 左移 1 位的结果,对应 Different; 同理,2 对应的就是 Same

参数比较的结果会更新到 $dirty 用于后续判断是否参与重组。这里可能有人会问为什么要用 or 进行更新,直接赋值不就好了? 别忘了 $dirty$changed 至多可以承载十个参数状态,我们这个例子只有一个参数看不出来 or 的意义,当有多个参数,就需要 or 去合并多个参数状态了。

ParamState.Same 与 ParamState.Different

前面讲了,一个 Uncertain 状态经过比较可以转换为 SameDifferent。如果 $changed 初始就是 SameDifferent,则意味着要么跳过重组,要么参与重组,总之行为是 Certaiin 的,因此需再进行参数比较了,参数值也不必存入 SlotTable 了,这样可以节省一些比较的开销以及 SlotTablle 的内存。

更新后的 $dirty 用来判断是否参与重组:

    if ($dirty and 11 !== 2 || !$composer.skipping) {
        Text(bar)
    } else {
       $composer.skipToGroupEnd()
    }

11 二进制表示是 0b1011, 2 是 0b10, 所以只有 DifferentUnKnown 符合条件。

  • Different : 0b0100 and 0b1011 != 2
  • Same:0b0010 and 0b1011 = 2
  • Static: 0b0110 and 0b1011 = 2
  • UnKnown:0b1000 and 0b101 != 2

Different 会对函数体进行重组,SameStatic 则跳过重组。 Unknown 虽然也符合条件,但是 Compiler 针对类型稳定性有其他优化,后文会看到。

ComposableFunctionBodyTransformer

上面的这些代码生成逻辑都是在 ComposableFunctionBodyTransformer 中实现的,这是 Compose Compiler 中最复杂的一个文件,今后我们再慢慢介绍,这里只看一下 $changed 的代码的生成部分,主要在 visitRestartableComposableFunction 函数内:

private fun visitRestartableComposableFunction(
    declaration: IrFunction,
    scope: Scope.FunctionScope,
    changedParam: IrChangedBitMaskValue,
    defaultParam: IrDefaultBitMaskValue?
): IrStatement {

    //...
    
    //是否可以跳过重组
    canSkipExecution = buildPreambleStatementsAndReturnIfSkippingPossible(
        ...
    )
    
    // if it has non-optional unstable params, the function can never skip, so we always
    // execute the body. Otherwise, we wrap the body in an if and only skip when certain
    // conditions are met.
    val dirtyForSkipping = if (dirty.used && dirty is IrChangedBitMaskVariable) {
        skipPreamble.statements.addAll(0, dirty.asStatements())
        dirty
    } else changedParam
    
    val transformedBody = if (canSkipExecution) {
        
        //是否应该执行重组
        var shouldExecute = irOrOr(
            dirtyForSkipping.irHasDifferences(scope.usedParams),
            irNot(irIsSkipping())
        )
    
        //...
    
        //生成执行重组或跳过重组的 if...else 代码块
        irIfThenElse(
            condition = shouldExecute,
            thenPart = irBlock(
                statements = bodyPreamble.statements + transformed.statements
            ),
            // Use end offsets so that stepping out of the composable function
            // does not step back to the start line for the function.
            elsePart = irSkipToGroupEnd(body.endOffset, body.endOffset),
            startOffset = body.startOffset,
            endOffset = body.endOffset
        )
    } else irComposite(
        statements = bodyPreamble.statements + transformed.statements
    )
    
    //...
}

canSkipExecution 表示是否可以跳过重组,党可以跳过重组时,生成下面这样的 if ... else 代码:

    if ($dirty and 11 !== 2 || !$composer.skipping) {
        Text(bar)
    } else {
       $composer.skipToGroupEnd()
    }

irIfThenElse 用来生成 if...else 代码, shouldExecute 是 if 里的 $dirty and 11 !== 2 || !$composer.skipping 判断。 thenPartelsePart 分别生成对应花括号里的代码。

这段代码告诉我们,如果 canSkipExecution 为 false,压根就不会生成上面的 if...else 的判断逻辑,一定会执行 Text(bar). 那么 canSkipExecution 是如何被赋值的呢?我们从 buildPreambleStatementsAndReturnIfSkippingPossible 里找一下实现

parameters.forEachIndexed { slotIndex, param ->
    val stability = stabilityOf(param.varargElementType ?: param.type)

    stabilities[slotIndex] = stability

    val isRequired = param.defaultValue == null
    val isUnstable = stability.knownUnstable()
    val isUsed = scope.usedParams[slotIndex]

    //...
    
    if (isUsed && isUnstable && isRequired) {
        // if it is a used + unstable parameter with no default expression, the fn
        // will _never_ skip
        mightSkip = false
    }
}

buildPreambleStatementsAndReturnIfSkippingPossible 最终返回的是上面的 mightSkip。也就是说当 Composable 的函数参数中,有任何一个参数是 isUsed && isUnstable && isRequired,即 参数是不稳定类型、且没有默认值而且函数体中被使用,则当前 Composable 的重组就不应该跳过,无需生成 skipToGroupEnd 相关的 if...else 逻辑,减少运行开销和产物体积。

我们做个一个实验验证一下:

data class Bar(var str: String)

@Composable
fun Foo(bar: Bar) {
    Text(bar.str)
}

上面的 Bar 就是一个不稳定类型。因此编译后的代码如下:

@Composable 
fun Foo(bar: Bar, $composer: Composer<*>, $changed: Int) {
    $composer.startRestartGroup(405544596)
    Text(bar.str)
    $composer.endRestartGroup().updateScope {
        Foo(bar, $changed)
    }
}

果然,$dirty 以及 skipToGroupEnd 相关的逻辑都没有了,100% 会执行重组。但是如果 Foo 中没有对依 bar 的读取,则即使 Bar 是不稳定类型,也会生成 skipToGroupEnd 的代码。

Bar 的类型稳定性来自 stabilityOf,那么编译器怎么决定类型是是否稳定呢,这个留着我们下一篇文章再做介绍。

最后

最后做一个总结:Compose Compiler 编译期提为参数提供了 ParamState 信息,可以减少无谓的参数比较,提升重组的性能。我们用下面的图做一个收尾: