在 Compose 中思考

Jetpack Compose 是适用于 Android 的现代声明式界面工具包。Compose 提供了一个声明式 API,让您无需命令式地修改前端视图即可渲染应用界面,从而更轻松地编写和维护应用界面。此术语需要一些解释,但其影响对您的应用设计至关重要。

声明式编程范式

从历史上看,Android 视图层次结构可以表示为界面微件树。当应用状态因用户互动等原因发生变化时,需要更新界面层次结构以显示当前数据。更新界面的最常用方法是使用 findViewById() 等函数遍历树,并通过调用 button.setText(String)container.addChild(View)img.setImageBitmap(Bitmap) 等方法来更改节点。这些方法会更改微件的内部状态。

手动操作视图会增加出错的可能性。如果一段数据在多个位置渲染,就很容易忘记更新显示该数据的一个视图。当两次更新以意想不到的方式冲突时,也很容易创建非法状态。例如,一次更新可能尝试设置刚从界面中移除的节点的值。通常,软件维护的复杂性会随着需要更新的视图数量而增长。

在过去几年中,整个行业已开始转向声明式界面模型,这极大地简化了构建和更新用户界面所涉及的工程。该技术的工作原理是概念性地从头开始重新生成整个屏幕,然后仅应用必要的更改。这种方法避免了手动更新有状态视图层次结构的复杂性。Compose 就是一个声明式界面框架。

重新生成整个屏幕的一个挑战是,它在时间、计算能力和电池使用方面可能代价高昂。为了减轻这种成本,Compose 会智能地选择在任何给定时间需要重绘的界面部分。这确实对您设计界面组件的方式产生了一些影响,如重新组合中所述。

一个简单的可组合函数

使用 Compose,您可以通过定义一组接受数据并发出界面元素的可组合函数来构建用户界面。一个简单的示例是 Greeting 微件,它接受一个 String 并发出一个显示问候消息的 Text 微件。

A screenshot of a phone showing the text "Hello World", and the code for the
simple Composable function that generates that
UI

图 1. 一个简单的可组合函数,它被传递数据并使用该数据在屏幕上渲染一个文本微件。

关于此函数的几个值得注意的地方

  • 此函数使用 @Composable 注解进行注解。所有可组合函数都必须具有此注解;此注解通知 Compose 编译器此函数旨在将数据转换为界面。

  • 该函数接收数据。可组合函数可以接受参数,这允许应用逻辑描述界面。在此示例中,我们的微件接受一个 String,以便可以通过名称问候用户。

  • 该函数在界面中显示文本。它通过调用 Text() 可组合函数来做到这一点,该函数实际创建文本界面元素。可组合函数通过调用其他可组合函数来发出界面层次结构。

  • 该函数不返回任何内容。发出界面的 Compose 函数不需要返回任何内容,因为它们描述的是所需的屏幕状态,而不是构建界面微件。

  • 此函数速度快、幂等,并且没有附带效应

    • 当使用相同参数多次调用该函数时,其行为方式相同,并且它不使用其他值,例如全局变量或对 random() 的调用。
    • 该函数描述界面,不带任何附带效应,例如修改属性或全局变量。

    通常,所有可组合函数都应使用这些属性编写,原因如重新组合中所述。

声明式范式转变

使用许多命令式面向对象界面工具包时,您可以通过实例化微件树来初始化界面。您通常通过膨胀 XML 布局文件来执行此操作。每个微件都维护自己的内部状态,并公开允许应用逻辑与微件交互的 getter 和 setter 方法。

在 Compose 的声明式方法中,微件相对无状态,不公开 setter 或 getter 函数。实际上,微件不作为对象公开。您通过使用不同参数调用相同的可组合函数来更新界面。这使得将状态提供给架构模式(例如 ViewModel)变得容易,如应用架构指南中所述。然后,每次可观察数据更新时,您的可组合项都负责将当前应用状态转换为界面。

Illustration of the flow of data in a Compose UI, from high-level objects down
to their
children.

图 2. 应用逻辑向顶级可组合函数提供数据。该函数使用数据通过调用其他可组合项来描述界面,并将适当的数据传递给这些可组合项,并向下传递到层次结构中。

当用户与界面互动时,界面会引发 onClick 等事件。这些事件应通知应用逻辑,然后应用逻辑可以更改应用的状态。当状态更改时,可组合函数会再次使用新数据调用。这会导致界面元素重新绘制——此过程称为重新组合

Illustration of how UI elements respond to interaction, by triggering events
that are handled by the app
logic.

图 3. 用户与界面元素互动,导致事件被触发。应用逻辑响应事件,然后可组合函数会根据需要自动使用新参数再次调用。

