0
  • 聊天消息
  • 系统消息
  • 评论与回复
登录后你可以
  • 下载海量资料
  • 学习在线课程
  • 观看技术视频
  • 写文章/发帖/加入社区
会员中心
创作中心

完善资料让更多小伙伴认识你,还能领取20积分哦,立即完善>

3天内不再提示

详解Jetpack Compose布局流程

谷歌开发者 来源:AndroidPub 2025-02-05 13:38 次阅读
加入交流群
微信小助手二维码

扫码添加小助手

加入工程师交流群

本文作者 / Android 谷歌开发者专家王鹏

前言 - 从 Compose 生命周期说起

ba236dbc-da36-11ef-9310-92fbcf53809c.png

Compose 绘制生命周期为三个阶段:

Composition/组合: Composable 源码经过运行后生成 LayoutNode 的节点树,这棵树被称为 Composition。

Layout/布局: 对节点树深度遍历测量子节点的尺寸,并将其在父容器内摆放到合适的位置。

Drawing/绘制: 基于布局后拿到的尺寸和位置信息,绘制上屏。

我们与 Android 经典视图系统的生命周期 (Measure,Layout,Drawing) 做一个对比: 组合是 Compose 的特有阶段,是其能够通过函数调用实现声明式 UI 的核心,想要深入理解 Compose 第一课就是理解这个过程。

绘制阶段与传统视图大同小异,都是通过 Android Cavas API,底层调用 skia 实现。

本文讨论的重点是布局阶段。Compose 的 Layout 把 Measure 也囊括了进来,相对于 Android View 有相似性,但也有其独有的特点和优势,接下来我们进入正题。

Compose 布局过程三步走

Compose 布局包括三个阶段,从当前 Node 出发,需要依次经历:

Measure children: 深度遍历子节点,并测量它们的尺寸

Decide own size: 根据收集到的子节点尺寸,决定当前节点自己的尺寸

Place children: 将子节点摆放到合理的相对位置

wKgZO2ei-d-ASRvkAACju1W21oo095.png

上面代码描述了一个卡片的布局,下面以这个布局的节点树为例,看一下布局流程。

ba53930c-da36-11ef-9310-92fbcf53809c.png

Step1: 从 Row 开始发起测量,遵循三步走第一步,深度遍历测量其子节点 Image 和 Column

Step2&3: Image 发起测量,因为没有子节点需要测量了,所以只需要计算自己的尺寸,也因为没有子节点需要摆放,空实现完成 place 即可

Step4: Column 发起测量,因其有子节点,继续深度遍历

Step5&6: 测量 Text,因为一个叶子节点,立即完成自己的 Size 和 Place 阶段

Step7&8: 测量另一个 Text,同上

Step9: Column 拿到两个子 Text 返回的 Size 后,计算出自己的 Size,不难猜到其计算逻辑应该是 width = maxOf(child1.w, child2.w),height = sumOf(child1.h, child2.h)。设置自己的 width 和 height 后,对两个子 Text 进行 Place,垂直线性摆放。

看一下代码是如何实现这三步。

所有的 Composable 最终都会调用一个公共 Layout Composable 方法,这里面创建 LayoutNode 存储在 Composition 节点树。

ba6b6c02-da36-11ef-9310-92fbcf53809c.png

以 Column 的实现为例,可以看到调用 Layout 时,传入了三个参数:

@Composable
inline fun Column(
    modifier: Modifier = Modifier,
    verticalArrangement: Arrangement.Vertical = Arrangement.Top,
    horizontalAlignment: Alignment.Horizontal = Alignment.Start,
    content: @Composable ColumnScope.() -> Unit
) {
    val measurePolicy = columnMeasurePolicy(verticalArrangement, horizontalAlignment)
    Layout(
        content = { ColumnScopeInstance.content() },
        measurePolicy = measurePolicy,
        modifier = modifier
    )
}

content: 在这里定义子 Composable,组合过后形成当前节点的子节点

measurePolicy: 这是定义了布局的三步走核心逻辑

modifier: 修饰符链,参与到布局或者绘制阶段

measurePolicy 和 modifier 会存储在当前 LayoutNode 上,等待 measure 的开始参与其中。下面重点分析 MeasurePolicy 了解三步走如何实现。

MeasurePolicy - 测量策略

fun interface MeasurePolicy {


    fun MeasureScope.measure(
        measurables: List,
        constraints: Constraints
    ): MeasureResult


}

