Compose布局进阶

本文根据自己的理解翻译自 MAD Skills Compose Layout and Modifier 系列文章的第四篇 # Advanced Layout concepts ,适合有Compose基础的同学阅读,另外建议有英语基础的同学直接看原文哈,附上原文链接

欢迎来到 "Jetpack Compose布局与modifiers",在之前的文章,我们讨论了 Compose 的布局阶段,具体来说,就是 modifier 链式调用和自上而下的 Constraints 传递对 Composable 的影响。

在今天的文章,我们的重点依旧在布局阶段和 constraints, 但是是从另一个角度来探讨---------如何利用它们来打造自定义 layout.

为了打造自定义 layout,我们会去了解布局阶段能做什么、布局阶段的入口在哪里、如何利用布局的两个子阶段(测量和摆放),并且最重要的,如何利用这些去构造出灵活的自定义 layout。

然后我们会再了解两个很重要但是不走常规布局流程的API来作为布局难题的最后两块拼图:SubComposeLayout 和Intrinsic measurements(固有特性测量)。理解这些概念会帮助你构建更加复杂和特殊的自定义Composable

Compose里的所有layouts概念

在之前的文章,我们讨论了 Compose 通过三个阶段把数据转化为UI:组合(要显示什么)、布局(要显示在哪里)、绘制(如何渲染)

但就如这系列文章的标题所示,我们主要关心布局阶段。

然而,"Layout"在 Compose 里有很多种概念,并可能带来歧义造成不必要的困惑,我们先梳理一下之前用到的一些"Layout":

  • Layout phase(布局阶段):用于父layout决定自己的尺寸和摆放子元素

  • layout:一个广泛的抽象术语,用于快速定义Compose中的任何UI元素

  • layout node:用于表示 Compose UI树上的节点,组合阶段的职责就是把 Composable 代码转化成 layout node

在这篇文章,我们会进一步学习更多的"Layout"概念来完善布局认知。先快速简单地提及一下,更多的解释会在后面的下文中:

  • Composable 的 Layout: 核心 Composable 函数,组合阶段时使用会在 Compose UI树中创建 layout node;我们常用的 Column、Row 等都是基于 Layout 封装而来

  • layout()方法: 布局阶段摆放子 Composable 的入口,在测量完子 Composable 后进行

  • .layout() modifier: 用于修改单个 Composable 的测量和摆放,(会取代父Composable原本对它的测量和摆放)

在弄清楚上面的概念后,我们先从布局阶段说起。如之前提及,在布局阶段,UI树上的所有元素都会测量自己的子元素并在二维空间里摆放他们。

Compose 里提供的所有开箱即用 Layout,如 Row,Column 等,都会自动帮你处理这个过程。

但是如果你的设计想要一些非常规的布局,像JetLaggeg sample里的TimeGraph,就需要你自己实现

这正是你需要了解有关布局阶段的更多信息的时候:它的入口在哪?如何利用它的两个子阶段(测量、摆放)实现你的需求?现在就让我们看看如何使用 Compose 的自定义布局去实现给定的设计需求

进入布局阶段

让我们先去搞清楚构建自定义布局最重要也是最基础的一步。当然,如果你想要一个详细、手把手教学的视频指南了解"何时需要创建自定义布局和复杂的UI设计并如何实现",这里是视频地址: Custom layouts and graphics in Compose video 又或者在JetLaggeg sample 源码仓库 里直接自行探索TimeGraph的源码。

调用 Layout Composable 函数是创建自定义布局和进入布局阶段的入口:

less 复制代码
@Composable
fun CustomLayout(
  content: @Composable () -> Unit,
  modifier: Modifier = Modifier
) {
  Layout() { ... }
}

Layout 函数是 Compose 里布局阶段的主角,同时也是 Compose 布局系统里的核心组件:

less 复制代码
@Composable inline fun Layout(
  content: @Composable @UiComposable () -> Unit,
  modifier: Modifier = Modifier,
  measurePolicy: MeasurePolicy
) {
  // ...
}

