Why doesn't Compose have layout nesting problems?

Keywords: Android Design Pattern kotlin mvc jetpack

Author: Ricardo mjiang

preface

Students who have done layout performance optimization know that in order to optimize the interface loading speed, the layout level should be reduced as much as possible. This is mainly because the increase of layout level may lead to the exponential growth of measurement time.
Composition does not have this problem. It fundamentally solves the impact of layout level on layout performance: the composition interface allows only one measurement. This means that the measurement time increases linearly with the deepening of layout level
Now let's take a look at how Compose can do the work only once. This article mainly includes the following contents:

  1. Why does too deep layout affect performance?
  2. Why doesn't Compose have layout nesting problems?
  3. Composition measurement process source code analysis

1. Why does too deep layout level affect performance?

We always say that too deep layout level will affect performance, so how does it affect? This is mainly because in some cases, the View Group will measure the child views multiple times
for instance

<?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 width is wrap_content, so it will select the maximum width of the child View as its last width
  2. However, there is a child View whose width is match_parent, which means that it will take the width of LinearLayout as the width, which falls into an endless loop
  3. Therefore, at this time, LinearLayout will first measure a View with 0 as the forced width, and normally measure the remaining sub views, and then measure the match again with the width of the widest one in the other sub views_ The child View of parent finally obtains its size, and takes this width as its final width.
  4. This is the secondary measurement of a single sub View. If multiple sub views write match_parent, you need to measure each of them twice.
  5. In addition, if weight is used in LinearLayout, it will lead to three or more measurements. Repeated measurements are very common in Android

The above describes why repeated measurements occur, and what is the impact? However, if it is measured several times, will it have any significant impact on the performance?
The reason why we need to avoid too deep layout level is that its impact on performance is exponential

  1. If our layout has two layers, in which the parent View will measure each child View twice, each child View needs to be measured twice in total
  2. If it is increased to three levels, and each parent View still makes two measurements, the number of measurements of the lowest child View will be doubled to four
  3. Similarly, if it is increased to 4 layers, it will double again, and the sub View needs to be measured 8 times

In other words, for systems that can do secondary measurement, the impact of level deepening on measurement time is exponential, which is why the official Android document suggests that we reduce the layout level

2. Why is there no layout nesting problem in compose?

We know that Compose only allows one measurement and does not allow repeated measurements.
If each parent component measures each child component only once, it directly means that each component in the interface will be measured only once

In this way, even if the layout level is deepened, the measurement time does not increase, and the time complexity of component loading is reduced from O(2 ⁿ) to O(n).

So the problem comes. We already know that multiple measurements are sometimes necessary, but why doesn't Compose need it?
Intrinsic measurement is introduced into Compose

Inherent characteristic measurement, that is, Compose allows the parent component to measure the "inherent dimension" of the sub component before measuring the sub component
As we mentioned above, the secondary measurement of ViewGroup is also the first "rough measurement" and then the final "formal measurement". The same effect can be achieved by using inherent characteristic measurement

The performance advantage of using inherent characteristic measurement is mainly because it will not double with the deepening of the level, and the inherent characteristic measurement is only carried out once
Compose will first make an Intrinsic measurement of the whole component tree, and then make a formal measurement of the whole. In this way, two parallel measurement processes can be opened to avoid the continuous doubling of measurement time caused by repeated measurement of the same sub component due to the increase of hierarchy.

To sum up, writing interfaces in composition is crazy and nested, and the performance is the same as writing all components into the same layer! Therefore, composite has no layout nesting problem

2.1 measurement and use of inherent characteristics

Suppose we need to create a composable item that displays two text separated by a separator on the screen, as shown below:

What can we do to make the separator as high as the highest text?

@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
        )
    }
}

Note that the height of Row here is set to intrinsicsize.min, which will recursively query the minimum height of its children. The minimum height of two texts is the width of the Text, and the minimum height of the Divider is 0. Therefore, the height of the last Row is the height of the longest Text, and the height of the Divider is fillMaxHeight, which is as high as the highest Text
If we do not set the height to IntrinsicSize.Min here, the height of the Divider will occupy the full screen, as shown below

3. Source code analysis of compose measurement process

Above, we introduced what intrinsic characteristic measurement is and the use of intrinsic characteristic measurement. Next, let's see how Compose measurement is realized

3.1 measuring inlet

As we know, in Compose, custom Layout is implemented through the Layout method

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

Three parameters are mainly passed in

  1. content: the sub items of the custom layout. We need to measure and locate them later
  2. modifier: some modifications added to Layout
  3. measurePolicy: that is, measurement rules. This is what we need to deal with

There are mainly five interfaces in 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
}

