Compose 智能重组:编译器视角下的黑科技

什么是智能重组

Android View 通过测量、布局和绘制三个阶段完成UI 渲染,Compose 整体上与 Android View 类似,但在开头多了一个叫做"组合"的重要阶段。在组合阶段,Compose 会执行 @Composable 方法并输出 UI 的树状结构与对应信息,为后续的布局阶段提供数据基础。

Compose 采用声明式 UI 范式,不再像传统 View 那样通过调用 View 的 setXXX 方法来手动更新 UI,而是在 UI 状态变更时再次执行组合、布局、绘制流程,以此完成 UI 的更新,重新组合的过程就叫做"重组"。

然而重组是一个比较重的过程,需要重新执行 @Composable方法并更新内存中关于 UI 树的信息,如果每一个状态的变更都要走一遍整个流程将会带来严重的性能问题。因此在 UI 状态变化时,Compose 会智能的选择必要的@Composable 方法进行重组,并尽可能跳过不必要的代码执行,这就是 Compose 的"智能重组"。

下面的代码展示了一个简单的重组过程,在 Column、Text 组件上设置了随机的背景色,如果它们被重新组合那么背景色就会随机变化,我们可以通过这个来判断 UI 是否发生重组

kotlin 复制代码
@Composable
fun RecomposeDemo() {
    var count by remember { mutableStateOf(0) }
    Column(Modifier.background(randomColor()).padding(20.dp)) {
        RecomposeAwareText("Count: $count", Modifier.clickable {
            count++
        })

        RecomposeAwareText("Static Text")
    }

}

@Composable
fun RecomposeAwareText(text: String, modifier: Modifier = Modifier) {
    Text(text, modifier.background(randomColor()).padding(20.dp))
}

fun randomColor(): Color {
    val random = Random(System.currentTimeMillis())
    return Color(
        red = random.nextInt(256),
        green = random.nextInt(256),
        blue = random.nextInt(256),
        alpha = 255
    )
}

运行效果如下图所示,点击第一个 Text 会触发 count 变化,从而触发 UI 的重组。从执行结果来看 Column 和第一个 Text 都发生了重组,而第二个 Text 并没有重新执行。这也比较符合直觉,毕竟第二个 Text 的内容没有发生变化,也就不应该重组。

然而重组的本质就是重新执行@Composable 方法,从代码逻辑上来说第一个RecomposeAwareText被执行的情况下,第二个RecomposeAwareText也理应被执行。但正是由于 Compose 的智能重组机制 跳过了不必要的执行,从而避免了对第二个 Text 的重组。

智能重组机制由 Compose 编译器运行时协同完成,本文将聚焦于 Compose 编译器在其中发挥的作用,彻底揭开智能重组背后的"黑科技"。

编译器做了什么

为了实现智能重组能力,Compose 编译器会在编译期对每个 @Composable 方法进行转换,插入额外的参数与控制逻辑。我们将从一个简单的示例入手,初步了解编译器到底做了哪些改动,建立整体认知。后续章节将逐步拆解各个关键环节,深入解析这些改动背后的设计原理。

在下面这个例子中,RecomposeDemo读取了 uiState 并将它的值传递给ComposeUI,在ComposeUI中将参数 content 进行打印。

kotlin 复制代码
var uiState by mutableStateOf("UI State")

@Composable
fun RecomposeDemo() {
    ComposeUI(uiState)
}

@Composable
fun ComposeUI(content: String) {
    println(content)
}

经过 Compose 编译器编译后的代码如下(仅保留关键的部分)

