Jetpack Compose 高级布局揭秘:SubcomposeLayout 核心解析与实践

124 阅读6分钟

前言

SubcomposeLayout 是 Compose 提供的一个高级的布局函数,它有着更为强大的功能,但这也是要付出一定代价的。

SubcomposeLayout 的应用场景有很多,比如我们常见的 LazyColumnLazyRow 滑动列表组件,其内部就使用了 SubcomposeLayout

核心概念

什么是 SubcomposeLayout?

那它是用来干嘛的?

首先在程序中,Subcompose 的意思是次级组合、子组合,就是这部分的组合不遵循主组合流程,是相对独立的部分,但还是属于整体组合的一部分。

具体到 SubcomposeLayout函数的作用就是让我们可以将一部分 Composable 函数的组合(Composition)过程延后执行,推迟到测量(Measure)阶段甚至是布局(Layout)阶段再进行。

有了这种“延迟组合”的能力,我们就能拿到组合过程中拿不到的数据,比如说测量结果。

为何需要“延迟组合”

为什么呢?我们先来看看界面的渲染流程。

在 Jetpack Compose 中,UI 的渲染流程中有四个阶段,分别是:组合(Composition)、测量(Measure)、布局(Layout)、绘制(Draw)。

image.png

通常情况下,这四个阶段是顺序执行的,它们之间是不进行穿插的。但有了 SubcomposeLayout,就可以将局部的组合行为推迟到测量或布局阶段。

实践应用

典型应用:BoxWithConstraints

具体是怎么推迟的,我们先来看看 BoxWithConstraints 函数,它内部就使用了 SubcomposeLayout,使得其内部组件的组合过程可以发生在测量阶段。

使用它的话,就和使用 Box 函数一样,因为它提供的 BoxWithConstraintsScope 上下文是继承自 BoxScope 上下文,只是额外提供了一些尺寸限制信息。

@Stable
interface BoxWithConstraintsScope : BoxScope 
    val constraints: Constraints // 父组件对当前组件的尺寸限制信息(像素)
    val minWidth: Dp // 最小宽度(Dp)
    val maxWidth: Dp // 最大宽度(Dp)
    val minHeight: Dp // 最小高度(Dp)
    val maxHeight: Dp // 最大高度(Dp)
}

注意:minWidthmaxWidthminHeightmaxHeight 这几个属性,它们只是 constraints 中对应限制的 Dp 版本,以 minWidth 为例,它的定义是:

override val minWidth: Dp get() = with(density) { constraints.minWidth.toDp() }

使用的还是 constraints 中的 minWidth

这些尺寸限制信息你在别的组件中是拿不到的,就比如在 Box 中是拿不到 constraints 的,因为你在 Box 中编写的代码,在组合阶段就被调用了,是无法拿到组合阶段后的测量阶段父组件才传递的尺寸限制的。

BoxWithConstraints 函数为什么能拿到这些信息?正是因为它使用了 SubcomposeLayout,使得其内容的组合过程被推迟到了测量阶段,此时就可以把在测量阶段获取的尺寸限制 constraints 传入到子组件的组合作用域当中。

这样做的目的,可以让我们实现与测量结果相关的动态布局。比如,根据设备的屏幕宽度,显示不同的布局,专为平板或者手机优化的布局。

SubcomposeLayout 的用法

SubcomposeLayout 具体怎么使用?

请看下面代码:

SubcomposeLayout { constraints: Constraints ->
    // 当前是在测量阶段
    // 1. 组合子 Composable 函数
    // slotId 是做性能优化的,只要它不变,就不会重组,会复用之前的组合效果和状态
    // 改变的话,之前的状态就会丢失,内部会完全重组
    val measurables: List<Measurable> =
        subcompose(slotId = Unit) { 
            // subcompose 提供了 Composable 的上下文
            Text("我是文本")
            Button(onClick = {}) {
                Text("我是按钮")
            }
        }

    // 2. 测量所有子组件
    val placeables: List<Placeable> = measurables.map { measurable ->
        measurable.measure(constraints) // 可以使用父级传递的 constraints,或根据需要创建新的
    }


    // 3. 摆放子组件,并确定自身尺寸
    layout(width = constraints.maxWidth, height = constraints.maxHeight) { // 尺寸可以基于子项的测量结果或 constraints
        placeables.forEach { placeable: Placeable ->
            placeable.placeRelative(0, 0) // 根据测量结果定位子项
        }
    }

}

以上只是最基本的写法。

重要的是,SubcomposeLayout 可以让我们就能在测量阶段,来自由地控制每个子组件的组合、测量、布局过程

例如:

我们可以给子组件传递 constraints 尺寸限制信息。或者先测量简介组件,根据简介组件的实际高度,来决定简介的文本是应该完整地显示,还是需要截断并添加一个“显示更多”的按钮。

在本质上就是让一部分的组合工作去依赖其他组件的测量结果。

常见的内置组件案例

另外呢,Compose 给我们提供的很多组件的内部都使用了 SubcomposeLayout,最典型的就是ScaffoldLazyColumn / LazyRow

Scaffold是脚手架的意思,可以让我们快速搭建一个具有标准结构的页面(如顶部栏、底部栏、浮动操作按钮等)。它的内部就使用了 SubcomposeLayout 来动态测量各个部分,然后根据测量结果来摆放子组件。比如测量 TopAppBar 的高度,以便能够正确地放置内容区域。

LazyColumn / LazyRow 滑动列表可能有成百上千的列表项,但是同一时间,出现在界面中的,可能就只有寥寥的几项。使用 SubcomposeLayout 可以让我们只去组合、测量当前需要显示或即将显示的列表项,而不用组合、测量所有的列表项,避免了不必要的性能消耗。

性能代价与使用原则

不可忽视的性能代价

我们开头说过,使用 SubcomposeLayout 是有代价的。

反过来想,如果没有任何代价的话,Box 函数就可以被抛弃了,因为功能上 BoxWithConstraintsBox 更强嘛。

而它的代价主要是性能不高

  1. 因为 SubcomposeLayout 在内部维护了一个独立的“节点树”,这样导致它的重组,无法参与到整体的重组优化

  2. 并且原本的测量、布局是不会导致重组的,但由于 SubcomposeLayout 将组合过程穿插到了测量、布局阶段,所以当 SubcomposeLayout 因为外部因素需要重新测量或重新布局时,其内部的 subcompose 函数也会被重新执行,导致这种额外的、局部的重组

    就比如 LazyColumn 进行尺寸变化的动画时,频繁的尺寸改变会导致频繁的重新测量,进而导致内部列表项频繁的重组,所以你可能会观察到卡顿现象。

使用原则

难道因为有性能消耗就不用了吗?当然不是。

我们只需遵循非必要不使用的原则就行了。

能使用常规的布局组件完成的,就优先使用它们。只需要获取到父组件的尺寸限制时,使用 BoxWithConstraints 组件。迫不得已,需要更底层、更精细的控制时,就使用 SubcomposeLayout

在很多时候,使用 SubcomposeLayout 带来的性能提升远大于其自身开销,例如:LazyColumn,你不使用 SubcomposeLayout,性能只会急剧下降。