Jetpack Compose中的绘制流程和自定义布局

Posted 川峰

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Jetpack Compose中的绘制流程和自定义布局相关的知识,希望对你有一定的参考价值。

Jetpack Compose中绘制流程的三个阶段

与大多数其他界面工具包一样,Compose 会通过几个不同的“阶段”来渲染帧。如果我们观察一下 android View 系统,就会发现它有 3 个主要阶段:测量、布局和绘制Compose 和它非常相似,但开头多了一个叫做“组合”的重要阶段。

Compose 有 3 个主要阶段:

  • 组合:要显示什么样的界面。Compose 运行Composable可组合函数并创建LayoutNode视图树。
  • 布局:要放置界面的位置。该阶段包含两个步骤:测量放置。对于视图树中的每个LayoutNode节点进行宽高尺寸测量并完成位置摆放,布局元素都会根据 2D 坐标来测量并放置自己及其所有子元素。
  • 绘制:渲染的方式。将所有LayoutNode界面元素会绘制到画布(通常是设备屏幕)之上。


这些阶段的顺序通常是相同的,从而让数据能够沿一个方向(从组合到布局,再到绘制)生成帧(也称为单向数据流)。

您可以放心地假设每个帧都会以虚拟方式经历这 3 个阶段,但为了保障性能,Compose 会避免在所有这些阶段中重复执行根据相同输入计算出相同结果的工作。如果可以重复使用前面计算出的结果,Compose 会跳过对应的可组合函数;如果没有必要,Compose 界面不会对整个树进行重新布局或重新绘制。Compose 只会执行更新界面所需的最低限度的工作。之所以能够实现这种优化,是因为 Compose 会跟踪不同阶段中的状态读取

所谓状态读取就是通常使用 mutableStateOf() 创建的,然后通过以下两种方式之一进行访问:

  • val state = remember mutableStateOf() , 然后访问 state.value 属性值
// State read without property delegate.
val paddingState: MutableState<Dp> = remember  mutableStateOf(8.dp) 
Text(
    text = "Hello",
    modifier = Modifier.padding(paddingState.value)
)
  • val state by remember mutableStateOf() 使用 Kotlin 属性委托 by 语法, 直接使用state
// State read with property delegate.
var padding: Dp by remember  mutableStateOf(8.dp) 
Text(
    text = "Hello",
    modifier = Modifier.padding(padding)
)

当您在上述任一阶段中读取快照状态值时,Compose 会自动跟踪在系统读取该值时正在执行的操作。通过这项跟踪,Compose 能够在状态值发生更改时重新执行读取程序;Compose 以这项跟踪为基础实现了对状态的观察。

第 1 阶段:组合



组合阶段的主要目标是生成并维护LayoutNode视图树,在Activity中执行setContent时,会开始首次组合,这会执行所有的Composable函数生成与之对应的LayoutNode视图树。而后当Composable依赖的状态值发生更改时,Recomposer 会安排重新运行所有要读取相应状态值的可组合函数,即所谓的重组。

  • 当前组件发生重组时,子Composable被依次重新调用,被调用的子Composable 会将当前传入的参数和之前重组中的参数进行比较,若参数变化,则Composable发生重组,更新LayoutNode视图树上的节点,UI发生更新。若参数无变化,则跳过本次执行,即所谓的智能重组,LayoutNode视图树中对应的节点不变,UI无变化。
  • 当前组件发生重组时,如果子Composable在重组中没有被调用到,其对应节点及其子节点会从LayoutNode视图树中被删除,UI会从屏幕移除。

根据组合结果,Compose 界面会运行布局和绘制阶段。请注意,如果输入未更改,运行时可能会决定跳过部分或全部可组合函数。如果内容保持不变,并且大小和布局也未更改,界面可能会跳过这些阶段

var padding by remember  mutableStateOf(8.dp) 
Text(
    text = "Hello",
    // The `padding` state is read in the composition phase
    // when the modifier is constructed.
    // Changes in `padding` will invoke recomposition.
    modifier = Modifier.padding(padding)
)