它接受一个 Composable 参数作为子 Composable; measure policy 的的参数定义具体测量和摆放。所有的上层布局都是基于这个 Composable.

一旦进入布局阶段,我们就会看到它由两个步骤组成,即测量和放置,顺序执行:

less 复制代码
@Composable
fun CustomLayout(
  content: @Composable () -> Unit,
  modifier: Modifier = Modifier
) {
  Layout(
    content = content,
    modifier = modifier,
    measurePolicy = { measurables, constraints ->
      // 1. 测量阶段
      // 子元素各自测量并返回自己的测量结果
      layout(...) {
        // 2. 摆放阶段
        // 开始决定子元素的摆放位置
      }
    }
  )
}

子元素的尺寸在测量阶段计算,摆放位置则在摆放阶段。这个顺序使用Kotlin DSL强制实现,他们的嵌套方式让你无法摆放一些未经测量的子元素:

less 复制代码
@Composable
fun CustomLayout(
  content: @Composable () -> Unit,
  modifier: Modifier = Modifier
) {
  Layout(
    content = content,
    modifier = modifier,
    measurePolicy = { measurables, constraints ->
      // 测量作用域
      // 1. 测量阶段
      // 子元素各自测量并返回自己的测量结果
      layout(...) {
        // 摆放作用域
        // 2. 摆放阶段
        // 开始决定子元素的摆放位置
      }
    }
  )
}

在测量阶段,子元素作为 measurables 被传入,在 Layout 方法里,measurables 默认为列表形式:

less 复制代码
@Composable
fun CustomLayout(
  content: @Composable () -> Unit,
  // ...
) {
  Layout(
    content = content,
    modifier = modifier
    measurePolicy = { measurables: List<Measurable>, constraints: Constraints ->
      // 测量作用域
      // 1. 测量阶段
      // 子元素各自测量并返回自己的测量结果
    }
  )
}

根据你自己的布局需求,你既可以使用传递进来的constraints继续在传递给子元素去测量列表里的每一个子元素,让子元素保持自己预定义的大小:

less 复制代码
@Composable
fun CustomLayout(
  content: @Composable () -> Unit,
  // ...
) {
    Layout(
      content = content,
      modifier = modifier
    ) { measurables, constraints ->
      // 测量作用域
      // 1. 测量阶段
      measurables.map { measurable ->
        measurable.measure(constraints)
      }
    } 
}

又或者根据自己的需要调整 constraints(通过修改传递给子元素的constraints实现):

less 复制代码
@Composable
fun CustomLayout(
  content: @Composable () -> Unit,
  // ...
) {
  Layout(
    content = content,
    modifier = modifier
  ) { measurables, constraints ->
    // 测量作用域
    // 1. 测量阶段
    measurables.map { measurable ->
      measurable.measure(
        constraints.copy(
          minWidth = newWidth,
          maxWidth = newWidth
        )
      )
    }
  }
}

在之前的文章我们已经了解到布局阶段里 constraints 在UI树里是自上而下传递的,当一个元素测量它的子元素的时候,会提供 constriants 让子元素了解自己可以使用的尺寸大小范围。

布局阶段一个非常重要的特性是单向传递测量结果。这意味着每个元素不会测量两遍。这个特性让 Compose 可以高效地测量大规模的UI树。

测量方法执行后会返回一个 placeables 列表,代表这些元素现在已经准备好被摆放了:

less 复制代码
@Composable
fun CustomLayout(
  content: @Composable () -> Unit,
  // ...
) {
  Layout(
    content = content,
    modifier = modifier
  ) { measurables, constraints ->
    // 测量作用域
    // 1. 测量阶段
    val placeables = measurables.map { measurable ->
      // 测量结果返回placeable
      measurable.measure(constraints)
    }
  }
}

摆放阶段通过调用 layout() 方法进入。这时候当前元素就可以决定自己的尺寸了(通过layout方法的参数传入),比如说下面的例子使用子元素的宽度和作为自己的宽度,子元素的高度和作为自己的高度:

kotlin 复制代码
@Composable
fun CustomLayout(
  // ...
) {
  Layout(
    // ...
  ) {
    // totalWidth 即子元素的宽度和
    // totalHeight 即子元素的高度和
    layout(totalWidth, totalHeight) {
      // 摆放作用域
      // 2. 摆放阶段
    }
  }
}

在摆放作用域里我们使用刚刚测量方法返回的placeables去摆放:

kotlin 复制代码
@Composable
fun CustomLayout(
  // ...
) {
  Layout(
    // ...
  ) {
    // ...
    layout(totalWidth, totalHeight) {
      // 摆放作用域
      // 2. 摆放阶段
      placeables // 开摆! 😎
    }
  }
}

摆放子元素时,我们需要决定他们的左上角x,y坐标,然后调用place()方法实现摆放:

kotlin 复制代码
@Composable
fun CustomLayout(
  // ...
) {
  Layout(
    // ...
  ) {
    // ...
    layout(totalWidth, totalHeight) {
      // 摆放作用域
      // 2. 摆放阶段
      placeables.map { it.place(xPosition, yPosition) }
    }
  }
}

通过写好这些,我们就完成了摆放阶段,也意味着布局阶段完成了。现在你的自定义布局已经准备好被使用了!

对于单个元素使用的 .layout() modifier

Layout composable 用于控制所有子元素的尺寸、摆放,但是如果你只想控制单个元素,那这多少有点杀鸡用牛刀了。

在这种情况下,Compose 框架提供了更好、更简单的解决方案:.layout() modifier ---------用于控制单个元素的尺寸和摆放

让我们看一个具体的例子:一个Column包着4个条状元素

我们想要其中一个元素的宽度表现得不受父元素里的40dp内边距影响,让它看起来撑满整个父元素:

scss 复制代码
@Composable
fun LayoutModifierExample() {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .background(Color.LightGray)
            .padding(40.dp)
    ) {
        Element()
        Element()
        // 想要改变下面这个元素的宽度
        Element()
        Element()
    }
}

为了实现这样的效果,我们给第三个元素设置 .layout() modifier

事实上使用 .layout() modifier 和刚刚提及的 Layout composable 非常相似,它接受一个 lambda 参数, lambda 里提供了代表当前元素的 measurable 和外面传递进来的 constraints,有了这些你可以单独实现这个元素的测量和摆放:

scss 复制代码
Modifier.layout { measurable, constraints ->
    // 测量
    val placeable = measurable.measure(...)

    layout(placeable.width, placeable.height) {
        // 摆放
        placeable.place(...)
    }
}

回到上面的例子,我们通过让constraints的最大宽度增加80dp来改变子元素的宽度:

ini 复制代码
Element(modifier = Modifier.layout { measurable, constraints ->
  val placeable = measurable.measure(
    constraints.copy(
      // 修改constraints的最大宽度
      maxWidth = constraints.maxWidth + 80.dp.roundToPx()
    )
  )
  layout(placeable.width, placeable.height) {
    // 还原元素的摆放偏移
    placeable.place(0, 0)
  }
})

Compose框架的灵活性让你可以有多种方式解决问题。如果你知道这个元素的确切宽度,实现上面的需求的另一种方式是使用 .requiredWidth() modifier, 这样元素就会无视父元素由 padding modifier 带来的 constraints 约束了。

SubcomposeLayout---------打破常规Compose三阶

在之前的文章,我们提及了Compose把数据到UI转化分为三个阶段:1.组合2.布局3.绘制。且顺序进行。其中布局阶段又可以细分为测量和摆放两个子阶段。这样的规则适用于绝大部分 Composable 布局,其实还存在不遵循这种模式且有充分理由这样做的布局---------SubcomposeLayout.

试想一下下面的场景:你在构建一个含有成千上万元素的列表UI,显然屏幕是不能一次显示完的,这种情况下把所有的元素都组合、布局、渲染出来显然是性能代价高昂且没有必要的。