kotlin 复制代码
@Composable
fun RecomposeDemo($composer: Composer?, $changed: Int) {
  $composer = $composer.startRestartGroup(1961523638)
  // 判断参数是否变化,如果没有变化则不执行代码
  if ($changed != 0 || !$composer.skipping) {
    ComposeUI(recordReadValue($readState, "uiState", uiState), $composer, 0)
  } else {
    $composer.skipToGroupEnd()
  }
  $composer.endRestartGroup()?.updateScope { $composer: Composer?, $force: Int ->
    // 为 RestartGroup 注册 State 变更时的回调,重新触发 RecomposeDemo 执行
    RecomposeDemo($composer, updateChangedFlags($changed or 0b0001))
  }
}
@Composable
fun ComposeUI(content: String, $composer: Composer?, $changed: Int) {
  $composer = $composer.startRestartGroup(-1501355475)
  val $dirty = $changed
  if ($changed and 0b0110 == 0) {
    // 判断 content 参数是否变化
    $dirty = $dirty or if ($composer.changed(content)) 0b0100 else 0b0010
  }
  if ($dirty and 0b0011 != 0b0010 || !$composer.skipping) {
    println(content)
  } else {
    $composer.skipToGroupEnd()
  }
  $composer.endRestartGroup()?.updateScope { $composer: Composer?, $force: Int ->
    ComposeUI(content, $composer, updateChangedFlags($changed or 0b0001))
  }
}

核心包含以下三部分变化

1. 插入参数

Compose 编译器在两个 @Composable 方法上都增加了$composer$changed两个参数,$composer可以看作是当前 Compose 的上下文环境,该参数会贯穿整个 Compose 组合阶段,在 Composable 方法调用链上层层传递。$changed参数则是用于提供当前方法参数变化信息,在方法内会结合该参数来判断是否跳过当前 @Composable 方法的执行。

2. 插入重组逻辑

两个 @Composable 方法的首尾都插入了startRestartGroupendRestartGroup调用,这其实是创建了一个 RestartGroup,在这个 Group 内如果某个方法调用了 State.getValue方法,那么这个 State 就会与当前的 RestartGroup 绑定,后续这个 State 变更时就会触发该 RestartGroup 的执行,也就是触发重组。

3. 跳过执行逻辑

ComposeUI方法中,插入了$dirty变量以及对应的计算逻辑,该变量用于最终判断当前方法入参content是否发生变化,并根据该变量来决定是否跳过ComposeUI内容的执行,这是智能重组的核心所在。

创建重组作用域

什么是重组作用域

通过前面对反编译后代码的分析,我们知道每个 Compose 方法都被包装在一个名为 RestartGroup 的特殊结构中。当一个 Compose 方法执行时,它会启动一个 RestartGroup。在这个 RestartGroup 的作用域内,如果读取了任何 State,那么这个 State 就会与当前的 RestartGroup 建立关联。当 Compose 方法执行完毕,这个 RestartGroup 也就随之结束。

一旦后续这个 State 的值发生更新,Compose 就会自动触发与该 State 关联的 RestartGroup 进行重组。而这个 RestartGroup 所属的 @Composable 方法,就是我们所说的 重组作用域

kotlin 复制代码
@Composable
fun RecomposeDemo($composer: Composer?, $changed: Int) {
  $composer = $composer.startRestartGroup(1961523638)
  // 判断参数是否变化,如果没有变化则不执行代码
  if ($changed != 0 || !$composer.skipping) {
    ComposeUI(recordReadValue($readState, "uiState", uiState), $composer, 0)
  } else {
    $composer.skipToGroupEnd()
  }
  $composer.endRestartGroup()?.updateScope { $composer: Composer?, $force: Int ->
    // 为 RestartGroup 注册 State 变更时的回调,重新触发 RecomposeDemo 执行
    RecomposeDemo($composer, updateChangedFlags($changed or 0b0001))
  }
}
@Composable
fun ComposeUI(content: String, $composer: Composer?, $changed: Int) {
  $composer = $composer.startRestartGroup(-1501355475)
  val $dirty = $changed
  if ($changed and 0b0110 == 0) {
    // 判断 content 参数是否变化
    $dirty = $dirty or if ($composer.changed(content)) 0b0100 else 0b0010
  }
  if ($dirty and 0b0011 != 0b0010 || !$composer.skipping) {
    println(content)
  } else {
    $composer.skipToGroupEnd()
  }
  $composer.endRestartGroup()?.updateScope { $composer: Composer?, $force: Int ->
    ComposeUI(content, $composer, updateChangedFlags($changed or 0b0001))
  }
}