第 2 阶段:布局

布局阶段包含两个步骤:测量放置测量步骤会运行传递给 Layout 可组合项的测量 lambdaLayoutModifier 接口的 MeasureScope.measure 方法,等等。放置步骤会运行 layout 函数的放置位置块、Modifier.offset … lambda 块,等等。

每个步骤的状态读取都会影响布局阶段,并且可能会影响绘制阶段。当状态值发生更改时,Compose 界面会安排布局阶段。如果大小位置发生更改,界面还会运行绘制阶段

更确切地说,测量步骤和放置步骤分别具有单独的重启作用域,这意味着,放置步骤中的状态读取不会在此之前重新调用测量步骤。不过,这两个步骤通常是交织在一起的,因此在放置步骤中读取的状态可能会影响属于测量步骤的其他重启作用域

var offsetX by remember  mutableStateOf(8.dp) 
Text(
    text = "Hello",
    modifier = Modifier.offset 
        // The `offsetX` state is read in the placement step
        // of the layout phase when the offset is calculated.
        // Changes in `offsetX` restart the layout.
        IntOffset(offsetX.roundToPx(), 0)
    
)

布局阶段用来对视图树中每个LayoutNode节点进行宽高尺寸测量并完成位置摆放。在Compose中,父节点会向子节点传递布局约束,布局约束中包含了父节点允许子节点的 最大宽高 和 最小宽高,当父节点希望子节点测量的宽高为某个具体的值时,约束中的最大宽高和最小宽高就是相同的。子节点根据父节点传给自己的布局约束进行自我测量。

Compose框架中,LayoutNode不允许被多次测量,换句话说就是每个子元素只允许被测量一次。这意味着,你不能为了尝试不同的测量配置而多次测量任何子元素。在 Compose 的世界中,这个规则是强制性的,如果你不小心对某个子元素进行了多次测量,那么Compose会直接抛出异常导致应用崩溃。但在传统View中,每个父View可以对子View进行一次或多次测量,因此很容易导致测量次数发生指数级爆炸。所以传统View的性能优化点之一就是减少xml中布局的嵌套层级,而在Compose中组件的多级嵌套也不会导致此问题。这也是为什么Compose比传统View性能高的原因之一。


在 Compose 中,每个界面元素都有一个父元素,还可能有多个子元素。每个元素在其父元素中都有一个位置,指定为 (x, y) 位置;也都有一个尺寸,指定为 widthheight

在界面树中每个节点的布局过程分为三个步骤:

  1. 测量所有子项
  2. 确定自己的尺寸
  3. 放置其子项

在测量完所有子元素的尺寸后,父元素才会对子元素进行摆放。在摆放时,系统会遍历视图树,执行所有的place命令。

第 3 阶段:绘制

绘制代码期间的状态读取会影响绘制阶段。常见示例包括 Canvas()Modifier.drawBehindModifier.drawWithContent。当状态值发生更改时,Compose 界面只会运行绘制阶段

var color by remember  mutableStateOf(Color.Red) 
Canvas(modifier = modifier) 
    // The `color` state is read in the drawing phase
    // when the canvas is rendered.
    // Changes in `color` restart the drawing.
    drawRect(color)

使用 Modifier.layout() 自定义布局

当我们需要进行自定义布局时,首选推荐的就是Modifier.layout()修饰符,通过使用 Modifier.layout() 可以手动控制元素的测量和布局。

Modifier.layout  measurable, constraints ->
  ...

当使用 Modifier.layout() 修饰符时,传入的回调 lambda 需要包含两个参数:measurableconstraints

  • measurable:子元素LayoutNode的测量句柄,通过调用其提供的 measure() 方法完成LayoutNode的测量。
  • constraints: 来自父LayoutNode的布局约束,包括最大宽高值与最小宽高值。

Modifier.layout() 使用示例