It can be seen that:

  1. When using intrinsic characteristic measurement, the corresponding internalmeasurescope method will be called. For example, when using modifier. Height (internalsize. Min), the mininternalheight method will be called
  2. When the parent item is measured, it calls measure.meausre(constraints) in the MeasureScope.measure method, but how is it implemented? Let's take an example
@Composable
fun MeasureTest() {
    Row() {
        Layout(content = { }, measurePolicy = { measurables, constraints ->
        	measurables.forEach {
            	it.measure(constraints)
        	}
            layout(100, 100) {

            }
        })
    }
} 

For a simple example, we make a breakpoint in the measure method, as shown in the following figure:

  1. As shown in the following figure, the measurement of sub items is started from the Row's MeasurePolicy. rowColumnMeasurePolicy is defined as ParentPolicy
  2. Then call the measure method of LayoutNode,OuterMeasurablePlaceable,InnerPlaceable.
  3. Finally, we call the sub item MeasurePolicy from InnerPlaceable, that is, the part of our custom Layout implementation. We define it as ChildPolicy.
  4. Its children may also be measured. In this case, it becomes a ParentPolicy, and then continue the subsequent measurement

To sum up, when the parent item measures the child item, the measurement entry of the child item is LayoutNode.measure, and then through a series of calls to the child item's own MeasurePolicy, that is, the customized part in our customized Layout

3.2 LayoutNodeWrapper chain construction

As we said above, the measurement entry is LayoutNode, and the subsequent measurement methods of outermeasurable placeable and innerplaceable need to be passed. Then the problem comes. How do these things come from?
Firstly, the conclusion is given

  1. All children exist in the children of the Parent in the form of LayoutNode
  2. The modifier of the settings given to Layout will be stored in LayoutNode in the form of LayoutNodeWrapper chain, and then corresponding transformation will be made later

Due to space reasons, the first point will not be detailed here. Interested students can refer to: Jetpack Compose measurement process source code analysis
Here we mainly look at how the LayoutNodeWrapper chain is built

  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

            // Create a new LayoutNodeWrappers chain
            // foldOut is equivalent to traversing 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 {
                      // ... some Modifier judgments are omitted 
                      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)
                    }
                    // Layout related modifiers
                    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

            ......
        }

As shown above:

  1. The default LayoutNodeWrapper chain consists of layoutnode, outermeasurable placeable and innerplaceable
  2. When a modifier is added, the LayoutNodeWrapper chain will be updated and the modifier will be inserted as a node

For example, if we set some modifier s for Layout:

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

The corresponding LayoutNodeWrapper chain is shown in the following figure

In this way, the next measure is called in a chain one by one until the last node InnerPlaceable
So where will InnerPlaceable be called?

InnerPlaceable finally calls the measure method written when we customize the Layout

3.3 how is the inherent characteristic measurement realized?

Above, we introduced the use of inherent characteristic measurement and the construction of LayoutNodeWrapper chain. How is inherent characteristic measurement realized?
In fact, the inherent characteristic measurement is to insert a Modifier into the LayoutNodeWrapper chain

@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 {
    	//Before formal measurement, a constraint is obtained according to the measurement of inherent characteristics
        val contentConstraints = calculateContentConstraints(measurable, constraints)
        //Formal measurement
        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)
}

As shown above:

  1. IntrinsicSize.Min is also a Modifier
  2. Minintrinsichightmodifier will first call calculateContentConstraints to calculate constraints between measurements
  3. calculateContentConstraints will recursively call the mininsiciheight of the child item and find the maximum value, so that the height of the parent item is determined
  4. After the inherent characteristic measurement is completed, call measurable.measure to start the real recursive measurement

3.4 summary of measurement process

Preparation stage
When a child item is declared, it will generate a LayoutNode and add it to the parent item's chindredn. At the same time, the modifier of the child item will also construct a LayoutNodeWrapper chain and save it in LayoutNode
It is worth noting that if intrinsic property measurement is used, an IntrinsicSizeModifier will be added to the LayoutNodeWrapper chain

Measurement phase
The parent container executes the measure function of child in the measure function of its measurement policy MeasurePolicy.
The child's measure method executes the measure function of each node step by step according to the built LayoutNodeWrapper chain, and finally goes to the InnerPlaceable measure function, where it will continue to measure its children. At this time, its children will perform the above process like it until all children are measured.

Use the chart below to summarize the above process.

summary

This paper mainly introduces the following contents

  1. Why does too deep layout level affect performance in Android?
  2. Why is there no layout nesting problem in Compose?
  3. What is inherent characteristic measurement and the use of inherent characteristic measurement
  4. How to realize the source code analysis and inherent characteristic measurement of Compose measurement process?

Posted by SteveMT on Thu, 30 Sep 2021 13:49:09 -0700