为什么 Compose 没有布局嵌套问题?

Posted 冬天的毛毛雨

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了为什么 Compose 没有布局嵌套问题?相关的知识,希望对你有一定的参考价值。

作者:RicardoMJiang

前言

做过布局性能优化的同学都知道,为了优化界面加载速度,要尽可能的减少布局的层级。这主要是因为布局层级的增加,可能会导致测量时间呈指数级增长。
Compose却没有这个问题,它从根本上解决了布局层级对布局性能的影响: Compose界面只允许一次测量。这意味着随着布局层级的加深,测量时间也只是线性增长的.
下面我们就一起来看看Compose到底是怎么只测量一次就把活给干了的,本文主要包括以下内容:

  1. 布局层级过深为什么影响性能?
  2. Compose为什么没有布局嵌套问题?
  3. Compose测量过程源码分析

1. 布局层级过深为什么影响性能?

我们总说布局层级过深会影响性能,那么到底是怎么影响的呢?主要是因为在某些情况下ViewGroup会对子View进行多次测量
举个例子

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <View
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:background="@android:color/holo_red_dark" />

    <View
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:background="@android:color/black" />
</LinearLayout>
  1. LinearLayout宽度为wrap_content,因此它将选择子View的最大宽度为其最后的宽度
  2. 但是有个子View的宽度为match_parent,意思它将以LinearLayout的宽度为宽度,这就陷入死循环了
  3. 因此这时候, LinearLayout 就会先以0为强制宽度测量一下子View,并正常地测量剩下的其他子View,然后再用其他子View里最宽的那个的宽度,二次测量这个match_parent的子 View,最终得出它的尺寸,并把这个宽度作为自己最终的宽度。
  4. 这是对单个子View的二次测量,如果有多个子View写了match_parent ,那就需要对它们每一个都进行二次测量。
  5. 除此之外,如果在LinearLayout中使用了weight会导致测量3次甚至更多,重复测量在Android中是很常见的

上面介绍了为什么会出现重复测量,那么会有什么影响呢?不过是多测量了几次,会对性能有什么大的影响吗?
之所以需要避免布局层级过深是因为它对性能的影响是指数级的

  1. 如果我们的布局有两层,其中父View会对每个子View做二次测量,那它的每个子View一共需要被测量 2 次
  2. 如果增加到三层,并且每个父View依然都做二次测量,这时候最下面的子View被测量的次数就直接翻倍了,变成 4 次
  3. 同理,增加到 4 层的话会再次翻倍,子 View 需要被测量 8 次

也就是说,对于会做二次测量的系统,层级加深对测量时间的影响是指数级的,这就是Android官方文档建议我们减少布局层级的原因

2. Compose为什么没有布局嵌套问题?

我们知道,Compose只允许测量一次,不允许重复测量。
如果每个父组件对每个子组件只测量一次,那就直接意味着界面中的每个组件只会被测量一次

这样即使布局层级加深,测量时间却没有增加,把组件加载的时间复杂度从O(2ⁿ) 降到了 O(n)

那么问题就来了,上面我们已经知道,多次测量有时是必要的,但是为什么Compose不需要呢?
Compose中引入了固有特性测量(Intrinsic Measurement)

固有特性测量即Compose允许父组件在对子组件进行测量之前,先测量一下子组件的「固有尺寸」
我们上面说的,ViewGroup的二次测量,也是先进行这种「粗略测量」再进行最终的「正式测量」,使用固有特性测量可以产生同样的效果

而使用固有特性测量之所以有性能优势,主要是因为其不会随着层级的加深而加倍,固有特性测量也只进行一次
Compose会先对整个组件树进行一次Intrinsic测量,然后再对整体进行正式的测量。这样开辟两个平行的测量过程,就可以避免因为层级增加而对同一个子组件反复测量所导致的测量时间的不断加倍了。

总结成一句话就是,在Compose里疯狂嵌套地写界面,和把所有组件全都写进同一层里面,性能是一样的!所以Compose没有布局嵌套问题

2.1 固有特性测量使用

假设我们需要创建一个可组合项,该可组合项在屏幕上显示两个用分隔线隔开的文本,如下所示:

为了实现分隔线与最高的文本一样高,我们可以怎么做呢?

@Composable
fun TwoTexts(
    text1: String,
    text2: String,
    modifier: Modifier = Modifier
) {
    Row(modifier = modifier.height(IntrinsicSize.Min)) {
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(start = 4.dp)
                .wrapContentWidth(Alignment.Start),
            text = text1
        )
        Divider(
            color = Color.Black,
            modifier = Modifier
                .fillMaxHeight()
                .width(1.dp)
        )
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(end = 4.dp)
                .wrapContentWidth(Alignment.End),
            text = text2
        )
    }
}