MeasurePolicy 通过 measure 方法完成测量。这里有两个重要参数:

measurables: 等待测量的对象,其实就是当前节点的子节点

constraints: 测量约束。节点需要基于当前的 Constaints 进行测量,它规定了节点尺寸的上限和下限,如下:

class Constraints {
    val minWidth: Int
    val maxWidth: Int
    val minHeight: Int
    val maxHeight: Int
    ...
}

Constraints - 测量约束

父节点通过 Constraints 约束子节点的测量。Constraints 非常重要,我们常说 Compose 不怕布局嵌套正是得益于它。反观 Android 原生视图,由于测量阶段的约束不明确,子 View 需要再次请求父 View 给出清楚的 View.MeasureSpec,导致出现多次绘制。

举几个例子理解一下 Constraints 如何设置:

ba8aefbe-da36-11ef-9310-92fbcf53809c.png

对于页面的根节点, Activity 的 Window 的长宽就是其 Constraints 的最大长宽。如果是一个垂直可滚动容器的节点,那么它的 Constraints 的 height 应该是 Infinity,因为它可以跨多个屏幕存在。

此外, Modifier 的装饰能力本质也是通过修改 Constraints 完成的。例如 fillMaxWidth 要求被修饰的节点填充整个父容器,所以 Modifier 会在布局阶段将 minHeight/minWidth 对齐 max 组值。关于 Modifier 参与布局的流程,稍后介绍。

三步走实现 - Kotlin 语法优势的体现

举例看一下三步走代码如何实现。

baa449f0-da36-11ef-9310-92fbcf53809c.png

我们实现一个类似 Column 的布局效果,在 measurePolicy#measure 中实现三步走逻辑。

measurePolicy = { // this: MeasureScope
    // Step1:Measure each children
    val placeables = measurables.map { measurable ->
        measurable.measure(constraints)
    }


    // Step2: Deciee own size
    val height = placeables.sumOf { it.height }
    val width = placeables.maxOf { it.width }


    layout(width, height) { //this: Placeable.PlacementScope


        // Step3: Place children by changing the offset of y co-ord    
        var yPosition = 0


        placeables.forEach { placeable ->
            // Position item on the screen
            placeable.placeRelative(x = 0, y = yPosition)


            // Record the y co-ord placed up to
            yPosition += placeable.height
        }
    }
}

每个 measuable 提供了参与测量的 measure 方法,此处会传入 Constraints,返回的 placeable 中已经存储了测量后的 widht 和 height,等待 place

基于各个 placeable 的 w 和 h 计算当前节点的 Size,并通过 layout 方法设置。layout 方法内会真正的创建 LayoutNode

layout 方法的末参是一个 lambda,这里是第三步摆放子节点的逻辑,通过设置 y 轴的偏移量实现纵向布局,非常简单

特别值得一提的是,通过 meause 一个方法就完成三步走,布局逻辑相对传统的 View 系统更加高效,回想传统自定义 View 你需要分别实现 onMeasure,onLayout,onDraw 等,逻辑分散,可读性差。

但是这种集中式的写法有一个弊端,需要人为保证代码顺序。试想如果把 layout 写在 measure 前面怎么办?幸好 Kotlin 强大的编译期检查能力,很好地指导大家写出正确代码:

measure 方法的返回值是 MeasureResult 类型,layout 方法也返回此类型,所以保证了尾部一定是调用 layout 完成三步走

Measuable#measure 调用后返回 Placeable 类型,然后才能调用 Placeable#place,这保证了 place 和 measure 的先后关系

Measuable#measure 只能在 MeasureScope 中调用,Placeable#place 只能在 Placeable.PlacementScope 中调用,这确保了 place 需要在 layout 的 lambda 中调用

通过各种返回值类型、作用域类型的约束,大家可以写出安全又一气呵成的代码,这种 API 设计理念值得推崇。

Modifier Node

接下来介绍一下 Modifier 如何参与布局的。

bab8a40e-da36-11ef-9310-92fbcf53809c.png

Modifier 在组合之后也会成为 Node 存储在节点树上,Modifier 的调用链生成一条单向继承的子节点树,而被修饰的 Composable 会成为这条树枝的叶子结点。

比如上面例子中,Image 最终成为 clip->size 的子节点。实际上 Image 内部有一些内置的 Modifier,所以全部展开后 Image 所在的树枝上有一连串 ModifierNode。