动态内容

因为可组合函数是用 Kotlin 而不是 XML 编写的,所以它们可以像任何其他 Kotlin 代码一样动态。例如,假设您要构建一个问候用户列表的界面

@Composable
fun Greeting(names: List<String>) {
    for (name in names) {
        Text("Hello $name")
    }
}

此函数接受一个名称列表,并为每个用户生成问候语。可组合函数可以非常复杂。您可以使用 if 语句来决定是否显示特定的界面元素。您可以使用循环。您可以调用辅助函数。您拥有底层语言的全部灵活性。这种能力和灵活性是 Jetpack Compose 的主要优势之一。

重新组合

在命令式界面模型中,要更改微件,您需要调用微件上的 setter 以更改其内部状态。在 Compose 中,您需要使用新数据再次调用可组合函数。这样做会导致函数被重新组合——如果需要,函数发出的微件会使用新数据重新绘制。Compose 框架可以智能地仅重新组合已更改的组件。

例如,考虑这个显示按钮的可组合函数

@Composable
fun ClickCounter(clicks: Int, onClick: () -> Unit) {
    Button(onClick = onClick) {
        Text("I've been clicked $clicks times")
    }
}

每次点击按钮时,调用者都会更新 clicks 的值。Compose 会再次使用 Text 函数调用 lambda 以显示新值;此过程称为重新组合。不依赖于该值的其他函数不会重新组合。

如我们所讨论的,重新组合整个界面树在计算上可能代价高昂,这会消耗计算能力和电池续航。Compose 通过这种智能重新组合解决了这个问题。

重新组合是当输入更改时再次调用可组合函数的过程。当函数的输入更改时会发生这种情况。当 Compose 根据新输入进行重新组合时,它只会调用可能已更改的函数或 lambda,并跳过其余部分。通过跳过所有未更改参数的函数或 lambda,Compose 可以高效地重新组合。

切勿依赖执行可组合函数所产生的附带效应,因为函数的重新组合可能会被跳过。如果这样做,用户可能会在您的应用中遇到奇怪且不可预测的行为。附带效应是您的应用其余部分可见的任何更改。例如,这些操作都是危险的附带效应

  • 写入共享对象的属性
  • 更新 ViewModel 中的可观察对象
  • 更新共享偏好设置

可组合函数可能会像动画渲染时一样频繁地重新执行。可组合函数应快速执行,以避免动画期间的卡顿。如果您需要执行耗时操作,例如从共享偏好设置中读取,请在后台协程中执行,并将值结果作为参数传递给可组合函数。

例如,此代码创建一个可组合项来更新 SharedPreferences 中的值。该可组合项本身不应从共享偏好设置中读取或写入。相反,此代码将读取和写入操作移动到后台协程中的 ViewModel 中。应用逻辑使用回调传递当前值以触发更新。

@Composable
fun SharedPrefsToggle(
    text: String,
    value: Boolean,
    onValueChanged: (Boolean) -> Unit
) {
    Row {
        Text(text)
        Checkbox(checked = value, onCheckedChange = onValueChanged)
    }
}

本文档讨论了使用 Compose 时需要注意的许多事项

  • 重新组合会跳过尽可能多的可组合函数和 lambda。
  • 重新组合是乐观的,并且可能会被取消。
  • 可组合函数可能会非常频繁地运行,甚至像动画的每一帧一样频繁。
  • 可组合函数可以并行执行。
  • 可组合函数可以按任意顺序执行。

以下部分将介绍如何构建可组合函数以支持重新组合。在任何情况下,最佳实践是使您的可组合函数快速、幂等且无附带效应。

重新组合跳过尽可能多的部分

当您的界面部分无效时,Compose 会尽力仅重新组合需要更新的部分。这意味着它可能会跳过重新运行单个 Button 的可组合项,而无需执行界面树中其上方或下方的任何可组合项。

每个可组合函数和 lambda 都可能自行重新组合。以下是一个示例,演示了在渲染列表时重新组合如何跳过某些元素

/**
 * Display a list of names the user can click with a header
 */
@Composable
fun NamePicker(
    header: String,
    names: List<String>,
    onNameClicked: (String) -> Unit
) {
    Column {
        // this will recompose when [header] changes, but not when [names] changes
        Text(header, style = MaterialTheme.typography.bodyLarge)
        HorizontalDivider()

        // LazyColumn is the Compose version of a RecyclerView.
        // The lambda passed to items() is similar to a RecyclerView.ViewHolder.
        LazyColumn {
            items(names) { name ->
                // When an item's [name] updates, the adapter for that item
                // will recompose. This will not recompose when [header] changes
                NamePickerItem(name, onNameClicked)
            }
        }
    }
}