还是以第二节的代码为例子,RecomposeDemo 执行逻辑如下

  1. RecomposeDemo 执行

  2. RecomposeDemo 启动 RestartGroup

  3. 读取 uiState

  4. 调用 ComposeUI

    1. ComposeUI 启动 RestartGroup
    2. ComposeUI 结束 RestartGroup
  5. RecomposeDemo 结束 RestartGroup

uiState 被读取时处于 RecomposeDemo 的作用域内,所以后续 uiState 更新时将会触发RecomposeDemo的重新执行。

哪些 Compose 方法无法被重启

我们称那些在编译阶段被 Compose 编译器包装进 RestartGroup 的方法是"可重启"的,但我们需要明确一点:并非所有 Compose 方法都能被重启 。这意味着,简单地认为"调用一个 Compose 方法就定义了一个重组作用域"是不准确的。

我们可以通过阅读Compose 编译器源码来了解哪些方法无法被重启,具体逻辑在ComposableFunctionBodyTransformer#shouldBeRestartable中,代码如下

代码注释非常详细,下面介绍其中比较重要的场景

内联方法

当一个函数被内联后,它就不再拥有一个独立的函数调用帧。它的代码逻辑直接成为了调用函数的一部分,所以它就无法作为一个独立的代码块进行重组。

@NonRestartableComposable标记的方法

@NonRestartableComposable是 Compose 提供的注解,允许开发者指定某个 Compose 方法不可重启。一般用于优化简单的 Compose 方法,这些方法内部仅仅是调用其他 @Compose 方法,这样可以避免冗余的 RestartGroup 与逻辑处理。在 Compose 内部就有大量的场景使用

有非 Unit 返回值的方法

如果一个Compose 方法存在非 Unit 的返回值,那这个方法也不能够被重启。因为这种方法的返回值通常是被调用方依赖,如果某次重组只重启了该方法,那么调用方将无法感知该方法的返回值变更,可能造成预期外的 UI 异常。

open 方法

open 方法也无法被重启,因为这类方法被 override 后会生成新的 RestartGroup,那么就会在一个方法中出现两个RestartGroup,重组时可能发生异常。

内联方法的 Composable Lambda 参数

如果一个 Composable Lambda 是作为 inline 方法的参数,那么这个 Composable Lambda 也无法被重组。最常见的是 Column、Box 等布局组件,这些组件均为 inline 方法,且接受一个 Composable Lambda 作为参数。

在以下代码中,uiState 关联的重组作用域为 ComposeUI,而不是 Column 或 Column的 尾 Lambda

kotlin 复制代码
var uiState by mutableStateOf("UI State")

@Composable
fun ComposeUI(content: String) {
    Column {
        println(uiState)
    }
}

如果在这种场景下希望 ComposeLambda 能够被重启,可以为该参数添加 noinline 修饰符

kotlin 复制代码
@Composable
inline fun RestartableColumn(noinline content: @Composable ColumnScope.() -> Unit) {
    Column { 
        content()
    }
}

跳过 Compose 方法执行

虽然 Compose 会尽量限制重组范围,但仍可能执行一些其实无需更新的 Compose 方法。为避免这种非必要的执行,Compose 编译器会为 Compose 方法插入跳过逻辑,从而在无需更新时自动跳过方法体执行。

哪些 Compose 方法不可 跳过

未开启Strong skipping mode时编译器还会判断方法参数的稳定性,以此决定是否为该方法生成跳过逻辑,但是在 kotlin 2.0.20 后该功能默认开启,所以本文的原理分析均在该功能开启的前提下进行。

正如不是所有 Compose 方法都可以被重启一样,也不是所有 Compose 方法都可以被跳过。

我们可以通过阅读 Compose 编译器源码来了解哪些方法无法被跳过,具体逻辑在ComposableFunctionBodyTransformer#visitFunctionInScope中,代码如下

总结下来就是「不可被重启的方法同样不可被跳过」,编译器不会为不可重启的方法生成 Skip 相关逻辑。