使用 Compose 的 Text 组件时,有时希望指定 Text 顶部到文本基线的高度,让文本看的更自然一些,使用内置的 padding 修饰符是无法满足需求的,因为padding 修饰符只能指定 Text 顶部到文本顶部的高度。虽然 Compose 已经提供了 paddingFromBaseline 修饰符来解决这个问题,不妨用 layout 修饰符来自己实现一个试试。


我们创建一个 paddingBaselineToTop 的自定义修饰符,实现代码如下:

fun Modifier.paddingBaselineToTop(padding : Dp = 0.dp) = layout  measurable, constraints ->
    val placeable = measurable.measure(constraints) // 使用父节点约束进行自我测量
    check(placeable[FirstBaseline] != AlignmentLine.Unspecified) // 保证组件存在内容基线
    val firstBaseline = placeable[FirstBaseline] // 基线高度
    val paddingTop = padding.roundToPx() - firstBaseline // [设置的基线到顶部的距离] - [基线的高度]
    // 仅改变高度为:高度 + paddingTop
    layout(placeable.width, placeable.height + paddingTop) 
        placeable.placeRelative(0, paddingTop) // y坐标向下偏移paddingTop距离
    

接下来说明一下在上面代码中发生了什么:

  • 正如前面布局阶段中所提到的,每个LayoutNode只允许被测量一次,这里使用了measurable.measure(constraints) 方法来完成子元素的测量,这里将父节点的 constraints 约束参数直接传入了子节点的 measure 方法中进行测量,这意味着:你将父节点的布局约束限制直接提供给了当前的子元素,自身没有增加任何额外的限制。
  • 子元素测量的结果被包装在一个 Placeable 实例中,稍后即可通过该Placeable 实例来获取到刚刚子元素测量结果。
  • 完成测量流程后,接下来就是布局过程,这需要调用 layout(width, height) 方法对当前元素的宽度与高度进行指定,这里将高度增加了paddingTop距离。
  • 最后在 layout() 的 lambda 中调用 placeable.place(x, y)placeable.placeRelative(x, y) (支持RTL)进行位置摆放。

另外需要说明的一点是:作用域的使用决定了您可以衡量和放置子项的时机。即只能在测量和布局传递期间(即 MeasureScope 作用域中)测量布局,并且只能在布局传递期间(即 PlacementScope 作用域中)才能放置子项(且要在已进行测量之后)。此操作在编译时强制执行,所以不用担心你的代码放错了地方,编译器会提醒你。

接下来预览一下效果:

@Preview
@Composable
fun LayoutModifierExample() 
    Box(Modifier.background(Color.Green))
        // 设置和Modifier.paddingFromBaseline相同的效果
        Text(text = "paddingFromBaseline", Modifier.paddingBaselineToTop(25.dp))
    

在熟悉了layout修饰符的使用流程之后,就可以根据业务需求自己定义更多的自定义修饰符来使用。

例如,下面是实现自定义类似系统内置的offset修饰符的功能:

@Composable
fun LayoutModifierExample() 
    Box(Modifier.background(Color.Red)) 
        Text(text = "Offset", Modifier.myOffset(5.dp)) // 设置和 Modifier.offset(5.dp) 相同的效果
    


fun Modifier.myOffset(x : Dp = 0.dp, y : Dp = 0.dp) = layout  measurable, constraints ->
    val placeable = measurable.measure(constraints)
    layout(placeable.width, placeable.height) 
        placeable.placeRelative(x.roundToPx(), y.roundToPx())  
    
 

下面的例子实现了一个聊天对话框中消息气泡的效果:

@Composable
fun BubbleBox(
    modifier : Modifier = Modifier,
    text: String = "",
    fontSize : TextUnit = 18.sp,
    textColor : Color = Color.Black,
    arrowWidth: Dp = 16.dp,
    arrowHeight: Dp = 16.dp,
    arrowOffset: Dp = 8.dp,
    arrowDirection: ArrowDirection = ArrowDirection.Left,
    elevation: Dp = 2.dp,
    backgroundColor: Color = Color(0xffE7FFDB),
    padding: Dp = 8.dp
) 
    Box(
        modifier.drawBubble(
            arrowWidth = arrowWidth,
            arrowHeight = arrowHeight,
            arrowOffset = arrowOffset,
            arrowDirection = arrowDirection,
            elevation = elevation,
            color = backgroundColor
        )
        .padding(padding)
    ) 
        Text(text = text, fontSize = fontSize, color = textColor)
    


fun Modifier.drawBubble(
    arrowWidth: Dp,
    arrowHeight: Dp,
    arrowOffset: Dp,
    arrowDirection: ArrowDirection,
    elevation: Dp = 0.dp,
    color: Color = Color.Unspecified
) = composed 

    val arrowWidthPx: Float = arrowWidth.toPx()
    val arrowHeightPx: Float = arrowHeight.toPx()
    val arrowOffsetPx: Float = arrowOffset.toPx()

    val shape = remember(arrowWidth, arrowHeight, arrowOffset, arrowDirection) 
        createBubbleShape(arrowWidthPx, arrowHeightPx, arrowOffsetPx, arrowDirection)
    
    // 阴影和形状
    val shadowShapeModifier = Modifier.shadow(elevation, shape, spotColor = Color.Red, ambientColor = Color.Black)
    val shapeModifier = if (elevation > 0.dp) shadowShapeModifier else Modifier.clip(shape)

    Modifier.then(shapeModifier)
        .background(color, shape)
        .layout  measurable, constraints ->
            val isHorizontalArrow =
                arrowDirection == ArrowDirection.Left || arrowDirection == ArrowDirection.Right
            val isVerticalArrow =
                arrowDirection == ArrowDirection.Top || arrowDirection == ArrowDirection.Bottom
            // 箭头偏移量
            val offsetX = if (isHorizontalArrow) arrowWidthPx.toInt() else 0
            val offsetY = if (isVerticalArrow) arrowHeightPx.toInt() else 0

            // 测量文本 根据箭头偏移量来设置文本的约束偏移信息
            val placeable = measurable.measure(
                constraints.offset(horizontal = -offsetX, vertical = -offsetY)
            )
            // val placeable = measurable.measure(constraints)

            // 总宽度为文本宽度+箭头宽度
            val width = constraints.constrainWidth(placeable.width + offsetX)
            // 总高度为文本高度+箭头高度
            val height = constraints.constrainHeight(placeable.height + offsetY)

            val posX = when (arrowDirection) 
                ArrowDirection.Left -> arrowWidthPx.toInt()
                else -> 0
            
            val posY = when (arrowDirection) 
                ArrowDirection.Top -> arrowHeightPx.toInt()
                else -> 0
            
            layout(width, height) 
                placeable.placeRelative(posX, posY) // 摆放文本
            
        


enum class ArrowDirection  Left, Right, Top, Bottom 

fun createBubbleShape(
    arrowWidth: Float,
    arrowHeight: Float,
    arrowOffset: Float,
    arrowDirection: ArrowDirection
): GenericShape 
    return GenericShape  size: Size, layoutDirection: LayoutDirection ->
        val width = size.width
        val height = size.height
        val rect = RoundRect(
            rect = Rect(0f, 0f, width, height),
            cornerRadius = CornerRadius(x = 20f, y = 20f)
        )
        when (arrowDirection) 
            ArrowDirection.Left -> 
                moveTo(arrowWidth, arrowOffset)
                lineTo(0f, arrowOffset)
                lineTo(arrowWidth, arrowHeight + arrowOffset)
                addRoundRect(rect.copy(left = arrowWidth))
            
            ArrowDirection.Right -> 
                moveTo(width - arrowWidth, arrowOffset)
                lineTo(width, arrowOffset)
                lineTo(width - arrowWidth, arrowHeight + arrowOffset)
                addRoundRect(rect.copy(right = width - arrowWidth))
            
            ArrowDirection.Top -> 
                moveTo(arrowOffset, arrowHeight)
                lineTo(arrowOffset + arrowWidth / 2, 0f)
                lineTo(arrowOffset + arrowWidth, arrowHeight)
                addRoundRect(rect.copy(top = arrowHeight))
            
            ArrowDirection.Bottom -> 
                moveTo(arrowOffset, height - arrowHeight)
                lineTo(arrowOffset + arrowWidth / 2, height)
                lineTo(arrowOffset + arrowWidth, height - arrowHeight)
                addRoundRect(rect.copy(bottom = height - arrowHeight))
            
        
    

