PausableComposition 如何提升 Compose 滑动性能

362 阅读8分钟

引言

在 Android 开发中,列表滑动的流畅度是衡量用户体验的核心指标。长期以来,RecyclerView 围绕滑动场景进行了大量针对性的性能优化,在实际性能表现上始终优于 Compose 的 LazyList。然而官方最新的 Benchmark 数据显示,Compose 在 1.9 版本上的列表滑动性能已经追平传统 View 体系,这一优化背后的核心机制就是 PausableComposition。

Compose 现有的滑动优化机制

在介绍基于 PausableComposition 的优化策略之前,我们先来了解一下 Compose 中现有的优化机制 - LazyLayout Prefetch

Compose 中的 LazyColumn、LazyRow 等组件都是基于 LazyLayout 实现,通过按需渲染屏幕中的子组件减少性能损耗。然而这种策略在滑动场景下会有一个明显的问题:子组件只会在快要进入屏幕时进行组合、布局、渲染,容易造成滑动卡顿。

Compose 采用了 Prefetch 机制来优化这个问题,核心策略是:在当前帧的空闲时间内提前执行下一个子组件的 Compose、Measure 两个阶段,减少该组件后续上屏时的耗时。 如果子组件的 Compose、Measure 耗时较长,仍然会导致列表卡顿,因此Compose 在 Prefetch 过程中会记录过往每次 PreCompose、PreMeasure 执行的耗时,并基于此计算出平均耗时 averageTime,后续每次 Prefetch 都只会在当前帧的剩余时间 leftTime 大于averageTime时执行,如果时间不够则等待下一帧再进行判断。整体流程如下图所示

image.png

基于 PausableComposition 的优化机制

什么是 PausableComposition

普通 Composition 会在一次调用中完成整个 Compose 阶段的执行,而 PausableComposition 允许外部控制 Compose 阶段的执行与暂停,也就说我们可以将 Compose 阶段拆分为更小的粒度增量执行。

PausableComposition 接口定义如下:

public sealed interface PausableComposition : ReusableComposition {
    public fun setPausableContent(content: @Composable () -> Unit): PausedComposition
}

通过调用PausableComposition#setPausableContent方法启动 PausableComposition,并返回一个PausedComposition,可以将PausedComposition理解为PausableComposition 的控制器,通过它来控制PausableComposition的执行与暂停。

public sealed interface PausedComposition {
    public val isComplete: Boolean
    public fun resume(shouldPause: ShouldPauseCallback): Boolean
    public fun apply() 
}

PausableComposition 不会立刻启动 Compose ,而是等待 resume 方法被调用。从整体上看,PausableComposition 的执行可以理解为一个「可被多次 resume 的 Compose 任务」,直到最终完成并一次性 apply。大致执行流程如下:

  1. 调用 resume,开始执行 Compose
  2. 在执行过程中,通过 ShouldPauseCallback 判断是否需要暂停当前 Composition
  3. 若当前Composition完成执行, resume 返回 true
  4. 若未完成,则需要后续再次触发 resume 直至完成执行
  5. 当 Composition 完成后,调用 apply将变更提交到 Applier

简化后的流程如图所示

image.png PausableComposition 将 Compose 拆分为了更小的执行单元,并基于此实现 Compose 增量执行,将上图中的 Do Compose部分展开后,大致的流程如下图所示

image.png

基于 PausableComposition 优化

现有 Prefetch 机制在 Compose UI 比较复杂的场景下,单个组件的 Compose 耗时会比较长,很容易超过每一帧的剩余时间(leftTime),此时 Prefetch 的触发成功率会显著下降,效果接近于未开启状态。之所以会有这样的问题,根本上来说是因为 Compose 需要一次性完成执行,而 PausableComposition 支持增量执行的能力则为这一问题提供了一种有效的解决思路。

核心优化思路是:Prefetch 阶段不再完整执行 Compose,而是通过 PausableComposition 增量执行拆分后的子 Composable。具体策略如下

  1. 在 Prefetch 阶段,使用 PausableComposition 执行子 Composable
  2. 每一帧仅执行一部分 Compose
  3. 当检测到当前帧剩余时间不足时,暂停 Composition
  4. 在下一帧继续执行,直到完成

这样一来,Compose 的执行粒度由原先以完整 Compose 为单位的一次性执行,拆分为以更小粒度子 Composable 为单位的增量执行;相应地,Prefetch 的触发条件也从“当前帧剩余时间需大于整个 Compose 的平均耗时”,转变为“当前帧剩余时间只需大于单个子 Composable 的平均执行耗时”。显然后者更容易被满足,从而显著提升 Prefetch 的触发成功率,并进一步降低对主线程耗时的影响。

支持自定义 CacheWindow

在 Compose 1.9 中,LazyLayout 还引入了 LazyLayoutCacheWindow,允许开发者自定义 Prefetch 的覆盖范围。该能力与 PausableComposition 配合使用,可以进一步提升列表在高强度滑动场景下的稳定性。