/**
 * Display a single name the user can click.
 */
@Composable
private fun NamePickerItem(name: String, onClicked: (String) -> Unit) {
    Text(name, Modifier.clickable(onClick = { onClicked(name) }))
}

这些作用域中的每一个可能都是在重新组合期间唯一执行的内容。当 header 更改时,Compose 可能会跳到 Column lambda,而无需执行其任何父级。当执行 Column 时,如果 names 没有更改,Compose 可能会选择跳过 LazyColumn 的项。

再次强调,执行所有可组合函数或 lambda 都应无附带效应。当您需要执行附带效应时,请从回调中触发它。

重新组合是乐观的

当 Compose 认为可组合项的参数可能已更改时,重新组合就会开始。重新组合是乐观的,这意味着 Compose 预计在参数再次更改之前完成重新组合。如果参数在重新组合完成之前确实发生了更改,Compose 可能会取消重新组合并使用新参数重新启动它。

当重新组合被取消时,Compose 会丢弃重新组合中的界面树。如果您有任何依赖于界面显示的附带效应,即使组合被取消,该附带效应仍会应用。这可能导致应用状态不一致。

确保所有可组合函数和 lambda 都是幂等且无附带效应的,以处理乐观的重新组合。

可组合函数可能会运行得非常频繁

在某些情况下,可组合函数可能会在界面动画的每一帧都运行。如果函数执行耗时操作(例如从设备存储中读取),则该函数可能会导致界面卡顿。

例如,如果您的微件尝试读取设备设置,它可能会每秒读取这些设置数百次,对您应用的性能造成灾难性影响。

如果您的可组合函数需要数据,它应该为数据定义参数。然后,您可以将耗时的工作移到另一个线程,在组合之外,并使用 mutableStateOfLiveData 将数据传递给 Compose。

注意: 可组合函数目前无法并行运行,但您应该以多线程方式编写 Compose 代码。将来,Compose 可能会支持多线程。

这将使 Compose 能够利用多核优势,并以较低优先级运行不在屏幕上的可组合函数。

这种优化意味着可组合函数可能会在后台线程池中执行。如果可组合函数调用 ViewModel 上的函数,Compose 可能会同时从多个线程调用该函数。

为确保您的应用行为正确,所有可组合函数都不应有附带效应。相反,请从始终在界面线程上执行的回调(例如 onClick)触发附带效应。

当调用可组合函数时,该调用可能发生在与调用方不同的线程上。这意味着应避免在可组合 lambda 中修改变量的代码——既因为此类代码不是线程安全的,也因为它是可组合 lambda 的不允许的附带效应。

以下是一个示例,展示了一个显示列表及其计数的组合项

@Composable
fun ListComposable(myList: List<String>) {
    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Text("Item: $item")
            }
        }
        Text("Count: ${myList.size}")
    }
}

此代码无附带效应,并将输入列表转换为界面。这是显示小列表的好代码。但是,如果函数写入局部变量,此代码将不是线程安全的或不正确的

@Composable
fun ListWithBug(myList: List<String>) {
    var items = 0

    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Card {
                    Text("Item: $item")
                    items++ // Avoid! Side-effect of the column recomposing.
                }
            }
        }
        Text("Count: $items")
    }
}

在此示例中,items 会在每次重新组合时被修改。这可能是动画的每一帧,或者当列表更新时。无论哪种情况,界面都会显示错误的计数。因此,Compose 不支持此类写入;通过禁止这些写入,我们允许框架更改线程以执行可组合 lambda。

可组合函数可以按任意顺序执行

如果您查看可组合函数的代码,您可能会认为代码是按其出现的顺序运行的。但这不是保证正确的。如果可组合函数包含对其他可组合函数的调用,则这些函数可能以任意顺序运行。Compose 可以选择识别某些界面元素的优先级高于其他元素,并首先绘制它们。

例如,假设您有这样的代码,用于在标签布局中绘制三个屏幕

@Composable
fun ButtonRow() {
    MyFancyNavigation {
        StartScreen()
        MiddleScreen()
        EndScreen()
    }
}

StartScreenMiddleScreenEndScreen 的调用可能以任意顺序发生。这意味着您不能,例如,让 StartScreen() 设置一些全局变量(附带效应)并让 MiddleScreen() 利用该更改。相反,这些函数中的每一个都需要是自包含的。

了解详情

要了解更多关于如何在 Compose 和可组合函数中思考的信息,请查看以下附加资源。

视频