如何跳过执行

$changed 参数揭秘

编译器首先会为 Compose 方法插入一个参数$changed,用于表示当前方法各个参数的变化状态,为后续判断是否能够跳过重组提供辅助信息。

$changed是 Int 类型,每三位保存一个参数的信息,最低位用来表示是否强制重组,因此一个$changed能够保存 10 个参数的信息。如果参数个数大于 10,那么就会添加$changed1$changed2,以此类推。整体结构如下:

每个参数使用了 3 位来保存信息,其中低两位用来表示参数是否变化,最高位表示当前参数是否稳定(Stable)。

暂时无法在飞书文档外展示此内容

参数变化信息有以下 4 种取值

  • Uncertain (0b000):无法确定该参数较上一次重组是否有变化
  • Same (0b001):该参数较上一次重组没有发生变化
  • Different (0b010):该参数较上一次重组发生了变化
  • Static (0b011):该参数为静态对象,在 Compose 的生命周期内不会发生变化

生成 $dirty 跳过执行

Compose 方法是否跳过的判断条件为「所有被使用的参数相比上一次重组均没有发生变化 」,所以 Compose 编译器会结合 $changed 参数依次确认每个参数是否变化,并最终决定是否跳过执行。以一个简单的例子来分析编译生成的跳过逻辑

kotlin 复制代码
@Composable
fun ComposeDemo(param1: Int, param2: Int) {
    println("$param1 $param2")
}

// 编译后代码
@Composable
fun ComposeDemo(param1: Int, param2: Int, $composer: Composer?, $changed: Int) {
  val $dirty = $changed
  // 判断第一个参数是否是 Uncertain 0b000
  if ($changed and 0b0110 == 0) {
    // 通过 $composer.changed 来判断参数是否发生变化,并更新 $dirty
    $dirty = $dirty or if ($composer.changed(param1)) 0b0100 else 0b0010
  }
  // 同样的方式判断第二个参数
  if ($changed and 0b00110000 == 0) {
    $dirty = $dirty or if ($composer.changed(param2)) 0b00100000 else 0b00010000
  }
  
  // 判断是否跳过
  if ($dirty and 0b00010011 != 0b00010010 || !$composer.skipping) {
    println("$param1 $param2")
  } else {
    $composer.skipToGroupEnd()
  }
}

$composer.changed是 Compose 运行时提供用于判断参数是否变化的方法,不在本文讨论范围内

首先生成变量$dirty并赋值为$changed,用于表示每个参数最终的变化状态。

随后会对每个参数进行判断,当某个参数变化信息为 Uncertain 会通过 Composer 来判断参数是否发生变化,并更新$dirty。以第一个参数为例,当$composer.changed返回 true 时会执行 $dirty or 0b0100,也就是将$dirty中表示第一个参数状态的第二位置为 1,从 Uncertain 变为 Differen t,反之则是置为 Same。

完成所有参数校验后会判断$dirty and 0b00010011 != 0b00010010,如果为 true 则执行方法,也就是说想要跳过执行需要满足$dirty and 0b00010011 == 0b00010010。该判断的含义为:

  • 最低位需要为 0,表示当前并非强制重组,否则就需要执行方法
  • 两个参数的最低位都需要为 1,也就是两个参数都是 SameStatic,逻辑上就是参数较上一次重组没有发生变化

关于稳定性请参考官方文档

前面提到每个参数的最高位表示稳定性,对于不稳定的参数 Compose 会采用不同的方法来判断是否变化,由于上面的例子中参数均为编译期可推断的稳定类型(Int),所以采用了$composer.changed来判断。

如果我们将第二个参数类型改为编译期无法推断的类型,那么生成的逻辑将会有所变化

kotlin 复制代码
interface InterfaceType

@Composable
fun ComposeDemo(param: InterfaceType) {
    println("$param1 $param2")
}