挂在节点树上的 ModifierNode 可以参与到深度遍历的绘制流程中,在 Image 之前对 Constraints 做出调整,完成对末端 Image 的装饰。

以 Padding 修饰符为例,看一下源码:

//组合中调用 paddiung 会
fun Modifier.padding(
    start: Dp = 0.dp,
    top: Dp = 0.dp,
    end: Dp = 0.dp,
    bottom: Dp = 0.dp
) = this then PaddingElement(
    start = start,
    top = top,
    end = end,
    bottom = bottom
)


//Element 存储到链上,创建 PaddingNode
private class PaddingElement(
    ...
) : ModifierNodeElement() 




//PaddingNode 定义 measure 逻辑
private class PaddingNode(


    overide fun MeasureScope.measure(
        measurable: Measurable, // 注意不是list
        constraints: Constraints
    ): MeasureResult {
        ...
    }


):LayoutModifierNode,Modifier.Node()

组合阶段,Modifier#then 创建 Element 加入 Modifier chain 中。Element 是无状态的,重组中会重新生成,Element 会在组合中创建有状态的 ModifierNode。ModifierNode 有状态,重组中仅当状态发生变化时被更新,否则不会重新生成。Modifier Node 是 Compose 1.5 引入的新优化,目的就是通过存储 Modifier 状态参与比较,提升重组性能。

ModifierNode 按照参与的阶段不同,分为 LayoutModifierNode 和 DrawModifierNode。对于前者,布局逻辑就是现在 LayoutModifierNode#measure 中,和 MeasurePolicy#measure 的功能一样,唯一的区别是接受单个 measurable 参数而不是 List。因为我们知道了 ModifierNode 是单向继承,所以只会有一个后续子节点。如果把LayoutNode 的 measure 看做是自定义 ViewGroup 需要针对多个子 View 布局,那么 LayoutModifierNode 的 measure 更像是自定义 View,只对自身负责。

Modifier.layout {}

除了自定义一个 Modifier 来改变当前节点的布局,还有一个简单的方法就是使用 Modifier.layout {} 方法。

fun Modifier.layout(
    measure: MeasureScope.(Measurable, Constraints) -> MeasureResult
)
我们可以在 Modifier 调用链的任意位置插入 measure 自定义代码,对当前节点做装饰。例如下面代码中添加了一个自定义 50px 的 padding。
Box(Modifier
    .background(Color.Gray)
    .layout { measurable, constraints ->
        // an example modifier that adds 50 pixels of vertical padding
        val padding = 50
        val placeable = measurable.measure(constraints.offset(vertical = -padding))
        layout(placeable.width, placeable.height + padding) {
            placeable.placeRelative(0, padding)
        }
    }){ ... }

Modifier 布局流程

bade81ec-da36-11ef-9310-92fbcf53809c.png

上面代码绘制一个居中摆放 50*50 的矩形。我们通常不会同时设置这么多 size 相关的 modifier,这个例子只是为了展示 Modifier 的布局流程:

bafa741a-da36-11ef-9310-92fbcf53809c.png

先看一下自顶向下的测量流程: 从 fillMaxSize 对应的 LayoutModifierNode 出发,假设当前的 Constraints 是 w:0-200,h:0-300。fillMaxSize 的功能是让子节点填满当前全部剩余空间,会为子节点创建以下 childConstraints:

val childConstraints = Constraints (
    minWidth = outerConstraints.maxWidth,
    maxWidth = outerConstraints.maxWidth,
    minHeight = outerConstraints.maxHeight,
    maxHeight = outerConstraints.maxHeight,
)
来到 warpContentSize,它会让子自己决定 size 不设限,min 值再次回归 0,childConstraints 如下:
val childConstraints = Constraints (
    minWidth = 0,
    maxWidth = outerConstraints.maxWidth,
    minHeight = 0,
    maxHeight = outerConstraints.maxHeight,
)
来到 size(50),这里自然要给一个具体的 size 约束,如下:
val childConstraints = Constraints (
    minWidth = 50,
    maxWidth = 50,
    minHeight = 50,
    maxHeight = 50,
)
以此类推 Constraints 经过不断调整传入到叶子节点 Box 对应的 LayoutNode,完成三步走。 第一步测量

bb16c8cc-da36-11ef-9310-92fbcf53809c.png