注意,这里给Rowheight设置为了IntrinsicSize.Min,IntrinsicSize.Min会递归查询它子项的最小高度,其中两个Text的最小高度即文本的宽度,而Divider的最小高度为0 因此最后Row的高度即为最长的文本的高度,而Divider的高度为fillMaxHeight,也就跟最高的文本一样高了
如果我们这里不设置高度为IntrinsicSize.Min的话,Divider的高度是占满屏幕的,如下所示

3. Compose测量过程源码分析

上面我们介绍了固有特性测量是什么,及固有特性测量的使用,下面我们来看看Compose的测量究竟是怎么实现的

3.1 测量入口

我们知道,在Compose中自定义Layout是通过Layout方法实现的

@Composable inline fun Layout(
    content: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    measurePolicy: MeasurePolicy
)

主要传入3个参数

  1. content:自定义布局的子项,我们后续需要对它们测量和定位
  2. modifier: 对Layout添加的一些修饰modifier
  3. measurePolicy: 即测量规则,这个是我们主要需要处理的地方

measurePolicy中主要有五个接口

fun interface MeasurePolicy {
    fun MeasureScope.measure(measurables: List<Measurable>,constraints: Constraints): MeasureResult

    fun IntrinsicMeasureScope.minIntrinsicWidth(measurables: List<IntrinsicMeasurable>,height: Int): Int

    fun IntrinsicMeasureScope.minIntrinsicHeight(measurables: List<IntrinsicMeasurable>,width: Int): Int

    fun IntrinsicMeasureScope.maxIntrinsicWidth(measurables: List<IntrinsicMeasurable>,height: Int): Int

    fun IntrinsicMeasureScope.maxIntrinsicHeight(measurables: List<IntrinsicMeasurable>,width: Int): Int
}

可以看出:

  1. 使用固有特性测量的时候,会调用对应的IntrinsicMeasureScope方法,如使用Modifier.height(IntrinsicSize.Min),就会调用minIntrinsicHeight方法
  2. 父项测量子项时,就是在MeasureScope.measure方法中调用measure.meausre(constraints),但是具体是怎么实现的呢?我们来看个例子
@Composable
fun MeasureTest() {
    Row() {
        Layout(content = { }, measurePolicy = { measurables, constraints ->
        	measurables.forEach {
            	it.measure(constraints)
        	}
            layout(100, 100) {

            }
        })
    }
} 

一个简单的例子,我们在measure方法中打个断点,如下图所示:

  1. 如下图所示,是由RowMeasurePolicy中开始测量子项,rowColumnMeasurePolicy我们定义为ParentPolicy
  2. 然后调用到LayoutNode,OuterMeasurablePlaceable,InnerPlaceablemeasure方法
  3. 最后再由InnerPlaceable中调用到子项的MeasurePolicy,即我们自定义Layout实现的部分,我们定义它为ChildPolicy
  4. 子项中也可能会测量它的子项,在这种情况下它就变成了一个ParentPolicy,然后继续后续的测量

综上所述,父项在测量子项时,子项的测量入口就是LayoutNode.measure,然后经过一系列调用到子项自己的MeasurePolicy,也就是我们自定义Layout中自定义的部分

3.2 LayoutNodeWrapper链构建

上面我们说了,测量入口是LayoutNode,后续还要经过OuterMeasurablePlaceable,InnerPlaceablemeasure方法,那么问题来了,这些东西是怎么来的呢?
首先给出结论

  1. 子项都是以LayoutNode的形式,存在于Parentchildren中的
  2. Layout的设置的modifier会以LayoutNodeWrapper链的形式存储在LayoutNode中,然后后续做相应变换

由于篇幅原因,关于第一点就不在这里详述了,有兴趣的同学可以参考:Jetpack Compose 测量流程源码分析
我们这里主要看下LayoutNodeWrapper链是怎么构建的

  internal val innerLayoutNodeWrapper: LayoutNodeWrapper = InnerPlaceable(this)
  private val outerMeasurablePlaceable = OuterMeasurablePlaceable(this, innerLayoutNodeWrapper)
  override fun measure(constraints: Constraints) = outerMeasurablePlaceable.measure(constraints)
  override var modifier: Modifier = Modifier
        set(value) {
            // …… code
            field = value
            // …… code

            // 创建新的 LayoutNodeWrappers 链
            // foldOut 相当于遍历 modifier
            val outerWrapper = modifier.foldOut(innerLayoutNodeWrapper) { mod /*📍 modifier*/ , toWrap ->
                var wrapper = toWrap
                if (mod is OnGloballyPositionedModifier) {
                    onPositionedCallbacks += mod
                }
                if (mod is RemeasurementModifier) {
                    mod.onRemeasurementAvailable(this)
                }

                val delegate = reuseLayoutNodeWrapper(mod, toWrap)
                if (delegate != null) {
                    wrapper = delegate
                } else {
                      // …… 省略了一些 Modifier判断 
                      if (mod is KeyInputModifier) {
                        wrapper = ModifiedKeyInputNode(wrapper, mod).assignChained(toWrap)
                    }
                    if (mod is PointerInputModifier) {
                        wrapper = PointerInputDelegatingWrapper(wrapper, mod).assignChained(toWrap)
                    }
                    if (mod is NestedScrollModifier) {
                        wrapper = NestedScrollDelegatingWrapper(wrapper, mod).assignChained(toWrap)
                    }
                    // 布局相关的 Modifier
                    if (mod is LayoutModifier) {
                        wrapper = ModifiedLayoutNode(wrapper, mod).assignChained(toWrap)
                    }
                    if (mod is ParentDataModifier) {
                        wrapper = ModifiedParentDataNode(wrapper, mod).assignChained(toWrap)
                    }

                }
                wrapper
            }

            outerWrapper.wrappedBy = parent?.innerLayoutNodeWrapper
            outerMeasurablePlaceable.outerWrapper = outerWrapper

            ……
        }