// 编译后代码
@Composable
fun ComposeDemo(param1: Int, param2: InterfaceType, $composer: Composer?, $changed: Int) {
  val $dirty = $changed
  if ($changed and 0b0110 == 0) {
    $dirty = $dirty or if ($composer.changed(param1)) 0b0100 else 0b0010
  }
  if ($changed and 0b00110000 == 0) {
    $dirty = $dirty or if (if ($changed and 0b01000000 == 0) { // 判断参数稳定性
      $composer.changed(param2)
    } else {
      $composer.changedInstance(param2)
    }
    ) 0b00100000 else 0b00010000
  }
  if ($dirty and 0b00010011 != 0b00010010 || !$composer.skipping) {
    println("$param1 $param2")
  } else {
    $composer.skipToGroupEnd()
  }
}

可以看到针对第二个参数首先会判断最高位

  • 如果是 0 则为稳定类型,通过$composer.changed 判断,本质上是通过==来比较重组前后的参数
  • 如果是 1 则为不稳定类型,通过$composer.changedInstance 判断,本质上是通过===来比较重组前后的参数

而对于未使用的参数,Compose 编译器也会非常智能的忽略它,减少不必要的运算开销。去掉ComposeDemo中对 param2 的使用后,反编译代码如下所示,可以看到只判断了 param1 的变化情况。

kotlin 复制代码
@Composable
fun ComposeDemo(param1: Int, param2: Int) {
    println("$param1")
}

// 编译后代码
@Composable
fun ComposeDemo(param1: Int, param2: Int, $composer: Composer?, $changed: Int) {
  val $dirty = $changed
  if ($changed and 0b0110 == 0) {
    $dirty = $dirty or if ($composer.changed(param1)) 0b0100 else 0b0010
  }
  if ($dirty and 0b0011 != 0b0010 || !$composer.skipping) {
    println("$param1")
  } else {
    $composer.skipToGroupEnd()
  }
}

$changed 信息传递

如果没有$changed 参数,Compose 仍然会通过$composer.changed来判断参数是否发生变化,也可以正常实现跳过逻辑。但是$composer.changed是比较重的操作,如果$changed 已经提供了足够的信息,那么就可以避免调用$composer.changed,极大提升运行时性能,这也是$changed的设计初衷。

下面我们来看一下$changed参数在各个场景下如何为 Compose 提供有效信息

静态参数信息

当调用点方递静态对象(同一个对象或值相同的基础类型)作为参数时,编译器会将 $changed 对应参数信息设置为Static(011),这样被调用的 Composable 方法就可以直接跳过这个参数的对比。

在下面的例子中, 编译器识别出ComposeScreen传入的参数为常量 1,所以传递 $changed 值 0b0110 将参数设置为 Static。

kotlin 复制代码
@Composable
fun MyComposeUI(param: Int) {
    println("$param")
}

@Composable
fun ComposeScreen(param: UnstableImpl) {
    MyComposeUI(1)
}

// 编译后代码
@Composable
fun ComposeScreen(param: UnstableImpl, $composer: Composer?, $changed: Int) {
  if ($changed and 0b0001 != 0 || !$composer.skipping) {
    MyComposeUI(1, $composer, 0b0110)
  } else {
    $composer.skipToGroupEnd()
  }
}

除了直接传递常量的场景外,我们也可以通过在方法或属性 上标注@Stable 来帮助编译器识别方法或属性的值是否是静态对象,这种场景下 @Stable 的作用是告诉编译器:

  • 该方法的输入不变时,方法返回值也保持不变
  • 任意时刻该属性的返回值保持不变

修改上面的例子,将参数改为调用 stableFunction,生成代码如下

kotlin 复制代码
@Stable
fun stableFunction(value: Int): Int {
    return value + 1
}

@Composable
fun ComposeScreen(param: UnstableImpl) {
    MyComposeUI(stableFunction(1))
}

// 编译后代码
@Composable
fun ComposeScreen(param: UnstableImpl, $composer: Composer?, $changed: Int) {
  if ($changed and 0b0001 != 0 || !$composer.skipping) {
    MyComposeUI(stableFunction(1), $composer, 0b0110)
  } else {
    $composer.skipToGroupEnd()
  }
}

