【Android-JetpackCompose】5、三阶段:组合、布局、绘制,架构分层,设计原则、性能最佳实践

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

一、帧的3个阶段

  • 组合(Composition):要显示什么样的界面。Compose 运行可组合函数并创建界面说明。
  • 布局(Layout):要放置界面的位置。该阶段包含两个步骤:测量和放置。对于布局树中的每个节点,布局元素都会根据 2D 坐标来测量并放置自己及其所有子元素。
  • 绘制(Drawing):渲染的方式。界面元素会绘制到画布(通常是设备屏幕)中。

在这里插入图片描述

1.1 第 1 阶段:组合

@Composable 函数或 lambda 代码块中的状态读取会影响组合阶段,并且可能会影响后续阶段。当状态值发生更改时,Recomposer 会安排重新运行所有要读取相应状态值的可组合函数。

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

1.2 第 2 阶段:布局

布局阶段包含两个步骤:测量和放置。

测量步骤会运行传递给 Layout 可组合项的测量 lambda、LayoutModifier 接口的 MeasureScope.measure 方法,等等。

放置步骤会运行 layout 函数的放置位置块、Modifier.offset { … } 的 lambda 块,等等。

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

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

1.3 第 3 阶段:绘制

绘制代码期间的状态读取会影响绘制阶段。常见示例包括 Canvas()、Modifier.drawBehind 和 Modifier.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)
}

二、读取 state

状态通常是使用 mutableStateOf() 创建,然后通过以下两种方式之一进行访问:直接访问 value 属性,或使用 Kotlin 属性委托。

// State read without property delegate.
val paddingState: MutableState<Dp> = remember {
    
     mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(paddingState.value)
)

属性委托在后台使用“getter”和“setter”函数来访问和更新状态的 value。只有当您将相应属性作为值引用时,系统才会调用这些 getter 和 setter 函数(而不会在创建属性时调用),因此两种方法是等效的。

// State read with property delegate.
var padding: Dp by remember {
    
     mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(padding)
)

2.1 优化读取 state

由于 Compose 会执行局部状态读取跟踪,因此我们可以在适当阶段读取每个状态,从而尽可能降低需要执行的工作量。

下面我们来看一个示例。下面是一个 Image(),它使用偏移修饰符来偏移自己的最终布局位置,从而在用户滚动时产生视差效果。

Box {
    
    
    val listState = rememberLazyListState()

    Image(
        // Non-optimal implementation!
        Modifier.offset(
            with(LocalDensity.current) {
    
    
                // State read of firstVisibleItemScrollOffset in composition
                (listState.firstVisibleItemScrollOffset / 2).toDp()
            }
        )
    )

    LazyColumn(state = listState)
}

这段代码可以使用,但效果并不理想。如上所示,该代码会读取 firstVisibleItemScrollOffset 状态的值,并将其传递给 Modifier.offset(offset: Dp) 函数。当用户滚动时,firstVisibleItemScrollOffset 值会发生更改。如我们所知,Compose 会跟踪所有状态读取,以便重启(重新调用)读取代码(在我们的示例中,就是 Box 的内容)。

这是在组合阶段中读取的状态的示例。这不一定是坏事;事实上,这是重组的基础,这样一来,数据更改才能发出新的界面。

不过,在本例中,这并不是最理想的选择,因为每个滚动事件都会导致系统重新评估整个可组合项内容,还会导致系统进行测量、布局,最后再进行绘制。即使要显示的内容未发生更改,并且只有显示位置发生更改,我们也会在每次滚动时触发 Compose 阶段。我们可以优化状态读取,以便仅重新触发布局阶段。

我们还可以使用另一个版本的偏移修饰符:Modifier.offset(offset: Density.() -> IntOffset)。该版本接受 lambda 参数,此时,生成的偏移会通过 lambda 块返回。接下来,我们要更新代码以使用该版本:

Box {
    
    
    val listState = rememberLazyListState()

    Image(
        Modifier.offset {
    
    
            // State read of firstVisibleItemScrollOffset in Layout
            IntOffset(x = 0, y = listState.firstVisibleItemScrollOffset / 2)
        }
    )

    LazyColumn(state = listState)
}

那么,为什么该版本的效果更好呢?系统会在布局阶段(具体来说,是在布局阶段的放置步骤中)调用我们为修饰符提供的 lambda 块,这意味着,在组合过程中,系统将不再读取 firstVisibleItemScrollOffset 状态。由于 Compose 会跟踪读取状态的时间,因此,这项更改意味着如果 firstVisibleItemScrollOffset 值发生更改,Compose 只需重启布局和绘制阶段即可。