相反,更好的方法应该是:

  1. 测量子元素获取他们的尺寸

  2. 根据该尺寸计算当前屏幕能够显示多少元素

  3. 最后只把合适数量的UI转化出来

这就是SubcomposeLayout背后的主要思想,它需要先知道子元素的测量结果然后根据该结果决定是转化其中的一些还是所有元素为UI.

Compose 中的 Lazy 组件基于此构建,并且能够在滚动时按需追加内容。

SubcomposeLayout 把组合阶段延迟到布局阶段中,更准确地说,组合阶段需要在布局阶段里的测量阶段之后,这样在组合前父元素就得到了子元素的尺寸。

BoxWithConstraints 底层也是使用 SubcomposeLayout, 但是这个组件基于 SubcomposeLayout 封装的动机和 Lazy 组件稍微有些不同,BoxWithConstraints 是为了把上层传递来的 constraint 暴露给了调用者,我们都已经知道 constraints 本来只在 Layout 阶段才会知道:

scss 复制代码
BoxWithConstraints {
  // maxHeight这个在constraints里的信息只在BoxWithConstraints可用,
  // 为了拿到这个信息同样需要延迟组合阶段到布局里测量阶段之后
  if (maxHeight < 300.dp) {
    SmallImage()
  } else {
    BigImage()
  }
}

不应该滥用 SubcompositionLayout

SubcompositionLayout 为了实现一些更灵活的动态需求,改变了 Compose 的常规流程,但这同样是有性能代价和限制的。因此明白什么时候才应该使用 SubcompositionLayout 是非常重要的。

一个简易的辨别是否需要使用 SubcompositionLayout 的方法就是 一个子元素在编写时是否需要依赖其他子元素的测量结果,像刚刚提及的 Lazy 组件和 BoxWithConstraints 都是需要的。

如果你只是需要一个子元素的测量结果去测量另一个子元素,你可以使用常规的 Layout composable 实现,你依然可以根据彼此的结果分别测量子元素,只是无法更改它们的组合。

打破单向传递测量结果---------固有特性测量(Intrinsic measurements)

在布局阶段单向传递测量结果是我们之前提及的一个规则,因为这样有利于测量系统的性能表现。我们都知道重组会非常频繁地发生,试想一下一个复杂的UI树如果测量阶段不能高效执行,那么重组的性能就会非常差。

然而有些时候父布局确实在测量子元素前需要一些子元素的信息,比如说根据这些信息去修改传递给子元素的constraints.这就是固有特性测量的职责,让你在测量前知道子元素的信息。

让我们看看下面的例子,我们想要 Column 的子元素宽度一样并且等同于子元素里最宽的那一个(下面的例子就是"And Modifier"这个Text)。我们先写成这样:

ini 复制代码
@Composable
fun IntrinsicExample() {
    Column() {
        Text(text = "MAD")
        Text(text = "Skills")
        Text(text = "Layouts")
        Text(text = "And Modifiers")
    }
}

可以看到这并不满足我们的需求,每个子元素的宽度都只是各自所需要的宽度。我们再试试这样:

ini 复制代码
@Composable
fun IntrinsicExample() {
    Column() {
        Text(text = "MAD", Modifier.fillMaxWidth())
        Text(text = "Skills", Modifier.fillMaxWidth())
        Text(text = "Layouts", Modifier.fillMaxWidth())
        Text(text = "And Modifiers", Modifier.fillMaxWidth())
    }
}

em... 现在每个子元素的宽度都变成了 Column 的最大宽度,同样不满足需求。这种情况下,我们就需要使用固有尺寸了:

ini 复制代码
@Composable
fun IntrinsicExample() {
    Column(Modifier.width(IntrinsicSize.Max)) {
        Text(text = "MAD", Modifier.fillMaxWidth())
        Text(text = "Skills", Modifier.fillMaxWidth())
        Text(text = "Layouts", Modifier.fillMaxWidth())
        Text(text = "And Modifiers", Modifier.fillMaxWidth())
    }
}