叶子节点测量完后,再自底向上进行第二三步,整个流程不做赘述了,只提一点: wrapContentSize 从语义上是应该跟随子节点的大小,即 5050,为什么实际尺寸设置了 200300 呢?

因为其父节点 fillMaxSize 传入的 Constraints 是 200300,rwapContentSize 必须填满这个空间,而由于它有一个默认参数 align = Alignment.Center,所以才能出现 5050 矩形块居中的效果。

Intrinsic Measurements - 固有特性测量

中文将其翻译成 "固有特性",很多人不理解 "固有" 到底指什么?所以放在本文最后讨论一下。

Compose 要求布局过程中每个节点只被测量一次,测量总耗时只与节点数正相关,与层级无关,所以 ComopseUI 不怕嵌套过深,而传统 Android 视图系统中,某个 View 存在多次测量的情况,随着层级变多测量次数会指数级增长,所以传图视图下我们需要通过优化 View 的层级提升性能。

Compose 为了保证 "每个节点只测量一次" 的原则,甚至增加了编译期检查:

val constraints1 = ...
val constraints2 = ...
val placeable1 = measurable.measure(constraints1
val placeable2 = measurable.measure(constraints2)

bb280286-da36-11ef-9310-92fbcf53809c.png

"每个节点只测量一次"在提升性能的同时也带来了问题。来自官方文档的例子:

@Composable
fun TwoTexts(modifier: Modifier = Modifier, text1: String, text2: String) {
    Row(modifier = modifier) {
        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
        )
    }
}
上面代码的本意是希望打造以下的布局效果:

但发现实际效果不符合预期: Divider 的高度没有对齐左右的 Text,而是撑满了容器高度:

Row 为测量 Divider 传入Constraints 时,不知道对齐 Text 高度应该设置怎样的 maxHeight。传入的 maxHeight 值比较大导致 Divider 的 fillMaxSize 撑满了整个容器。 传统视图体系中类似的情况,Row 在测量了 Text 的高度后,会再测量一次 Divider 并给出更合适的 View.MeasureSpec,但 Compose 中不可以,因为这样违反了 "每个节点只测量一次"的原则。

为此, Compose 引入了 "固有特性测量" 的机制。在当前节点正式发起深度遍历子测量节点之前的一次 "预处理",从子节点提前获取必要信息,设置更合理的 Constraints,然后再发起正式测量。 MeasurePolicy 中提供了获取 "固有特性" 尺寸的方法: IntrinsicMeasureScope.minIntrinsicXXX

fun interface MeasurePolicy {


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


   fun IntrinsicMeasureScope.minIntrinsicHeight
   fun IntrinsicMeasureScope.maxIntrinsicWidth
   fun IntrinsicMeasureScope.maxIntrinsicHeight


}
Text 的固有特性的 minIntrinsicHeight 是文本内容单行展示的高度;Divider 的 minIntrinsicHeight 是 0,当我们改一下例子中的代码,在 Row 的Modifier.height 增加 IntrinsicSize.Min。
Row(modifier = modifier.height(IntrinsicSize.Min)) {...}
Row 在发起子节点测量前,通过 MeasurePolicy 提供的固有特性相关方法,获取所有子节点的minIntrinsicHeight,取最大的一个设为 Constraints.maxHeight 后发起正式测量。这样,Divider 的 fillMaxSize 就会跟 Text 两边高度对齐了。

看到这里相信大家理解 "固有"的含义了,其本质代表 "不依赖 Constraints"就可以获取的值,基于这些值更新 Constraints,后续测量只有一次也能正确约束。

声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉
  • Android
    +关注

    关注

    12

    文章

    3986

    浏览量

    133084
  • 函数
    +关注

    关注

    3

    文章

    4406

    浏览量

    66851
  • 代码
    +关注

    关注

    30

    文章

    4941

    浏览量

    73155

原文标题:【GDE 分享】一文看懂 Jetpack Compose 布局流程

文章出处:【微信号:Google_Developers,微信公众号:谷歌开发者】欢迎添加关注!文章转载请注明出处。

收藏 人收藏
加入交流群
微信小助手二维码

扫码添加小助手