尽管是将方法的返回值作为参数传递,但编译器仍然能够识别到该参数为静态参数,就是因为 stableFunction 被标记为@Stable,且ComposeScreen调用stableFunction传递的是一个常量。

这种方法在 Compose 内部也有普遍的使用,比如经常作为参数使用的 Alignment

同时 Compose 编译器也将一些常用的 Kotlin 标准库方法视为 Stable,比如 listOf(1, 2, 3)这样的调用就会被认为返回值是一个静态对象,这些内置的 Stable 方法在源码中可以找到

Compose 编译器对静态参数的识别还远不止于此,下表列出了 Compose 编译器能够识别的大部分场景

场景 代码快
基础类型常量 4
基础类型常量运算 (1f + 3f) / 2
字符串常量 "Hello world!"
Object object Singleton
Stable function + 常量 stableFunction(42)
listOf + 常量 listOf('a', 'b', 'c')
emptyList emptyList<Any?>()
Pair + 常量 'a' to 42
枚举 Foo.Bar
Dp + 常量 Dp(4f)
Dp 常量运算 2 * 4.dp
@Immutable/@Stable + 所有属性都是 Static KeyboardOptions(autoCorrect = false) PaddingValues(all = 16.dp)
参数变化信息

在某些场景下调用方会直接将自己的方法参数传递给下一个 Composable 方法,由于该参数在调用方内部已经做过一次判断,因此可以直接将判断的结果通过$changed 传递下去,省去后面对该参数的判断成本。

在下面的例子中,ComposeScreen 将自身的参数 param 透传给 MyComposeUI,编译器生成的代码中直接通过 $dirty & 0b1110获取到 param 的变化信息并传递给 MyComposeUI

kotlin 复制代码
@Composable
fun ComposeScreen(param: Int) {
    MyComposeUI(param)
}

// 编译后代码
@Composable
fun ComposeScreen(param: Int, $composer: Composer?, $changed: Int) {
  val $dirty = $changed
  if ($changed and 0b0110 == 0) {
    $dirty = $dirty or if ($composer.changed(param)) 0b0100 else 0b0010
  }
  if ($dirty and 0b0011 != 0b0010 || !$composer.skipping) {
    MyComposeUI(param, $composer, 0b1110 and $dirty)
  } else {
    $composer.skipToGroupEnd()
  }
}

处理默认参数

Kotlin 支持方法参数的默认值,原理上会在编译期为方法添加一个$default参数用于判断某个参数是否使用默认值,并在方法开头为使用了默认值的参数赋值。

而针对 Composable 函数中的参数默认值,Compose 选择了自己处理而不是交给 Kotlin 编译器,因为需要处理默认值对跳过逻辑的影响,以一个简单的例子看一下生成的代码

kotlin 复制代码
@Composable
fun DefaultTest(param: Int = 1) {
    println(param)
}

// 编译后代码
@Composable
fun DefaultTest(param: Int, $composer: Composer?, $changed: Int, $default: Int) {
  val $dirty = $changed
  if ($default and 0b0001 != 0) {
    // 使用默认值则设置为 Static
    $dirty = $dirty or 0b0110
  } else if ($changed and 0b0110 == 0) {
    // 未使用默认值正常判断 changed
    $dirty = $dirty or if ($composer.changed(param)) 0b0100 else 0b0010
  }
  if ($dirty and 0b0011 != 0b0010 || !$composer.skipping) {
    if ($default and 0b0001 != 0) {
      // 使用默认值时为参数赋值
      param = 1
    }
    println(param)
  } else {
    $composer.skipToGroupEnd()
  }
}

和 Kotlin 默认参数处理思路是一样的,处理流程为:

  • 为方法增加 $default 参数,每一位表示对应参数是否使用默认值
  • 如果参数使用了默认值,则设置 $dirty 设置为 Static,跳过判断
  • 如果参数未使用默认值,则正常走 changed 判断
  • 如果最终无法跳过当前 Composable 执行,则为使用了默认值的参数赋值