通过使用 IntrinsicSize.Max 作为 Column 的宽度,它会查询所有子元素正确显示所有内容的最大值来最为 Column 的宽度。这样也就实现需求了。

反之,如果我们使用 IntrinsicSize.Min, 那 Column 会查询所有子元素正确显示所有内容的最小值来最为 Column的宽度,在这个例子中,就是每行刚好显示一个单词:

ini 复制代码
@Composable
fun IntrinsicExample() {
    Column(Modifier.width(IntrinsicSize.Min) {
        Text(text = "MAD", Modifier.fillMaxWidth())
        Text(text = "Skills", Modifier.fillMaxWidth())
        Text(text = "Layouts", Modifier.fillMaxWidth())
        Text(text = "And Modifiers", Modifier.fillMaxWidth())
    }
}

固有特性的快速总结:

  • Modifier.width(IntrinsicSize.Min):正确显示内容所需要的最小宽度

  • Modifier.width(IntrinsicSize.Max):正确显示内容所需要的最大宽度

  • Modifier.height(IntrinsicSize.Min):正确显示内容所需要的最小高度

  • Modifier.height(IntrinsicSize.Max):正确显示内容所需要的最大高度

然而固有特性测量并不是测量子元素两遍,它通过其他计算,你可以把它想象成不需要指数测量时间的预测量,比常规测量更加高效。因此,这并没有完全打破单次测量这个规则(应该是指没有改变测量阶段的时间复杂度)。

当我们创建一个自定义 layout, 固有特性测量提供了一个基于近似值的默认实现。当然这并不是什么时候都如你预期,所以API也预留了重写他们的方法。

kotlin 复制代码
    Layout(
        modifier = modifier,
        content = content,
        measurePolicy = object : MeasurePolicy {
            override fun MeasureScope.measure(
                measurables: List<Measurable>,
                constraints: Constraints
            ): MeasureResult {
                // Measure and layout here
            }

            override fun IntrinsicMeasureScope.maxIntrinsicHeight(
                measurables: List<IntrinsicMeasurable>,
                width: Int
            ): Int {
                // Logic for calculating custom maxIntrinsicHeight here
            }
            
            // Other intrinsics related methods have a default value,
            // you can override only the methods that you need.
        }
    )

结语

今天我们了解了好多东西,Compose 中的各种不同含义的"Layout"以及它们之间的联系;如何利用布局阶段实现自定义布局;通过 SubcompositionLayout 和固有特性测量实现非常规需求。

至此,MAD Skills Compose Layouts and Modifiers系列文章就结束了!这几篇文章从最基础的布局和 Modifiers,简单强大的 Compose 布局,Compose 的三个阶段,再到进阶的 modifier 链式调用和 subcomposition. 我相信你收获颇丰!

我们希望你学习到Compose的新知识,也更新老知识,当然最重要的是你会感觉更有信心和准备好把应用迁移至Compose了😀!

相关推荐
翻滚丷大头鱼2 小时前
android View详解—View的刷新流程源码解析
android
zhangphil3 小时前
Android adb shell命令分析应用内存占用
android·adb
漠缠4 小时前
Android AI客户端开发(语音与大模型部署)面试题大全
android·人工智能
Lei活在当下5 小时前
一个基础问题:关于SDK初始化时机的选择
android
雨白7 小时前
Android 触摸反馈与事件分发原理解析
android
relis9 小时前
解密大语言模型推理:Prompt Processing 的内存管理与计算优化
android·语言模型·prompt
CYRUS STUDIO12 小时前
FART 自动化脱壳框架优化实战:Bug 修复与代码改进记录
android·自动化·逆向·fart
2501_9159090612 小时前
uni-app iOS 上架常见问题与解决方案,实战经验全解析
android·ios·小程序·https·uni-app·iphone·webview
如此风景13 小时前
Compose 多平台UI开发的基本原理
android
CYRUS_STUDIO13 小时前
静态分析根本不够!IDA Pro 动态调试 Android 应用的完整实战
android·逆向