总体思路就是:尝试将状态读取定位到尽可能靠后的阶段,从而尽可能降低 Compose 需要执行的工作量。

三、重组循环(循环阶段依赖项)

之前我们提到,系统始终按照相同的顺序来调用 Compose 的各个阶段,并且无法在同一帧中后退。不过,这并未禁止应用跨不同的帧进入组合循环。请思考以下示例:

Box {
    
    
    var imageHeightPx by remember {
    
     mutableStateOf(0) }

    Image(
        painter = painterResource(R.drawable.rectangle),
        contentDescription = "I'm above the text",
        modifier = Modifier
            .fillMaxWidth()
            .onSizeChanged {
    
     size ->
                // Don't do this
                imageHeightPx = size.height
            }
    )

    Text(
        text = "I'm below the image",
        modifier = Modifier.padding(
            top = with(LocalDensity.current) {
    
     imageHeightPx.toDp() }
        )
    )
}

在本例中,我们实现了一个垂直列(并不理想),其顶部是图片,而图片下方则是文本。我们要使用 Modifier.onSizeChanged() 来获取图片的解析大小,然后对文本使用 Modifier.padding() 以将其下移。从 Px 转换回 Dp 的过程很不自然,这就说明代码存在一些问题。

该示例的问题在于,我们没有在单个帧中达到“最终”布局。该代码依赖发生多个帧,它会执行不必要的工作,并导致界面在用户屏幕上跳动。

接下来,我们要逐一检查每个帧,看看发生了什么:

在第一帧的组合阶段,imageHeightPx 的值为 0,并且文本是按照 Modifier.padding(top = 0) 提供的。接着,布局阶段紧随其后,并且系统调用 onSizeChanged 修饰符的回调。此时,imageHeightPx 更新为了图片的实际高度。Compose 为下一帧安排重组。在绘制阶段,由于值发生的更改尚未得到反映,因此,系统渲染文本时将内边距设为了 0。

然后,Compose 启动根据 imageHeightPx 的值发生的更改安排的第二帧。系统在 Box 内容块中读取状态,并在组合阶段调用该状态。此时,系统提供文本时会采用与图片高度相匹配的内边距。在布局阶段,代码确实会再次设置 imageHeightPx 的值,但不会安排重组,因为该值会保持不变。

最终,我们会在文本上实现所需的内边距,但这并不是最理想的情况,因为我们还要使用一个额外的帧将内边距值传递回其他阶段,而这会导致产生一个内容重叠的帧。

在这里插入图片描述
该示例可能显得有些刻意,但请注意以下通用模式:

  • Modifier.onSizeChanged()、onGloballyPositioned() 或一些其他布局操作
  • 更新某种状态
  • 使用该状态作为对布局修饰符(padding()、height() 或类似元素)的输入
  • 可能会重复

若要修复以上示例,您可以使用适当的布局基元。以上示例可以使用一个简单的 Column() 来实现,但您可能会遇到需要进行自定义的更复杂的示例,为此,您需要编写自定义布局。如需了解详情,请参阅自定义布局指南。

一般原则是,对于应该以彼此相对的方式进行测量和放置的多个界面元素,我们要提供单一的可信来源。使用适当的布局基元或创建自定义布局意味着将最小的通用父级用作可以协调多个元素之间关系的可信来源。引入动态状态就违反了这一原则。

四、架构分层

Jetpack Compose 不是一个单体式项目;它由一些模块构建而成,这些模块组合在一起,构成了一个完整的堆栈。通过了解组成 Jetpack Compose 的不同模块,您可以:

  • 使用适当的抽象级别来构建应用或库
  • 了解何时可以“降级”到较低级别,以获取更多的控制权或更高的自定义程度
  • 尽可能减少依赖项

Jetpack Compose 的主要层包括:

在这里插入图片描述
每一层都是基于较低的级别构建的,同时通过组合功能来创建更高级别的组件。

  • 运行时:此模块提供了 Compose 运行时的基本组件,例如 remember、mutableStateOf、@Composable 注解和 SideEffect。如果您只需要 Compose 的树管理功能,而不需要其界面,则可以考虑直接基于此层进行构建。
  • 界面:界面层由多个模块(ui-text、ui-graphics 和 ui-tooling 等)组成。这些模块实现了界面工具包的基本组件,例如 LayoutNode、Modifier、输入处理程序、自定义布局和绘图。如果您只需要用到界面工具包的基本概念,则可以考虑基于此-层进行构建。
  • 基础:此模块为 Compose 界面提供了与系统无关的构建块,例如 Row 和 Column、LazyColumn、特定手势的识别等。您可以考虑基于基础层构建自己的设计系统。
  • Material:此模块为 Compose 界面提供了 Material Design 系统的实现,同时提供了一个主题系统以及若干样式化组件、涟漪效果指示元素和图标。在您的应用中使用 Material Design 时,不妨基于此层进行构建。

五、设计原则

提供可以组合在一起的重点突出的小块功能片段,而不是几个单体式组件。这种方法有许多优点。

5.1 控制

更高级别的组件往往能完成更多操作,但会限制您有多少直接控制权。如果您需要更多控制权,可以通过“降级”使用较低级别的组件。

例如,如果您想为某个组件的颜色添加动画效果,可以使用 animateColorAsState API:

val color = animateColorAsState(if (condition) Color.Green else Color.Red)

不过,如果您之后需要这个组件始终从灰色开始,此 API 就无能为力了。您可以通过“降级”改用较低级别的 Animatable API:

val color = remember {
    
     Animatable(Color.Gray) }
LaunchedEffect(condition) {
    
    
    color.animateTo(if (condition) Color.Green else Color.Red)
}

较高级别的 animateColorAsState API 本身基于较低级别的 Animatable API 构建而成。使用较低级别的 API 的过程更为复杂,但可提供更多的控制权。请选择最符合您需求的抽象化级别。

5.2 自定义

通过将较小的构建块组合成更高级别的组件,按需自定义组件的难度要小的多。例如,可以考虑使用 Material 层提供的 Button 的实现:

@Composable
fun Button(
    // …
    content: @Composable RowScope.() -> Unit
) {
    
    
    Surface(/* … */) {
    
    
        CompositionLocalProvider(/* … */) {
    
     // set LocalContentAlpha
            ProvideTextStyle(MaterialTheme.typography.button) {
    
    
                Row(
                    // …
                    content = content
                )
            }
        }
    }
}

Button 由 4 个组件组合而成:

  • Material Surface:用于提供背景、形状和点击处理方式等。
  • CompositionLocalProvider:用于在启用或停用相应按钮时更改内容的 alpha 值
  • ProvideTextStyle:用于设置要使用的默认文本样式
  • Row:用于为相应按钮的内容提供默认布局政策

为了使结构更加清晰,我们省略了一些参数和注释,但整个组件只有 40 行左右的代码,因为它只是组合了这 4 个组件来实现该按钮。Button 等组件会自行判断它们需要公开哪些参数,同时在实现常见的自定义项和可能使组件更难使用的参数突增之间创造平衡。例如,Material 组件可提供 Material Design 系统中指定的自定义项,这样可以轻松遵循 Material Design 原则。

不过,如果您希望在组件的参数之外进行自定义,可以“降级”到某个级别并为组件创建分支。例如,Material Design 指定按钮应具有纯色背景。如果您需要渐变背景,Button 参数就不适用了,因为它不支持此选项。在此类情况下,您可以将 Material Button 实现作为参考,并构建您自己的组件:

@Composable
fun GradientButton(
    // …
    background: List<Color>,
    content: @Composable RowScope.() -> Unit
) {
    
    
    Row(
        // …
        modifier = modifier
            .clickable(/* … */)
            .background(
                Brush.horizontalGradient(background)
            )
    ) {
    
    
        CompositionLocalProvider(/* … */) {
    
     // set material LocalContentAlpha
            ProvideTextStyle(MaterialTheme.typography.button) {
    
    
                content()
            }
        }
    }
}

如果您根本就不想使用 Material 概念,例如,在构建自己的定制设计系统时,可以降级为仅使用基础层组件:

@Composable
fun BespokeButton(
    // …
    content: @Composable RowScope.() -> Unit
) {
    
    
    Row(
        // …
        modifier = modifier
            .clickable(/* … */)
            .background(/* … */)
    ) {
    
    
        // No Material components used
        content()
    }
}

六、性能最佳实践

6.1 使用 remember 尽可能降低计算开销