加入工程师交流群

    评论

    相关推荐
    热点推荐

    Jetpack Compose 的基本布局

    mcu
    橙群微电子
    发布于 :2024年05月21日 15:48:29

    compose的使用技巧是什么?

    compose的使用技巧是什么?
    发表于 11-15 07:27

    详解Jetpack Compose 1.1版本的新功能

    我们一如既往地搭建产品路线图,现在已经发布了 Jetpack Compose 的 1.1 版本,这是 Android 的现代原生界面工具包。此版本新增了一些功能,比如经过优化的焦点处理、触摸目标值
    的头像 发表于 03-11 10:14 1933次阅读

    如何使用 Compose 进行构建

    适用于 Wear OS 的 Compose 已推出了开发者预览版,使用 Compose 构建 Wear OS 应用,不仅可以轻松遵循 Material You 指南,同时可以将 Compose 的优点发挥出来。
    的头像 发表于 03-17 13:44 2472次阅读

    Jetpack Compose基础知识科普

    Jetpack Compose 是用于构建原生 Android 界面的新工具包。它可简化并加快 Android 上的界面开发,使用更少的代码、强大的工具和直观的 Kotlin API,快速让应用生动
    的头像 发表于 04-02 13:38 3801次阅读

    Android Studio Dolphin稳定版正式发布

    预览动画。此外,针对应用界面调试,我们还在布局检查器 (Layout Inspector) 中引入了一个很好用的 Compose 界面计数工具,用以跟踪界面重新组合的次数。 Jetpack C
    的头像 发表于 10-12 19:37 3252次阅读

    Compose Material 3 稳定版现已发布 | 2022 Android 开发者峰会

    (新一代 Material Design ) 构建 Jetpack Compose 界面。立即开始在应用中使用 Material Design 3 吧! Compose Material 3
    的头像 发表于 11-21 18:10 2116次阅读

    Jetpack Compose 更新一览 | 2022 Android 开发者峰会

    作者 / Android 开发者关系工程师 Jolanda Verhoef 去年我们发布了 Jetpack Compose ,此后一直在进行优化。我们已添加了新的功能并创造出功能更强大的工具,帮助
    的头像 发表于 11-23 17:55 1853次阅读

    Google计划用Jetpack Compose来重建Android系统中的设置应用

    上周,Google 发布了 Android 14 的首个开发者预览版,除了那些最新的功能以外,Google 似乎还正在默默酝酿一个新的计划 —— 用更现代的 Jetpack Compose 来逐步
    的头像 发表于 02-18 11:16 2121次阅读

    Compose for Wear OS 1.1 推出稳定版: 了解新功能!

    为 Wear OS 构建出色的响应式应用。   Compose for Wear OS 1.1 版本 https://developer.android.google.cn/jetpack
    的头像 发表于 02-22 01:30 1554次阅读

    Kotlin声明式UI框架Compose Multiplatform支持iOS

    ,基于 Kotlin 和 Jetpack Compose 打造,由 JetBrains 和开源贡献者开发。 Jetpack Compose 是 Google 为构建原生 UI 打造的
    的头像 发表于 04-24 09:12 2154次阅读
    Kotlin声明式UI框架<b class='flag-5'>Compose</b> Multiplatform支持iOS

    Jetpack Compose和设备类型的三大重要更新

    2024 年 Google I/O 大会上我们分享了大量更新和公告,帮助开发者提升工作效率。了解 2024 年 Google I/O 大会上有关 Jetpack Compose 和设备类型的三大重要更新。
    的头像 发表于 08-09 17:07 1274次阅读

    docker-compose配置文件内容详解以及常用命令介绍

    一、Docker Compose 简介 Docker Compose是一种用于定义和运行多容器Docker应用程序的工具。通过一个  docker-compose.yml  文件,您可以配置应用程序
    的头像 发表于 12-02 09:29 6472次阅读
    docker-<b class='flag-5'>compose</b>配置文件内容<b class='flag-5'>详解</b>以及常用命令介绍

    Docker Compose的常用命令

    。它通过一个配置文件(docker-compose.yml)来详细定义多个容器之间的关联、网络设置、服务端口等信息。使用一条简单的命令,就可以轻松启动、停止和管理这些容器,极大地简化了多容器应用的部署与管理流程,方便实现应用的快速构建、开发、测试以及部署。
    的头像 发表于 04-30 13:40 935次阅读

    全新导航库Jetpack Navigation 3发布

    在应用中的不同屏幕之间导航理应简单明了。然而,构建稳健、可扩展且赏心悦目的导航体验却并非易事。多年来,Jetpack Navigation 库一直是开发者的重要工具,但随着 Android 界面格局的演变,尤其是 Jetpack Com
    的头像 发表于 08-06 13:56 961次阅读