看到这个代码不由得会产生一个疑问:为什么 param 一旦使用默认值,就可以被判定为 Static ?如果上一次组合调用 DefaultTest 没用默认值,而这次重组用了默认值,这种场景下 param 难道不是发生变化了吗?

其实仔细想想就可以理解:如果某次重组时 param 使用了默认值,那么在整个 Composition 周期内它必然始终都会使用默认值。这是由调用点在编译期就决定的,一旦出现非默认值的情况,就意味着调用点发生了变化,两次调用本质上已不再属于同一个 Compose UI。

不过在这个例子中默认值是 1,前面介绍过对于这种常量 Compose 能够识别为 Static 对象,如果我们将默认值改为一个方法调用会发生什么?

kotlin 复制代码
@Composable
fun DefaultTest1(param: Int = getInt()) {
    println(param)
}

fun getInt(): Int {
    return 1
}

// 编译后代码
@Composable
fun DefaultTest1(param: Int, $composer: Composer?, $changed: Int, $default: Int) {
  val $dirty = $changed
  // 首先判断 $changed
  if ($changed and 0b0110 == 0) {
    $dirty = $dirty or if ($default and 0b0001 == 0 && $composer.changed(param)) 0b0100 else 0b0010
  }
  if ($dirty and 0b0011 != 0b0010 || !$composer.skipping) {
    $composer.startDefaults()
    if ($changed and 0b0001 == 0 || $composer.defaultsInvalid) {
      if ($default and 0b0001 != 0) {
        param = getInt()
        // 将$dirty 中参数对应信息设置为 000 -> Uncertain 
        $dirty = $dirty and 0b1110.inv()
      }
    } else {
      $composer.skipToGroupEnd()
      if ($default and 0b0001 != 0) {
        $dirty = $dirty and 0b1110.inv()
      }
    }
    $composer.endDefaults()
    println(param)
  } else {
    $composer.skipToGroupEnd()
  }
}

将 param 默认值改为 getInt调用后,由于 Compose 无法推测该调用是否是 Static ,所以 $dirty 生成的策略有所变化

  1. 优先检查 $changed
    • 如果已有参数变化信息,直接使用,无需额外判断。
  2. 若无变化信息,则判断默认值使用情况
    • 使用了默认值,则设置为 Same
    • 未使用默认值,按常规通过 changed 判断。

此外,在后续无法跳过执行需要为参数赋值时,Compose 还会增加一段逻辑: $dirty 中参数对应信息设置为Uncertaion(0b000)

这么做的原因是:虽然使用默认值时被标记为了 Same ,但由于这类调用不是 Static,Compose 实际上无法保证其是否真的没有发生变化。为了避免对子 Composable 的判断产生误导,最终将其标记为 Uncertain,从而强制子 Composable 重新进行判断。在源码中也可以看到官方的解释

如何处理 Composable Lambda

上面讨论的场景以及例子都是针对普通 @Composable 方法,而对于 Composable Lambda 的处理稍有不同。Compose 编译器会将 Composable Lambda 分为三类,并采用不同的处理策略。

无法跳过执行的Composable Lambda

需要注意的是,@NonRestartableComposable、@NonSkippableComposable对 Lambda 无效

这部分前面已经介绍过,如果 Composable Lambda 有返回值或者是作为 inline 方法的参数,那么该 Composable Lambda 则无法跳过执行,编译器不会做任何的优化。

kotlin 复制代码
@Composable
fun TestComposeLambda() {
    // 有返回值的 Composable Lambda
    val lambda = @Composable { text: String ->
        println("ComposeLambda: $text")
        ""
    }
}

// 编译后代码
fun TestComposeLambda($composer: Composer?, $changed: Int) {
  if ($changed != 0 || !$composer.skipping) {
    val lambda = { text: String, $composer: Composer?, $changed: Int ->
      $composer.startReplaceGroup(1957901905)
      println("ComposeLambda: $text")
      $composer.endReplaceGroup()
      tmp0
    }
  } else {
    $composer.skipToGroupEnd()
  }
}

可跳过执行的Composable Lambda