可组合函数可能会非常频繁地运行,可能针对动画中的每一帧都运行一次。因此,您应当在可组合函数的主体部分中尽可能减少计算。

一种重要的技巧是使用 remember 来存储计算结果。这样一来,计算就只会运行一次,而且可以根据需要随时获取结果。

例如,以下这段代码显示了一个经过排序的名称列表,但其排序算法的性能开销非常高:

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    
    
    LazyColumn(modifier) {
    
    
        // DON’T DO THIS
        items(contacts.sortedWith(comparator)) {
    
     contact ->
            // ...
        }
    }
}

其问题在于,每次重组 ContactsList 时都会重新对整个联系人列表进行排序,即使该列表并没有发生变化。如果用户滚动列表,则每当出现新行时,可组合函数都会执行重组。

如需解决此问题,请在 LazyColumn 外部对列表进行排序,并使用 remember 存储已排序列表:

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    
    
    val sortedContacts = remember(contacts, sortComparator) {
    
    
        contacts.sortedWith(sortComparator)
    }

    LazyColumn(modifier) {
    
    
        items(sortedContacts) {
    
    
          // ...
        }
    }
}

现在,列表只会在 ContactList 首次组合时执行一次排序。如果联系人或比较器发生变化,则系统会重新生成经过排序的列表。否则,可组合函数会继续使用缓存中的已排序列表。

6.2 使用延迟布局key

延迟布局会尽可能智能化地重用各个项,即仅在必要时才会重新生成或重组这些项。但是,您可以帮助其做出最佳决策。

假设某项用户操作会导致项在列表中移动。例如,假设您显示一个按修改时间排序的备注列表,最近修改的备注位于最顶部。

@Composable
fun NotesList(notes: List<Note>) {
    
    
    LazyColumn {
    
    
        items(
            items = notes
        ) {
    
     note ->
            NoteRow(note)
        }
    }
}

但这段代码有一个问题。假设底部的备注发生了更改。它现在是最近修改过的备注,因此会移到列表顶部,而其他备注都会向下移动一个位置。

问题在于,如果您未提供帮助,则 Compose 并不会意识到未更改的项只在列表中发生了移动。Compose 会认为旧的“项 2”已被删除,并且创建了一个新的“项 2”;对于项 3、项 4 一直到最后一个项,都依此类推。其结果是,Compose 会重组列表中的每一个项,即使其中只有一个项实际发生了更改。

此处的解决方案是提供项key。为每一个项提供一个稳定的键可确保 Compose 避免不必要的重组。在这种情况下,Compose 可以看到现在位于位置 3 的项与之前位置 2 上的项是相同的。由于该项数据并未发生任何更改,因此 Compose 无需重组此项数据。

@Composable
fun NotesList(notes: List<Note>) {
    
    
    LazyColumn {
    
    
        items(
            items = notes,
             key = {
    
     note ->
                // Return a stable, unique key for the note
                note.id
            }
        ) {
    
     note ->
            NoteRow(note)
        }
    }
}

6.3 使用 derivedStateOf 限制重组

在组合中使用状态的一项风险就是,如果状态快速变化,则界面的重组次数可能会超出您的实际需求。例如,假设您要显示一个可滚动列表。您可以检查列表状态并确定列表中的哪一项是第一个可见项:

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    
    
    // ...
}

val showButton = listState.firstVisibleItemIndex > 0

AnimatedVisibility(visible = showButton) {
    
    
    ScrollToTopButton()
}

问题在于,当用户滚动列表时,listState 会随着用户拖动手指而不断变化。这意味着该列表会不断重组。但是,您实际上并不需要如此频繁地进行重组 - 在底部显示新项之前,并不需要重组。因此,这将完成大量的额外计算,从而导致界面性能较差。

解决方案是使用派生状态。借助派生状态,您可以告知 Compose 哪些状态更改应当实际触发重组。针对本例,请指定需要跟踪第一个可见项发生更改的时间。当该状态值发生更改时,界面需要重组。而如果用户尚未滚动到新项,则不需要重组。

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    
    
  // ...
  }

val showButton by remember {
    
    
    derivedStateOf {
    
    
        listState.firstVisibleItemIndex > 0
    }
}

AnimatedVisibility(visible = showButton) {
    
    
    ScrollToTopButton()
}

6.4 尽可能延后读取 state