使用:

@Composable
fun BubbleBoxExample() 
    Column(
        modifier = Modifier.background(Color(0xffFBE9E7)).padding(8.dp).fillMaxWidth()
    )  
        val message1 = "脱水!".repeat(10)
        val message2 = "浸泡!".repeat(10) 
        BubbleBox(
            text = message1,
            arrowWidth = 16.dp,
            arrowHeight = 16.dp,
            arrowOffset = 8.dp,
            arrowDirection = ArrowDirection.Left,
            backgroundColor = Color(0xFFDEF3D4),
        ) 
        Spacer(Modifier.height(10.dp)) 
        Box(modifier = Modifier.fillMaxWidth()) 
            BubbleBox(
                text = message2,
                modifier = Modifier.align(Alignment.CenterEnd),
                arrowWidth = 16.dp,
                arrowHeight = 16.dp,
                arrowOffset = 8.dp,
                arrowDirection = ArrowDirection.Right,
                backgroundColor = Color(0xFF7EBAE2),
            )
         
        Spacer(Modifier.height(10.dp)) 
        BubbleBox(
            text = message1,
            fontSize = 16.sp,
            arrowWidth = 24.dp,
            arrowHeight = 16.dp,
            arrowOffset = 10.dp,
            arrowDirection = ArrowDirection.Top,
            backgroundColor = Color.Magenta,
        ) 
        Spacer(Modifier.height(10.dp)) 
        BubbleBox(
            text = message2,
            fontSize = 16.sp,
            arrowWidth = 24.dp,
            arrowHeight = 16.dp,
            arrowOffset = 10.dp,
            arrowDirection = ArrowDirection.Bottom,
            backgroundColor = Color.Cyan,
        )
    

显示效果:

使用 Layout 组件自定义布局

除了Modifier.layout()修饰符,Compose 还提供了一个叫 Layout 的 Composable 组件,可以直接在 Composable 函数中调用,方便自定义布局。

前面的 Layout Modifier 似于传统 View 系统的 View 单元定制,而对于 “ViewGroup” 场景的定制,就需要使用 Layout 组件了。

@Composable
fun CustomLayout(
    modifier: Modifier = Modifier,
    // custom layout attributes 
    content: @Composable () -> Unit
) 
    Layout(
        modifier = modifier,
        content = content
    )  measurables, constraints ->
        // measure and position children given constraints logic here
    

可以看到,Layout 需要填写三个参数:modifiercontentmeasurePolicy

  • modifier:由外部传入的修饰符,会决定该 UI 元素的 constraints
  • content:content是一个槽位,注意它的类型是一个Composable函数类型,在 content 中可以放置所有子元素
  • measurePolicy:测量策略,默认场景下只实现 measure 方法即可,上面示例中最后传入的 lambda 就是 measure 方法的实现。

Layout 组件使用示例

下面通过 Layout 组件定制一个自己专属的 Column,下面是实现代码:

@Composable
fun MyColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) 
    Layout(content = content, modifier = modifier)  measurables, constraints ->
        val placeables = measurables.map  measurable ->
            measurable.measure(constraintsjetpack Compose绘制流程原理

jetpack Compose绘制流程原理

如何在jetpack compose中为画布中的路径绘制阴影

Jetpack Compose 自定义绘制

使用 Jetpack Compose 完成自定义绘制

使用 Jetpack Compose 自定义"View"