对于可正常跳过执行的 Composable Lambda,编译器会对其进行一层封装,具体封装逻辑取决于该 Lambda 是否捕获外部变量

不捕获外部变量

在 Kotlin 中,一个不捕获外部变量的 Lambda 最终会被优化为一个单例,因为这种 Lambda 没有任何状态,优化为单例对逻辑没有任何影响且能够节省运行开销。

类似的,针对不捕获外部变量的 Composable Lambda,Compose 编译器也会为期生成一个单例,同时通过composableLambdaInstance进行封装。

kotlin 复制代码
@Composable
fun TestComposeLambda() {
    // 无状态 Composable Lambda
    val lambda = @Composable { text: String ->
        println("ComposeLambda: $text")
    }
}

// 编译后代码
fun TestComposeLambda($composer: Composer?, $changed: Int) {
  if ($changed != 0 || !$composer.skipping) {
    val lambda = ComposableSingletons$ComposeLambdaTestKt.lambda$1010909634
  } else {
    $composer.skipToGroupEnd()
  }
}

// 生成单例
internal object ComposableSingletons$ComposeLambdaTestKt {
  // 使用 composableLambdaInstance 封装 Lambda
  val lambda$1010909634: Function3<String, Composer, Int, Unit> = composableLambdaInstance(1010909634, false) { text: String, $composer: Composer?, $changed: Int ->
    val $dirty = $changed
    if ($changed and 0b0110 == 0) {
      $dirty = $dirty or if ($composer.changed(text)) 0b0100 else 0b0010
    }
    if ($dirty and 0b00010011 != 0b00010010 || !$composer.skipping) {
      println("ComposeLambda: $text")
    } else {
      $composer.skipToGroupEnd()
    }
  }
}

捕获外部变量

如果 Composable Lambda 捕获了外部变量,则无法优化为单例。这种情况下 Compose 会使用 remember 来缓存该Composable Lambda对象,避免每次重组都会创建新的 Lambda 实例。

kotlin 复制代码
@Composable
fun TestComposeLambda() {
    var name: String = ""
    // 捕获外部变量 name
    val lambda = @Composable { text: String ->
        println("ComposeLambda: $text $name")
    }
}

// 编译后代码
fun TestComposeLambda($composer: Composer?, $changed: Int) {
  if ($changed != 0 || !$composer.skipping) {
    val lambda = rememberComposableLambda(2141696259, true, { text: String, $composer: Composer?, $changed: Int ->
      val $dirty = $changed
      if ($changed and 0b0110 == 0) {
        $dirty = $dirty or if ($composer.changed(text)) 0b0100 else 0b0010
      }
      if ($dirty and 0b00010011 != 0b00010010 || !$composer.skipping) {
        println("ComposeLambda: $text $name")
      } else {
        $composer.skipToGroupEnd()
      }
    }, $composer, 0b00110110)
  } else {
    $composer.skipToGroupEnd()
  }
}

rememberComposableLambda实际上是基于 remember 创建 Lambda 对象

相关推荐
小孔龙2 小时前
05.Kotlin Serialization - 多态序列化入门
kotlin·json
vivo高启强2 小时前
R8 如何优化我们的代码(1) -- 减少类的加载
android·android studio
诺诺Okami4 小时前
Android Framework-WMS-从setContentView开始
android
前行的小黑炭5 小时前
Android :Compose如何监听生命周期?NavHostController和我们传统的Activity的任务栈有什么不同?
android·kotlin·app
Lei活在当下13 小时前
【业务场景架构实战】5. 使用 Flow 模式传递状态过程中的思考点
android·架构·android jetpack
前行的小黑炭16 小时前
Android 关于状态栏的内容:开启沉浸式页面内容被状态栏遮盖;状态栏暗亮色设置;
android·kotlin·app
用户0919 小时前
Flutter构建速度深度优化指南
android·flutter·ios
PenguinLetsGo20 小时前
关于「幽灵调用」一事第三弹:完结?
android
雨白1 天前
Android 多线程:理解 Handler 与 Looper 机制
android