应当尽可能延后读取状态变量。延后读取状态有助于确保 Compose 在重组时重新运行尽可能少的代码。例如,如果界面的状态在可组合项树中向上提升,而您在可组合子项中读取状态,则可以将状态封装在 lambda 函数中。这种方式可以确保仅在实际需要时才会执行读取操作。您可以了解我们如何将此方法应用于 Jetsnack 示例应用。Jetsnack 在其详情屏幕中实现了类似于工具栏的折叠效果。 如需了解此技术的工作原理,请参阅博文:调试重组。

为了实现这种效果,Title 可组合项需要知晓滚动偏移,以便于使用 Modifier 实现自行偏移。下面列出了未进行优化的简化版 Jetnack 代码:

@Composable
fun SnackDetail() {
    
    
    // ...

    Box(Modifier.fillMaxSize()) {
    
     // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack, scroll.value)
        // ...
    } // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scroll: Int) {
    
    
    // ...
    val offset = with(LocalDensity.current) {
    
     scroll.toDp() }

    Column(
        modifier = Modifier
            .offset(y = offset)
    ) {
    
    
        // ...
    }
}

当滚动状态发生更改时,Compose 会查找最近的父项重组范围并使其失效。在本例中,最近的父项是 Box 可组合项。因此,Compose 会对 Box 进行重组,还会对 Box 中的所有可组合项进行重组。如果您将代码更改为仅在实际使用时才读取状态,则可以减少需要重组的元素数量,代码如下:

@Composable
fun SnackDetail() {
    
    
    // ...

    Box(Modifier.fillMaxSize()) {
    
     // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack) {
    
     scroll.value }
        // ...
    } // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    
    
    // ...
    val offset = with(LocalDensity.current) {
    
     scrollProvider().toDp() }
    Column(
        modifier = Modifier
            .offset(y = offset)
    ) {
    
    
    // ...
    }
}

滚动参数现在是一个 lambda。这意味着 Title 仍然可以引用提升的状态,但该值仅在 Title 内部读取,这也是实际需要的。因此,当滚动值发生更改时,最近的重组范围现在是 Title 可组合项 - Compose 不再需要重组整个 Box。

这是一项非常重大的改进,但是您还可以做得更好!如果您触发重组只是为了重新布局或重新绘制可组合项,那么您肯定会充满了疑惑。在本例中,您只是更改了 Title 可组合项的偏移量,而此操作可以在布局阶段完成。

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    
    
    // ...
    Column(
        modifier = Modifier
            .offset {
    
     IntOffset(x = 0, y = scrollProvider()) }
    ) {
    
    
      // ...
    }
}

之前,代码使用 Modifier.offset(x: Dp, y: Dp) 接受偏移量作为参数。通过切换为 lambda 版本的修饰符,您可以确保函数在布局阶段读取滚动状态。因此,当滚动状态发生变化时,Compose 可以完全跳过组合阶段,而直接进入布局阶段。当您将频繁更改的状态变量传递到修饰符中时,应当尽可能使用其 lambda 版本。

下例中,通过 lambda 表达式,可以仅在绘制阶段读color,跳过composition 阶段和 layout 阶段,代码如下:

// 优化前:每个阶段都会读color
val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(Modifier.fillMaxSize().background(color))

// 优化后:仅绘制阶段才会读color
val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(
   Modifier
      .fillMaxSize()
      .drawBehind {
    
    
         drawRect(color)
      }
)

6.5 避免向后写入

Compose 有一项核心假设,即您永远不会向已被读取的状态写入数据。此操作被称为向后写入,它可能会导致无限次地在每一帧上进行重组。

以下可组合项展示了此类错误的示例。

@Composable
fun BadComposable() {
    
    
    var count by remember {
    
     mutableStateOf(0) }

    // Causes recomposition on click
    Button(onClick = {
    
     count++ }, Modifier.wrapContentSize()) {
    
    
        Text("Recompose")
    }

    Text("$count")
    count++ // Backwards write, writing to state after it has been read
}

此代码会可组合项末尾处更新计数,也就是上面代码中读取计数后。如果运行此代码,您会看到点击按钮会触发 Compose 执行重组,随后计数器会进入无限增长的循环,状态读取会过期。因此,Compose 会再次安排重组。

您完全可以避免向后写入数据,只需避免在组合中写入状态即可。请尽可能在响应事件时写入状态,并采用 lambda 的形式,如上文中的 onClick 示例所示。

猜你喜欢

转载自blog.csdn.net/jiaoyangwm/article/details/127177960