Compose 思考

Jetpack Compose 是 Android 的现代声明式 UI 工具包。Compose 通过提供声明式 API 使编写和维护您的应用程序 UI 变得更加容易,该 API 允许您渲染应用程序 UI 而无需强制性地修改前端视图。此术语需要一些解释,但对您的应用程序设计的影响非常重要。

声明式编程范式

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

手动操作视图会增加出错的可能性。如果一个数据片段在多个地方渲染,很容易忘记更新显示它的其中一个视图。也很容易创建非法状态,当两个更新以意外的方式发生冲突时。例如,更新可能会尝试设置一个刚刚从 UI 中删除的节点的值。通常,软件维护复杂度会随着需要更新的视图数量而增加。

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

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

一个简单的可组合函数

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

A screenshot of a phone showing the text

图 1. 一个简单的可组合函数,它接收数据并使用它在屏幕上渲染一个文本小部件。

关于此函数的一些值得注意的事情

  • 该函数用@Composable注释。所有可组合函数都必须具有此注释;此注释通知 Compose 编译器此函数旨在将数据转换为 UI。

  • 该函数接收数据。可组合函数可以接受参数,这些参数允许应用程序逻辑描述 UI。在本例中,我们的 widget 接受一个String,以便它可以按姓名向用户问好。

  • 该函数在 UI 中显示文本。它通过调用Text()可组合函数来实现,该函数实际上创建了文本 UI 元素。可组合函数通过调用其他可组合函数来发出 UI 层次结构。

  • 该函数不返回值。发出 UI 的 Compose 函数不需要返回值,因为它们描述了所需的屏幕状态,而不是构造 UI 小部件。

  • 此函数速度快,幂等,并且没有副作用

    • 该函数在多次使用相同参数调用时表现相同,并且不使用其他值,例如全局变量或对random()的调用。
    • 该函数描述 UI 没有任何副作用,例如修改属性或全局变量。

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

声明式范式转变

在许多强制性面向对象的 UI 工具包中,您通过实例化小部件树来初始化 UI。您通常通过膨胀 XML 布局文件来实现这一点。每个小部件都维护自己的内部状态,并公开 getter 和 setter 方法,允许应用程序逻辑与小部件交互。

在 Compose 的声明式方法中,小部件是相对无状态的,并且不公开 setter 或 getter 函数。事实上,小部件不被公开为对象。您通过使用不同的参数调用相同的可组合函数来更新 UI。这使得将状态提供给ViewModel等架构模式变得容易,如应用程序架构指南中所述。然后,您的可组合项负责在可观察数据每次更新时将当前应用程序状态转换为 UI。

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

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

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

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

图 3. 用户与 UI 元素交互,导致事件触发。应用程序逻辑对事件做出响应,然后可组合函数会在必要时使用新参数自动再次调用。

动态内容

由于可组合函数是用 Kotlin 而不是 XML 编写的,因此它们可以像任何其他 Kotlin 代码一样动态。例如,假设您想构建一个向用户列表问好的 UI

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

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

重新组合

在强制性 UI 模型中,要更改小部件,您需要调用小部件上的 setter 来更改其内部状态。在 Compose 中,您需要使用新数据再次调用可组合函数。这样做会导致函数重新组合——函数发出的的小部件会根据需要使用新数据重新绘制。Compose 框架可以智能地仅重新组合已更改的组件。

例如,考虑以下可组合函数,它显示一个按钮

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

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

正如我们所讨论的,重新组合整个 UI 树在计算上可能是昂贵的,这会使用计算能力和电池寿命。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 有选择地识别某些 UI 元素比其他元素优先级更高,并先绘制它们。

例如,假设您有以下代码来在选项卡布局中绘制三个屏幕

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

StartScreenMiddleScreenEndScreen的调用可能会以任何顺序发生。这意味着您不能,例如,让StartScreen()设置一些全局变量(副作用)并让MiddleScreen()利用该更改。相反,这些函数中的每一个都需要是独立的。

可组合函数可以并行运行

Compose 可以通过并行运行可组合函数来优化重新组合。这使 Compose 能够利用多个核心,并以较低的优先级运行屏幕上没有的函数。

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

为了确保您的应用程序正常运行,所有可组合函数都应该没有副作用。相反,应该从回调(如 onClick)触发副作用,这些回调始终在 UI 线程上执行。

当调用可组合函数时,调用可能发生在与调用者不同的线程上。这意味着应该避免修改可组合 lambda 中变量的代码,原因有两点:这种代码不是线程安全的,并且它是可组合 lambda 的不可接受的副作用。

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

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

这段代码没有副作用,它将输入列表转换为 UI。对于显示小型列表来说,这是一段很棒的代码。但是,如果该函数写入局部变量,这段代码将不是线程安全的或正确的。

@Composable
@Deprecated("Example with bug")
fun ListWithBug(myList: List<String>) {
    var items = 0

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

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

重新组合尽可能地跳过

当您的 UI 的部分失效时,Compose 会尽力只重新组合需要更新的部分。这意味着它可能跳过重新运行单个按钮的可组合函数,而不会执行 UI 树中该按钮的上方或下方的任何可组合函数。

每个可组合函数和 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)
        Divider()

        // 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 会丢弃来自重新组合的 UI 树。如果您有任何依赖于显示的 UI 的副作用,即使组合被取消,副作用也会被应用。这会导致应用程序状态不一致。

确保所有可组合函数和 lambda 都是幂等的且没有副作用,以便处理乐观的重新组合。

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

在某些情况下,可组合函数可能在 UI 动画的每一帧运行。如果该函数执行昂贵的操作(例如从设备存储读取数据),该函数会导致 UI 卡顿。

例如,如果您的窗口小部件试图读取设备设置,它可能会每秒读取这些设置数百次,这对您的应用程序的性能造成灾难性的影响。

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

了解更多

要详细了解如何在 Compose 中思考以及可组合函数,请查看以下其他资源。

视频