如上所示:

  1. 默认的LayoutNodeWrapper链即由LayoutNode , OuterMeasurablePlaceable, InnerPlaceable 组成
  2. 当添加了modifier时,LayoutNodeWrapper链会更新,modifier会作为一个结点插入到其中

举个例子,如果我们给Layout设置一些modifier:

Modifier.size(100.dp).padding(10.dp).background(Color.Blue)

那么对应的LayoutNodeWrapper链如下图所示

这样一个接一个链式调用下一个的measure,直到最后一个结点InnerPlaceable
那么InnerPlaceable又会调用到哪儿呢?

InnerPlaceable最终调用到了我们自定义Layout时写的measure方法

3.3 固有特性测量是怎样实现的?

上面我们介绍了固有特性测量的使用,也介绍了LayoutNodeWrapper链的构建,那么固有特性测量是怎么实现的呢?
其实固有特性测量就是往LayoutNodeWrapper链中插入了一个Modifier

@Stable
fun Modifier.height(intrinsicSize: IntrinsicSize) = when (intrinsicSize) {
    IntrinsicSize.Min -> this.then(MinIntrinsicHeightModifier)
    IntrinsicSize.Max -> this.then(MaxIntrinsicHeightModifier)
}

private object MinIntrinsicHeightModifier : IntrinsicSizeModifier {
	override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {
    	//正式测量前先根据固有特性测量获得一个约束
        val contentConstraints = calculateContentConstraints(measurable, constraints)
        //正式测量
        val placeable = measurable.measure(
            if (enforceIncoming) constraints.constrain(contentConstraints) else contentConstraints
        )
        return layout(placeable.width, placeable.height) {
            placeable.placeRelative(IntOffset.Zero)
        }
    }

    override fun MeasureScope.calculateContentConstraints(
        measurable: Measurable,
        constraints: Constraints
    ): Constraints {
        val height = measurable.minIntrinsicHeight(constraints.maxWidth)
        return Constraints.fixedHeight(height)
    }

    override fun IntrinsicMeasureScope.maxIntrinsicHeight(
        measurable: IntrinsicMeasurable,
        width: Int
    ) = measurable.minIntrinsicHeight(width)
}

如上所示:

  1. IntrinsicSize.Min其实也是个Modifier
  2. MinIntrinsicHeightModifier会在测量之间,先调用calculateContentConstraints计算约束
  3. calculateContentConstraints中则会递归地调用子项的minIntrinsicHeight,并找出最大值,这样父项的高度就确定了
  4. 固有特性测量完成后,再调用measurable.measure,开始真正的递归测量

3.4 测量过程小结

准备阶段
子项在声明时,会生成LayoutNode添加到父项的chindredn中,同时子项的modifier也将构建成LayoutNodeWrapper链,保存在LayoutNode
值得注意的是,如果使用了固有特性测量,将会添加一个IntrinsicSizeModifierLayoutNodeWrapper链中

测量阶段
父容器在其测量策略MeasurePolicymeasure函数中会执行childmeasure函数。
childmeasure方法按照构建好的LayoutNodeWrapper链一步步的执行各个节点的measure函数,最终走到InnerPlaceablemeasure函数,在这里又会继续它的children进行测量,此时它的children 就会和它一样进行执行上述流程,一直到所有children测量完成。

用下面这张图总结一下上述流程。

总结

本文主要介绍了以下内容

  1. Android中布局层级过深为什么会对性能有影响?
  2. Compose中为什么没有布局嵌套问题?
  3. 什么是固有特性测量及固有特性测量的使用
  4. Compose测量过程源码分析及固有特性测量到底是怎样实现的?

以上是关于为什么 Compose 没有布局嵌套问题?的主要内容,如果未能解决你的问题,请参考以下文章

Android中布局层级过深为什么会对性能有影响?为什么 Compose 没有布局嵌套问题?

嵌套片段没有找到id的视图

Recyclerview 滚动在嵌套滚动视图中的片段中不起作用

Android片段XML布局问题

有没有更聪明的方法将布局绑定到片段?

将数据从嵌套片段发送到父片段