考虑这样一种情况:当用户快速上滑列表时,当前帧可能需要立即展示多个新的 Item(例如 3 个)。然而默认的 Prefetch 机制仅会提前创建 1 个 Item,那么剩余的 Item 就只能在滑动过程中同步创建;一旦 Item 的 Compose 或布局开销较大,就很容易引发瞬时卡顿。

借助 LazyLayoutCacheWindow,我们可以显式扩大 Cache Window 的范围,确保在 Prefetch 阶段能够提前准备更多的 Item(例如 3 个)。这样即使遇到上述极端滑动场景,也能通过已完成 Prefetch 的内容直接完成展示,从而避免因同步创建 Item 带来的性能波动,显著提升滑动过程的稳定性。

image.png

PausableComposition 实现原理

Composable 拆分

PausableComposition 之所以能够实现 Compose 的增量执行,并不是在运行期简单地暂停 Composable 的执行流程,而是建立在编译期与运行期协同工作的基础之上。

回顾之前的文章,Compose 编译器会在每一个 @Composable 函数中插入一系列辅助代码,用于在运行期动态判断当前方法是否可以被跳过执行。PausableComposition 在这一机制上做了一些改动,下面我们结合高版本 Compose Compiler 的编译结果,来看这一变化具体是什么。

@Composable
fun ComposeUI(param: Int) {
    println(param)
}

// 编译后代码
@Composable
fun ComposeUI(param: Int, $composer: Composer?, $changed: Int) {
  $composer = $composer.startRestartGroup(-1995069302)
  val $dirty = $changed
  if ($changed and 0b0110 == 0) {
    $dirty = $dirty or if ($composer.changed(param)) 0b0100 else 0b0010
  }
  if ($composer.shouldExecute($dirty and 0b0011 != 0b0010, $dirty and 0b0001)) {
    println(param)
  } else {
    $composer.skipToGroupEnd()
  }
  $composer.endRestartGroup()?.updateScope { $composer: Composer?, $force: Int ->
    ComposeUI(param, $composer, updateChangedFlags($changed or 0b0001))
  }
}

从编译后的代码可以看到,相比早期版本,是否执行 Composable 的判断逻辑不再直接写在生成代码中,而是被统一收敛到了 Composer#shouldExecute 方法内部,这一调整是 PausableComposition 能够实现的关键。

shouldExecute 核心逻辑为:

  • 如果当前不是插入新UI 或复用 UI,则正常走原有逻辑(判断参数是否变更)

  • 通过 shouldPause 判断当前是否应该暂停

    • 如果是,则将当前 RecomposeScope 保存下来,同时返回 false 跳过本次执行
    • 反之则返回 true 正常执行 Compose 逻辑

这样一来,Compose 的执行就可以在 Composable 之间暂停,并且能够在后续合适时机从保存的RecomposeScope 处继续恢复执行。

Apply Changes

由于 PausableComposition 将 Compose 阶段拆开执行,使得一次 Composable 的执行过程不再具备“不可中断、立即生效”的语义。为了避免在中途暂停或取消时将 UI 置于不可靠的中间状态, Compose 执行阶段必须与 Apply Changes 解耦,也就是说要等 PausableComposition 完成执行后再一次性提交变更。

PausableComposition的解决方案是采用 RecordingApplier来记录执行过程中的所有变更,并在完成执行后统一更新到真正的 Applier 上

internal class RecordingApplier<N>(root: N) : Applier<N> {
    // 保存变更操作
    private val operations = mutableIntListOf()
    private val instances = mutableObjectListOf<Any?>()
    
    override fun down(node: N) {
        operations.add(DOWN)
        instances.add(node)
    }
    
    override fun up() {
        operations.add(UP)
    }
    
    override fun remove(index: Int, count: Int) {
        operations.add(REMOVE)
        operations.add(index)
        operations.add(count)
    }
    
    override fun move(from: Int, to: Int, count: Int) {
        operations.add(MOVE)
        operations.add(from)
        operations.add(to)
        operations.add(count)
    }
    
    // 将变更同步至真正的 Applier
    fun playTo(applier: Applier<N>, rememberManager: RememberEventDispatcher) {
            // ...
            try {
                while (currentOperation < size) {
                    val operation = operations[currentOperation++]
                    when (operation) {
                        UP -> {
                            applier.up()
                        }
                        DOWN -> {
                            applier.down(node)
                        }
                        REMOVE -> {
                            applier.remove(index, count)
                        }
                        MOVE -> {
                            applier.move(from, to, count)
                        }
                        CLEAR -> {
                            applier.clear()
                        }
                        INSERT_TOP_DOWN -> {
                            applier.insertTopDown(index, instance)
                        }
                        INSERT_BOTTOM_UP -> {
                            applier.insertBottomUp(index, instance)
                        }
                        APPLY -> {
                            applier.apply(block, value)
                        }
                        REUSE -> {
                            applier.reuse()
                        }
                    }
                }
            }
            // ...
        }
}