Jetpack-和-Compose-安卓-UI-开发-全-
Jetpack 和 Compose 安卓 UI 开发(全)
原文:
zh.annas-archive.org/md5/e05c96dfd82582fb473d20ec96f6ad3e
译者:飞龙
前言
Jetpack Compose 是 Android 的新框架,用于构建快速、美观且可靠的本地用户界面。它通过声明式方法简化并显著加速了 Android 的 UI 开发。这本书将帮助开发者亲身体验 Jetpack Compose,并采用现代方式构建 Android 应用。本书不是 Android 开发的入门指南,但它将建立在您对 Android 应用开发方式的理解之上。
配备了动手教程和项目,这本易于遵循的指南将帮助您掌握 Jetpack Compose 的基础知识,如状态提升、单向数据流和组合优于继承,并帮助您使用 Compose 构建自己的 Android 应用。您还将涵盖测试、动画以及与现有 Android UI 工具包的互操作性等概念。
在本书结束时,您将能够使用 Jetpack Compose 编写自己的 Android 应用。
本书面向的对象
这本书适合任何希望了解新 Jetpack Compose 框架基础和原生开发优势的移动应用开发者。对 Android 应用开发有扎实的理解,以及一些 Kotlin 编程语言的知识,将会很有帮助。掌握这本书中涵盖的概念需要基本的编程知识。
本书涵盖的内容
第一章,构建您的第一个 Compose 应用,展示了如何构建您的第一个 Compose 应用。同时,还介绍了重要的关键概念,如可组合函数和预览的使用。为了尽早取得成功,我们将在深入细节之前构建、预览并运行可组合函数。
第二章,理解声明式范式,解释了之前是如何做到的,以及旧方法存在的问题。此外,您还将了解可组合函数与视图的不同之处,以及为什么这既重要又有益。
第三章,探索 Compose 的关键原则,介绍了 Jetpack Compose 所依赖的关键原则。这些知识对于编写表现良好的 Compose 应用至关重要。
第四章,布局 UI 元素,向您介绍了一些现有的布局。它还展示了如何实现自定义布局。如果内置布局无法提供所需的 UI 元素在屏幕上的分布,这些布局就是必需的。
第五章,管理您的可组合函数的状态,探讨了 Jetpack Compose 如何使用状态。状态是可能随时间变化的应用数据。可组合函数显示和修改状态。Jetpack Compose 基于一组关于如何使用状态的原则。本章将向您介绍这些原则。
第六章,整合碎片,回顾了之前学到的概念,并将它们整合到一个应用中。在真实代码中看到概念有助于理解它们,并使您在自己的程序中重用它们变得更加容易。
第七章,技巧、窍门和最佳实践,在使用 Compose 时提供了最佳实践。这些将包括诸如持久化和检索状态以及使用所谓的副作用等主题。本章还展示了要避免的事情,例如在可组合函数内部进行重量级计算。
第八章,使用动画,介绍了所有相关的 API。动画和过渡使应用更加出色。Jetpack Compose 在添加动画效果方面极大地简化了旧视图方法的过程。
第九章,探索互操作性 API,讨论了在单个应用中结合声明性和命令式方法的策略,并提供了将现有 UI 无缝更新到 Jetpack Compose 的迁移策略。
第十章,测试和调试 Compose 应用,介绍了 Compose 应用的基本测试场景。测试 Compose 应用的用户界面与测试基于视图的 UI 不同。Compose 使用更声明性的方法进行测试。
第十一章,结论与下一步行动,总结了本书,并指导你尝试下一步的内容。此外,本章还试图猜测 Jetpack Compose 的未来,并探讨邻**台以及如何从你对它们的了解中受益。
要充分利用本书
您至少需要 Android Studio Arctic Fox 或更高版本。要运行示例应用,您还需要配置 Android 模拟器或真实设备。Jetpack Compose 适用于 API 级别 21 或更高的*台。
如果您正在使用本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助您避免与代码复制和粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/Android-UI-Development-with-Jetpack-Compose
。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富的书籍和视频目录的代码包,可在github.com/PacktPublishing/
找到。查看它们!
下载彩色图片
我们还提供了一份包含本书中使用的截图和图表彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781801812160_ColorImages.pdf
。
使用的约定
在本书中使用了多种文本约定。
文本中的代码
:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“如果您已经克隆或下载了本书的仓库,其项目文件夹位于chapter_01
内部。”
代码块设置如下:
@Composable
fun Greeting(name: String) {
Text(
text = stringResource(id = R.string.hello, name),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.subtitle1
)
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
TextField(
value = name.value,
onValueChange = {
name.value = it
},
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“在您输入姓名并点击完成按钮后,您将看到问候信息。”
小贴士或重要注意事项
看起来是这样的。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将非常感激您向我们报告。请访问www.packtpub.com/support/errata并填写表格。
盗版:如果您在互联网上发现我们作品的任何形式的非法副本,我们将非常感激您提供位置地址或网站名称。请通过 copyright@packt.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为本书做出贡献,请访问authors.packtpub.com。
分享您的想法
读完使用 Jetpack Compose 进行 Android UI 开发后,我们非常乐意听到您的想法!请选择 https://www.amazon.in/review/create-review/error?asin=1801812160 为这本书并分享您的反馈。
您的评审对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。
第一部分:Jetpack Compose 的基础知识
在本部分中,您将了解 Jetpack Compose 的重要基本概念。对这些概念的理解对于编写表现良好的 Compose 应用是必要的。
在本节中,我们将涵盖以下章节:
-
第一章, 构建您的第一个 Compose 应用
-
第二章, 理解声明式范式
-
第三章, 探索 Compose 的关键原则
第一章:构建你的第一个 Compose 应用
当安卓系统在 10 多年前推出时,它迅速在开发者中获得了人气,因为它编写应用极其简单。你只需在 XML 文件中定义用户界面(UI)并将其连接到你的activity即可。这工作得非常完美,因为应用体积小,开发者只需要支持少数几款设备。
自那以后,发生了许多变化。
每当新的*台版本推出时,安卓都会获得新的功能。经过多年,设备制造商推出了数千款具有不同屏幕尺寸、像素密度和形态的设备。虽然谷歌尽力保持安卓视图系统的可理解性,但应用的复杂性显著增加;基本任务,如实现滚动列表或动画,需要大量的模板代码。
结果表明,这些问题并不仅限于安卓。其他*台和操作系统也面临着这些问题。大多数问题源于 UI 工具包过去的工作方式;它们遵循所谓的命令式方法(我将在第二章,理解声明式范式中解释)。解决方案是一个范式转变。Web 框架 React 是第一个普及声明式方法的应用。其他*台和框架(例如 Flutter 和 SwiftUI)随后效仿。
Jetpack Compose是谷歌为安卓提供的声明式 UI 框架。它极大地简化了 UI 的创建。在阅读完这本书后,你一定会同意使用 Jetpack Compose 既简单又有趣。但在我们深入之前,请注意 Jetpack Compose 仅支持 Kotlin。这意味着你所有的 Compose 代码都必须用 Kotlin 编写。为了跟随这本书,你应该对 Kotlin 语法和函数式编程模型有基本的了解。如果你想要了解更多关于这些主题的内容,请参考本章末尾的进一步阅读部分。
本章涵盖了三个主要主题:
-
向可组合函数问好
-
使用预览功能
-
运行 Compose 应用
我将解释如何使用 Jetpack Compose 构建一个简单的 UI。接下来,你将学习如何在 Android Studio 中使用预览功能以及如何运行一个 Compose 应用。在本章结束时,你将基本了解可组合函数的工作方式、它们如何集成到你的应用中以及你的项目必须如何配置才能使用 Jetpack Compose。
技术要求
本章的所有代码文件都可以在 GitHub 上找到,网址为 github.com/PacktPublishing/Android-UI-Development-with-Jetpack-Compose/tree/main/chapter_01
。请下载压缩版本或克隆存储库到您计算机上的任意位置。项目至少需要 Android Studio Arctic Fox。您可以在 developer.android.com/studio
下载最新版本。请按照 developer.android.com/studio/install
上的详细安装说明进行操作。
要打开本书的项目,启动 Android Studio,在“欢迎使用 Android Studio”窗口右上角点击打开按钮,并在文件夹选择对话框中选择项目的基目录。请确保不要打开存储库的基目录,因为 Android Studio 不会识别项目。相反,您必须选择包含您想要工作的项目的目录。
要运行示例应用程序,您需要一个真实设备或 Android 模拟器。请确保在真实设备上启用了开发者选项和 USB 调试,并且设备通过 USB 或 WLAN 连接到您的开发机器。请按照 developer.android.com/studio/debug/dev-options
上的说明操作。您还可以设置 Android 模拟器。您可以在 developer.android.com/studio/run/emulator
找到详细说明。
向可组合函数问好
如您很快就会看到的,可组合函数是 Compose 应用程序的基本构建块;这些元素构成了用户界面。
要初步了解它们,我将带您浏览一个名为 chapter_01
的简单应用程序。否则,请现在就这样做。要跟随本节内容,请在 Android Studio 中打开项目并打开 MainActivity.kt
。我们的第一个 Compose 应用程序的使用场景非常简单。在您输入您的名字并点击完成按钮后,您将看到一个问候信息:
![图 1.1 – Hello 应用程序
图 1.1 – Hello 应用程序
从概念上讲,应用程序由以下内容组成:
-
欢迎文本
-
一行包含
EditText
等效元素和按钮 -
一条问候信息
让我们看看如何创建应用程序。
显示欢迎文本
让我们从欢迎文本开始,这是我们的第一个可组合函数:
@Composable
fun Welcome() {
Text(
text = stringResource(id = R.string.welcome),
style = MaterialTheme.typography.subtitle1
)
}
可组合函数可以通过 @Composable
注解轻松识别。它们不需要有特定的返回类型,而是发出 UI 元素。这通常是通过调用其他可组合函数(为了简洁起见,我有时会省略“函数”一词)来完成的。第三章,探索 Compose 的关键原则,将更详细地介绍这一点。
在这个例子中,Welcome()
调用一个文本。Text()
是一个内置的可组合函数,属于 androidx.compose.material
包。
要仅通过其名称调用 Text()
,您需要导入它:
import androidx.compose.material.Text
请注意,您可以使用 *
通配符来保存 import
行。
要使用 Text()
和其他 Material Design 元素,您的 build.gradle
文件必须包含对 androidx.compose.material:material
的实现依赖项。
回顾欢迎文本代码,Welcome()
中的 Text()
可组合函数通过两个参数 text
和 style
进行配置。
第一个,text
,指定将显示什么文本。R.string
可能看起来很熟悉;它指的是 strings.xml
文件中的定义。就像在基于视图的应用程序中一样,您在那里为 UI 元素定义文本。stringResource()
是一个预定义的可组合函数。它属于 androidx.compose.ui.res
包。
style
参数修改文本的视觉外观。在这种情况下,输出将看起来像副标题。我将在 第六章,将部件组合在一起 中向您展示如何创建自己的主题。
下一个可组合函数看起来相当相似。你能找到它们之间的区别吗?
@Composable
fun Greeting(name: String) {
Text(
text = stringResource(id = R.string.hello, name),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.subtitle1
)
}
在这里,stringResource()
接收一个额外的参数。这对于用实际文本替换占位符非常方便。字符串在 strings.xml
中定义,如下所示:
<string name="hello">Hello, %1$s.\nNice to meet you.</string>
textAlign
参数指定文本如何水*定位。在这里,每一行都是居中的。
使用行、文本字段和按钮
接下来,让我们转向文本输入字段(Row()
,它属于 androidx.compose.foundation.layout
包。就像所有可组合函数一样,Row()
可以在括号内接收一个逗号分隔的参数列表,其子项放在花括号内:
@Composable
fun TextAndButton(name: MutableState<String>,
nameEntered: MutableState<Boolean>) {
Row(modifier = Modifier.padding(top = 8.dp)) {
...
}
}
TextAndButton()
需要两个参数,name
和 nameEntered
。您将在 显示问候消息 部分看到它们是如何使用的。现在,请忽略它们的 MutableState
类型。
Row()
接收一个名为 modifier
的参数。修饰符是 Jetpack Compose 中影响可组合函数外观和行为的关键技术。我将在 第三章,探索 Compose 的关键原则 中更详细地解释它们。
padding(top = 8.dp)
表示该行在其顶部将有一个八密度无关像素(.dp
)的填充,从而使其与上面的欢迎信息隔开。
现在,我们将查看文本输入字段,它允许用户输入一个名字:
TextField(
value = name.value,
onValueChange = {
name.value = it
},
placeholder = {
Text(text = stringResource(id = R.string.hint))
},
modifier = Modifier
.alignByBaseline()
.weight(1.0F),
singleLine = true,
keyboardOptions = KeyboardOptions(
autoCorrect = false,
capitalization = KeyboardCapitalization.Words,
),
keyboardActions = KeyboardActions(onAny = {
nameEntered.value = true
})
)
TextField()
属于 androidx.compose.material
包。该可组合函数可以接收相当多的参数;尽管大多数是可选的。请注意,前面的代码片段同时使用了 name
和 nameEntered
参数,这些参数传递给了 TextAndButton()
。它们的类型是 MutableState
。MutableState
对象携带可变值,您可以通过 name.value
或 nameEntered.value
访问它们。
TextField()
可组合组件的 value
参数接收文本输入字段的当前值,例如,已经输入的文字。当文本发生变化时(如果用户输入或删除了某些内容),会调用 onValueChange
。但为什么两个地方都使用了 name.value
?我将在 显示问候消息 部分回答这个问题。
重组
某些类型会触发所谓的重组。现在,你可以将这想象成重新绘制一个相关的可组合组件。MutableState
就是这样一种类型。如果我们改变它的值,TextField()
可组合组件就会被重新绘制或重绘。请注意,这两个术语并不完全准确。我们将在 第三章 中介绍重组,探索 Compose 的关键原则。
让我们简要地看看剩余的代码。使用 alignByBaseline()
,我们可以很好地对齐特定 Row()
中的其他可组合函数的基线。placeholder
包含用户输入内容之前的文本。singleLine
控制用户是否可以输入多行文本。最后,keyboardOptions
和 keyboardActions
描述了屏幕键盘的行为。例如,某些操作会将 nameEntered.value
设置为 true
。我很快就会向你展示我们为什么要这样做。
然而,我们首先需要看看 Button()
可组合组件。它也属于 androidx.compose.material
包:
Button(modifier = Modifier
.alignByBaseline()
.padding(8.dp),
onClick = {
nameEntered.value = true
}) {
Text(text = stringResource(id = R.string.done))
}
一些东西看起来可能已经熟悉了。例如,我们调用 alignByBaseline()
来对齐按钮的基线与文本输入字段,并使用 padding()
在按钮的四周应用八个密度无关像素的填充。现在,onClick()
指定了按钮被点击时要执行的操作。在这里,我们也设置了 nameEntered.value
为 true
。下一个可组合函数 Hello()
最终会向你展示为什么要这样做。
显示问候消息
Hello()
发射 Box()
,它(根据 nameEntered.value
)包含 Greeting()
或一个包含 Welcome()
和 TextAndButton()
的 Column()
可组合组件。Column()
可组合组件与 Row()
很相似,但垂直排列其兄弟组件。像后者和 Box()
一样,它属于 androidx.compose.foundation.layout
包。Box()
可以包含一个或多个子组件。它们根据 contentAlignment
参数在盒内定位。我们将在 第四章 的 组合基本构建块 部分更详细地探讨这一点,布局 UI 元素:
@Composable
fun Hello() {
val name = remember { mutableStateOf("") }
val nameEntered = remember { mutableStateOf(false) }
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
if (nameEntered.value) {
Greeting(name.value)
} else {
Column(horizontalAlignment =
Alignment.CenterHorizontally) {
Welcome()
TextAndButton(name, nameEntered)
}
}
}
}
你注意到 remember
和 mutableStateOf
吗?它们对于创建和维护状态都非常重要。一般来说,应用中的状态指的是随时间可能发生变化的值。虽然这也适用于领域数据(例如,网络服务调用的结果),但状态通常指的是由 UI 元素显示或使用的某些内容。如果一个可组合函数(或依赖于)状态,当该状态发生变化时,它将重新组合(目前,重新绘制或重新绘制)。为了理解这意味着什么,请回忆这个可组合组件:
@Composable
fun Welcome() {
Text(
text = stringResource(id = R.string.welcome),
style = MaterialTheme.typography.subtitle1
)
}
Welcome()
被称为无状态;所有可能触发重新组合的值在目前都保持不变。另一方面,Hello()
是有状态的,因为它使用了 name
和 nameEntered
变量。这些变量会随时间变化。如果你查看 Hello()
的源代码,这可能并不明显。请记住,name
和 nameEntered
都被传递给了 TextAndButton()
并在那里进行了修改。
你还记得在上一节中我承诺解释为什么在两个地方使用了 name.value
,提供要显示的文本并在用户输入某些内容后接收变化吗?这是一个常见的模式,通常与状态一起使用;Hello()
通过调用 mutableStateOf()
和 remember
来创建和记住状态,并将状态传递给另一个可组合组件(TextAndButton()
),这被称为状态提升。你将在第五章中了解更多关于管理你的可组合函数的状态。
到目前为止,你已经看到了很多可组合函数的源代码,但还没有看到它们的输出。Android Studio 有一个非常重要的功能,称为Compose 预览。它允许你在不运行应用的情况下查看可组合函数。在下一节中,我将向你展示如何使用这个功能。
使用预览
Android Studio 代码编辑器的右上角有三个按钮,代码、分割和设计(图 1.2):
图 1.2 – Compose 预览(分割模式)
它们会在以下不同的显示模式之间切换:
-
仅代码
-
代码和预览
-
仅预览
要使用 Compose 预览,你的可组合函数必须包含一个额外的注解,@Preview
,它属于 androidx.compose.ui.tooling.preview
包。这需要在你的 build.gradle
文件中实现依赖 androidx.compose.ui:ui-tooling-preview
。
不幸的是,如果你尝试将 @Preview
添加到 Greeting()
中,你会看到一个类似这样的错误信息:
Composable functions with non-default parameters are not supported in Preview unless they are annotated with @PreviewParameter.
那么,你如何预览接受参数的可组合组件呢?
预览参数
最明显的解决方案是一个包装可组合组件:
@Composable
@Preview
fun GreetingWrapper() {
Greeting("Jetpack Compose")
}
这意味着您编写了另一个不接收任何参数但调用现有函数并提供所需参数(在我的例子中是文本)的复合函数。根据您的源文件中包含的复合函数数量,您可能需要创建相当多的样板代码。这些包装器除了启用预览外,没有增加任何价值。
幸运的是,还有其他选项。例如,您可以为您的复合函数添加默认值:
@Composable
fun AltGreeting(name: String = "Jetpack Compose") {
虽然这样做看起来不那么复杂,但它改变了调用复合函数的方式(即,不传递参数)。如果您最初没有定义默认值的原因,这可能不是您想要的。
使用 @PreviewParameter
,您可以传递仅影响预览的值。不幸的是,这有点冗长,因为您需要编写一个新的类:
class HelloProvider : PreviewParameterProvider<String> {
override val values: Sequence<String>
get() = listOf("PreviewParameterProvider").asSequence()
}
该类必须扩展 androidx.compose.ui.tooling.preview.PreviewParameterProvider
,因为它将为预览提供参数。现在,您可以使用 @PreviewParameter
注解复合函数的参数并传递您的新类:
@Composable
@Preview
fun AltGreeting2(@PreviewParameter(HelloProvider::class)
name: String) {
从某种意义上说,您也在创建样板代码。因此,您最终选择哪种方法取决于个人喜好。@Preview
注解可以接收相当多的参数。它们修改预览的视觉外观。让我们探索其中的一些。
配置预览
您可以使用 backgroundColor =
为预览设置背景颜色。该值是 Long
类型,表示 ARGB 颜色。请确保也将 showBackground
设置为 true
。以下代码片段将生成纯红色背景:
@Preview(showBackground = true, backgroundColor =
0xffff0000)
默认情况下,预览维度是自动选择的。如果您想显式设置它们,可以传递 heightDp
和 widthDp
:
@Composable
@Preview(widthDp = 100, heightDp = 100)
fun Welcome() {
Text(
text = stringResource(id = R.string.welcome),
style = MaterialTheme.typography.subtitle1
)
}
图 1.3 展示了结果。两个值都被解释为密度无关像素,因此您不需要像在复合函数内部那样添加 .dp
。
图 1.3 – 设置预览的宽度和高度
要测试不同的用户区域设置,您可以添加 locale
参数。例如,如果您的应用在 values-de-rDE
中包含德语字符串,您可以通过添加以下内容来使用它们:
@Preview(locale = "de-rDE")
字符串匹配 values-
后的目录名。请记住,如果您在翻译编辑器中添加语言,Android Studio 会创建该目录。
如果您想显示状态栏和操作栏,您可以使用 showSystemUi
来实现这一点:
@Preview(showSystemUi = true)
要了解您的复合函数对不同形态因子、宽高比和像素密度的反应,您可以使用 device
参数。它接受一个字符串。传递 Devices
中的其中一个值,例如,Devices.PIXEL_C
或 Devices.AUTOMOTIVE_1024p
。
在本节中,您已经看到了如何配置预览。接下来,我将向您介绍预览组。如果您的源代码文件包含多个您想要预览的复合函数,它们将非常有用。
预览分组
Android Studio 以源代码中出现的顺序显示带有 @Preview
注解的可组合函数。您可以选择 垂直布局 和 网格布局(图 1.4):
图 1.4 – 在垂直布局和网格布局之间切换
根据您的可组合函数数量,预览窗格可能会在某些时候显得拥挤。如果是这种情况,只需通过添加 group
参数将您的可组合函数放入不同的组中:
@Preview(group = "my-group-1")
您可以显示所有可组合函数或仅显示属于特定组的那些函数(图 1.5):
图 1.5 – 在组之间切换
到目前为止,我已经向您展示了可组合函数的源代码看起来是什么样子,以及您如何在 Android Studio 中预览它们。在下一节中,我们将在一个 Android 模拟器或真实设备上执行一个可组合函数,您将学习如何将可组合函数连接到应用程序的其他部分。但在那之前,这里有一个小贴士:
将预览导出为图片
如果您用鼠标右键单击 Compose 预览,您将看到一个小的弹出菜单。选择 复制图像 将预览的位图放入系统剪贴板。大多数图形应用程序允许您将其粘贴到新文档中。
运行 Compose 应用程序
如果您想查看可组合函数在 Android 模拟器或真实设备上的外观和感觉,您有两个选择:
-
部署可组合函数
-
运行应用程序
如果您想专注于特定的可组合函数而不是整个应用程序,第一个选项很有用。此外,部署一个可组合函数所需的时间可能比部署一个完整的应用程序短得多(取决于应用程序的大小)。所以,让我们从这个开始。
部署可组合函数
要将可组合函数部署到真实设备或 Android 模拟器,请单击右上角的小图像 部署预览 按钮(图 1.6):
图 1.6 – 部署可组合函数
这将自动创建新的启动配置(图 1.7):
图 1.7 – 表示 Compose 预览的启动配置
您可以在 运行/调试配置 对话框中修改或删除 Compose 预览配置。要访问它们,请打开 Compose 预览 节点。然后,例如,您可以更改其名称或通过取消选中 允许并行运行 来阻止并行运行。
本章的目标是在真实设备或 Android 模拟器上部署和运行你的第一个 Compose 应用。你几乎就完成了;在下一节中,我将向你展示如何将组合函数嵌入到活动中,这是先决条件。你最终将在 按下播放按钮 部分运行该应用。
在活动中使用组合函数
活动 自从第一个*台版本以来一直是 Android 应用的基本构建块之一。几乎每个应用至少有一个活动。它们在清单文件中配置。要从主屏幕启动活动,相应的条目看起来像这样:
...
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category
android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
...
这对 Compose 应用仍然成立。一个希望显示组合函数的活动设置起来就像一个填充传统布局文件的活动一样。但它的源代码是什么样的呢?Hello
应用程序的主活动被命名为 MainActivity
,如下一个代码块所示:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Hello()
}
}
}
如你所见,它非常简短。UI(Hello()
组合函数)是通过调用一个名为 setContent
的函数来显示的,这是一个扩展函数,属于 androidx.activity.ComponentActivity
并属于 androidx.activity.compose
包。
要渲染组合,你的活动必须扩展 ComponentActivity
或具有 ComponentActivity
作为其直接或间接祖先的另一个类。这是 androidx.fragment.app.FragmentActivity
和 androidx.appcompat.app.AppCompatActivity
的情况。
这是一个重要的区别;虽然 Compose 应用调用 setContent()
,基于视图的应用调用 setContentView()
并传递布局的 ID(例如 R.layout.activity_main
)或根视图本身(这通常是通过某种绑定机制获得的)。让我们看看旧机制是如何工作的。以下代码片段取自我的一个开源应用(你可以在 GitHub 上找到它 github.com/MATHEMA-GmbH/TKWeek
,但本书将不再进一步讨论):
class TKWeekActivity : TKWeekBaseActivity() {
private var backing: TkweekBinding? = null
private val binding get() = backing!!
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
backing = TkweekBinding.inflate(layoutInflater, null,
false)
setContentView(binding.root)
...
如果你比较这两种方法,一个显著的区别是,在使用 Jetpack Compose 时,不需要维护 UI 组件树或其单个元素的引用。我将在 第二章,“理解声明式范式”中解释为什么这会导致易于维护且错误率较低的代码。
现在让我们回到 setContent()
。它接收两个参数,一个 parent
(可以是 null
)和 content
(UI)。parent
是 androidx.compose.runtime.CompositionContext
的一个实例。它用于在逻辑上将两个组合连接起来。这是一个高级主题,我将在 第三章,“探索 Compose 的关键原则”中进行讨论。
重要提示
您有没有注意到 MainActivity
不包含任何可组合函数?它们不需要成为类的一部分。实际上,您应该尽可能地将它们实现为顶级函数。Jetpack Compose 提供了访问 android.content.Context
的替代方法。您已经看到了 stringResource()
可组合函数,它是 getString()
的替代品。
现在,您已经看到了如何在活动中嵌入可组合函数,是时候看看基于 Jetpack Compose 的项目的结构了。虽然如果您使用项目向导创建 Compose 应用程序,Android Studio 会为您设置一切,但了解底层涉及哪些文件是很重要的。
查看底层
Jetpack Compose 严重依赖于 Kotlin。这意味着您的应用程序项目必须配置为使用 Kotlin。但这并不意味着您不能完全使用 Java。实际上,只要您的可组合函数是用 Kotlin 编写的,您就可以轻松地在项目中混合使用 Kotlin 和 Java。您还可以结合传统视图和可组合项。我将在 第九章 中讨论此主题,探索互操作性 API。
首先,请确保在项目级别的 build.gradle 文件中配置与您的 Android Studio 版本相对应的 Android Gradle 插件:
buildscript {
...
dependencies {
classpath "com.android.tools.build:gradle:7.0.4"
classpath "org.jetbrains.kotlin:kotlin-gradle- plugin:1.5.31"
...
}
}
以下代码片段属于模块级别的 build.gradle 文件:
plugins {
id 'com.android.application'
id 'kotlin-android'
}
接下来,请确保您的应用程序的最小 API 级别设置为 21 或更高,并且已启用 Jetpack Compose。以下代码片段还设置了 Kotlin 编译器插件的版本:
android {
defaultConfig {
...
minSdkVersion 21
}
buildFeatures {
compose true
}
...
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
composeOptions {
kotlinCompilerExtensionVersion compose_version
}
}
最后,声明依赖项。以下代码片段是一个很好的起点。根据您的应用程序使用的包,您可能需要额外的依赖项:
dependencies {
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.appcompat:appcompat:1.4.0'
Implementation
"androidx.compose.ui:ui:$compose_version"
implementation
"androidx.compose.material:material:$compose_version"
implementation
"androidx.compose.ui:ui-tooling-
preview:$compose_version"
implementation
'androidx.lifecycle:lifecycle-runtime-ktx:2.4.0'
implementation
'androidx.activity:activity-compose:1.4.0'
debugImplementation
"androidx.compose.ui:ui-tooling:$compose_version"
}
一旦您配置了项目,构建和运行 Compose 应用程序的工作方式就像传统的基于视图的应用程序一样。
按下播放按钮
要运行您的 Compose 应用程序,请选择您的目标设备,确保已选中 app 模块,然后按下绿色的 播放 按钮 (图 1.8):
![图 1.8 – 启动应用程序的 Android Studio 工具栏元素]
![img/B17505_01_08.jpg]
图 1.8 – 启动应用程序的 Android Studio 工具栏元素
恭喜!做得好。您现在已经启动了您的第一个 Compose 应用程序,并且已经取得了相当大的成就。让我们回顾一下。
摘要
在本章中,我们学习了如何编写我们的第一个可组合项:带有 @Composable
注解的 Kotlin 函数。可组合函数是基于 Jetpack Compose 的 UI 的核心构建块。您将现有的库可组合项与您自己的组合在一起,以创建美观的应用程序屏幕。要查看预览,我们可以添加 @Preview
注解。要在项目中使用 Jetpack Compose,两个 build.gradle
文件都必须相应地配置。
在第二章《理解声明式范式》中,我们将更深入地探讨 Jetpack Compose 的声明式方法和传统 UI 框架(如 Android 的基于视图的组件库)的命令式本质之间的区别。
进一步阅读
本书假设您对 Kotlin 的语法和 Android 开发有基本的了解。如果您想了解更多关于这方面的内容,我建议查看《Kotlin 编程入门》,作者约翰·霍顿,Packt Publishing出版社,2019 年,ISBN 9781789615401。
第二章:理解声明式范式
Jetpack Compose 标志着 Android UI 开发的根本性转变。虽然传统的基于视图的方法以组件和类为中心,但新的框架遵循声明式方法。
在第一章中,构建您的第一个 Compose 应用,我向你介绍了可组合函数,它是基于 Compose 的 UI 的基本构建块。在本章中,我们将简要回顾如何使用传统的类和技术实现 Android UI。你将了解这种方法的一些问题,以及如何通过声明式框架克服这些问题。
本章的主要部分如下:
-
查看 Android 视图系统
-
从组件到可组合函数
-
检查架构概念
我们将从查看我的第二个示例应用Hello View开始。它是第一章中构建您的第一个 Compose 应用的Hello应用的重新实现。Hello View使用视图、XML 布局文件和视图绑定。
接下来,我们将介绍组件的关键方面,它们是基于视图世界的 UI 构建块。你将了解可组合函数的相似之处和不同之处,我们将找出可组合函数如何克服以组件为中心的框架的一些局限性。
最后,我们将探讨 Android 框架的不同层次以及它们与构建 UI 的关系。到本章结束时,你将收集到足够的背景信息来探索 Jetpack Compose 的关键原则,这是下一章的主题。
技术要求
请参阅第一章中的技术要求部分,构建您的第一个 Compose 应用,了解如何安装和设置 Android Studio 以及如何获取示例应用。本章的所有代码文件都可以在 GitHub 上找到:github.com/PacktPublishing/Android-UI-Development-with-Jetpack-Compose/tree/main/chapter_02
。
查看 Android 视图系统
传统构建 Android UI 的方法是定义组件树并在运行时修改它们。虽然这可以完全通过编程实现,但首选的方法是创建布局文件。它们使用 XML 标签和属性来定义哪些 UI 元素应该显示在屏幕上。让我们看一下:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/message"
style="@style/TextAppearance.AppCompat.Medium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAlignment="center"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintBottom_toTopOf="@id/name"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.5"
app:layout_constraintVertical_chainStyle="packed" />
...
</androidx.constraintlayout.widget.ConstraintLayout>
布局文件定义了一个层次结构(一棵树)。在前面的 XML 片段中,根节点(ConstraintLayout
)只包含一个子节点(TextView
)。Hello View的完整 XML 文件还有两个额外的子节点,一个EditText
组件和一个Button
组件。现实世界的应用布局文件可以非常嵌套,包含数十个子节点。
一般而言,...Layout
元素负责调整其子元素的大小和位置。虽然它们可能有视觉表示(例如,背景颜色或边框),但它们通常不与用户交互。ScrollView
是这个规则的例外之一。所有其他(非 ...Layout
)元素,如按钮、复选框和可编辑文本字段不仅允许用户交互——这是它们的目的。
布局和非布局元素统称为组件。我们将在 从组件到可组合函数的迁移 部分回到这个术语。但在那之前,让我们看看布局文件在应用中的使用方式。
布局文件展开
活动(Activities)是 Android 应用程序的核心构建块之一。它们实现了一个相当复杂的生命周期,这反映在我们可以重写的几个方法中。
通常,onCreate()
方法用于准备应用并通过调用 setContentView()
来显示 UI。此方法可以接收一个表示布局文件的 ID,例如,R.layout.main
。因此,你必须定义指向你希望访问的 UI 元素的变量。这看起来可能如下所示:
private lateinit var doneButton: Button
...
val doneButton = findViewById(R.id.done)
结果表明,这在大应用中扩展性不好。有两个重要的问题需要记住:
-
如果在变量初始化之前访问它,你可能会在运行时遇到崩溃。
-
如果你有很多组件,代码会很快变得冗长。
有时,你可以通过使用局部变量来防止第一个问题,如下所示:
val doneButton = findViewById<Button>(R.id.done)
这样,你可以在声明后立即访问 UI 元素。但变量只存在于它被定义的作用域内——一个块或一个函数。这可能会带来问题,因为你经常需要在 onCreate()
之外修改组件。这是因为在一个基于组件的世界中,你通过修改组件的属性来修改 UI。结果发现,通常需要对应用的不同部分进行相同的更改集,为了避免代码重复,它们被重构为需要知道要更改的组件的方法。
为了解决第二个问题——即,让开发者免于保持组件引用的任务——谷歌引入了视图绑定。它属于 Jetpack,并在 Android Studio 3.6 中首次亮相。让我们看看它是如何使用的:
class MainActivity : AppCompatActivity() {
private lateinit var binding: MainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = MainBinding.inflate(layoutInflater)
setContentView(binding.root)
...
enableOrDisableButton()
}
...
}
无论一个活动的 UI 多么复杂,我们只需要保留一个引用。这个变量通常被称为 binding
,它通过调用 ...Binding
实例的 inflate()
方法进行初始化。在我的示例中,MainBinding
类在修改 main.xml
时会自动生成和更新。每个布局文件都会对应一个 ...Binding
类。为了启用此机制,必须在模块级别的 build.gradle
文件中将 viewBinding
构建选项设置为 true
:
android {
...
buildFeatures {
viewBinding true
}
}
因此,在通过调用 ...Binding.inflate()
指示一个布局文件并分配给实例变量之后,您可以通过这些变量通过它们的 ID 访问其所有组件。ID 是使用 XML 属性 android:id
(例如,android:id="@+id/message"
)设置的。
重要提示
传统的 findViewById()
和视图绑定之间存在一个重要的区别。如果您使用后者,您必须将根组件(binding.root
)传递给 setContentView()
,而不是表示布局文件的 ID(R.layout.main
)。
在本节中,我向您展示了如何获取 UI 元素的引用。下一节 修改 UI 将解释如何利用这些信息。
修改 UI
在本节中,我们将了解如何更改基于视图的 UI。让我们首先查看在 onCreate()
中调用的 enableOrDisableButton()
函数,其名称为您提供了关于其目的的线索——启用或禁用按钮。但我们为什么需要这样做呢?Hello View 是 Hello 应用(在 第一章,构建您的第一个 Compose 应用)的重新实现,但它有一个额外的功能。只要用户没有输入至少一个非空白字符,完成 就不能点击:
private fun enableOrDisableButton() {
binding.done.isEnabled = binding.name.text.isNotBlank()
}
binding.done
在运行时引用按钮。只有当 isEnabled
为 true
时才能点击。文本输入字段用 binding.name
表示。它的 text
属性反映了用户已经输入的内容。isNotBlank()
告诉我们是否至少有一个非空白字符存在。
在我向您展示的代码中,enableOrDisableButton()
只在 onCreate()
的末尾被调用。但我们还需要在用户输入内容时调用该函数。让我们看看如何做到这一点(请注意,以下代码片段属于 onCreate()
内部,以便在活动创建时执行):
binding.name.run {
setOnEditorActionListener { _, _, _ ->
binding.done.performClick()
true
}
doAfterTextChanged {
enableOrDisableButton()
}
visibility = VISIBLE
}
文本输入字段可以修改屏幕键盘的某些方面。例如,为了在布局文件中显示 android:imeOptions="actionDone"
属性。为了对此键的点击做出反应,我们需要通过调用 setOnEditorActionListener()
注册代码。然后,binding.done.performClick()
模拟对 完成 按钮的点击。您很快就会看到我这样做的原因。
我们传递给 doAfterTextChanged()
的 lambda 函数会在用户在文本输入字段中输入或删除内容时被调用。当这种情况发生时,会调用 enableOrDisableButton()
,如果输入字段中当前显示的文本不为空,则使按钮可点击。
最后,visibility = VISIBLE
发生在 binding.name.run {
内部,因此使文本输入字段可见。这是活动创建时的期望状态。
现在,让我们转向与 完成 按钮相关的代码:
binding.done.run {
setOnClickListener {
val name = binding.name.text
if (name.isNotBlank()) {
binding.message.text = getString(R.string.hello,
name)
binding.name.visibility = GONE
it.visibility = GONE
}
}
visibility = VISIBLE
}
当 visibility
属性为 visibility = VISIBLE
时,会使 完成 按钮可见。这是活动创建时的期望状态。
你还记得我承诺解释为什么在setOnEditorActionListener
的 lambda 函数中调用performClick()
吗?这样,我可以在不重构为单独函数的情况下重用按钮监听器内的代码,这当然是一个可行的替代方案。
在我们继续之前,让我们回顾一下到目前为止我们所看到的内容:
-
UI 在 XML 文件中定义。
-
在运行时,它被填充为组件树。
-
要更改 UI,必须修改所有相关组件的属性。
-
即使 UI 元素不可见,它仍然是组件树的一部分。
这就是为什么常见的 UI 框架被称为命令式。任何对 UI 的更改都是通过故意修改所有相关组件的属性来完成的。正如你在我例子中看到的那样,这对于小型应用来说效果相当好。但随着应用 UI 元素的增多,跟踪这些更改的要求也会增加。让我来解释一下。领域数据的变化(向列表中添加项目、删除文本或从远程服务加载图像)需要 UI 的变化。开发者需要知道哪些领域数据部分与哪个 UI 元素相关,然后必须相应地修改组件树。应用越大,这越困难。
此外,如果没有明确的架构指导,更改组件树的代码几乎总是最终会与修改应用所使用数据的代码混合。这使得维护和进一步开发应用变得更加困难和容易出错。在下一节中,我们将转向可组合函数。你将了解它们与组件的不同之处以及为什么这有助于克服命令式方法中的弱点。
从组件到可组合函数的转变
到目前为止,我通过说它指的是 UI 元素来解释单词组件。实际上,这个术语在许多其他领域也被使用。一般来说,组件通过分离系统的不同部分或部分来结构化系统。组件的内部工作通常对外部隐藏(称为黑盒原理)。
小贴士
想了解更多关于黑盒原理的信息,请参阅en.wikipedia.org/wiki/Black_box
。
组件通过发送和接收消息与其他系统部分进行通信。组件的外观或行为通过一组属性或属性进行控制。
以TextView
为例。我们通过修改text
属性来设置文本,并通过visibility
来控制其可见性。那么发送和接收消息呢?让我们看看Button
。我们可以通过注册(发送消息)一个OnClickListener
实例来对点击(接收消息)做出反应。同样的原则也适用于EditText
。我们通过设置属性(text
)来配置其外观,通过调用setOnEditorActionListener()
来发送消息,并通过我们作为参数传递的 lambda 表达式来接收消息。
基于属性的消息传递和配置使组件非常易于使用工具。事实上,大多数基于组件的 UI 框架与绘图板式编辑器配合得很好。开发者通过拖放定义 UI。组件通过属性表进行配置。图 2.1显示了 Android Studio 中的布局编辑器。您可以在设计视图、浏览代码(XML 文件)或两者的组合(分割)之间切换:
![Figure 2.1 – The Layout Editor in Android Studio
![img/B17505_02_01.jpg]
图 2.1 – Android Studio 中的布局编辑器
我们现在对在 UI 上下文中使用“组件”一词有了更精确的理解。在此基础上,我们现在将探讨组件层次结构。
组件层次结构
如果你比较ConstraintLayout
、TextView
和EditText
的 XML 属性,你会发现每个标签都有独特的属性,一个例子是android:inputType
。另一方面,android:layout_width
和android:layout_height
在所有三个标签中都有,定义了相应元素的大小。大小和位置对所有组件都相关。
然而,特定的属性会影响视觉外观或行为;这并不适用于所有类型的 UI 元素,而只是其中的一部分。这里有一个例子:文本字段和按钮会显示或接收文本。而FrameLayout
UI 元素则不会。可以这样想:属性越专业化,在另一个组件中重用的可能性就越小。然而,一般的属性(如width
、height
、location
或color
)在大多数 UI 元素中都是必需的。
根据其属性,每个组件都有一个专业化的级别。例如,EditText
比TextView
更具体,因为它可以处理文本输入。Button
是一个通用按钮;点击它将触发某些操作。另一方面,CheckBox
组件可以是选中的或未选中的。这种按钮可以表示两种状态。Switch
组件也有两种状态。它是一个可以在这两个选项之间选择的切换开关小部件。
在面向对象编程语言中,可以通过继承轻松地模拟专业化的程度。一个更专业的 UI 元素(类)扩展了一个通用元素。因此,许多常用的 UI 框架都是用 Java、C++或 C#(面向对象的语言)实现的。然而,需要注意的是,类似组件的概念也可以用其他类型的编程语言实现。所以,面向对象可能是一个优势,但不是必需的。
在这一点上,你可能正在想,他不是把两件事物混淆了吗?Android 布局文件的标签和属性与类有什么关系? 让我来解释一下。之前我说过,一个 XML 文件通过inflate()
方法根据这些信息创建一个对象树。
因此,Android 布局文件使用不同的语法(XML 语法)描述 Java 或 Kotlin 文件之外的组件树,但它们并不像 Jetpack Compose 那样声明式。因为布局文件定义的 UI 与当前状态无关。例如,它们不考虑按钮应该被禁用,因为文本字段为空。另一方面,Compose UI 是基于那个状态声明的。
本节剩余部分将更详细地探讨一些 Android 的 UI 组件及其相互关系。在此之前,让我们回顾一下到目前为止我们已经学到了什么:
-
所有 Android 视图都是类。
-
布局文件中的标签代表类,属性则是它们的成员。
-
inflate()
创建一个对象树。 -
通过修改此树来实现对 UI 的更改。
Android 的某些 UI 元素非常具体。例如,RatingBar
允许用户通过选择一定数量的星星来对某物进行评分。其他则更为通用;例如,ImageView
仅显示图像资源,而 FrameLayout
则在屏幕上划出一个区域以显示子视图堆栈。
为了理解 Android 的 UI 元素是如何相互关联的,让我们更详细地看看在 Hello View 中使用的那些。我们将从 ConstraintLayout
开始:
java.lang.Object
↳ android.view.View
↳ android.view.ViewGroup
↳ androidx.constraintlayout.widget.ConstraintLayout
Java 中所有类的根是 java.lang.Object
。Android 框架的许多重要部分基于 Java 和其类库。因此,所有视图都直接或间接地扩展了 java.lang.Object
。ConstraintLayout
的直接父类是 android.view.ViewGroup
,而 android.view.View
则是其兄弟。
现在,让我们看看 android.widget.Button
。
java.lang.Object
↳ android.view.View
↳ android.widget.TextView
↳ android.widget.Button
它的直接祖先是 android.widget.TextView
,它扩展了 android.view.View
。这里我们是否看到了一个模式?android.view.View
似乎是一切 Android UI 元素的根源。让我们通过检查另一个组件来验证我们的假设:
java.lang.Object
↳ android.view.View
↳ android.widget.TextView
↳ android.widget.EditText
如您所见,显示或接收文本的组件通常扩展了 android.widget.TextView
,其父类是 android.view.View
。
重要提示
android.view.View
是所有 Android UI 元素的根。所有定位和调整子视图大小的组件都扩展了 android.view.ViewGroup
。
到目前为止,基于专业化的层次结构来组织 UI 元素似乎效果良好。不幸的是,这种方法确实存在局限性。我们将在下一节中探讨它们。
组件层次结构的局限性
按钮通常显示文本。因此,扩展一个更通用的文本组件似乎是自然的。正如我们在上一节中看到的,Android 正是这样做的。如果你的应用程序需要一个没有文本且显示图像的按钮,在这种情况下,你可以使用 ImageButton
:
java.lang.Object
↳ android.view.View
↳ android.widget.ImageView
↳ android.widget.ImageButton
这个类扩展了 android.widget.ImageView
。这很有道理,因为这个组件的目的就是显示一个图像,就像 Button
和文本一样。但如果我们想显示一个包含文本和图像的按钮呢?ImageButton
和普通文本按钮最接*的共同祖先是 android.view.View
,它是 Android UI 元素层次结构的根。因此,Button
从 TextView
继承的所有内容并不立即对 ImageButton
(反之亦然)可用。
原因是 Java 基于 Button
想要利用 TextView
和 ImageView
的功能,它需要同时扩展这两个类,但它不能这样做。这意味着如果 Java 支持 多重继承,事情会有所不同吗?我们可以组合几个组件的行为,但我们仍然无法重用与 单个 属性、方法或它们的集合相关的功能。让我们看看为什么这很重要。
View
类了解填充(为其边界内部提供空间),但不了解边距(为其边界外部提供空间)。边距在 ViewGroup
中定义。因此,如果组件想使用它们,它必须扩展 ViewGroup
。但这样做,它不可避免地继承了该类的所有其他功能(例如,布局子元素的能力),无论是否需要。根本问题是,在以组件为中心的框架中,将一个或多个组件的 单个功能 组合起来创建一个更专业的 UI 元素是不可能的,因为你不能去除这些功能。这是因为重用发生在组件级别。
要使单个功能可重用,我们需要抛开组件的概念。这就是 Flutter(Jetpack Compose 的非常成功的跨*台替代品)所做的事情。它的 UI 框架完全声明式,仍然是基于类的。Flutter 依赖于一个简单的原则,称为 Container
、Padding
、Align
或 GestureDetector
,而不是修改父元素。
在 Jetpack Compose 中,我们也组合简单的构建块。我们使用可组合函数而不是类。在我们转向它们之前,我想简要地展示组件的另一个潜在问题。
正如你所看到的,在基于类的 UI 组件框架中,专业化是通过继承来建模的。类的专业化版本(可能具有新功能、新外观或行为略有不同)扩展了类的更通用版本。然而,大多数面向对象编程语言提供了禁止这种做法的方法;例如,如果一个 Java 类被标记为 final 或 Kotlin 类不是开放的,它们就不能被扩展。
因此,框架开发者可以做出明确的决定来防止进一步的继承。android.widget.Space
是一个轻量级的 View
子类,用于在 UI 元素之间创建间隔,它是最终的。同样适用于 android.view.ViewStub
。它是一个不可见的、零大小的 View
,用于在运行时延迟填充布局资源。幸运的是,Android 的大多数 UI 元素都可以扩展。对于这两个例子,我们似乎不太可能想要扩展它们。因此,您可能根本不会遇到这个潜在的问题。重点是,在基于组合而不是继承的框架中,这并不重要。
使用函数组合 UI
现在是时候回到可组合函数上了。在本节中,我们将查看我的示例应用 阶乘 (图 2.2)。当用户选择一个介于 0 到 9 之间的数字时,它的阶乘(即它和所有大于 0 的整数的乘积)将被计算并输出,如下所示:
![Figure 2.2 – The Factorial app
![img/B17505_02_02.jpg]
图 2.2 – 阶乘应用
这里是一个创建输出文本的简单函数:
fun factorialAsString(n: Int): String {
var result = 1L
for (i in 1..n) {
result *= i
}
return "$n! = $result"
}
一个非负整数 n
的阶乘是所有小于或等于 n
的正整数的乘积。因此,结果可以通过乘以 1
到 n
之间的所有整数来轻松计算。请注意,Kotlin Long
类型的最大值是 9,223,372,036,854,775,807。因此,如果 result
需要大于这个值,我的实现将不起作用。
接下来,我将向您展示如何组合 UI:
@Composable
fun Factorial() {
var expanded by remember { mutableStateOf(false) }
var text by remember {
mutableStateOf(factorialAsString(0)) }
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
modifier = Modifier.clickable {
expanded = true
},
text = text,
style = MaterialTheme.typography.h2
)
DropdownMenu(
expanded = expanded,
onDismissRequest = {
expanded = false
}) {
for (n in 0 until 10) {
DropdownMenuItem(onClick = {
expanded = false
text = factorialAsString(n)
}) {
Text("${n.toString()}!")
}
}
}
}
}
Factorial()
可组合函数包含一个预定义的可组合函数 Box()
,它又包含两个子元素,Text()
和 DropdownMenu()
。我在 第一章,构建您的第一个 Compose 应用 中简要介绍了您 Text()
和 Box()
。所以让我们专注于 DropdownMenu()
。
一个下拉菜单(相当于 Spinner
)以紧凑的方式显示一系列条目。它在与元素交互时出现,如下所示:
-
一个图标或按钮
-
当用户执行特定操作时
在我的例子中,必须点击 Text()
可组合元素。
菜单的内容可以由 for
循环语句提供,或者逐个添加。通常,但不一定,使用 DropdownMenuItem()
。如果菜单已展开(即打开或可见),则由 expanded
参数控制。onDismissRequest
用于在没有选择任何内容的情况下响应菜单的关闭。DropdownMenuItem()
通过 onClick
参数接收点击处理程序。当项目被点击时执行该代码。
到目前为止,我已经向您介绍了相当多的关于可组合函数的信息。在我们继续之前,让我们回顾一下我们目前所知道的内容:
-
Compose UI 的入口是一个可组合函数。
-
从那里,其他可组合函数被调用。
-
通常,可组合函数接收 内容,即其他可组合函数。
-
调用顺序控制 UI 元素相对于其他 UI 元素的位置。
让我们继续探讨Factorial()
的工作原理。它定义了两个变量,expanded
和text
。但它们是如何使用的呢?虽然 Android 布局文件在其初始状态中定义了组件树,但可组合 UI 总是使用实际数据声明的。这意味着在第一次显示 UI 之前,不需要设置或准备 UI。每次显示时,它看起来都是您想要的。让我们看看它是如何工作的。
大多数可组合函数通过一组参数进行配置。其中一些是必需的;其他可以省略。重要的是,可组合函数始终使用实际值调用。另一方面,组件(即视图)在创建时初始化。并且它们保持这种方式,直到通过更改属性值故意更改。这就是为什么应用程序需要保留所有希望修改的组件(UI 元素)的引用。但 Compose UI 是如何更新的?
更新 Compose UI 的过程称为Text()
,不需要重新组合。另一方面,如果您传递 Jetpack Compose 知道它可以更改的内容,当该更改发生时,Compose 运行时会启动更新,即重新组合。随时间变化的价值被称为mutableStateOf()
。要在一个可组合函数中引用状态,您需要在该函数中remember
它。
expanded
和text
都包含状态。当这些变量用作可组合函数的参数时,这些可组合函数将在这些变量的值发生变化时重新组合。将expanded
设置为true
会立即在屏幕上显示下拉菜单。这是在传递给clickable {}
的 lambda 函数内部完成的。我将在下一节中讨论这个问题。给text
赋予新值会改变Text()
的显示,因为我们传递了与参数同名的变量text
。例如,这发生在传递给onClick
的代码块内部。
为了声明基于状态和因此免费获得状态变化更新的 UI,而不是更新(需要故意更改)组件树,这可能是声明性方法最令人兴奋的优点之一。在下一节中,我将解释更多基于组件和声明性 UI 框架的架构原则。
检查架构方面
在组件层次结构部分,我向您展示了基于组件的 UI 框架依赖于专业化。通用特性和概念是在根组件或其直接后继组件中实现的。以下是一些通用特性:
-
屏幕上的位置和大小
-
基本视觉方面,如背景(颜色)
-
简单的用户交互(对点击做出反应)
任何组件都会以专门的方式或其基本实现提供这些功能。Android 的视图系统是类基于的,所以改变功能是通过覆盖父类的方法来完成的。
相反,可组合函数没有共享的属性集。通过使用@Composable
注解一个函数,我们使 Jetpack Compose 的某些部分能够识别它。但是,除了不指定返回类型外,组合函数似乎很少有共同之处。然而,这将会是一个相当短视的架构决策。事实上,Jetpack Compose 使得提供简单、可预测的 API 变得非常容易。本节的剩余部分通过向您展示如何对点击做出反应,以及如何调整 UI 元素的大小和位置来展示这一点。
对点击做出反应
Android 的View
类包含一个名为setOnClickListener()
的方法。它接收一个View.OnClickListener
实例。此接口包含一个方法,onClick(View v)
。此方法的实现提供了当视图被点击时应执行的代码。此外,还有一个名为clickable
的视图属性。它通过setClickable()
和isClickable()
访问。如果在设置监听器之后将clickable
设置为false
,则点击事件将不会传递(onClick()
不会被调用)。
Jetpack Compose 可以通过两种方式提供点击处理。首先,需要它的可组合函数(因为它是它们的核心功能)有一个专门的onClick
参数。其次,通常不需要点击处理的组合函数可以通过修饰符进行修改。让我们从第一个开始。
@Composable
@Preview
fun ButtonDemo() {
Box {
Button(onClick = {
println("clicked")
}) {
Text("Click me!")
}
}
}
请注意,onClick
是强制性的;你必须提供它。
如果你想要显示按钮,但用户不应能够点击它,代码看起来是这样的:
Button(
onClick = {
println("clicked")
},
enabled = false
) {
Text("Click me!")
}
图 2.3显示了当enabled
为true
或false
时按钮的外观:
![图 2.3 – 当 enabled = true 或 false 时的按钮
![img/B17505_02_03.jpg]
图 2.3 – 当 enabled = true 或 false 时的按钮
Text()
没有onClick
属性。如果你想使其可点击(就像我在阶乘应用中做的那样),你将clickable { ... }
传递给modifier
参数:
modifier = Modifier.clickable { ...
如其名称所示,修饰符提供了一种影响可组合函数的视觉外观和行为的基础设施。我将在下一节中展示另一个修饰符的示例。第三章,探索 Compose 的关键原则,更详细地介绍了它们。
调整 UI 元素的大小和位置
在以组件为中心的 UI 框架中,屏幕上的大小和位置(或相对于另一个组件)是核心属性。它们在根组件中定义(在 Android 中是View
类)。ViewGroup
的子类通过更改相应的属性来调整其子项的大小和位置。例如,RelativeLayout
基于指令,如toStartOf
、toEndOf
或below
。FrameLayout
按堆栈绘制其子项。而LinearLayout
水*或垂直排列其子项。因此,...Layout
s 是具有调整其子项大小和位置能力的容器。
Jetpack Compose 有一个非常类似的概念。你已经学习了Row()
和Column()
,它们分别水*或垂直排列其内容。Box()
类似于FrameLayout
。它按照代码中出现的顺序组织其内容。盒子内的位置由contentAlignment
控制:
@Composable
@Preview
fun BoxDemo() {
Box(contentAlignment = Alignment.Center) {
Box(
modifier = Modifier
.size(width = 100.dp, height = 100.dp)
.background(Color.Green)
)
Box(
modifier = Modifier
.size(width = 80.dp, height = 80.dp)
.background(Color.Yellow)
)
Text(
text = "Hello",
color = Color.Black,
modifier = Modifier.align(Alignment.TopStart)
)
}
}
内容可以使用modifier = Modifier.align()
来覆盖这一点,结果我们在图 2.4中可以看到:
图 2.4 – 包含两个彩色框和文本的不可见框
修饰符也可以用来请求一个大小。在我的一些示例中,你可能已经注意到了Modifier.fillMaxSize()
,它使可组合函数尽可能大。Modifier.size()
请求一个特定的大小。修饰符可以链式使用。这个链的根是Modifier
伴随对象。后续修饰符通过点添加。
在关闭本章之前,我想通过另一个例子强调修饰符概念的好处。你注意到第一个和第二个内容框的background()
修饰符了吗?这个修饰符允许你为任何可组合函数设置背景颜色。当你需要某个可组合函数不提供的内容时,你可以通过修饰符添加它。由于你可以编写自定义修饰符,因此调整可组合函数以满足你需求的可能性几乎是无限的。我将在下一章中详细阐述这一点。
摘要
在本章中,你学习了组件中心 UI 框架的关键元素。我们看到了这种方法的一些局限性以及声明式范式如何克服它们。例如,专业化发生在组件级别。如果框架基于继承,那么将功能分配给子组件可能过于广泛。Jetpack Compose 通过修饰符机制解决这个问题,这允许我们在非常细粒度的级别上修改功能;这意味着可组合函数只获得它们需要的功能(例如,背景颜色)。
本书剩余的章节完全基于声明式方法。在第三章 探索 Compose 的关键原则中,我们将更深入地研究可组合函数,并检查组合和重新组合的概念。并且,正如承诺的那样,我们还将深入探讨修饰符。
第三章:探索 Compose 的关键原则
在本书的第一章中,我们构建并运行了我们的第一个 Jetpack Compose 应用。然后,在第二章,理解声明式范式中,我们解释了 Android 传统 UI 工具箱的命令式本质,展示了其一些弱点,并看到了声明式方法如何克服它们。
在本章中,我们通过考察 Jetpack Compose 依赖的一些关键原则来建立这些基础。这些知识对于编写表现良好的 Compose 应用至关重要。本章介绍了这些关键原则。
在本章中,我们将涵盖以下主题:
-
仔细研究可组合函数
-
组合和重新组合用户界面(UI)
-
修改可组合函数的行为
我们将首先回顾可组合函数,这是可组合 UI 的构建块。这次,我们将深入挖掘其背后的思想和概念。到第一主要部分的结尾,你将全面理解什么是可组合函数,它们是如何编写的,以及它们是如何被使用的。
以下部分将专注于创建和更新 UI。您将了解 Jetpack Compose 如何实现其他 UI 框架所说的重绘。这种机制在 Compose 中被称为重新组合,每当与 UI 相关的内容发生变化时,它就会自动发生。为了保持这个过程流畅,您的可组合函数必须遵循一些最佳实践。我将在本节中为您解释它们。
我们将通过扩展我们对修饰符概念的了解来结束本章。我们将仔细研究修饰符链是如何工作的,以及您需要记住什么才能始终得到预期的结果。您还将学习如何实现自定义修饰符。它们允许您修改任何可组合函数,使其看起来或表现成您想要的方式。
现在,让我们开始吧!
技术要求
请参阅第一章,构建您的第一个 Compose 应用中的技术要求部分,了解如何安装和设置 Android Studio,以及如何获取示例应用。如果您想尝试仔细研究可组合函数部分中的ShortColoredTextDemo()
和ColoredTextDemo()
可组合函数,您可以使用位于本书 GitHub 仓库顶层目录中的Sandbox
应用项目。打开SandboxActivity
,从位于/chapter_03
文件夹中的code_snippets.txt
复制可组合函数。
仔细研究可组合函数
Compose 应用程序的 UI 是通过编写和调用可组合函数来构建的。我们已经在之前的章节中做了这两件事,但关于可组合函数的结构及其内部结构的解释相当基础——现在是时候解决这个问题了。
可组合函数的构建块
一个 @Composable
。
所有可组合函数都必须这样标记,因为注解会通知 Compose 编译器该函数将数据转换为 UI 元素。
Kotlin 函数的签名由以下部分或构建块组成:
-
一个可选的可见性修饰符(
private
、protected
、internal
或public
) -
fun
关键字 -
一个名称
-
参数列表(可以空着)或,可选地,一个默认值
-
一个可选的返回类型
-
一段代码块
让我们更详细地探讨这些部分。
默认可见性(如果你省略了修饰符)是 public
。这意味着(可组合的)函数可以从任何地方调用。如果一个函数打算被重用(例如,一个与你的品牌风格匹配的文本),它应该是公开可用的。另一方面,如果一个函数与特定的 上下文(如一个类)相关联,限制其访问可能是有意义的。关于函数可见性应该有多严格,有一个公开的讨论。最终,你和你的团队需要就一个观点达成一致,并坚持下去。为了简单起见,我的例子通常是公开的。
可组合函数的名称使用 PascalCase 语法:它以大写字母开头,其余字符为小写。如果名称由多个单词组成,每个单词都遵循这个规则。名称应该是名词(Demo
),或者是一个带有描述性形容词的前缀的名词(FancyDemo
)。与其它(普通)Kotlin 函数不同,它不应该是一个动词或动词短语(getDataFromServer
)。可在 github.com/androidx/androidx/blob/androidx-main/compose/docs/compose-api-guidelines.md
找到的 Jetpack Compose API 指南 文件,详细说明了这些命名约定。
你想要传递给可组合函数的所有数据都通过一个逗号分隔的列表提供,该列表被括号包围。如果一个可组合函数不需要值,列表保持为空。以下是一个可以接收两个参数的可组合函数示例:
@Composable
fun ColoredTextDemo(
text: String = "",
color: Color = Color.Black
) {
Text(
text = text,
style = TextStyle(color = color)
)
}
在 Kotlin 中,函数参数定义为 name: type
。参数通过逗号分隔。你可以通过添加 = ...
来指定默认值。当函数被调用时,如果没有为特定参数提供值,就会使用这个默认值。
函数的返回类型是可选的。在这种情况下,函数返回Unit
。Unit
是一个只有一个值的类型:Unit
。如果,像这个例子一样,它被省略了,函数体将直接跟在参数列表之后。您将要编写的多数可组合函数都不需要返回任何内容,因此不需要返回类型。需要返回类型的情况将在返回值部分介绍。
如果一个函数的代码包含多个语句或表达式,它将被括号包围。Kotlin 提供了一个很好的缩写,如果只需要执行一个表达式,那就是 Jetpack Compose 本身经常使用的。
@Composable
fun ShortColoredTextDemo(
text: String = "",
color: Color = Color.Black
) = Text(
text = text,
style = TextStyle(color = color)
)
如您所见,表达式遵循一个等号。这意味着ShortColoredTextDemo()
返回Text()
返回的任何内容。
与 Java 不同,Kotlin 不知道void
关键字,所以所有函数都必须返回一些内容。通过省略返回类型,我们隐式地告诉 Kotlin 函数的返回类型是kotlin.Unit
。这个类型只有一个值:Unit
对象。因此,Unit
对应于 Java 中的void
。
让我们通过打印调用一个可组合函数的结果来测试这一点:
class SandboxActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
println(ColoredTextDemo(
text = "Hello Compose",
color = Color.Blue
))
}
}
}
如果您运行应用程序,以下行将被打印:
I/System.out: kotlin.Unit
虽然这看起来可能并不太令人兴奋,但其影响是深远的。想想看:尽管ColoredTextDemo()
可组合函数返回了没有太多意义的内容,但屏幕上显示了某些文本。这是因为它调用了另一个可组合函数,称为Text()
。所以,无论需要显示文本的什么内容,都必须在Text()
内部发生,并且它与可组合函数的返回值无关。
在上一章中,我说过可组合函数会发出UI 元素。我将在下一节解释这意味着什么。
发出 UI 元素
通过嵌套对可组合函数的调用创建 Compose UI,这些函数可以由 Jetpack Compose 库、其他开发者的代码或您的应用程序提供。
让我们找出一旦ColoredTextDemo()
调用了androidx.compose.material.Text()
会发生什么。要在 Android Studio 中查看(包括其他)可组合函数的源代码,您可以在按住Ctrl键(在 Mac 上为cmd键)的同时单击它们的名称。
请注意
我只会展示重要的步骤,因为否则我需要复制太多的代码。为了获得最佳的学习体验,请直接在您的 IDE 中跟随调用链。
Text()
定义了两个变量,textColor
和mergedStyle
,并将它们传递给androidx.compose.foundation.text.BasicText()
。尽管您可以在代码中使用BasicText()
,但如果可能的话,您应该选择androidx.compose.material.Text()
,因为它会从主题中消耗样式信息。请参阅第六章,将部件组合在一起,了解更多关于主题的信息。
BasicText()
立即委托给 CoreText()
,它也属于 androidx.compose.foundation.text
包。它是一个内部可组合函数,这意味着你无法在应用程序中使用它。
CoreText()
初始化并记住相当多的变量。这里没有必要解释它们的所有内容,但最重要的是调用另一个可组合函数:Layout()
。
Layout()
属于 androidx.compose.ui.layout
包。它是布局的核心可组合函数,其目的是对子节点进行尺寸和位置调整。第四章,布局 UI 元素,对此进行了详细的介绍。现在,我们还需要找出 发出 UI 元素 的含义。所以,让我们看看 Layout()
做了什么:
图 3.1 – Layout() 的源代码
Layout()
调用 ReusableComposeNode()
,它属于 androidx.compose.runtime
包。这是一个可组合函数,它 发出 一个所谓的 factory
参数。update
和 skippableUpdate
参数接收执行节点更新的代码,后者处理修饰符(我们将在本章末尾对其进行更详细的探讨)。最后,content
包含成为节点子节点的可组合函数。
注意事项
当我们谈论可组合函数 发出 UI 元素时,我们的意思是 节点 被添加到 Jetpack Compose 内部的数据结构中。这最终会导致 UI 元素可见。
要完成调用链,让我们简要看看 ReusableComposeNode()
:
图 3.2 – ReusableComposeNode() 的源代码
currentComposer
是 androidx.compose.runtime.Composables.kt
内的一个顶级变量。它的类型是 Composer
,这是一个接口。Composer
是由 Jetpack Compose Kotlin 编译器插件针对的,并由代码生成助手使用;你的代码不应直接调用它。ReusableComposeNode
确定是否创建新节点或是否重用现有节点。然后它执行更新,并通过调用 content()
最终将内容发出到节点。
根据你目前所知,让我对节点进行更详细的阐述。Layout()
将 ComposeUiNode.Constructor
传递给 ReusableComposeNode
作为 factory
参数,该参数用于创建一个节点(currentComposer.createNode(factory)
)。因此,节点的特性由 ComposeUiNode
接口定义:
图 3.3 – ComposeUiNode 的源代码
节点有四个属性,如以下类或接口定义:
-
MeasurePolicy
-
LayoutDirection
-
Density
-
Modifier
从本质上讲,一个节点是 Compose 层次结构中的一个元素。你不会在代码中处理它们,因为节点是 Jetpack Compose 内部工作的一部分,这些工作没有暴露给应用程序。然而,你将在本书中看到MeasurePolicy
、LayoutDirection
、Density
和Modifier
。它们代表与应用程序相关的重要数据结构和概念。
这就结束了我们对 UI 元素是如何发出的(节点被添加到 Jetpack Compose 内部的某些数据结构中)的调查。在下一节中,我们将查看返回值的可组合函数。
返回值
你的大部分可组合函数不需要返回任何东西,因此它们不会指定返回类型。这是因为可组合函数的主要目的是组合 UI。正如你在上一节中看到的,这是通过发出 UI 元素或元素层次结构来完成的。但何时我们需要返回不同于Unit
的东西呢?
一些我的例子使用了remember {}
来保留状态以供将来使用,以及stringResource()
来访问存储在strings.xml
文件中的字符串。为了能够执行它们的任务,这两个都必须是可组合的函数。
让我们看看stringResource()
来了解原因。记住,你可以按 Ctrl + 点击一个名称来查看其源代码。这个函数相当短;它只做了两件事:
val resources = resources()
return resources.getString(id)
resources()
也是一个可组合函数。它返回LocalContext.current.resources
。LocalContext
是AndroidCompositionLocals.android.kt
中的一个顶级变量,属于androidx.compose.ui.platform
包。它返回一个StaticProvidableCompositionLocal
的实例,该实例持有android.content.Context
。此对象提供对资源的访问。
即使返回的数据与 Jetpack Compose 无关,获取它的代码也必须遵循 Jetpack Compose 的机制,因为最终它将从一个可组合函数中被调用。重要的是要记住,如果你需要返回属于组合和重新组合机制的部分,你必须通过使用@Composable
注解来使你的函数可组合。此外,这样的函数不遵循可组合函数的命名约定,而是遵循camelCase风格(它们以小写字母开头,后续单词以大写字母开头)并且由动词短语(如rememberScrollState
)组成。
在下一节中,我们将回到在应用程序级别组合 UI。你将了解更多关于组合和重新组合这两个术语。
组合和重新组合 UI
与命令式 UI 框架不同,Jetpack Compose 不需要开发者主动修改组件树,当应用程序数据的变化需要更改 UI 时。相反,Jetpack Compose 会自动检测这些变化,并只更新受影响的部分。
如您现在所知,Compose UI 是基于当前应用数据声明的。在我之前的例子中,您已经看到了很多条件表达式(如if
或when
),这些表达式决定了哪个可组合函数被调用或它接收哪些参数。因此,我们在代码中描述了完整的 UI。将要执行的分支取决于运行时的应用数据(状态)。React 的 Web 框架有一个类似的概念,称为 Virtual DOM。但这不是与我说Compose 会自动检测这些更改并只更新受影响的部分相矛盾吗?
从概念上讲,Jetpack Compose 在需要应用更改时重新生成整个 UI。这当然会浪费时间、电池和处理能力。而且可能会被用户注意到屏幕闪烁。因此,该框架投入了大量精力确保仅重新生成需要更新的 UI 元素树的部分。
在上一节中,您已经看到了一些这些努力的例子,我简要提到了update
和skippableUpdate
。为了确保ColorPickerDemo
快速且可靠:
图 3.4 – ColorPickerDemo 应用
该应用旨在通过指定其红色、绿色和蓝色(RGB)部分来设置颜色。此颜色用作文本的背景色(显示颜色的十六进制字符串值)。前景色与所选颜色互补。
在接下来的几节中,我们将查看其代码。您将了解滑块如何在其值发生变化时进行通信。
在可组合函数之间共享状态
有时候,您可能想在多个可组合函数中使用一个状态。例如,您可能希望使用一个滑块设置的颜色部分来创建完整的颜色,而这个颜色反过来又成为文本的背景色。那么,您如何共享状态?让我们先看看ColorPicker()
– 它在Column()
中垂直排列三个滑块:
@Composable
fun ColorPicker(color: MutableState<Color>) {
val red = color.value.red
val green = color.value.green
val blue = color.value.blue
Column {
Slider(
value = red,
onValueChange = { color.value = Color(it, green,
blue)
})
Slider(
value = green,
onValueChange = { color.value = Color(red, it, blue) })
Slider(
value = blue,
onValueChange = { color.value = Color(red, green, it) })
}
}
可组合函数接收一个参数:MutableState<Color>
。color
的value
属性包含一个androidx.compose.ui.graphics.Color
实例。它的red
、green
和blue
属性返回一个基于所谓的ColorSpaces.Srgb
的Float
。
我的代码没有设置特定的颜色空间,因此它默认为ColorSpaces.Srgb
。这导致返回的值在0F
和1F
之间。前三条线将颜色的红色、绿色和蓝色部分分配给名为red
、green
和blue
的局部变量。它们用于Slider()
函数;让我们看看它是如何工作的。
在我的例子中,每个滑块接收两个参数:value
和onValueChange
。第一个指定滑块将显示的值。它必须在0F
和1F
之间(这与red
、green
和blue
相匹配)。如果需要,你可以通过可选的valueRange
参数提供替代范围。onValueChange
在用户拖动滑块手柄或点击下方的细线时被调用。三个 lambda 表达式的代码相当相似:创建一个新的Color
对象并将其分配给color.value
。受其他滑块控制的颜色部分来自相应的局部变量。它们没有被更改。当前滑块的新颜色部分可以从it
中获得,因为它是新的滑块值,它被传递给onValueChange
。
到目前为止,你可能想知道为什么ColorPicker()
接收被包裹在MutableState<Color>
中的颜色。直接使用color: Color
传递它不是足够吗?如图 3.4所示,应用程序以互补的背景和前景颜色显示所选颜色作为文本。但ColorPicker()
不会发出文本。这发生在其他地方(如你将很快看到的,在Column()
内部)。为了显示正确的颜色,文本也必须接收它。由于颜色更改发生在ColorPicker()
内部,我们必须通知调用者。作为参数传递的普通Color
实例无法做到这一点,因为 Kotlin 函数参数是不可变的。
我们可以使用全局属性来实现可变性。但这对 Jetpack Compose 来说并不推荐。可组合组件不应使用任何全局变量。将影响可组合函数外观或行为的所有数据作为参数传递是一种最佳实践。如果这些数据在可组合组件内部被修改,你应该使用MutableState
。通过接收状态将状态移动到可组合组件的调用者称为MutableState
,在可组合组件内部应用更改是将更改逻辑作为 lambda 表达式传递。在我的例子中,onValueChange
只会将新的滑块值提供给 lambda 表达式。
重要
尽量使你的可组合组件无副作用。无副作用意味着使用相同的参数集重复调用函数,将始终产生相同的结果。除了从调用者获取所有相关数据外,无副作用还要求不依赖全局属性或调用返回不可预测值的函数。有一些场景下你希望有副作用。我将在第七章“技巧、窍门和最佳实践”中介绍这些内容。
现在,让我们学习颜色是如何传递给文本的:
Column(
modifier = Modifier.width(min(400.dp, maxWidth)),
horizontalAlignment = Alignment.CenterHorizontally
) {
val color = remember { mutableStateOf(Color.Magenta) }
ColorPicker(color)
Text(
modifier = Modifier
.fillMaxWidth()
.background(color.value),
text =
"#${color.value.toArgb().toUInt().toString(16)}",
textAlign = TextAlign.Center,
style = MaterialTheme.typography.h4.merge(
TextStyle(
color = color.value.complementary()
)
)
)
}
ColorPicker()
和Text()
在Column()
内部垂直排列(水*居中)。列的宽度是400
密度无关像素或maxWidth
,取决于哪个值更小。maxWidth
由预定义的BoxWithConstraints()
组合函数定义(你将在控制大小部分了解更多关于它的内容)。ColorPicker()
和Text()
的颜色定义如下:
val color = remember { mutableStateOf(Color.Magenta) }
当Column()
首次组合时,会执行mutableStateOf(Color.Magenta)
。这创建了color
。
但remember
是什么意思?任何后续的组合,称为color
接收由mutableStateOf
创建的值——即MutableState<Color>
(状态提升)。传递给remember
的 lambda 表达式被称为计算。它只会被评估一次。重组总是返回相同的值。
如果引用保持不变,如何改变颜色?实际颜色是通过value
属性访问的。你在ColorPicker()
的代码中看到了这一点。Text()
不会修改颜色——它只是与之一起工作。因此,我们将color.value
(即颜色)而不是可变状态(color
)传递给其一些参数,例如background
。请注意,这是一个修饰符。你将在修改行为部分了解更多关于它们的内容。它设置由组合函数发出的 UI 元素的背景颜色。
此外,你注意到TextStyle()
内部的complementary()
调用吗?这是它的作用:
fun Color.complementary() = Color(
red = 1F - red,
green = 1F - green,
blue = 1F - blue
)
complementary()
是Color
的扩展函数。它计算接收到的颜色的互补色。这样做是为了使文本(使用三个滑块选择的颜色的十六进制 RGB 值)可读,无论当前选中的颜色(用作文本的背景)如何。
在本节中,我谈到了一些非常重要的 Jetpack Compose 概念。让我们回顾一下我们到目前为止学到了什么:
-
组合 UI 是通过嵌套调用组合函数来定义的
-
组合函数会发出 UI 元素或 UI 元素层次结构
-
首次构建 UI 的过程被称为组合
-
在对应用数据进行更改后重建 UI 的过程被称为重组
-
重组是自动发生的
重要
你的应用无法预测重组何时以及多久发生一次。如果涉及动画,这可能会在每一帧发生。因此,使你的组合函数尽可能快是非常重要的。你永远不应该进行耗时的计算、加载数据或保存数据,或访问网络。任何这样的代码都必须在组合函数之外执行。它们只接收准备好的数据。此外,请注意,重组的顺序是不确定的。这意味着
Column()
的第一个子元素可能在源代码中出现在其后的兄弟元素之后才被重组。重组可以并行发生,也可能被跳过。因此,永远不要依赖于特定的重组顺序,也永远不要在组合函数中计算在其他地方需要的东西。
在下一节中,我们将完成对 ColorPickerDemo
应用的浏览。我会向你展示如何指定和限制组合函数的尺寸。
控制尺寸
我的大部分示例都包含 fillMaxSize()
或 fillMaxWidth()
这样的代码。这两个修饰符都控制组合函数的大小。fillMaxSize()
使用所有可用的水*和垂直空间,而 fillMaxWidth()
只最大化水*扩展。
然而,fillMaxWidth()
可能不是滑块的合适选择。在我看来,由于需要拖动滑块的手柄到达最小或最大值,大滑块使用起来会感到笨拙。所以,问题是如何限制其宽度?最直接的方法是使用 width()
修饰符。它将组合函数的首选宽度设置为特定的大小。我希望滑块的最大宽度为 400 密度无关像素。如果屏幕更小,则使用其宽度。以下是实现方法:
modifier = Modifier.width(min(400.dp, maxWidth)),
修饰符属于包含 ColorPicker()
和 Text()
的 Column()
属性。
maxWidth
由 BoxWithConstraints()
组合函数提供:
BoxWithConstraints(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Column ...
}
其内容接收一个 BoxWithConstraintsScope
范围的实例,该范围提供了对 constraints
、minWidth
、minHeight
、maxWidth
和 maxHeight
的访问。BoxWithConstraints()
根据传入的约束定义其内容,基于可用空间。你将在 第四章 中了解更多,布局 UI 元素。
这就完成了对 ColorPickerDemo
应用的浏览。在下一节中,我们将更详细地了解组合函数层次结构如何在 Activity
中显示。
在 Activity 中显示组合函数层次结构
在前一节中,我们构建了一个由三个滑块和一些文本组成的 UI 元素层次结构。我们使用 setContent
(androidx.activity.ComponentActivity
的扩展函数)将其嵌入到一个 Activity
中。这意味着你不能在 任何 活动上调用 setContent
,而只能是在扩展 ComponentActivty
的活动上。androidx.appcompat.app.AppCompatActivity
就是这种情况。
然而,这个类继承了很多与旧 View 基础世界相关的功能,例如对工具栏和选项菜单的支持。Jetpack Compose 处理这些方式不同。你将在 第六章,整合组件 中了解更多。因此,你应该避免使用 AppCompatActivity
,如果可能的话,应该扩展 ComponentActivity
。有关结合基于 View 和 Compose UI 的信息,请参阅 第九章,探索互操作性 API。
让我们回到 setContent
。它期望两个参数:
-
parent
,一个可选的CompositionContext
-
content
,一个用于声明 UI 的可组合函数
你很可能会在大多数情况下省略 parent
。CompositionContext
是一个属于 androidx.compose.runtime
包的抽象类。它用于在逻辑上连接两个组合。这指的是 Jetpack Compose 的内部工作原理,你不需要在你的应用代码中担心这些。然而,为了了解这意味着什么,让我们看看 setContent
的源代码:
图 3.5 – setContent
的源代码
首先,使用 findViewById()
来确定活动是否已经包含一个 androidx.compose.ui.platform.ComposeView
的实例。如果是这样,这个视图的 setParentCompositionContext()
和 setContent()
方法将被调用。
首先让我们看看 setParentCompositionContext()
方法。它属于 AbstractComposeView
,这是 ComposeView
的直接父类。它设置一个 CompositionContext
,这个上下文应该是视图组合的父级。如果这个上下文是 null
,它将自动确定:AbstractComposeView
包含一个名为 ensureCompositionCreated()
的私有函数。它调用另一个 setContent
的实现(这是定义在 Wrapper.android.kt
中的 ViewGroup
的内部扩展函数)并将 resolveParentCompositionContext()
调用的结果作为 parent
传递。
现在,让我们回到前面截图所示的 setContent()
版本。一旦调用 setParentCompositionContext()
,它将调用另一个版本的 setContent()
。这个实现属于 ComposeView
。它设置视图的内容。
如果 findViewById()
返回的不是 ComposeView
,则创建一个新的实例,并在调用 setParentCompositionContext()
和 setContent()
之后将其传递给 setContentView
。
在本节中,我们继续探讨了 Jetpack Compose 的一些内部工作原理。你现在知道 ComposeView
是连接旧式 View 基础世界的缺失环节。我们将在 第九章,探索互操作性 API 中重新访问这个类。
在下一节中,我们将返回修饰符;你将学习它们在底层是如何工作的,以及你如何编写自己的修饰符。
修改可组合函数的行为
与传统命令式 UI 框架中的组件不同,可组合函数不共享一组基本属性。它们也不自动(在继承意义上)重用功能。这必须通过调用其他可组合函数来显式完成。它们的视觉外观和行为可以通过参数、修饰符或两者共同控制。从某种意义上说,修饰符拾取了组件属性的概念,但对其进行了增强 – 与组件属性不同,修饰符可以完全由开发者自行决定使用。
你已经在我的例子中看到了很多修饰符,例如以下这些:
-
width()
-
fillMaxWidth()
-
fillMaxSize()
这些控制相应 UI 元素的宽度和大小;background()
可以设置背景颜色和形状,而 clickable {}
允许用户通过点击 UI 元素与可组合函数进行交互。Jetpack Compose 提供了大量的修饰符,因此熟悉其中大部分可能需要一些时间。从概念上讲,这些修饰符可以分配到几个类别之一,如 动作 (draggable()
), 对齐 (alignByBaseline()
) 或 绘图 (paint()
)。你可以在 developer.android.com/jetpack/compose/modifiers-list
找到按类别分组的修饰符列表。
为了进一步熟悉修饰符,让我们看看 ModifierDemo
示例。它包含几个可组合函数。以下截图显示了正在运行 OrderDemo()
的应用程序:
![图 3.6 – ModifierDemo 应用程序
图 3.6 – ModifierDemo 应用程序
可组合函数在其所有边上产生一个 32 密度无关像素的间隙,然后是一个 2 密度无关像素宽的蓝色边框。内部矩形被涂成浅灰色。
这就是代码的样子:
@Composable
fun OrderDemo() {
var color by remember { mutableStateOf(Color.Blue) }
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp)
.border(BorderStroke(width = 2.dp, color = color))
.background(Color.LightGray)
.clickable {
color = if (color == Color.Blue)
Color.Red
else
Color.Blue
}
)
}
Box()
是可点击的 – 点击它将边框颜色从蓝色变为红色,然后再变回。如果你在间隙内点击,则不会发生任何事。然而,如果你在 .padding(32.dp)
之前移动 .clickable { }
,点击也会在间隙内工作。这是故意的。下面是发生的事情:你通过结合几个修饰符并使用 .
来定义一个修饰符链。这样做时,你指定了修饰符的使用顺序。修饰符在链中的位置决定了它的执行时机。由于 clickable {}
只对可组合组件边界内的点击做出反应,因此当它出现在 clickable {}
之前时,填充不会考虑点击。
在下一节中,我将向你展示 Jetpack Compose 如何在内部处理修饰符和修饰符链。
理解修饰符如何工作
接受修饰符的可组合函数应通过 modifier
参数接收它们,并将其分配一个默认值 Modifier
。modifier
应该是第一个可选参数,因此它出现在所有必需参数之后,除了尾随 lambda 参数。
让我们看看一个可组合函数如何接收 modifier
参数:
@Composable
fun TextWithYellowBackground(
text: String,
modifier: Modifier = Modifier
) {
Text(
text = text,
modifier = modifier.background(Color.Yellow)
)
}
这样,可组合的元素可以从调用者那里接收一个修饰符链。如果没有提供,Modifier
就会作为一个新的空链。在两种情况下,可组合的元素都可以添加额外的修饰符,例如在之前的代码片段中使用的 background()
。
如果一个组合函数接受一个将应用于其对应 UI 元素特定部分或子元素的修饰符,那么这个部分或子元素的名称应该用作前缀,例如 titleModifier
。这样的修饰符遵循我之前提到的规则。它们应该分组并出现在父修饰符之后。请参阅 developer.android.com/reference/kotlin/androidx/compose/ui/Modifier
以获取有关修饰符参数定义的更多信息。
现在你已经知道了如何在组合函数中定义 modifier
参数,让我们更深入地关注一下链式调用的概念。Modifier
既是接口也是伴随对象。接口属于 androidx.compose.ui
包。它定义了几个函数,例如 foldIn()
和 foldOut()
。尽管如此,你不需要它们。重要的是 then()
。它连接两个修饰符。正如你很快就会看到的,你需要在你的修饰符中调用它。Element
接口扩展了 Modifier
。它定义了 Modifier
链中包含的单个元素。最后,Modifier
伴随对象是空的、默认的修饰符,它不包含任何元素。
总结
修饰符是一个有序的、不可变的修饰符元素集合。
接下来,让我们看看 background()
修饰符是如何实现的:
图 3.7 – background() 修饰符的源代码
background()
是 Modifier
的一个扩展函数。它接收一个 Modifier
实例。它调用 then()
并返回结果(一个连接的修饰符)。then()
需要一个参数:应该与当前修饰符连接的 其他 修饰符。在 background()
的情况下,其他 是 Background
的一个实例。这个类扩展了 InspectorValueInfo
并实现了 DrawModifier
接口,而 DrawModifier
接口反过来又扩展了 Modifier.Element
。由于 InspectorValueInfo
主要用于调试目的,我将不再进一步阐述。另一方面,DrawModifier
非常有趣。实现可以绘制到 UI 元素的空间中。我们将在最后一节中使用这一点。
实现自定义修饰符
虽然 Jetpack Compose 包含了一个广泛的修饰符列表,但你可能想实现自己的。让我给你展示如何做到这一点。我的例子 drawYellowCross()
在内容后面绘制了两条粗黄色的线条,这里的 Text()
是一些文本:
图 3.8 – 自定义修饰符
修饰符的调用方式如下:
Text(
text = "Hello Compose",
modifier = Modifier
.fillMaxSize()
.drawYellowCross(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.h1
)
如你所见,修饰符很好地整合到了现有的修饰符链中。现在,让我们看看源代码:
fun Modifier.drawYellowCross() = then(
object : DrawModifier {
override fun ContentDrawScope.draw() {
drawLine(
color = Color.Yellow,
start = Offset(0F, 0F),
end = Offset(size.width - 1, size.height - 1),
strokeWidth = 10F
)
drawLine(
color = Color.Yellow,
start = Offset(0F, size.height - 1),
end = Offset(size.width - 1, 0F),
strokeWidth = 10F
)
drawContent()
}
}
)
drawYellowCross()
是 Modifier
的一个扩展函数。这意味着我们可以调用 then()
并简单地返回结果。then()
接收一个 DrawModifier
的实例。之后,我们只需要实现一个函数,称为 draw()
,它是 ContentDrawScope
的扩展函数。这个接口定义了一个函数(drawContent()
)并扩展了 DrawScope
;这样,我们就能够访问到相当多的绘图原语,例如 drawLine()
、drawRect()
和 drawImage()
。drawContent()
绘制 UI 元素,因此根据它的调用时间,元素会出现在其他绘图原语之前或之后。在我的例子中,它是最后的指令,所以 UI 元素(例如,Text()
)是最顶层的一个。
Jetpack Compose 还包括一个名为 drawBehind {}
的修饰符。它接收一个包含绘图原语的表达式 lambda,就像我的例子一样。为了更深入地了解 Jetpack Compose 的内部机制,你可能想看看它的源代码。要查看它,只需在按住 Ctrl 键的同时点击代码中的 drawBehind()
即可。
这就结束了我对修饰符的解释。正如你所见,它们是一种非常优雅的方式来控制可组合函数的视觉外观和行为。
摘要
本章向你介绍了 Jetpack Compose 的关键原则。我们仔细研究了可组合函数的底层思想和概念,你现在知道了它们是如何编写和使用的。我们还关注了如何创建和更新 UI,以及 Jetpack Compose 如何实现其他框架所说的重绘或更新屏幕。当相关应用数据发生变化时,UI 会发生变化,或者所谓的重新组合会自动发生,这是与传统基于 View 的方法相比的一个优势,在传统方法中,开发者必须强制性地更改组件树。
然后,我们扩展了对修饰符概念的理解。我们研究了修饰符链的工作方式以及你需要注意什么,以确保始终得到预期的结果。例如,为了在填充内接收点击,padding {}
必须在 modifier
链中的 clickable {}
之后出现。最后,你学习了如何实现自定义修饰符。
在第四章“布局 UI 元素”中,我们将探讨如何布局 UI 元素,并介绍单测量遍历。我们将探索内置布局,同时也会编写一个自定义的 Compose 布局。
第二部分:构建用户界面
本部分采用实用方法教您如何编写快速、健壮且美观的 Jetpack Compose 应用程序。众多示例将为您提供对库如何工作的坚实基础。
我们在本节中将涵盖以下章节:
-
第四章,布局 UI 元素
-
第五章,管理可组合函数的状态
-
第六章,整合各个部分
-
第七章,技巧、窍门和最佳实践
第四章:布局 UI 元素
在前面的章节中,你学习了如何构建简单的 UI。尽管它们只包含几个 UI 元素,但它们需要按照特定的顺序、方向或层次结构排列它们的按钮、文本字段和滑块。本章将更详细地探讨布局。
在本章中,我们将涵盖以下主题:
-
使用预定义布局
-
理解单测量传递
-
创建自定义布局
我们将首先探索Row()
、Column()
和Box()
的预定义布局。你将学习如何将它们组合起来创建美观的 UI。接下来,我将向你介绍ConstraintLayout
。它将相对于屏幕上的其他可组合项放置可组合项,并使用属性来简化 UI 元素层次结构。这是嵌套Row()
、Column()
和Box()
的替代方案。
第二个主要部分将解释为什么 Jetpack Compose 中的布局系统比传统的基于 View 的方法更高效。我们还将深入 Compose 运行时的内部结构。这将为你准备本章的最后一个主要部分,创建自定义布局。
在本节的最后,你将学习如何创建自定义布局,从而对子元素的渲染有精确的控制。如果预定义的布局对于特定的用例不够灵活,这很有帮助。
现在,让我们开始吧!
技术要求
本章展示了三个示例应用程序:
-
PredefinedLayoutsDemo
-
ConstraintLayoutDemo
-
CustomLayoutDemo
请参阅第一章的技术要求部分,构建您的第一个 Compose 应用,了解如何安装和设置 Android Studio,以及如何获取它。如果你想尝试组合基本构建块部分的CheckboxWithLabel()
可组合项,你可以使用本书 GitHub 仓库顶级目录中的Sandbox应用程序项目github.com/PacktPublishing/Android-UI-Development-with-Jetpack-Compose
。打开其SandboxActivity
,并从位于/chapter_04
文件夹中的code_snippets.txt
文件中复制可组合函数。
使用预定义布局
当你创建一个 UI 时,你必须定义其元素出现的位置以及它们的大小。Jetpack Compose 提供了一些基本的布局,它们沿着一个主要轴排列其内容。有三种轴需要考虑:
-
水*
-
竖直
-
堆叠
每个轴都由一个布局表示。Row()
水*排列其内容,而Column()
垂直排列。Box()
和BoxWithConstraints()
将它们的内容堆叠在一起。通过组合这些轴定向的构建块,你可以轻松地创建外观精美的 UI。
组合基本构建块
下面的PredefinedLayoutsDemo
示例应用显示了三个复选框,分别切换红色、绿色和蓝色矩形。只有当相应的复选框被选中时,这些盒子才会出现:
图 4.1 – 示例 PredefinedLayoutsDemo 应用
让我们看看这是如何完成的。首先,我将向你展示如何创建一个带有伴随标签的复选框:
@Composable
fun CheckboxWithLabel(label: String, state: MutableState<Boolean>) {
Row(
modifier = Modifier.clickable {
state.value = !state.value
}, verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = state.value,
onCheckedChange = {
state.value = it
}
)
Text(
text = label,
modifier = Modifier.padding(start = 8.dp)
)
}
}
Jetpack Compose 内置了一个Checkbox()
。它接收当前状态(checked
)和一个 lambda 表达式(onCheckedChange
),当复选框被点击时,该表达式会被调用。在撰写本文时,你不能传递一个标签。然而,我们可以通过在Row()
中放置Checkbox()
和Text()
来实现类似的效果。我们需要使行可点击,因为我们希望当文本被点击时也改变复选框的状态。为了使带有标签的复选框更具视觉吸引力,我们可以通过将verticalAlignment
设置为Alignment.CenterVertically
来在行内垂直居中Checkbox()
和Text()
。
CheckboxWithLabel()
接收一个MutableState<Boolean>
,因为当onCheckedChange
内部值改变时,其他可组合元素需要重新组合。
接下来,让我们看看状态是如何创建的:
@Composable
fun PredefinedLayoutsDemo() {
val red = remember { mutableStateOf(true) }
val green = remember { mutableStateOf(true) }
val blue = remember { mutableStateOf(true) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
…
PredefinedLayoutsDemo()
通过将其内容放入Column()
中来垂直排列其内容。该列填充所有可用空间(fillMaxSize()
),并在所有四边都有 16 密度无关像素的填充(padding(16.dp)
)。三个状态(red
、green
和blue
)被传递给CheckboxWithLabel()
。下面是这些调用看起来像什么:
CheckboxWithLabel(
label = stringResource(id = R.string.red),
state = red
)
CheckboxWithLabel(
label = stringResource(id = R.string.green),
state = green
)
CheckboxWithLabel(
label = stringResource(id = R.string.blue),
state = blue
)
它们几乎相同,只是在状态(red
、green
、blue
)和标签字符串(R.string.red
、R.string.green
或R.string.blue
)上有所不同。
现在,让我们来看看如何创建堆叠的彩色盒子:
Box(
modifier = Modifier
.fillMaxSize()
.padding(top = 16.dp)
) {
if (red.value) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Red)
)
}
if (green.value) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp)
.background(Color.Green)
)
}
if (blue.value) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(64.dp)
.background(Color.Blue)
)
}
}
三个彩色盒子被放入另一个Box()
中,该盒子填充所有可用空间。为了在它和最后一个复选框之间创建一个间隙,我指定了 16 密度无关像素的顶部填充。
只有当相应的状态为true
时,才会添加彩色盒子(例如,if (red.value) { …)
)。所有彩色盒子都填充可用空间。由于它们将堆叠在一起,只有最后一个(顶部的)一个是可见的。为了解决这个问题,绿色和蓝色盒子接收不同大小的填充:蓝色盒子(最后一个)的填充为 64 密度无关像素,因此在填充区域,绿色盒子变得可见。绿色盒子有一个 32 密度无关像素的填充,因此在这一区域,第一个盒子(红色)可以被看到。
正如你所见,通过组合基本的布局如Box()
和Row()
,你可以轻松地创建出看起来很棒的 UI。在下一节中,我将向你介绍一种替代方法,我们将基于约束来定义 UI。
基于约束创建布局
基于约束定义 UIs 是 Android 传统View
世界中最新的首选方法,因为像RelativeLayout
或LinearLayout
这样的旧布局在用于大型、多层嵌套布局时可能会影响性能。ConstraintLayout
通过简化View
层次结构来避免这个问题。正如您将在理解单测量传递部分看到的那样,这对 Jetpack Compose 来说不是问题。
然而,对于 Compose 应用程序中更复杂的布局,您可能仍然想要限制Box()
、Row()
和Column()
的嵌套,以使您的代码更简单、更清晰。这就是ConstraintLayout()
可以提供帮助的地方。
ConstraintLayoutDemo
示例应用程序是基于ConstraintLayout()
的PredefinedLayoutsDemo
的重实现。通过比较这两个版本,您可以全面了解这个可组合函数是如何工作的。要在您的应用程序中使用ConstraintLayout()
,您需要在模块级别的build.gradle
文件中添加一个依赖项。请注意,这里显示的版本号只是一个示例。您可以在developer.android.com/jetpack/androidx/versions/all-channel
找到最新版本:
implementation "androidx.constraintlayout:constraintlayout-compose:1.0.0-rc02"
那么,我们如何定义基于约束的布局呢?让我们通过检查CheckboxWithLabel()
的重实现来找出答案。它将文本放置在复选框旁边:
@Composable
fun CheckboxWithLabel(
label: String,
state: MutableState<Boolean>,
modifier: Modifier = Modifier
) {
ConstraintLayout(modifier = modifier.clickable {
state.value = !state.value
}) {
val (checkbox, text) = createRefs()
Checkbox(
checked = state.value,
onCheckedChange = {
state.value = it
},
modifier = Modifier.constrainAs(checkbox) {
}
)
Text(
text = label,
modifier = Modifier.constrainAs(text) {
start.linkTo(checkbox.end, margin = 8.dp)
top.linkTo(checkbox.top)
bottom.linkTo(checkbox.bottom)
}
)
}
}
ConstraintLayout()
使用ConstraintLayout()
必须与一个引用相关联,该引用是通过createRefs()
创建的。约束是通过constrainAs()
修饰符提供的。它的 lambda 表达式接收一个ConstrainScope
。它包括start
、top
和bottom
等属性。这些被称为linkTo()
将它们链接到另一个可组合的位置。
让我们看看Text()
。它的constrainAs()
包含bottom.linkTo(checkbox.bottom)
。这意味着文本的底部被约束到复选框的底部。由于文本的顶部与复选框的顶部相关联,因此文本的高度等于复选框的高度。下一行意味着文本的起始端被复选框的末端约束,并额外增加了 8 个密度无关像素的边距:
start.linkTo(checkbox.end, margin = 8.dp)
因此,在阅读方向上,文本位于复选框之后。接下来,让我们看看ConstraintLayoutDemo()
:
@Composable
fun ConstraintLayoutDemo() {
val red = remember { mutableStateOf(true) }
val green = remember { mutableStateOf(true) }
val blue = remember { mutableStateOf(true) }
ConstraintLayout(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
val (cbRed, cbGreen, cbBlue, boxRed, boxGreen, boxBlue) =
createRefs()
CheckboxWithLabel(
label = stringResource(id = R.string.red),
state = red,
modifier = Modifier.constrainAs(cbRed) {
top.linkTo(parent.top)
}
)
...
一旦我们使用createRefs()
创建了定义约束所需的引用,我们就添加我们的第一个CheckboxWithLabel()
。它的top
与parent
的top
(即ConstraintLayout()
)相关联。因此,带标签的第一个复选框是最上面的一个。下面是如何调用第二个复选框(它切换绿色框)的示例:
CheckboxWithLabel(
label = stringResource(id = R.string.green),
state = green,
modifier = Modifier.constrainAs(cbGreen) {
top.linkTo(cbRed.bottom)
}
)
它的top
被第一个带标签的复选框的bottom
约束。最后,这是我们需要约束第三个CheckboxWithLabel()
的方式:
modifier = Modifier.constrainAs(cbBlue) {
top.linkTo(cbGreen.bottom)
}
为了总结本节,让我向您展示如何定义彩色框。这是红色框的示例:
if (red.value) {
Box(
modifier = Modifier
.background(Color.Red)
.constrainAs(boxRed) {
start.linkTo(parent.start)
end.linkTo(parent.end)
top.linkTo(cbBlue.bottom, margin = 16.dp)
bottom.linkTo(parent.bottom)
width = Dimension.fillToConstraints
height = Dimension.fillToConstraints
}
)
}
start
和end
都与parent
(即ConstraintLayout()
)的相应锚点相关联。top
由最后一个复选框的bottom
约束,所以红色框出现在其下方。红色框的bottom
由parent
的bottom
约束。请注意,目前,我们必须将width
和height
设置为从Dimension.fillToConstraints
获得的值。否则,框将不会有正确的大小。
接下来,让我们看看绿色框的约束条件:
constrainAs(boxGreen) {
start.linkTo(parent.start, margin = 32.dp)
end.linkTo(parent.end, margin = 32.dp)
top.linkTo(cbBlue.bottom, margin = (16 + 32).dp)
bottom.linkTo(parent.bottom, margin = 32.dp)
width = Dimension.fillToConstraints
height = Dimension.fillToConstraints
}
这段代码实际上是一样的。一个区别是所有边都接收一个 32 密度无关像素的margin
。这是必要的,因为我们希望红色框(位于绿色框下方)在边框的位置可见。由于红色框已经有一个 16 的top
边距,我们必须将这个值加到top
边距上。你可能想知道为什么我没有链接到boxRed
。那是因为如果对应的复选框没有被勾选,红色框将不会出现。在这种情况下,锚点将不会存在。
这里是蓝色框的约束条件将呈现的样子:
constrainAs(boxBlue) {
start.linkTo(parent.start, margin = 64.dp)
end.linkTo(parent.end, margin = 64.dp)
top.linkTo(cbBlue.bottom, margin = (16 + 64).dp)
bottom.linkTo(parent.bottom, margin = 64.dp)
width = Dimension.fillToConstraints
height = Dimension.fillToConstraints
}
我需要改变的是所有四边的边距,否则下面的框(绿色框)将不可见。
简而言之,这就是ConstrainLayout()
的工作方式:
-
你通过将可组合元素的锚点链接到其他元素来约束它
-
链接基于引用。为了设置这些引用,你必须调用
createRefs()
。
结合Box()
、Row()
和Column()
的主要优势是你可以简化你的 UI 元素层次结构。可以这样想:在PredefinedLayoutsDemo
中,我需要在父Box()
中堆叠彩色框。在ConstrainLayoutDemo
中,框和三个CheckboxWithLabel()
共享同一个父元素(一个ConstrainLayout()
)。这减少了可组合元素的数量,并使代码更简洁。
在下一节中,我们将再次深入了解 Jetpack Compose 的内部结构。我们将学习布局过程是如何工作的,以及为什么它比传统的基于 View 的方法更高效。
理解单测量过程
布局 UI 元素层次结构意味着确定所有元素的大小,并根据其父元素的布局策略在屏幕上定位它们。起初,获取例如一些文本的大小听起来并不太复杂。毕竟,它不是由字体和要输出的文本决定的吗?以下是一个例子,其中两行文本在一个Column()
中布局:
@Composable
@Preview
fun ColumnWithTexts() {
Column {
Text(
text = "Android UI development with Jetpack Compose",
style = MaterialTheme.typography.h3
)
Text(
text = "Hello Compose",
style = MaterialTheme
.typography.h5.merge(TextStyle(color = Color.Red))
)
}
}
如果你部署了预览,你会注意到,在纵向模式下,第一行文本在垂直方向上所需的空格比在横向模式下多。第二行文本总是适应一行。一个可组合元素在屏幕上占据的大小部分取决于从外部强加的条件。在这里,列的最大宽度(父元素)影响了第一行文本的高度。这样的条件被称为ConstraintLayout()
。
一旦布局获取并测量了其内容的尺寸,布局将定位其子元素(内容)。让我们通过查看 Column()
的源代码来了解这是如何工作的:
图 4.2 – Column()
的源代码
可组合项非常短。除了为 measurePolicy
分配值外,它还只调用了 Layout()
,传递了 content
、measurePolicy
和 modifier
。我们在 第三章 的 “发射 UI 元素” 部分简要地查看过 Layout()
的源代码,以了解发射 UI 元素的含义。现在,我们将专注于布局过程。measurePolicy
变量引用了 MeasurePolicy
接口的一个实现。在这种情况下,它是 columnMeasurePolicy()
调用的结果。
定义测量策略
根据 verticalArrangement
和 horizontalAlignment
的值,对 columnMeasurePolicy()
的调用将返回 DefaultColumnMeasurePolicy
(一个变量)或 rowColumnMeasurePolicy()
的结果。DefaultColumnMeasurePolicy
调用 rowColumnMeasurePolicy
。因此,此函数定义了任何 Column()
的测量策略。它返回一个 MeasurePolicy
。
小贴士
请记住,您可以通过按 Ctrl 键并单击名称,例如 columnMeasurePolicy
,来查看策略的源代码。
MeasurePolicy
属于 androidx.compose.ui.layout
包。它定义了布局如何进行测量和布局,因此它是预定义布局(例如,Box()
、Row()
和 Column()
)和自定义布局的主要构建块。它的最重要的功能是 measure()
,这是一个 MeasureScope
的扩展函数。此函数接收两个参数,List<Measurable>
和 Constraints
。列表中的元素代表布局的子元素。它们可以使用 Measurable.measure()
进行测量。此函数返回一个 Placeable
实例,表示子元素想要占据的大小。
MeasureScope.measure()
返回一个 MeasureResult
实例。此接口定义了以下组件:
-
布局的尺寸(
width
、height
) -
对齐线(
alignmentLines
) -
定位子元素的逻辑(
placeChildren()
)
您可以在 “创建自定义布局” 部分找到 MeasureResult
的实现。
对齐线定义了一个偏移线,父布局可以使用它来对齐和定位其子元素。例如,文本基线是对齐线。
根据 UI 的复杂性,布局可能会发现其子元素无法很好地适应其边界。布局可能想要重新测量子元素,传递不同的测量配置。在 Android View
系统中,重新测量子元素是可能的,但这可能会导致性能下降。因此,在 Jetpack Compose 中,布局可能只测量其内容一次。如果它再次尝试,将抛出异常。
然而,布局可以查询 MeasurePolicy
定义了 IntrinsicMeasureScope
的四个扩展函数。minIntrinsicWidth()
和 maxIntrinsicWidth()
返回给定特定高度的布局的最小或最大宽度,以便完全绘制布局的内容。minIntrinsicHeight()
和 maxIntrinsicHeight()
返回给定特定宽度的布局的最小或最大高度,以便完全绘制布局的内容。为了了解它们是如何工作的,让我们简要地看看其中一个:
图 4.3 – minIntrinsicWidth()
的源代码
IntrinsicMeasureScope.minIntrinsicWidth()
接收两个参数:height
和子视图列表(measurables
)。IntrinsicMeasurable
接口定义了四个函数,用于获取特定元素的最小或最大值(minIntrinsicWidth()
、maxIntrinsicWidth()
、minIntrinsicHeight()
和 maxIntrinsicHeight()
)。measurables
的每个元素都被转换成 DefaultIntrinsicMeasurable
的实例。由于该类实现了 Measurable
接口,它提供了一个 measure()
的实现。它返回 FixedSizeIntrinsicsPlaceable
,为给定的 height
提供可能的最小宽度。转换后的子视图由 IntrinsicsMeasureScope
的一个实例进行测量。
我们将通过转向 Constraints
来完成对 Compose 布局过程内部结构的查看。例如,它们会被传递给 MeasureScope.measure()
。该类属于 androidx.compose.ui.unit
包。它存储四个值:minWidth
、minHeight
、maxWidth
和 maxHeight
。它们定义了布局子视图在测量自身时必须遵守的最小和最大值。因此,它们的宽度必须不小于 minWidth
且不大于 maxWidth
。它们的高度必须在 minHeight
和 maxHeight
之间。
伴随对象定义了 Infinity
常量。它用于表示约束应该被视为无限。要创建 Constraints
实例,你可以调用顶层 Constraints()
函数。
这有很多信息。在继续之前,让我们回顾一下我们已经学到的内容。
-
Layout()
组合函数接收三个参数:内容、度量策略和一个修饰符。 -
度量策略定义了布局如何被测量和布局。
-
布局的固有大小决定了对应输入的最小或最大维度。
在传统的视图系统中,父视图可能会对其子视图多次调用 measure()
方法(请参阅developer.android.com/guide/topics/ui/how-android-draws
获取详细信息)。另一方面,Jetpack Compose 要求在子视图定位之前必须精确测量一次。这导致测量性能更优。
在下一节中,我们将利用这些知识来实现一个简单的自定义布局。它将从左到右和从上到下定位其子项。当一行填满时,下一行将在其下方开始。
创建自定义布局
有时,您可能希望在一行中依次排列子项,并在当前行填满后开始新的一行。下面的截图显示了 CustomLayoutDemo
示例应用程序,它展示了如何做到这一点。它创建了 43 个随机着色的框,宽度和高度各不相同:
![图 4.4 – 示例 CustomLayoutDemo 应用程序
图 4.4 – 示例 CustomLayoutDemo 应用程序
让我们从查看创建彩色框的组成函数开始:
@Composable
fun ColoredBox() {
Box(
modifier = Modifier
.border(
width = 2.dp,
color = Color.Black
)
.background(randomColor())
.width((40 * randomInt123()).dp)
.height((10 * randomInt123()).dp)
)
}
一个彩色框由一个带有黑色、宽度为两个密度无关像素的 Box()
组成。width()
和 height()
修饰符设置框的首选大小。这意味着布局可以覆盖它。为了简单起见,我的示例没有这样做。randomInt123()
随机返回 1
、2
或 3
:
private fun randomInt123() = Random.nextInt(1, 4)
randomColor()
随机返回红色、绿色或蓝色:
private fun randomColor() = when (randomInt123()) {
1 -> Color.Red
2 -> Color.Green
else -> Color.Blue
}
接下来,我将向您展示如何创建并设置彩色框作为我自定义布局的内容:
@Composable
@Preview
fun CustomLayoutDemo() {
SimpleFlexBox {
for (i in 0..42) {
ColoredBox()
}
}
}
SimpleFlexBox()
是我们的自定义布局。它就像任何预定义布局一样使用。您甚至可以提供一个修饰符(这里为了简单起见没有这样做)。那么,自定义布局是如何工作的呢?让我们来看看:
@Composable
fun SimpleFlexBox(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content,
measurePolicy = simpleFlexboxMeasurePolicy()
)
}
自定义布局应至少接收两个参数 – content
和一个默认值为 Modifier
的 modifier
。其他参数可能会影响自定义布局的行为。例如,您可能希望使子项的对齐方式可配置。为了简单起见,示例没有这样做。
如您从上一节所知,测量和定位是通过测量策略定义的。我将在下一节中向您展示如何实现它。
实现自定义测量策略
到目前为止,我已经向您展示了几乎所有的自定义布局代码。唯一缺少的是测量策略。让我们看看它是如何工作的:
private fun simpleFlexboxMeasurePolicy(): MeasurePolicy =
MeasurePolicy { measurables, constraints ->
val placeables = measurables.map { measurable ->
measurable.measure(constraints)
}
layout(
constraints.maxWidth,
constraints.maxHeight
) {
var yPos = 0
var xPos = 0
var maxY = 0
placeables.forEach { placeable ->
if (xPos + placeable.width >
constraints.maxWidth
) {
xPos = 0
yPos += maxY
maxY = 0
}
placeable.placeRelative(
x = xPos,
y = yPos
)
xPos += placeable.width
if (maxY < placeable.height) {
maxY = placeable.height
}
}
}
}
MeasurePolicy
实现必须提供 MeasureScope.measure()
方法的实现。此方法返回 MeasureResult
接口的一个实例。您不需要自己实现它。相反,您必须调用 layout()
。此函数属于 MeasureScope
。
我们传递布局的测量大小和一个 placementBlock
,它是 Placeable.PlacementScope
的扩展函数。这意味着您可以调用 placeRelative()
等函数来在父坐标系统中定位子项。
测量策略接收内容或子项作为 List<Measurable>
。正如你在 理解单次测量传递 部分所知,在定位之前,子项必须精确测量一次。我们可以通过创建一个 placeables
映射,对每个 measurable
调用 measure()
来实现这一点。我的示例没有进一步约束子视图,而是使用给定的约束来测量它们。
placementBlock
遍历 placeables
映射,通过增加 xPos
和 yPos
来计算可放置项的位置。在调用 placeRelative()
之前,算法会检查可放置项是否完全适合当前行。如果不是这种情况,yPos
将会增加,而 xPos
将重置为 0
。yPos
增加的量取决于当前行中所有可放置项的最大高度。这个值存储在 maxY
中。
正如你所见,实现简单的自定义布局很简单。关于对齐线(有助于/需要用于 X...)等高级主题超出了本书的范围。你可以在 developer.android.com/jetpack/compose/layouts/alignment-lines
找到更多关于它们的信息。
摘要
本章探讨了预定义的布局 Row()
、Column()
和 Box()
。你学习了如何将它们组合起来创建美观的用户界面。你还介绍了 ConstraintLayout
,它将相对于屏幕上的其他可组合项放置可组合项并简化 UI 元素层次结构。
第二个主要部分探讨了为什么 Jetpack Compose 中的布局系统比传统的基于 View 的方法更高效。我们查看了一些 Compose 运行时的内部机制,这为我们学习本章的最后一部分,创建自定义布局,打下了基础,在那里你学习了如何创建自定义布局并因此对子元素的渲染获得精确控制。
下一章,管理 Composable 函数的状态,将深化你对状态的理解。我们将研究无状态和有状态的可组合函数之间的区别。此外,我们还将探讨诸如配置更改后存活等高级用例。
第五章:管理你的可组合函数的状态
在 第四章,布局 UI 元素 中,我向你展示了如何通过拖动滑块来设置颜色的红色、绿色和蓝色部分。我们使用 状态 在可组合函数之间共享这些值。前几章的许多其他示例应用也处理了状态。事实上,对状态变化的反应是现代移动应用工作方式的关键。
到目前为止,我已经描述了状态为随时间变化的数据。你了解了一些重要的函数,例如,remember { }
和 mutableStateOf()
。我还简要提到了一个称为 状态提升 的概念。
本章建立在这些基础之上。例如,你将了解无状态和有状态的可组合组件之间的区别,以及何时选择哪一个。此外,我将向你展示在表现良好的 Compose 应用中事件应该如何流动。
本章的主要部分如下:
-
理解有状态和无状态的可组合函数
-
提升状态和传递事件
-
应对配置更改
我们将首先探讨有状态和无状态的可组合函数之间的区别。你将了解它们的典型用例,并理解为什么你应该尽量保持你的可组合函数无状态。提升状态是一个实现这一目标的工具;我们将在第二主要部分中介绍这个重要主题。此外,我将向你展示你可以通过传递逻辑作为参数来使你的可组合函数可重用,而不是在可组合函数内部实现它。
最后,应对配置更改 部分将探讨在活动中集成 Compose UI 层次结构,关注如何保留用户输入。如果用户从纵向模式切换到横向模式(或反之),活动将被销毁并重新创建。当然,输入信息不应该丢失。我们将探讨 Compose 应用实现这一点的几种方法。
技术要求
本章包含三个示例应用。请参考 第一章,构建你的第一个 Compose 应用,了解如何安装和设置 Android Studio,以及如何获取它们。StateDemo
包含了 理解有状态和无状态的可组合函数 部分的所有示例。提升状态和传递事件 部分讨论了 FlowOfEventsDemo
示例。最后,ViewModelDemo
属于 应对配置更改 部分。
本章的所有代码文件都可以在 GitHub 上找到,链接为 github.com/PacktPublishing/Android-UI-Development-with-Jetpack-Compose/tree/main/chapter_05
。
理解有状态和无状态的可组合函数
在本节中,我将向您展示有状态和无状态的可组合函数之间的区别。为了理解为什么这很重要,让我们首先关注一下 状态 这个词。在之前的章节中,我将状态描述为 随时间变化的数据。数据存储的位置(一个 SQLite 数据库、一个文件或一个对象内的值)并不重要。重要的是 UI 必须始终显示当前数据。因此,如果值发生变化,UI 必须得到通知。为了实现这一点,我们使用 可观察 类型。这不仅仅局限于 Jetpack Compose,而是许多框架、编程语言和*台中的常见模式。例如,Kotlin 通过属性委托支持可观察性:
var counter by observable(-1) { _, oldValue, newValue ->
println("$oldValue -> $newValue")
}
for (i in 0..3) counter = i
observable()
返回一个可以读取和写入的属性的委托。在上一个代码片段中,初始值被设置为 -1
。当属性值改变时(counter = i
),该属性会调用一个指定的函数。我的示例会打印出旧值和新值。在一个命令式 UI 框架中,状态变化需要修改组件树。这样的代码可以放在回调函数中。幸运的是,Jetpack Compose 不需要这样做,因为状态变化会自动触发相关 UI 元素的重新组合。让我们看看这是如何工作的。
androidx.compose.runtime.State
基础接口定义了一个值持有者,一个对象,它在名为 value
的属性中存储特定类型的值。如果在可组合函数的执行过程中读取该属性,当 value
发生变化时,可组合函数将重新组合,因为内部当前的 RecomposeScope
接口将订阅该值的更改。请注意,为了能够更改值,状态必须是 MutableState
的实现;与它的直接前身(State
)不同,该接口使用 var
而不是 val
来定义 value
。
创建 State
实例的最简单方法是调用 mutableStateOf()
。此函数返回一个新的 MutableState
实例,并使用传入的值进行初始化。下一节将解释如何使用 mutableStateOf()
创建有状态的可组合函数。
在可组合函数中使用状态
被称为 remember {}
的可组合函数。让我们看一下:
@Composable
@Preview
fun SimpleStateDemo1() {
val num = remember { mutableStateOf(Random.nextInt(0,
10)) }
Text(text = num.value.toString())
}
SimpleStateDemo1()
创建了一个包含随机整数的可变状态。通过调用 remember {}
,我们保存了状态,并在使用 =
时将其分配给 num
。我们通过 num.value
获取随机数。请注意,尽管我们使用 val
关键字定义了 num
,但我们仍然可以通过 num.value = …
来更改其值,因为 num
持有可变值持有者的引用(其 value
属性是可写的)。把它想象成修改列表中的一个项目,而不是改变到另一个列表。我们可以稍微修改一下代码,如下面的代码片段所示。你能发现其中的区别吗?
@Composable
@Preview
fun SimpleStateDemo2() {
val num by remember { mutableStateOf(Random.nextInt(0,
10)) }
Text(text = num.toString())
}
SimpleStateDemo2()
创建了一个包含随机整数的可变状态。使用 by
,我们不是将状态本身赋值给 num
,而是它存储的值(随机数)。这样我们就可以避免使用 .value
,这使得代码更短,也许更容易理解。然而,如果我们想改变 num
,我们必须将 val
改为 var
。否则,我们会看到一个 Val cannot be reassigned
错误信息。
你可能想知道 remember {}
在底层做了什么。让我们看看它的代码,并找出答案:
![Figure 5.1 – remember {} 的源代码]
![img/B17505_05_1.jpg]
![Figure 5.1 – remember {} 的源代码]
只读的顶级 currentComposer
属性属于 androidx.compose.runtime
包。它引用了一个 Composer
实例。这个接口被 Compose Kotlin 编译器插件所针对,并由代码生成助手使用。你不应该直接调用它,因为运行时假设调用是由编译器生成的,因此不包含太多的验证逻辑。"Cache()" 是 Composer
的一个扩展函数。它在组合的数据中存储一个值。因此,remember {}
创建了内部状态。因此,包含 remember {}
的组合函数是有状态的。
calculation
代表一个 lambda 表达式,它创建要记住的值。它只在组合期间评估一次。随后的 remember {}
调用(在重新组合期间)总是返回这个值。表达式不会被再次评估。但如果我们需要重新评估计算,即记住一个新的值呢?毕竟,状态数据是可以随时间变化的。下面是如何做到这一点的方法:
@Composable
@Preview
fun RememberWithKeyDemo() {
var key by remember { mutableStateOf(false) }
val date by remember(key) { mutableStateOf(Date()) }
Column(horizontalAlignment =
Alignment.CenterHorizontally) {
Text(date.toString())
Button(onClick = { key = !key }) {
Text(text = stringResource(id = R.string.click))
}
}
}
RememberWithKeyDemo()
的预览显示在 图 5.2 中:
![Figure 5.2 – RememberWithKeyDemo() 的预览]
![img/B17505_05_2.jpg]
图 5.2 – RememberWithKeyDemo() 的预览
RememberWithKeyDemo()
发射带有两个水*居中子项的 Column()
。
-
Text()
显示一个被记住的Date
实例的字符串表示。 -
Button()
切换一个布尔值(key
)。
你有没有注意到我把 key
传递给了 remember { mutableStateOf(Date()) }
?下面是发生的事情——当 remember {}
首次被调用时,计算的结果(mutableStateOf(Date())
)被记住并返回。在重新组合过程中,除非 key
与前一次组合不相等,否则不会重新评估计算。在这种情况下,会计算一个新的值,记住并返回。
提示
你可以向 remember {}
传递任意数量的键。如果其中之一自上次组合以来已更改,则计算将被重新评估,新的值将被记住并返回。
将键传递给 remember {}
允许你更改记住的值。但请记住,这会使组合函数的可预测性降低。因此,你应该考虑是否需要将这种逻辑组合起来,或者是否可以将所有状态传递给它。
在下一节中,我们将转向无状态组合。
编写无状态组合函数
remember {}
使组合函数有状态。另一方面,无状态组合组件不持有任何状态。以下是一个例子:
@Composable
@Preview
fun SimpleStatelessComposable1() {
Text(text = "Hello Compose")
}
SimpleStatelessComposable1()
不接收参数,并且总是以相同的参数调用Text()
。显然,它不持有任何状态。但以下一个如何?
@Composable
fun SimpleStatelessComposable2(text: State<String>) {
Text(text = text.value)
}
虽然它通过text
参数接收状态,但它不存储它,也不记住其他状态。因此,SimpleStatelessComposable2()
也是无状态的。当多次用相同的参数调用时,它的行为也是一样的。这样的函数被称为SimpleStatelessComposable2()
是你自己的组合函数的好蓝图。它们应该如下所示:
-
快速:你的组合组件不得进行重(即耗时)的计算。永远不要调用网络服务或执行任何其他 I/O 操作。组合组件使用的任何数据都应该传递给它。
-
无副作用:不要修改全局属性或产生意外的可观察效果(修改传递给组合组件的状态当然是故意的)。
-
remember {}
,不要访问全局属性,也不要调用不可预测的代码。例如,SimpleStateDemo1()
和SimpleStateDemo2()
使用Random.nextInt()
,根据定义,它是(实际上)不可预测的。
这样的组合函数既容易重用又容易测试,因为它们不依赖于作为参数传递之外的东西。
当开发可重用的组合组件时,你可能希望公开有状态和无状态的版本。让我们看看这看起来是什么样子:
@Composable
fun TextFieldDemo(state: MutableState<TextFieldValue>) {
TextField(
value = state.value,
onValueChange = {
state.value = it
},
placeholder = { Text("Hello") },
modifier = Modifier.fillMaxWidth()
)
}
这个版本是无状态的,因为它接收状态但不记住任何东西。无状态版本对于需要控制状态或自己提升状态的调用者来说是必要的:
@Composable
@Preview
fun TextFieldDemo() {
val state = remember { mutableStateOf(TextFieldValue("")) }
TextFieldDemo(state)
}
这个版本是有状态的,因为它记得它创建的状态。有状态版本对于不关心状态的调用者来说很方便。
总结一下,尽量通过不依赖remember {}
或其他记住状态的功能(例如,rememberLazyListState()
或rememberSaveable()
)来使你的组合组件无状态。相反,将状态传递给组合组件。你将在下一节中看到更多用例。
提升状态和传递事件
因此,状态是任何随时间变化的价值。由于 Jetpack Compose 是一个声明式 UI 框架,更新组合组件的唯一方法是使用新的参数调用它。当组合组件使用的状态发生变化时,这会自动发生。状态提升是一种将状态向上移动以使组合组件无状态的模式。
除了使组合组件更容易重用和测试外,将状态向上移动对于在多个组合函数中使用它是必要的。你已经在我的许多示例应用中看到了这一点。例如,在第三章的“组合和重新组合 UI”部分,我们使用三个滑块创建和显示一个颜色。
当状态控制组合函数的视觉表示(即它在屏幕上的外观)时,FlowOfEventsDemo
应用是一个简单的温度转换器。用户输入一个值,指定它代表摄氏度还是华氏度,然后点击 转换 按钮:
图 5.3 – 示例 FlowOfEventsDemo 应用
UI 由 Column()
组成,包含四个子元素:一个文本输入字段、一组带文本的单选按钮、一个按钮和一些结果文本。让我们首先看看文本输入字段:
@Composable
fun TemperatureTextField(
temperature: MutableState<String>,
modifier: Modifier = Modifier,
callback: () -> Unit
) {
TextField(
value = temperature.value,
onValueChange = {
temperature.value = it
},
…
modifier = modifier,
keyboardActions = KeyboardActions(onAny = {
callback()
}),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done
),
singleLine = true
)
}
它接收 MutableState<String>
,在 onValueChange {}
中向其中推送文本更改。虚拟键盘配置为显示 完成 按钮。如果它被调用,通过 callback
传递给组合函数的代码将被执行。您稍后将会看到,如果用户点击 转换 按钮,相同的代码也会运行。
在下一节中,我将向您展示如何创建单选按钮并将它们分组,以便每次只能选择一个按钮。本节还涵盖了按钮和结果文本,您可以在 图 5.3 中看到。
创建单选按钮组
该应用在摄氏度和华氏度之间进行转换。因此,用户必须选择目标刻度。此类选择可以很容易地在 Jetpack Compose 中使用 androidx.compose.material.RadioButton()
实现。这个组合函数不显示一些描述性文本,但很容易添加。下面是如何做的:
@Composable
fun TemperatureRadioButton(
selected: Boolean,
resId: Int,
onClick: (Int) -> Unit,
modifier: Modifier = Modifier
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
) {
RadioButton(
selected = selected,
onClick = {
onClick(resId)
}
)
Text(
text = stringResource(resId),
modifier = Modifier
.padding(start = 8.dp)
)
}
}
RadioButton()
和 Text()
简单地添加到 Row()
中并垂直居中。TemperatureRadioButton()
接收一个带有 onClick
参数的 lambda 表达式。当单选按钮被点击时,将执行该代码。我的实现将 resId
参数传递给 lambda 表达式,该表达式将用于确定组中的按钮。下面是如何做的:
@Composable
fun TemperatureScaleButtonGroup(
selected: MutableState<Int>,
modifier: Modifier = Modifier
) {
val sel = selected.value
val onClick = { resId: Int -> selected.value = resId }
Row(modifier = modifier) {
TemperatureRadioButton(
selected = sel == R.string.celsius,
resId = R.string.celsius,
onClick = onClick
)
TemperatureRadioButton(
selected = sel == R.string.fahrenheit,
resId = R.string.fahrenheit,
onClick = onClick,
modifier = Modifier.padding(start = 16.dp)
)
}
}
两个 TemperatureRadioButton()
被放入一个 Row()
中。第一个配置为表示摄氏度,第二个表示华氏度。两者都接收相同的 onClick
lambda。它将 TemperatureRadioButton()
接收到的 resId
参数设置为 selected
参数的新值,这是一个可变状态。那么这里发生了什么?单选按钮的点击不是在 TemperatureRadioButton()
内部处理,而是传递给父元素 TemperatureScaleButtonGroup()
。事件,即按钮点击,被称为 冒泡。这样,父元素可以协调其子元素并通知其父元素。在我的例子中,这意味着改变一些状态。
接下来,让我们看看当用户点击 FlowOfEventsDemo()
时会发生什么。以下是这个组合函数的整体结构:
@Composable
@Preview
fun FlowOfEventsDemo() {
...
val calc = {
val temp = temperature.value.toFloat()
convertedTemperature = if (scale.value ==
R.string.celsius)
(temp * 1.8F) + 32F
else
(temp - 32F) / 1.8F
}
val result = remember(convertedTemperature) {
if (convertedTemperature.isNaN())
""
else
"${convertedTemperature}${
if (scale.value == R.string.celsius)
strFahrenheit
else strCelsius
}"
}
val enabled = temperature.value.isNotBlank()
Column( ... ) {
TemperatureTextField(
temperature = temperature,
modifier = Modifier.padding(bottom = 16.dp),
callback = calc
)
TemperatureScaleButtonGroup(
selected = scale,
modifier = Modifier.padding(bottom = 16.dp)
)
Button(
onClick = calc,
enabled = enabled
) {
Text( ... )
}
if (result.isNotEmpty()) {
Text(text = result, …
)
}
}
}
转换逻辑被分配给一个名为 calc
的只读变量。它被传递给 TemperatureTextField()
和 Button()
。将响应事件的代码传递给组合函数而不是在内部硬编码,使得组合函数更容易重用和测试。
转换后显示的文本会被记住并分配给 result
。当 convertedTemperature
发生变化时,它会重新评估。这发生在 calc
lambda 表达式内部。请注意,我需要向 remember {}
传递一个键;否则,如果用户选择了另一个刻度,结果也会改变。
在下一节中,我们将探讨如何持久化状态。更准确地说,我们将转向配置更改。如果用户旋转设备,UI 不应该被重置。不幸的是,到目前为止我所展示的所有示例应用都是这样做的。是时候解决这个问题了。
适应配置更改
请记住,我们关于状态的定义——即可能随时间变化的数据——相当广泛。例如,我们没有指定数据存储的位置。如果它位于数据库、文件或云中的某个后端,则应用应包含一个专门的持久化层。然而,直到 2017 年 Google 引入了 Android 架构组件,开发者实际上没有关于如何构建应用的指导。因此,持久化代码、UI 逻辑和领域逻辑通常被挤在一个活动中。这样的代码难以维护,并且往往容易出错。更复杂的是,当活动被销毁并在不久后重新创建时,会出现这种情况。例如,当用户旋转设备时就会发生这种情况。当然,数据应该被记住。
Activity
类有几个方法来处理这种情况。例如,当活动(暂时)被销毁时,会调用 onSaveInstanceState()
方法。它的对应方法 onRestoreInstanceState()
只在之前已经保存了实例状态时被调用。这两个方法都接收一个 Bundle
实例,它为各种数据类型提供了获取器和设置器。然而,实例状态的概念是为传统的视图系统设计的。大多数活动持有 UI 元素的引用,因此可以在 onSaveInstanceState()
和 onRestoreInstanceState()
中轻松访问。
相反,可组合项通常被实现为顶级函数。那么,如何在活动中设置或查询它们的状态呢?为了在 Compose 应用中临时保存状态,你可以使用 rememberSaveable {}
。这个可组合函数会记住由工厂函数产生的值。它的行为类似于 remember {}
。存储的值将存活于活动或进程的重创。内部,使用了 savedInstanceState
机制。示例 ViewModelDemo
应用展示了如何使用 rememberSaveable {}
。以下是主活动的外观:
class ViewModelDemoActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ViewModelDemo()
}
}
}
我们不需要重写 onSaveInstanceState()
来临时保存与可组合项一起使用的状态:
@Composable
@Preview
fun ViewModelDemo() {
...
val state1 = remember {
mutableStateOf("Hello #1")
}
val state2 = rememberSaveable {
mutableStateOf("Hello #2")
}
...
state3.value?.let {
Column(modifier = Modifier.fillMaxWidth()) {
MyTextField(state1) { state1.value = it }
MyTextField(state2) { state2.value = it }
...
}
}
}
应用程序显示了三个文本输入字段,它们从分配给 state1
、state2
和 state3
的状态接收值。现在,我们将专注于前两个。state3
将是 使用 ViewModel 部分的主题。state1
调用 remember {}
,而 state2
使用 rememberSaveable {}
。如果您运行了 ViewModelDemo
,更改了文本输入字段的内容,并旋转了设备,第一个将重置为原始文本,而第二个将保留您的更改。
MyTextField
是一个非常简单的可组合组件。它看起来像这样:
@Composable
fun MyTextField(
value: State<String?>,
onValueChange: (String) -> Unit
) {
value.value?.let {
TextField(
value = it,
onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth()
)
}
}
您注意到 value
是 State<String?>
类型吗?为什么我需要一个其值可以是 null
的值持有者,因此需要使用 value.value?.let {}
来检查它不是 null
?我们将在下一节中重用这个可组合组件,您将在那里找到这个问题的答案。请注意,尽管如此,对于 state1
和 state2
,这并不是必要的。
使用 ViewModel
虽然 rememberSaveable {}
在临时存储状态时效果很好,但应用程序仍然必须获取持久化时间较长的数据(例如,在数据库或文件中),并将其作为可以在可组合组件中使用的状态提供。Android 架构组件包括 ViewModel
和 LiveData
。两者都可以与 Jetpack Compose 无缝使用。
首先,您需要将一些实现依赖项添加到模块级别的 build.gradle
文件中:
implementation "androidx.compose.runtime:runtime-
livedata:$compose_version"
implementation 'androidx.lifecycle:lifecycle-runtime-
ktx:2.4.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-
compose:2.4.0'
下一步是定义一个 ViewModel
类。它扩展了 androidx.lifecycle.ViewModel
。ViewModel
类以生命周期感知的方式存储和管理与 UI 相关的数据。这意味着数据将生存配置更改,例如屏幕旋转。MyViewModel
公开一个名为 text
的属性和一个名为 setText()
的方法来设置它:
class MyViewModel : ViewModel() {
private val _text: MutableLiveData<String> =
MutableLiveData<String>("Hello #3")
val text: LiveData<String>
get() = _text
fun setText(value: String) {
_text.value = value
}
}
我的示例显示了一个使用 LiveData
的 ViewModel
类。根据应用程序的架构,您可以使用其他机制来处理可观察数据。然而,更详细的介绍超出了本书的范围。您可以在 应用程序架构指南 中找到更多信息,网址为 developer.android.com/jetpack/guide
。
要从可组合函数内部访问 ViewModel
类,我们调用 viewModel()
可组合函数。它属于 androidx.lifecycle.viewmodel.compose
包:
val viewModel: MyViewModel = viewModel()
LiveData
以如下状态提供:
val state3 = viewModel.text.observeAsState()
让我们快速看一下它的源代码:
![图 5.4 – observeAsState() 扩展函数的源代码
图 5.4 – observeAsState() 扩展函数的源代码
observeAsState()
是 LiveData
的一个扩展函数。它将 LiveData
实例的 value
属性传递给一个接受参数的 observeAsState()
变体。你注意到返回类型是 State<T?>
吗?这就是为什么我在上一节中定义了 MyTextField()
来接收 State<String?>
的原因。为了能够像使用 remember {}
和 rememberSaveable {}
一样使用 State<String>
,我们需要像这样定义 state3
:
val state3 =
viewModel.text.observeAsState(viewModel.text.value) as
State<String>
在我看来,这不如使用 State<String?>
有利,因为我们使用了一个未经检查的类型转换。
要在 ViewModel
类中反映状态的变化,我们需要像这样的代码:
MyTextField(state3) {
viewModel.setText(it)
}
与使用 MutableState
不同,我们必须显式调用 MyViewModel
的 setText()
方法并传递更改后的文本。
总结来说,rememberSaveable {}
简单易用。对于比本章所展示的更复杂的场景,你可以提供 androidx.compose.runtime.saveable.Saver
实现,这将使你的数据对象更简单,并将它们转换为可保存的内容。较大的应用程序应使用 Google 建议已久的 ViewModel
类。ViewModel
和 LiveData
类的组合可以很好地集成到使用 observerAsState()
的可组合应用程序中。
摘要
本章旨在更详细地探讨 Compose 应用程序中的状态。我们首先探讨了有状态和无状态的可组合函数之间的区别。你学习了它们的典型用例以及为什么你应该尽量保持你的可组合函数无状态。提升状态是一个实现这一目标的工具。我们在第二个主要部分中涵盖了这一重要主题。我还向你展示了你可以通过传递参数而不是在可组合函数内部实现逻辑来使你的可组合函数更具可重用性。上一节探讨了将 Compose UI 层次集成到活动中的整合,关注了如何保留用户输入。我们研究了 remember {}
和 rememberSaveable {}
之间的区别,并给你展示了较大的 Compose 应用程序如何从 ViewModel
类中受益。
第一章到第五章介绍了 Jetpack Compose 的各个方面,如可组合函数、状态和布局。第六章,整合组件,专注于一个应用程序,为你提供了一个更全面的视角,了解这些组件如何协同工作以形成一个真实世界的应用程序。我们将实现一个简单的单位转换应用程序,重点关注应用程序架构和 UI,包括主题和导航。
第六章:整合各个部分
前几章探讨了 Jetpack Compose 的各个方面。例如,第二章,理解声明式范式,将传统的视图系统与组合函数进行了比较,并解释了声明式方法的好处。第四章,布局 UI 元素,使你对一些内置布局组合器,如Box()
、Row()
和Column()
,有了扎实的理解。在第五章,管理组合函数的状态中,我们探讨了状态及其在 Compose 应用中的重要作用。
现在,是时候看看这些关键元素如何在现实世界的应用中协同工作了。在本章中,您将学习如何为主题化 Compose 应用。我们还将查看Scaffold()
,这是一个集成 UI 元素,它收集了与活动最初相关的一些概念,如工具栏和菜单,我们将学习如何添加基于屏幕的导航。
在本章中,我们将涵盖以下主题:
-
为 Compose 应用添加样式
-
集成工具栏和菜单
-
添加导航
我们将首先为 Compose 应用设置一个自定义主题。您可以定义很多颜色、形状和文本样式,内置的 Material 组合器在绘制自身时将使用这些样式。我还会向您展示在添加依赖于应用主题的额外 Jetpack 组件时需要注意的事项,例如Jetpack Core Splashscreen。
在下一节,集成工具栏和菜单中,我们将向您介绍应用栏和选项菜单。您还将学习如何创建 snack bars。
在最后的主体部分,添加导航,我将向您展示如何将您的应用结构化为屏幕。我们将使用Jetpack Navigation的 Compose 版本在它们之间进行导航。
技术要求
本章包含一个示例应用,ComposeUnitConverter
,如下截图所示:
图 6.1 – ComposeUnitConverter 应用
请参阅第一章,构建您的第一个 Compose 应用中的技术要求部分,了解如何安装和设置 Android Studio,以及如何获取本书的配套仓库。
本章的所有代码文件都可以在 GitHub 上找到,地址为github.com/PacktPublishing/Android-UI-Development-with-Jetpack-Compose/tree/main/chapter_06
。
为 Compose 应用添加样式
你的大部分 Compose UI 可能都会使用来自androidx.compose.material
包的内置可组合函数。它们实现了被称为Material Design的设计语言及其继任者Material You(随着 Android 12 的推出而引入)。Material You 是 Android 的原生设计语言,尽管它也将在其他*台上可用。它扩展了笔、纸和卡片的概念,并大量使用基于网格的布局、响应式动画和过渡,以及填充和深度效果。Material You 提倡更大的按钮和圆角。可以从用户的壁纸生成自定义颜色主题。
定义颜色、形状和文本样式
虽然应用应该当然尊重系统和使用者对视觉外观的偏好,但你可能希望添加反映你的品牌或企业身份的颜色、形状或文本样式。那么,你如何修改内置的 Material 可组合函数的外观?
材料主题化的主要入口点是MaterialTheme()
。这个可组合函数可以接收自定义颜色、形状和文本样式。如果没有设置值,则使用相应的默认值(MaterialTheme.colors
、MaterialTheme.typography
或MaterialTheme.shapes
)。以下主题设置了自定义颜色,但将文本样式和形状保留为默认值:
@Composable
fun ComposeUnitConverterTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colors = if (darkTheme) {
DarkColorPalette
} else {
LightColorPalette
}
MaterialTheme(
colors = colors,
content = content
)
}
isSystemInDarkTheme()
可组合函数检测设备当前是否正在使用深色主题。你的应用应该使用适合这种配置的颜色。我的例子有两个调色板,DarkColorPalette
和LightColorPalette
。以下是后者的定义方式:
private val LightColorPalette = lightColors(
primary = AndroidGreen,
primaryVariant = AndroidGreenDark,
secondary = Orange,
secondaryVariant = OrangeDark
)
lightColors()
是androidx.compose.material
包中的一个顶级函数。它为 Material 颜色规范提供了完整的颜色定义。你可以在material.io/design/color/the-color-system.html#color-theme-creation
找到更多关于此的信息。LightColorPalette
覆盖了默认的 primary、primaryVariant
、secondary 和secondaryVariant
值。所有其他(例如,background
、surface
和onPrimary
)保持不变。
primary
将在你的应用屏幕和组件中显示得最频繁。使用secondary
,你可以突出和区分你的应用。例如,它用于单选按钮。开关的选中拇指颜色是secondaryVariant
,而未选中拇指颜色则来自surface
。
小贴士
材料可组合函数通常从名为colors()
的可组合函数接收默认颜色,这些函数属于它们的伴随…Defaults
对象。例如,如果没有传递颜色参数给Switch()
,则Switch()
会调用SwitchDefaults.colors()
。通过查看这些colors()
函数,你可以找出在你的主题中应该设置哪个颜色属性。
你可能想知道我是如何定义例如AndroidGreen
的。实现这一点的最简单方法是这样的:
val AndroidGreen = Color(0xFF3DDC84)
如果你的应用不需要其他库或组件,这些库或组件依赖于传统的 Android 主题系统,这将非常有效。我们将在使用基于资源的主题部分转向这些场景。
除了颜色之外,MaterialTheme()
还允许你提供替代形状。形状引导注意力并传达状态。基于大小,Material 可组合项被分组到形状类别中:
-
小型(按钮、snack bars、工具提示等)
-
中型(卡片、对话框、菜单等)
-
大型(纸张和抽屉等)
要传递给MaterialTheme()
的替代形状集,你必须实例化androidx.compose.material.Shapes
,并为你想要修改的类别(small
、medium
和large
)提供androidx.compose.foundation.shape.CornerBasedShape
抽象类的实现。AbsoluteCutCornerShape
、CutCornerShape
、AbsoluteRoundedCornerShape
和RoundedCornerShape
是CornerBasedShape
的直接子类。
以下截图显示了一个带有切角的按钮。虽然这使按钮看起来不那么熟悉,但它给你的应用带来了一种独特的风格。然而,你应该确保你想要添加这个:
![Figure 6.2 – A button with cut corners
图 6.2 – 带有切角的按钮
要实现这一点,只需在调用MaterialTheme()
时添加以下行:
shapes = Shapes(small = CutCornerShape(8.dp)),
你可以在material.io/design/shape/applying-shape-to-ui.html#shape-scheme
找到有关将形状应用于 UI 的更多信息。
要更改 Material 可组合函数使用的文本样式,你需要将androidx.compose.material.Typography
的实例传递给MaterialTheme()
。Typography
接收许多参数,例如h1
、subtitle1
、body1
、button
和caption
。所有这些都是androidx.compose.ui.text.TextStyle
的实例。如果你没有为参数传递值,则使用默认值。
以下代码块增加了按钮的文本大小:
typography = Typography(button = TextStyle(fontSize =
24.sp)),
如果你将此行添加到MaterialTheme()
的调用中,使用你的主题的所有按钮的文本将高 24 个缩放无关像素。但如何设置主题?为了确保你的完整 Compose UI 使用它,你应该尽早调用你的主题:
class ComposeUnitConverterActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val factory = …
setContent {
ComposeUnitConverter(factory)
}
}
}
在我的例子中,ComposeUnitConverter()
是应用的可组合 UI 层次结构的根,因为它在setContent {}
内部被调用:
@Composable
fun ComposeUnitConverter(factory: ViewModelFactory) {
…
ComposeUnitConverterTheme {
Scaffold( ...
ComposeUnitConverter()
立即委托给ComposeUnitConverterTheme {}
,它接收剩余的 UI 作为其内容。Scaffold()
是现实世界 Compose UI 的骨架。我们将在集成工具栏和菜单部分更详细地探讨这一点。
如果你需要以不同的方式样式化应用的部分,你可以通过覆盖父主题(图 6.3)来嵌套主题。让我们看看这是如何工作的:
@Composable
@Preview
fun MaterialThemeDemo() {
MaterialTheme(
typography = Typography(
h1 = TextStyle(color = Color.Red)
)
) {
Row {
Text(
text"= "He"lo",
style = MaterialTheme.typography.h1
)
Spacer(modifier = Modifier.width(2.dp))
MaterialTheme(
typography = Typography(
h1 = TextStyle(color = Color.Blue)
)
) {
Text(
text"= "Comp"se",
style = MaterialTheme.typography.h1
)
}
}
}
}
在前面的代码片段中,基本主题配置了任何被样式化为h1
的文本,使其显示为红色。第二个Text()
使用嵌套主题将h1
样式化为蓝色。因此,它覆盖了父主题:
![图 6.3 – 嵌套主题
图 6.3 – 嵌套主题
注意事项
您应用的所有部分都必须具有一致的外观。因此,您应谨慎使用嵌套主题。
在下一节中,我们将继续探讨样式和主题。我们将查看如何在清单文件中设置主题,以及库如何影响您定义 Compose 主题的方式。
使用基于资源的主题
自 API 级别 1 以来,Android 上就存在应用样式或主题。它基于资源文件。从概念上讲,样式和主题之间有一个区别。样式是一组属性,用于指定单个视图的外观(例如,字体颜色、字体大小或背景颜色)。因此,样式对可组合函数不重要。主题也是一组属性,但它应用于整个应用、活动或视图层次结构。Compose 应用中的许多元素由 Material 可组合元素提供;对于它们,基于资源的主题也不重要。然而,主题可以将样式应用于非视图元素,如状态栏和窗口背景。这可能对 Compose 应用相关。
样式和主题在res/values
目录内的 XML 文件中声明,通常根据内容命名为styles.xml
和themes.xml
。主题通过清单文件中<application />
或<activity />
标签的android:theme
属性应用于应用或活动。如果没有它们接收主题,ComposeUnitConverter
将如下所示:
![图 6.4 – 组合单元转换器显示了一个额外的标题栏
图 6.4 – 组合单元转换器显示了一个额外的标题栏
为了避免不想要的额外标题栏,Compose 应用必须配置一个没有操作栏的主题,例如Theme.AppCompat.DayNight.NoActionBar
,使用android:theme="@style/..."
为<application />
或<activity />
。这样,ComposeUnitConverter
看起来就像图 6.1。您注意到状态栏有深灰色背景吗?
当使用Theme.AppCompat.DayNight
时,状态栏从colorPrimaryDark
主题属性(或自 API 级别 21 以来的android:statusBarColor
)接收其背景颜色。如果没有指定值,则使用默认值。因此,为了确保状态栏以适合剩余 UI 元素的颜色显示,您必须在res/values
中添加一个名为themes.xml
的文件:
<resources>
<style name="Theme.ComposeUnitConverter"
parent="Theme.AppCompat.DayNight.NoActionBar">
<item
name="colorPrimaryDark">@color/android_green_dark
</item>
</style>
</resources>
在清单文件中,android:theme
的值必须更改为@style/Theme.ComposeUnitConverter
。@color/android_green_dark
代表颜色。除了这个表达式,你也可以直接传递值;例如,#FF20B261
。然而,将它们存储在res/values
目录下的名为colors.xml
的文件中是最佳实践:
<resources>
<color name="android_green_dark">#FF20B261</color>
<color name="orange_dark">#FFCC8400</color>
</resources>
这样,你可以为深色主题分配不同的值。以下版本的themes.xml
应该放在res/values-night
中:
<resources>
<style name="Theme.ComposeUnitConverter"
parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="colorPrimaryDark">@color/orange_dark</item>
</style>
</resources>
现在状态栏的背景颜色与剩余的 UI 元素相匹配。但是,我们需要在两个地方定义颜色:colors.xml
和 Compose 主题。幸运的是,这很容易解决。通常,我们传递一个字面量,如下所示:
val AndroidGreenDark = Color(0xFF20B261)
而不是这样做,我们应该从资源中获取值。colorResource()
可组合函数属于androidx.compose.ui.res
包。它返回与 ID 标识的资源相关联的颜色。
以下调色板未指定一个二级
颜色:
private val LightColorPalette = lightColors(
primary = AndroidGreen,
primaryVariant = AndroidGreenDark,
secondaryVariant = OrangeDark
)
使用colorResource()
添加颜色的工作方式如下:
@Composable
fun ComposeUnitConverterTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colors = if (darkTheme) {
DarkColorPalette
} else {
LightColorPalette.copy(secondary = colorResource(
id = R.color.orange_dark))
}
MaterialTheme(
colors = colors,
…
你在定义颜色、形状和文本样式部分看到了大部分内容。重要的区别在于我使用copy()
创建了一个修改版的LightColorPalette
(带有二级颜色),然后将其传递给MaterialTheme()
。如果你将所有颜色存储在colors.xml
中,你应该完全在你的主题可组合中创建你的调色板。
正如你所见,你可能需要为基于资源的主题提供一些值,这取决于你想要如何强烈地品牌你的应用。此外,某些非 Compose Jetpack 库也使用主题,例如Jetpack Core Splashscreen。这个组件使得 Android 12 的高级启动屏幕功能在旧*台上可用。启动屏幕的图像和颜色通过主题属性进行配置。库要求启动活动的主题具有Theme.SplashScreen
作为其父主题。此外,主题必须提供postSplashScreenTheme
属性,该属性指向启动屏幕关闭后要使用的主题。你可以在 Android 开发者文档中找到有关启动屏幕的更多信息:developer.android.com/guide/topics/ui/splash-screen
。
小贴士
为了确保颜色的一致使用,如果多个组件依赖于基于资源的主题,colors.xml
文件应该是你应用中的单一真相来源。
这就结束了我们对 Compose 主题的探讨。在下一节中,我们将转向一个重要的集成 UI 元素,称为Scaffold()
,它充当内容的框架,提供顶部和底部栏、导航和操作的支持。
集成工具栏和菜单
早期安卓版本并不知道动作或应用栏的存在。它们是在 API 级别 11(蜂巢)中引入的。另一方面,选项菜单从一开始就存在,但需要通过按下专用硬件按钮来打开,并显示在屏幕底部。在安卓 3 中,它移动到了顶部,并变成了一个垂直列表。一些元素可以作为动作永久显示。从某种意义上说,选项菜单和动作栏合并了。最初,动作栏的所有方面都由宿主活动处理,但AppCompat
支持库引入了另一种实现(getSupportActionBar()
)。它至今仍作为 Jetpack 的一部分被广泛使用。
使用 Scaffold()来结构化你的屏幕
Jetpack Compose 包含几个与 Material Design 或 Material You 规范紧密相关的应用栏实现。它们可以通过Scaffold()
添加到 Compose UI 中,这是一个充当应用框架或骨骼的可组合函数。以下代码片段是ComposeUnitConverter
UI 的根。它设置主题,然后委托给Scaffold()
:
@Composable
fun ComposeUnitConverter(factory: ViewModelFactory) {
val navController = rememberNavController()
val menuItems = listOf("Item #1", "Item #2")
val scaffoldState = rememberScaffoldState()
val snackbarCoroutineScope = rememberCoroutineScope()
ComposeUnitConverterTheme {
Scaffold(scaffoldState = scaffoldState,
topBar = {
ComposeUnitConverterTopBar(menuItems) { s ->
snackbarCoroutineScope.launch {
scaffoldState.snackbarHostState.showSnackbar(s)
}
}
},
bottomBar = {
ComposeUnitConverterBottomBar(navController)
}
) {
ComposeUnitConverterNavHost(
navController = navController,
factory = factory
)
}
}
}
Scaffold()
实现了基本的 Material Design 视觉布局结构。你可以添加其他几个 Material 可组合函数,如TopAppBar()
或BottomNavigation()
。谷歌称这为Scaffold()
可能需要记住不同的状态。你可以传递一个ScaffoldState
,它可以通过rememberScaffoldState()
创建。
我的示例使用ScaffoldState
来显示一个 snackbar,这是一个出现在屏幕底部的简短临时消息。由于showSnackbar()
是一个挂起函数,它必须从协程或另一个挂起函数中调用。因此,我们必须使用rememberCoroutineScope()
创建和记住一个CoroutineScope
,并调用其launch {}
函数。
在下一节中,我将向你展示如何创建一个带有选项菜单的顶部应用栏。
创建顶部应用栏
屏幕顶部的应用栏是通过TopAppBar()
实现的。你可以在这里提供一个导航图标、标题和动作列表:
@Composable
fun ComposeUnitConverterTopBar(menuItems: List<String>,
onClick: (String) -> Unit) {
var menuOpened by remember { mutableStateOf(false) }
TopAppBar(title = {
Text(text = stringResource(id = R.string.app_name))
},
actions = {
Box {
IconButton(onClick = {
menuOpened = true
}) {
Icon(Icons.Default.MoreVert, "")
}
DropdownMenu(expanded = menuOpened,
onDismissRequest = {
menuOpened = false
}) {
menuItems.forEachIndexed { index, s ->
if (index > 0) Divider()
DropdownMenuItem(onClick = {
menuOpened = false
onClick(s)
}) {
Text(s)
}
}
}
}
}
)
}
TopAppBar()
没有针对选项菜单的特定 API。相反,菜单被当作普通动作处理。动作通常是IconButton()
可组合函数。它们以水*行的形式显示在应用栏的末尾。一个IconButton()
接收一个onClick
回调和一个可选的enabled
参数,该参数控制用户是否可以与 UI 元素交互。
在我的示例中,回调仅将一个Boolean
可变状态(menuOpened
)设置为false
。正如你很快就会看到的,这将关闭菜单。content
(通常是一个图标)被绘制在按钮内部。Icon()
可组合函数接收一个ImageVector
实例和一个内容描述。你可以从资源中获取图标数据,但如果可能的话,应使用预定义的图形 – 在我的示例中,Icons.Default.MoreVert
。接下来,让我们学习如何显示菜单。
Material Design 下拉菜单 (DropdownMenu()
) 允许您紧凑地显示多个选择。它通常在您与另一个元素(如按钮)交互时出现。我的例子将 DropdownMenu()
放置在 Box()
中,并使用 IconButton()
确定屏幕上的位置。展开参数使菜单可见(打开)或不可见(关闭)。当用户请求关闭菜单,例如通过在菜单边界外轻触时,会调用 onDismissRequest
。
内容应包含 DropdownMenuItem()
组合器。当点击相应的菜单项时,会调用 onClick
。您的代码必须确保菜单被关闭。如果可能,您应该将执行的域逻辑作为参数传递,以便使您的代码可重用且无状态。在我的例子中,显示了一个 snack bar。
这就结束了我们对顶级应用栏的探讨。在下一节中,我将向您展示如何使用 BottomNavigation()
通过 Jetpack Navigation 的 Compose 版本在不同的屏幕间进行导航。
请注意
要在您的应用中使用 Jetpack Navigation 的 Compose 版本,您必须将 androidx.navigation:navigation-compose
的实现依赖项添加到您的模块级 build.gradle
文件中。
添加导航
Scaffold()
允许您使用其 bottomBar
参数在屏幕底部放置内容。例如,这可以是一个 BottomAppBar()
。Material Design 底部应用栏提供访问底部导航抽屉以及最多四个操作,包括一个浮动操作按钮。ComposeUnitConverter
则添加了 BottomNavigation()
。Material Design 底部导航栏允许在应用中的主要目的地之间进行切换。
定义屏幕
从概念上讲,主要目的地是 屏幕,在 Jetpack Compose 之前,这可能是显示在单独的活动中的。以下是 ComposeUnitConverter
中定义屏幕的方式:
sealed class ComposeUnitConverterScreen(
val route: String,
@StringRes val label: Int,
@DrawableRes val icon: Int
) {
companion object {
val screens = listOf(
Temperature,
Distances
)
const val route_temperature = "temperature"
const val route_distances = "distances"
}
private object Temperature : ComposeUnitConverterScreen(
route_temperature,
R.string.temperature,
R.drawable.baseline_thermostat_24
)
private object Distances : ComposeUnitConverterScreen(
route_distances,
R.string.distances,
R.drawable.baseline_square_foot_24
)
}
ComposeUnitConverter
由两个屏幕组成——Temperature
和 Distances
。route
唯一标识一个屏幕。label
和 icon
将显示给用户。让我们看看这是如何实现的:
@Composable
fun ComposeUnitConverterBottomBar(navController:
NavHostController) {
BottomNavigation {
val navBackStackEntry by
navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
ComposeUnitConverterScreen.screens.forEach { screen ->
BottomNavigationItem(
selected = currentDestination?.hierarchy?.any {
it.route == screen.route } == true,
onClick = {
navController.navigate(screen.route) {
launchSingleTop = true
}
},
label = {
Text(text = stringResource(id = screen.label))
},
icon = {
Icon(
painter = painterResource(id = screen.icon),
contentDescription = stringResource(id =
screen.label)
)
},
alwaysShowLabel = false
)
}
}
}
BottomNavigation()
的内容由 BottomNavigationItem()
项目组成。每个项目代表一个 目的地。我们可以通过简单的循环来添加它们:
ComposeUnitConverterScreen.screens.forEach { screen ->
如您所见,ComposeUnitConverterScreen
实例的 label
和 icon
属性在调用 BottomNavigationItem()
时被使用。alwaysShowLabel
控制当项目被选中时标签是否可见。如果相应的屏幕当前正在显示,则项目将被选中。当点击 BottomNavigationItem()
时,会调用其 onClick
回调。我的实现调用提供的 NavHostController
实例上的 navigate()
,传递来自相应的 ComposeUnitConverterScreen
对象的 route
。
到目前为止,我们已经定义了屏幕并将它们映射到 BottomNavigationItem()
项目上。当点击一个项目时,应用程序会导航到指定的路由。但是,路由是如何与可组合函数相关的呢?我将在下一节中向你展示。
使用 NavHostController 和 NavHost()
一个 NavHostController
的实例允许我们通过调用其 navigate()
函数来导航到不同的屏幕。我们可以在 ComposeUnitConverter()
内部通过调用 rememberNavController()
获取其引用,然后将其传递给 ComposeUnitConverterBottomBar()
。路由与可组合函数之间的映射是通过 NavHost()
建立的。它属于 androidx.navigation.compose
包。以下是这个可组合函数的调用方式:
@Composable
fun ComposeUnitConverterNavHost(
navController: NavHostController,
factory: ViewModelProvider.Factory?
) {
NavHost(
navController = navController,
startDestination =
ComposeUnitConverterScreen.route_temperature
) {
composable(ComposeUnitConverterScreen.route_temperature) {
TemperatureConverter(
viewModel = viewModel(factory = factory)
)
}
composable(ComposeUnitConverterScreen.route_distances) {
DistancesConverter(
viewModel = viewModel(factory = factory)
)
}
}
}
NavHost()
接收三个参数:
-
我们
NavHostController
的引用 -
起始目的地的路由
-
构建导航图的构建器
在 Jetpack Compose 之前,导航图通常通过一个 XML 文件定义。NavGraphBuilder
提供了对简单领域特定语言的访问。composable()
添加一个可组合函数作为目的地。除了路由之外,你还可以传递一个参数列表和一个深度链接列表。
小贴士
Jetpack Navigation 的详细描述超出了本书的范围。你可以在developer.android.com/guide/navigation
找到更多信息。
摘要
本章展示了 Jetpack Compose 的关键元素如何在真实世界的应用程序中协同工作。你学习了如何为主题 Compose 应用程序以及如何保持 Compose 主题与基于资源的主题同步。
我还向你展示了 Scaffold()
如何充当应用程序框架或骨架。我们使用其槽 API 插入了一个带有菜单的顶部应用程序栏,以及一个底部栏,用于使用 Jetpack Navigation 的 Compose 版本在屏幕之间导航。
在下一章,“技巧、窍门和最佳实践”中,我们将讨论如何分离 UI 和业务逻辑。我们将重新访问 ComposeUnitConverter
,这次将重点介绍其使用 ViewModel 的方式。
第七章:技巧、窍门和最佳实践
在第六章,整合组件中,我们在一个实际示例中结合了 Jetpack Compose 的几个关键技术,如状态提升、应用主题和导航。ComposeUnitConverter
将状态存储在ViewModel
中,并最终使用Repository模式持久化它。在本章中,我将向您展示如何在实例化时将对象传递给ViewModel
,并使用这些对象来加载和保存数据。在第三章,探索 Compose 的关键原则中,我们检查了表现良好的可组合函数的特性。可组合函数应该是无副作用的,以便它们可重用且易于测试。然而,在某些情况下,你可能需要对外部作用域之外发生的状态变化做出反应或启动。我们将在本章末尾讨论这个问题。
本章的主要部分包括:
-
持久化和检索状态
-
保持你的可组合响应性
-
理解副作用
我们首先继续探索在第五章,使用 ViewModel部分中开始的ViewModel
模式。这次,我们将向ViewModel
添加业务逻辑并注入一个可以持久化和检索数据的对象。
保持你的可组合响应性部分回顾了可组合函数的一个关键要求。由于重组可能非常频繁,因此可组合函数必须尽可能快。这极大地影响了代码可以和不可以做的事情。长时间运行的任务——例如,复杂的计算或网络调用——不应同步调用。
理解副作用部分涵盖了需要对外部作用域之外发生的状态变化做出反应或启动的情况。例如,我们将使用LaunchedEffect
来启动和停止复杂的计算。
技术要求
“持久化和检索状态”和“保持你的可组合响应性”部分进一步讨论了示例ComposeUnitConverter
应用。理解副作用部分基于EffectDemo
示例。请参考第一章,构建你的第一个 Compose 应用,了解如何安装和设置 Android Studio 以及如何获取本书的配套仓库。
本章所有代码文件都可以在 GitHub 上找到,地址为github.com/PacktPublishing/Android-UI-Development-with-Jetpack-Compose/tree/main/chapter_07
。
持久化和检索状态
状态是可能随时间变化的应用程序数据。在 Compose 应用中,状态通常表示为 State
或 MutableState
的实例。如果这些对象在可组合函数内部使用,则在状态变化时将触发重新组合。如果状态传递给多个可组合函数,它们都可能被重新组合。这导致了 状态提升 原则:状态传递给可组合函数,而不是在它们内部记住。通常,此类状态在作为使用该状态的组件的父组件的可组合函数中记住。另一种方法是实现一个名为 ViewModel
的架构模式。它在各种*台的许多 用户界面 (UI) 框架中都有使用。在 Android 上,它自 2017 年以来作为 Android 架构组件 的一部分可用。
ViewModel
的一般思想是将特定于应用程序某个部分的数据和访问逻辑结合起来。根据*台的不同,这可能是屏幕、窗口、对话框或其他类似顶级容器。在 Android 上,它通常是活动。数据是可观察的,因此 UI 元素可以注册并在变化时收到通知。可观察模式是如何实现的取决于*台。Android 架构组件引入了 LiveData
和 MutableLiveData
。在 第五章 的 Surviving configuration changes 部分,Managing the State of Your Composable Functions,我向您展示了如何在 ViewModel
内部使用它们来存储在设备旋转后仍然存在的数据,以及如何将 LiveData
实例连接到可组合函数。
这里简要回顾一下:要将 LiveData
对象连接到 Compose 世界,我们首先使用 androidx.lifecycle.viewmodel.compose.viewModel()
获取一个 ViewModel
实例,然后在一个 ViewModel
的属性上调用 observeAsState()
扩展函数。返回的状态是只读的,因此如果可组合想要更新属性,它必须调用一个需要由 ViewModel
提供的设置器。
到目前为止,我还没有解释如何持久化状态并在以后恢复它。换句话说:ViewModel
实例从哪里获取其数据的初始值,它们在变化时做什么?让我们在下一节中找出答案。
将对象注入到 ViewModel 中
如果一个 ViewModel
想要加载和保存数据,它可能需要访问数据库、本地文件系统或某些远程网络服务。然而,对于 ViewModel
来说,后台如何读取和写入数据应该是无关紧要的。Android 架构组件建议实现 Repository 模式。仓库抽象了加载和保存数据的机制,并通过类似集合的接口使其可用。您可以在 martinfowler.com/eaaCatalog/repository.html
了解更多关于仓库模式的信息。
你很快就会看到简单仓库的实现可能是什么样子,但首先,我需要向你展示如何在实例化时将对象传递给 ViewModel
。viewModel()
接收一个类型为 ViewModelProvider.Factory
的 factory
参数。它用于创建 ViewModel
实例。如果你传递 null
(默认值),则使用内置的默认工厂。ComposeUnitConverter
有两个屏幕,因此其工厂必须能够为每个屏幕创建 ViewModel
实例。
下面是 ViewModelFactory
的样子:
class ViewModelFactory(private val repository: Repository)
:ViewModelProvider.NewInstanceFactory() {
override fun <T : ViewModel?> create(modelClass:
Class<T>): T =
if (modelClass.isAssignableFrom
(TemperatureViewModel::class.java))
TemperatureViewModel(repository) as T
else
DistancesViewModel(repository) as T
}
ViewModelFactory
扩展了 ViewModelProvider.NewInstanceFactory
静态类,并重写了 create()
方法(该方法属于父 Factory
接口)。modelClass
代表要创建的 ViewModel
。因此,如果以下代码为 true
,则我们实例化 TemperatureViewModel
并传递 repository
:
modelClass.isAssignableFrom
(TemperatureViewModel::class.java)
此参数传递给了 ViewModelFactory
的构造函数。否则,将创建一个 DistancesViewModel
实例。其构造函数也接收 repository
。如果你的工厂需要区分更多的 ViewModel
实例,你可能使用 when
。
接下来,让我们看看我的 Repository
类,以了解 ComposeUnitConverter
如何加载和保存数据。你可以在下面的代码片段中看到这一点:
class Repository(context: Context) {
private val prefs =
PreferenceManager.getDefaultSharedPreferences(context)
fun getInt(key: String, default: Int) =
prefs.getInt(key, default)
fun putInt(key: String, value: Int) {
prefs.edit().putInt(key, value).apply()
}
fun getString(key: String,
default: String) = prefs.getString(key, default)
fun putString(key: String, value: String) {
prefs.edit().putString(key, value).apply()
}
}
Repository
使用 Jetpack Preference。这个库是 android.preference
包内*台类和接口的替代品,该包在 API 级别 29 时已被弃用。
重要提示
*台类和库都是为用户设置设计的。你不应该使用它们来访问更复杂的数据、更大的文本或图像。记录型数据最好保存在 SQLite 数据库中,而文件则非常适合大文本或图像。
要使用 Jetpack Preference,我们需要在模块级别的 build.gradle
文件中添加对 androidx.preference:preference-ktx
的实现依赖。getDefaultSharedPreferences()
需要一个 android.content.Context
实例,该实例传递给 Repository
构造函数。
在我们继续之前,让我们回顾一下我之前展示的内容,如下所示:
-
TemperatureViewModel
和DistancesViewModel
在它们的构造函数中接收一个Repository
实例。 -
Repository
接收一个Context
对象。 -
ViewModel
实例与活动解耦。它们在配置更改中存活。
最后一个要点关于我们可以传递给仓库的上下文有一个重要的后果。让我们在下一节中了解更多。
使用工厂
下面是创建仓库和工厂的方式:
class ComposeUnitConverterActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val factory =
ViewModelFactory(Repository(applicationContext))
setContent {
ComposeUnitConverter(factory)
}
}
}
Repository
和 ViewModelFactory
都是普通对象,因此它们只是简单地实例化,并将所需的参数传递给它们。
重要提示
可能会诱使你传递this
(调用活动)作为上下文。然而,由于ViewModel
实例在配置更改(即活动的重新创建)中存活,上下文可能会改变。如果它改变了,仓库将访问一个不再可用的活动。通过使用applicationContext
,我们确保这个问题不会发生。
ComposeUnitConverter()
是组合函数层次结构的根。它将工厂传递给ComposeUnitConverterNavHost()
,然后它反过来在composable {}
内部作为屏幕的参数使用,如以下代码片段所示:
composable(ComposeUnitConverterScreen.route_temperature) {
TemperatureConverter(
viewModel = viewModel(factory = factory)
)
}
在本节中,我向您展示了如何使用简单的构造函数调用将仓库对象注入到ViewModel
中。如果你的应用依赖于使用仓库的ViewModel
。
保持你的组合函数响应
在实现组合函数时,你应该始终牢记它们的主要目的是声明 UI 和处理用户交互。理想情况下,任何需要实现这一目标的内容都应该传递给组合函数,包括状态和逻辑(例如点击处理程序),使其无状态。如果状态仅在组合函数内部需要,函数可以使用remember {}
临时保持状态。这样的组合函数被称为ViewModel
,组合函数必须与之交互。因此,ViewModel
的代码也必须快速。
与ViewModel
实例通信
ViewModel
内的数据应该是可观察的。ComposeUnitConverter
使用 Android Architecture Components 中的LiveData
和MutableLiveData
来实现这一点。你可以选择其他实现观察者模式的方案,只要有一种方法可以获得在ViewModel
更改时更新的State
或MutableState
实例。这超出了本书的范围。TemperatureViewModel
是TemperatureConverter()
组合函数的ViewModel
。
让我们看看它的实现。在以下代码片段中,为了简洁起见,我省略了与scale
属性相关的代码。你可以在 GitHub 仓库中找到完整的实现:
class TemperatureViewModel(private val repository:
Repository): ViewModel() {
...
private val _temperature: MutableLiveData<String>
= MutableLiveData(
repository.getString("temperature", "")
)
val temperature: LiveData<String>
get() = _temperature
fun getTemperatureAsFloat(): Float
= (_temperature.value ?: "").let {
return try {
it.toFloat()
} catch (e: NumberFormatException) {
Float.NaN
}
}
fun setTemperature(value: String) {
_temperature.value = value
repository.putString("temperature", value)
}
fun convert() = getTemperatureAsFloat().let {
if (!it.isNaN())
if (_scale.value == R.string.celsius)
(it * 1.8F) + 32F
else
(it - 32F) / 1.8F
else
Float.NaN
}
}
ViewModel
实例通过一对变量来展示其数据,如下所示:
-
一个公共只读属性(
temperature
) -
一个私有的可写后置变量(
_temperature
)
属性不是通过分配新值来更改,而是通过调用一些设置函数(setTemperature()
)。你可以在第五章的使用 ViewModel部分找到为什么这样做的原因,管理你的组合函数的状态。可能还有其他组合函数可以调用的函数——例如,将温度从°C 转换为°F 的逻辑(convert()
)不应是组合函数代码的一部分。同样适用于格式转换(从String
到Float
)。这些最好保留在ViewModel
中。
如此是ViewModel
在组合函数中使用的样子:
@Composable
fun TemperatureConverter(viewModel: TemperatureViewModel) {
…
val currentValue = viewModel.temperature.observeAsState(
viewModel.temperature.value ?: "")
val scale = viewModel.scale.observeAsState(
viewModel.scale.value ?: R.string.celsius)
var result by remember { mutableStateOf("") }
val calc = {
val temp = viewModel.convert()
result = if (temp.isNaN())
""
else
"$temp${
if (scale.value == R.string.celsius)
strFahrenheit
else strCelsius
}"
}
…
Column(
…
) {
TemperatureTextField(
temperature = currentValue,
modifier = Modifier.padding(bottom = 16.dp),
callback = calc,
viewModel = viewModel
)
…
Button(
onClick = calc,
…
if (result.isNotEmpty()) {
Text(
text = result,
style = MaterialTheme.typography.h3
)
}
…
你是否注意到TemperatureConverter()
接收其ViewModel
作为参数?
小贴士
如果可能,您应该为预览和可测试性提供默认值(viewModel()
)。然而,如果ViewModel
需要存储库(如我的例子所示)或其他构造函数值,则这可能不起作用。
通过调用ViewModel
属性的observeAsState()
(例如temperature
和scale
),获取State
实例,这些属性是LiveData
实例。分配给calc
的代码在result
或用于Text()
可组合函数中的状态发生变化时执行。请注意,calc
lambda 表达式调用ViewModel
函数的convert()
函数以获取转换后的温度。你应该始终尝试将业务逻辑从可组合函数中移除,并将其放入ViewModel
中。
到目前为止,我向您展示了如何观察ViewModel
中的变化以及如何调用其内部的逻辑。还有一个部分:更改属性。在前面的代码片段中,TemperatureTextField()
接收ViewModel
。让我们看看它如何处理它:
@Composable
fun TemperatureTextField(
temperature: State<String>,
modifier: Modifier = Modifier,
callback: () -> Unit,
viewModel: TemperatureViewModel
) {
TextField(
value = temperature.value,
onValueChange = {
viewModel.setTemperature(it)
},
…
每当文本发生变化时,都会使用新值调用setTemperature()
。请记住,设置器执行以下操作:
_temperature.value = value
ViewModel
更新了后端变量_temperature
(MutableLiveData
)的值。由于temperature
公共属性引用_temperature
,其观察者(在我的例子中,是TemperatureConverter()
中observeAsState()
返回的状态)被通知。这触发了重组。
在本节中,我们关注了可组合函数与ViewModel
实例之间通信的流程。接下来,我们将探讨如果ViewModel
违反了与可组合函数的合约会发生什么,以及你可以采取哪些措施来防止这种情况。
处理长时间运行的任务
可组合函数通过设置属性的新值(setTemperature()
)和调用实现业务逻辑的函数(convert()
)来积极地与ViewModel
进行交互。由于重组可能频繁发生,这些函数可能被非常频繁地调用。因此,它们必须非常快地返回。对于简单的算术,例如在°C 和°F 之间转换,这肯定是对的。
另一方面,某些算法对于某些输入可能变得越来越耗时。以下是一个例子。斐波那契数可以通过递归和迭代来计算。虽然递归算法更容易实现,但对于大数来说,它需要更长的时间。如果同步函数调用没有及时返回,可能会影响用户对您的应用的感知。您可以通过在convert()
内部将while (true) ;
作为第一行代码来测试这一点。如果您运行ComposeUnitConverter
,输入一些数字,然后按转换,应用将不再响应。
重要提示
必须异步实现可能长时间运行的任务。
为了避免出现应用因计算耗时过长而无法响应的情况,你必须将计算与结果交付解耦。这只需几个步骤即可完成,如下所示:
-
将结果作为可观察属性提供。
-
使用协程或 Kotlin flow 计算结果。
-
计算完成后,更新
result
属性。
这里是一个从 DistancesViewModel
中摘取的示例实现:
private val _convertedDistance: MutableLiveData<Float>
= MutableLiveData(Float.NaN)
val convertedDistance: LiveData<Float>
get() = _convertedDistance
fun convert() {
getDistanceAsFloat().let {
viewModelScope.launch {
_convertedDistance.value = if (!it.isNaN())
if (_unit.value == R.string.meter)
it * 0.00062137F
else
it / 0.00062137F
else
Float.NaN
}
}
}
viewModelScope
通过模块级别的 build.gradle
文件中的 androidx.lifecycle:lifecycle-viewmodel-ktx
实现依赖项可用。convert()
启动一个协程,一旦计算完成,就会更新 _convertedDistance
的值。可组合函数可以通过在 convertedDistance
公共属性上调用 observeAsState()
来观察变化。但如何访问 convertedDistance
和 convert()
?下面是 DistancesConverter.kt
中的一个代码片段:
val convertedValue by
viewModel.convertedDistance.observeAsState()
val result by remember(convertedValue) {
mutableStateOf(
if (convertedValue?.isNaN() != false)
""
else
"$convertedValue ${
if (unit.value == R.string.meter)
strMile
else strMeter
}"
)
}
val calc = {
viewModel.convert()
}
result
在距离转换完成后接收要输出的文本,因此它应该在 convertedValue
发生变化时更新自己。因此,我将 convertedValue
作为键传递给 remember {}
。每当键发生变化时,mutableStateOf()
lambda 表达式会被重新计算,所以 result
会更新。当 convertedValue
发生变化时,会调用 calc
。
在本节中,我经常使用术语 计算。计算不仅意味着算术。访问数据库、文件或网络服务也可能消耗大量资源并且耗时。这些操作必须异步执行。请记住,长时间运行的任务可能不是 ViewModel
本身的一部分,而是从它(例如,一个仓库)中调用的。因此,这样的代码也必须快速。我的 Repository
实现为了简单起见,同步访问 Preferences
API。严格来说,即使是这样的基本操作也应该异步执行。
小贴士
Jetpack DataStore 允许你使用协议缓冲区存储键值对或类型对象。它使用 Kotlin 协程和 Flow 异步存储数据。你可以在 developer.android.com/topic/libraries/architecture/datastore
找到有关 Jetpack DataStore 的更多信息。
这就结束了我们对可组合函数和 ViewModel
实例之间通信的探讨。在下一节中,我将向你介绍那些在组合完成时不会发出 UI 元素但会导致副作用运行的组合器。
理解副作用
在 第六章 的 使用 Scaffold() 结构化屏幕 部分,组装组件,我向你展示了如何使用 rememberCoroutineScope {}
和 scaffoldState.snackbarHostState.showSnackbar()
显示一个 snack bar。由于 showSnackbar()
是一个挂起函数,它必须在一个协程或另一个挂起函数中被调用。因此,我们使用 rememberCoroutineScope()
创建并记住 CoroutineScope
,然后调用它的 launch {}
函数。
调用挂起函数
LaunchedEffect()
组合器是一个用于生成挂起函数的替代方法。要了解它是如何工作的,让我们看看 LaunchedEffectDemo()
组合器。它属于 EffectDemo
示例,如下面的屏幕截图所示:
![图 7.1 – 展示 LaunchedEffectDemo() 的 EffectDemo 示例
图 7.1 – 展示 LaunchedEffectDemo()
的 EffectDemo 示例
LaunchedEffectDemo()
实现了一个计数器。一旦点击了开始按钮,计数器每秒增加一次。点击重启将计数器重置。停止将终止它。实现此功能的代码在下面的代码片段中展示:
@Composable
fun LaunchedEffectDemo() {
var clickCount by rememberSaveable { mutableStateOf(0) }
var counter by rememberSaveable { mutableStateOf(0) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row {
Button(onClick = {
clickCount += 1
}) {
Text(
text = if (clickCount == 0)
stringResource(id = R.string.start)
else
stringResource(id = R.string.restart)
)
}
Spacer(modifier = Modifier.width(8.dp))
Button(enabled = clickCount > 0,
onClick = {
clickCount = 0
}) {
Text(text = stringResource(id =
R.string.stop))
}
if (clickCount > 0) {
LaunchedEffect(clickCount) {
counter = 0
while (isActive) {
counter += 1
delay(1000)
}
}
}
}
Text(
text = "$counter",
style = MaterialTheme.typography.h3
)
}
}
clickCount
计算了多少次 0
。大于 0
的值表示应该每秒增加另一个记忆变量(counter
)。这是通过传递给 LaunchedEffect()
的挂起函数来完成的。这个组合器用于在组合器内部安全地调用挂起函数。让我们看看它是如何工作的。
当 LaunchedEffect()
进入组合(if (clickCount > 0) …
)时,它将使用作为参数传递的代码块启动一个协程。如果 LaunchedEffect()
离开组合(clickCount <= 0
),则协程将被取消。你注意到它接收一个参数吗?如果 LaunchedEffect()
使用不同的键重新组合(我的示例只使用了一个,但如果你需要可以传递更多),现有的协程将被取消,并启动一个新的协程。
正如你所看到的,LaunchedEffect()
使启动和重启异步任务变得容易。相应的协程将自动清理。但是,如果你需要在键更改或组合器离开组合时执行一些额外的清理工作(例如注销监听器),该怎么办?让我们在下一节中找出答案。
使用 DisposableEffect()
清理
DisposableEffect()
组合器函数在其键更改时运行代码。此外,你可以传递一个 lambda 表达式用于清理目的。它将在 DisposableEffect()
函数离开组合时执行。代码在下面的代码片段中展示:
DisposableEffect(clickCount) {
println("init: clickCount is $clickCount")
onDispose {
println("dispose: clickCount is $clickCount")
}
}
每当 clickCount
发生变化时(即当 dispose:
出现时或当 DisposableEffect()
离开组合时),都会打印以 init:
开头的消息。
重要提示
DisposableEffect()
必须 在其代码块的最后包含一个 onDispose {}
子句。
我已经给了你两个使用 Compose 应用中副作用的手动示例。Effect
API 包含了其他几个有用的组合器——例如,你可以使用 SideEffect()
将 Compose 状态发布到应用的非 Compose 部分中,而 produceState()
允许你将非 Compose 状态转换为 State
实例。
你可以在 developer.android.com/jetpack/compose/side-effects
找到有关 Effect
API 的更多信息。
概述
本章涵盖了ComposeUnitConverter
示例的更多方面。我们继续探索在第五章的使用 ViewModel部分开始探讨的ViewModel
模式,即管理可组合函数的状态。这次,我们在ViewModel
中添加了业务逻辑,并注入了一个可以持久化和检索数据的对象。
保持你的可组合函数响应性部分回顾了可组合函数的一个关键要求。重组可能非常频繁发生,因此可组合函数必须尽可能快,这决定了它们内部可能和不可能执行哪些代码。我向你展示了简单的循环如何导致 Compose 应用程序停止响应,以及协程如何对抗这种情况。
在最后的主体部分,理解副作用,我们探讨了所谓的副作用,并使用LaunchedEffect
实现了一个简单的计数器。
在第八章的与动画一起工作中,你将学习如何使用动画显示和隐藏 UI 元素。我们将通过视觉效果来丰富过渡效果,并使用动画来可视化状态变化。
第三部分:高级主题
本部分重点介绍如何提高 Compose 应用的质量,例如,通过动画增强其视觉吸引力。它还说明了如何以及为什么测试可组合函数,以及如何将可组合元素与传统的视图混合。
在本节中,我们将涵盖以下章节:
-
第八章, 使用动画
-
第九章, 探索互操作性 API
-
第十章, 测试和调试 Compose 应用
-
第十一章, 结论和下一步行动
第八章:使用动画
在前面的章节中,我向您介绍了 Jetpack Compose 的许多技术方面,并展示了如何编写表现良好且外观美观的应用。现在,添加动画和过渡将使您的应用真正闪耀!Compose 在添加动画效果方面极大地简化了旧视图方法的过程。
在本章中,你将学习重要的动画相关应用程序编程接口,看到单多个属性的动画,以及组合件之间的过渡动作,并掌握状态变化和视觉交互之间的关系。
本章的主要部分如下:
-
使用动画来可视化状态变化
-
使用动画显示和隐藏 UI 元素
-
通过视觉效果增强过渡
我们首先使用动画来可视化状态变化。考虑一个简单的用例:点击按钮可能会改变 UI 对象的颜色。但是,只是颜色之间的切换感觉有些突然,而渐变变化则更加视觉上令人愉悦。此外,如果您想在动画期间更改多个值,Jetpack Compose 也可以轻松做到。我将向您介绍updateTransition()
组合函数,它用于此类场景。
使用动画显示和隐藏 UI 元素这一节介绍了AnimatedVisibility()
组合函数。它允许你应用进入和退出过渡,这些过渡将在内容出现或消失时播放。我们还将动画化尺寸变化,并了解相应的animateContentSize()
修饰符。
在通过视觉效果增强过渡这一节中,我们将使用Crossfade()
组合函数通过交叉淡入淡出动画在两个布局之间切换。此外,你还将了解AnimationSpec
。此接口表示动画的规范。无限动画的探讨结束了这一节。
技术要求
本章基于AnimationDemo
示例。请参考第一章,构建您的第一个 Compose 应用,了解如何安装和设置 Android Studio,以及如何获取本书的配套仓库。
本章的所有代码文件都可以在 GitHub 上找到,网址为github.com/PacktPublishing/Android-UI-Development-with-Jetpack-Compose/tree/main/chapter_08
。
使用动画来可视化状态变化
状态是可能随时间变化的应用数据。在 Compose 应用中,状态(例如,颜色)通过State
或MutableState
实例表示。状态变化会触发重新组合。以下示例显示了一个按钮和一个盒子。点击按钮会通过改变状态在盒子的红色和白色之间切换颜色:
@Composable
fun StateChangeDemo() {
var toggled by remember {
mutableStateOf(false)
}
val color = if (toggled)
Color.White
else
Color.Red
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(onClick = {
toggled = !toggled
}) {
Text(
stringResource(R.string.toggle)
)
}
Box(
modifier = Modifier
.padding(top = 32.dp)
.background(color = color)
.size(128.dp)
)
}
}
在这个例子中,color
是一个简单的不可变变量。每次 toggled
(一个可变的 Boolean
状态)改变时(这发生在 onClick
内部),它都会被设置。由于 color
与应用于 Box()
的修饰符一起使用(background(color = color)
),点击按钮会改变盒子的颜色。
如果您尝试这段代码,开关的感觉非常突然和生硬。这是因为白色和红色并不非常相似。使用动画会使变化更加愉快。让我们看看它是如何工作的。
动画化单个值的变化
要动画化颜色,您可以使用内置的 animateColorAsState()
可组合函数。将 StateDemo()
内部的 val color = if (toggled) …
赋值替换为以下代码块。如果您想尝试它,您可以在 AnimationDemoActivity.kt
中找到一个名为 SingleValueAnimationDemo()
的可组合函数,它属于 AnimationDemo
示例:
val color by animateColorAsState(
targetValue = if (toggled)
Color.White
else
Color.Red
)
animateColorAsState()
返回一个 State<Color>
实例。每当 targetValue
发生变化时,动画将自动运行。如果变化发生在动画进行中,正在进行的动画将调整以匹配新的目标值。
小贴士
使用 by
关键字,您可以像访问普通变量一样访问颜色状态。
您可以提供一个可选的监听器,以便在动画完成后收到通知。以下代码行打印出与新状态匹配的颜色:
finishedListener = { color -> println(color)}
要自定义您的动画,您可以将 AnimationSpec<Color>
的实例传递给 animateColorAsState()
。默认值是 colorDefaultSpring
,它是 SingleValueAnimation.kt
中的一个私有值:
private val colorDefaultSpring = spring<Color>()
spring()
是 AnimationSpec.kt
中的一个顶级函数。它接收阻尼比、刚度和可见性阈值。以下代码行使颜色动画非常柔和:
animationSpec = spring(stiffness = Spring.StiffnessVeryLow)
spring()
返回 SpringSpec
。这个类实现了 FiniteAnimationSpec
接口,该接口反过来又扩展了 AnimationSpec
。这个接口定义了动画的规范,包括要动画化的数据类型和动画配置,在这种情况下,是一个弹簧隐喻。还有其他一些。我们将在 通过视觉效果增强过渡 部分回到这个接口。接下来,我们将查看如何动画化多个值的变化。
动画化多个值的变化
在本节中,我将向您展示如何在状态改变时同时动画化几个值。设置类似于 StateDemo()
和 SingleValueAnimationDemo()
:一个 Column()
实例包含一个 Button()
实例和一个 Box()
实例。但这次,盒子中的内容是 Text()
。按钮切换状态,从而启动动画。
以下版本的 MultipleValuesAnimationDemo()
还不包含动画。它将被插入到读取为 FIXME: animation setup missing 的注释下方:
@Composable
fun MultipleValuesAnimationDemo() {
var toggled by remember {
mutableStateOf(false)
}
// FIXME: animation setup missing
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(onClick = {
toggled = !toggled
}) {
Text(
stringResource(R.string.toggle)
)
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.padding(top = 32.dp)
.border(
width = borderWidth,
color = Color.Black
)
.size(128.dp)
) {
Text(
text = stringResource(id = R.string.app_name),
modifier = Modifier.rotate(degrees = degrees)
)
}
}
}
Box()
显示一个黑色边框,其宽度由 borderWidth
控制。要为你的可组合函数应用边框,只需添加 border()
修饰符。Text()
被旋转。你可以使用 rotate()
修饰符实现这一点。degrees
变量持有角度。degrees
和 borderWidth
将在动画过程中发生变化。以下是实现方式:
val transition = updateTransition(targetState = toggled)
val borderWidth by transition.animateDp() { state ->
if (state)
10.dp
else
1.dp
}
val degrees by transition.animateFloat() { state ->
if (state) -90F
else
0F
}
updateTransition()
可组合函数配置并返回一个 Transition
。当 targetState
发生变化时,转换将运行其所有子动画以达到目标值。子动画通过 animate…()
函数添加。它们不是 Transition
实例的一部分,而是扩展函数。animateDp()
基于密度无关像素添加动画。
在我的示例中,它控制边框宽度。animateFloat()
创建一个 Float
动画。这个函数非常适合改变 Text()
的旋转,因为 Text()
是一个 Float
值。还有更多的 animate…()
函数,它们作用于其他数据类型。例如,animateInt()
与 Int
值一起工作。animateOffset()
动画化一个 Offset
实例。你可以在 Transition.kt
文件中找到它们,该文件属于 androidx.compose.animation.core
包。
Transition
实例提供了一些反映转换状态的属性。例如,isRunning
指示转换中的任何动画是否正在运行。segment
包含当前正在进行的转换的初始状态和目标状态。转换的当前状态可通过 currentState
获取。这将是在转换完成之前的初始状态。然后,currentState
被设置为目标状态。
正如你所见,使用状态变化来触发动画非常简单。到目前为止,这些动画已经修改了一个或多个可组合函数的视觉外观。在下一节中,我将向你展示如何在显示或隐藏 UI 元素时应用动画。
使用动画显示和隐藏 UI 元素
通常,你的 UI 将包含不需要始终可见的信息。例如,在地址簿中,你可能只想显示联系人的关键属性,并在请求时提供详细信息,通常是在按钮点击后。然而,仅仅显示和隐藏附加数据会感觉突然且生硬。使用动画可以使体验更加愉快,所以让我们更深入地了解一下。
理解 AnimatedVisibility()
在本节中,我们将查看我的示例可组合函数 AnimatedVisibilityDemo()
。它属于 AnimationDemo
项目。与 StateDemo()
、SingleValueAnimationDemo()
和 MultipleValuesAnimationDemo()
类似,它使用一个 Column()
实例,该实例包含一个 Button()
实例和一个 Box()
实例。这部分代码简单直接,因此无需在打印中重复。按钮切换状态,从而启动动画。让我们看看它是如何工作的:
AnimatedVisibility(
visible = visible,
enter = slideInHorizontally(),
exit = slideOutVertically()
) {
Box(
modifier = Modifier
.padding(top = 32.dp)
.background(color = Color.Red)
.size(128.dp)
)
}
框架被包裹在 AnimatedVisibility()
中。这个内置的可组合函数在 visible
参数变化时动画其内容的出现和消失。你可以指定不同的 EnterTransition
和 ExitTransition
实例。在我的例子中,框架通过水*滑动进入,通过垂直滑动退出。
目前,有三种过渡类型:
-
淡入
-
扩展和缩小
-
滑动
它们可以通过 +
组合使用:
enter = slideInHorizontally() + fadeIn(),
组合顺序并不重要,因为动画是同时开始的。
如果你没有为 enter
传递值,内容将默认在垂直扩展的同时淡入。省略 exit
将导致内容在垂直缩小的同时淡出。
请注意
在撰写本文时,AnimatedVisibility()
是实验性的。要在你的应用中使用它,你必须添加 @ExperimentalAnimationApi
注解。这将在 Jetpack Compose 1.1 中改变。
在本节中,我向你展示了如何动画内容的出现和消失。这个主题的一个变体是可视化大小变化(如果 width
、height
或两者都是 0
,则 UI 元素不再可见)。让我们在下一节中找出如何做到这一点。
动画大小变化
有时你可能想改变 UI 元素在屏幕上所需的空间量。想想文本字段。在紧凑模式下,你的应用可能只显示三行,而在详细模式下可能显示 10 行或更多。我的 SizeChangeAnimationDemo()
示例可组合函数(图 8.1)使用滑块来控制 Text()
的 maxLines
值:
图 8.1 – 显示 SizeChangeAnimationDemo() 的 AnimationDemo 示例
通用设置遵循上一节中的示例:一个 Column()
实例作为一些可组合函数的容器,在本例中是一个 Slider()
实例和一个 Text()
实例。然后状态变化触发动画。以下是代码:
@Composable
fun SizeChangeAnimationDemo() {
var size by remember { mutableStateOf(1F) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Slider(
value = size,
valueRange = (1F..4F),
steps = 3,
onValueChange = {
size = it
},
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
text = stringResource(id = R.string.lines),
modifier = Modifier
.fillMaxWidth()
.background(Color.White)
.animateContentSize(),
maxLines = size.toInt(),
color = Color.Blue
)
}
}
size
是一个可变的 Float
状态。它被传递给 Slider()
作为其默认值。当滑块被移动时,onValueChange {}
被调用。lambda 表达式接收新值,并将其分配给 size
。Text()
可组合函数使用状态作为 maxLines
的值。
动画由 animateContentSize()
修饰符处理。它属于 androidx.compose.animation
包。该修饰符期望两个参数,animationSpec
和 finishedListener
。我在 动画单个值变化 部分简要介绍了这两个。animationSpec
默认为 spring()
。如果你想在延迟后一次性显示所有线条,可以添加以下内容:
animationSpec = snap(1000)
快照动画立即将动画值切换到最终值。您需要传递动画运行前等待的毫秒数。默认值为0
。现在,snap()
返回一个SnapSpec
实例,它是AnimationSpec
的实现。我们将在通过视觉效果增强过渡效果部分转向此接口。
finishedListener
的默认值是null
。如果您希望当大小变化动画完成时收到通知,您可以提供实现。初始值和最终大小都会传递给监听器。如果动画被中断,初始值将是中断点的大小。这有助于确定大小变化的方向。
这就结束了我们关于使用动画显示和隐藏 UI 元素的探讨。在下一节中,我们将专注于交换 UI 的某些部分。例如,我们将使用Crossfade()
通过交叉淡入淡出动画在两个可组合函数之间切换。
通过视觉效果增强过渡效果
到目前为止,我向您展示了修改 UI 元素某些方面的动画,例如其颜色、大小或可见性。但有时您可能想要交换UI 的某些部分。这时,Crossfade()
就派上用场了。它允许您通过交叉淡入淡出动画在两个可组合函数之间切换。让我们看看我的CrossfadeAnimationDemo()
示例(图 8.2),它是AnimationDemo
项目的一部分,看看它是如何工作的:
![图 8.2 – 显示 CrossfadeAnimationDemo()的 AnimationDemo 示例]
![img/B17505_08_2.jpg]
图 8.2 – 显示 CrossfadeAnimationDemo()的 AnimationDemo 示例
开关在两个屏幕之间切换。由于我们专注于动画,我保持了Screen()
可组合的简单性,只是一个可自定义背景颜色的盒子,以及一个居中的大文本。您可以在AnimationDemoActivity.kt
中找到其源代码。
混叠可组合函数
与本章中的大多数示例一样,CrossfadeAnimationDemo()
使用Column()
作为根元素。该列包含一个开关和要显示的屏幕。显示哪个取决于一个可变的Boolean
状态:
@Composable
fun CrossfadeAnimationDemo() {
var isFirstScreen by remember { mutableStateOf(true) }
Column(
modifier = Modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Switch(
checked = isFirstScreen,
onCheckedChange = {
isFirstScreen = !isFirstScreen
},
modifier = Modifier.padding(top = 16.dp,
bottom = 16.dp)
)
Crossfade(targetState = isFirstScreen) { it ->
if (it) {
Screen(
text = stringResource(id = R.string.letter_w),
backgroundColor = Color.Gray
)
} else {
Screen(
text = stringResource(id = R.string.letter_i),
backgroundColor = Color.LightGray
)
}
}
}
}
Switch()
的onCheckedChange
lambda 表达式切换isFirstScreen
。这个状态作为targetState
参数传递给Crossfade()
。像之前我向您展示的其他动画一样,每次值改变时都会触发动画。具体来说,调用旧值的内联内容将淡出,而调用新值的内联内容将淡入。
Crossfade()
接收一个类型为FiniteAnimationSpec<Float>
的animationSpec
。默认为tween()
。此函数返回一个配置了给定持续时间、延迟和缓动曲线的TweenSpec
实例。参数默认为DefaultDurationMillis
(300 毫秒)、0
和FastOutSlowInEasing
。缓动曲线由CubicBezierEasing
类的实例表示。此类模拟三阶贝塞尔曲线。其构造函数接收四个参数:
-
第一个控制点的x和y坐标
-
第二个控制点的x和y坐标
文档解释说,通过点(0, 0)和第一个控制点的线在点(0, 0)处与缓动函数相切,而通过点(1, 1)和第二个控制点的线在点(1, 1)处与缓动函数相切。CubicBezierEasing
是Easing
接口的实现(位于androidx.compose.animation.core
包中)。除了FastOutSlowInEasing
之外,你还可以从其他三个预定义曲线中选择:LinearOutSlowInEasing
、FastOutLinearInEasing
和LinearEasing
来自定义你的动画。
由于Crossfade()
接收一个类型为FiniteAnimationSpec<Float>
的animationSpec
,例如,你可以传递以下代码来使用具有非常低刚度的弹簧动画:
animationSpec = spring(stiffness = Spring.StiffnessVeryLow)
在下一节中,我们将探讨动画的不同规范之间的关系。
理解动画规范
AnimationSpec
是定义动画规范的基接口。它存储要动画化的数据类型和动画配置。它的唯一功能vectorize()
,创建一个带有给定TwoWayConverter
(将给定类型转换为AnimationVector
并从AnimationVector
转换回)的VectorizedAnimationSpec
实例。
动画系统在AnimationVector
实例上操作。VectorizedAnimationSpec
描述了这些向量应该如何被动画化,例如,简单地插值于起始值和结束值之间(正如你在TweenSpec
中看到的),完全不显示动画(SnapSpec
),或者应用弹簧物理来产生运动(SpringSpec
)。
FiniteAnimationSpec
接口扩展了AnimationSpec
。它直接由RepeatableSpec
和SpringSpec
类实现。它重写了vectorize()
方法以返回VectorizedFiniteAnimationSpec
。现在,FiniteAnimationSpec
是接口DurationBasedAnimationSpec
的父接口,该接口重写了vectorize()
方法以返回VectorizedDurationBasedAnimationSpec
。然后,DurationBasedAnimationSpec
由TweenSpec
、SnapSpec
和KeyframesSpec
类实现。
要创建一个KeyframesSpec
实例,你可以调用keyframes()
函数并传递一个用于动画的初始化函数。在动画的持续时间之后,你传递在给定毫秒数的时间点上的动画值的映射:
animationSpec = keyframes {
durationMillis = 8000
0f at 0
1f at 2000
0f at 4000
1f at 6000
}
在这个示例中,动画持续了 8 秒,这比实际使用中你可能会用到的任何时间都要长,但它允许你观察这些变化。如果你将代码片段应用到CrossfadeAnimationDemo()
中,你会注意到在动画过程中每个字母都可见两次。
到目前为止,我们已经探讨了有限动画。如果你想要一个动画永远继续下去怎么办?Jetpack Compose 在CircularProgressIndicator()
和LinearProgressIndicator()
可组合组件中这样做。InfiniteRepeatableSpec
会重复提供的动画,直到手动取消。
当与过渡或其他动画组合组件一起使用时,当组合组件从 Compose 树中移除时,动画将停止。InfiniteRepeatableSpec
实现了AnimationSpec
接口。构造函数期望两个参数,animation
和repeatMode
。RepeatMode
枚举类定义了两个值,Restart
和Reverse
。repeatMode
的默认值是RepeatMode.Restart
,意味着每次重复都从开始处重新开始。
你可以使用infiniteRepeatable()
来创建一个InfiniteRepeatableSpec
实例。我的InfiniteRepeatableDemo()
示例组合组件(图 8.3)展示了如何做到这一点:
图 8.3 – 显示 InfiniteRepeatableDemo()的 AnimationDemo 示例
可组合的组件将文本顺时针旋转 0 到 359 度。然后,动画重新开始。Text()
组件在Box()
组件内居中:
@Composable
fun InfiniteRepeatableDemo() {
val infiniteTransition = rememberInfiniteTransition()
val degrees by infiniteTransition.animateFloat(
initialValue = 0F,
targetValue = 359F,
animationSpec = infiniteRepeatable(animation =
keyframes {
durationMillis = 1500
0F at 0
359F at 1500
})
)
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(text = stringResource(id = R.string.app_name),
modifier = Modifier.rotate(degrees = degrees))
}
}
要创建一个可能无限循环的动画,你首先需要使用rememberInfiniteTransition()
记住一个无限过渡。然后你可以在过渡实例上调用animateFloat()
。这返回State<Float>
,它与rotate()
修饰符一起使用。infiniteRepeatable()
作为其animationSpec
参数传递给animateFloat()
。动画本身基于关键帧。我们只需要定义两个帧,第一个代表起始角度,第二个代表结束角度。
如果你想让文本返回到其初始角度而不是持续旋转,你可以将repeatMode
参数更改为以下内容:
repeatMode = RepeatMode.Reverse
然后你应该在开始和结束处添加短暂的延迟。keyframes {}
应该看起来像这样:
keyframes {
durationMillis = 2000
0F at 500
359F at 1500
}
这就结束了我们对动画规格的探讨。为了完成本章,让我简要总结一下你所学到的内容,以及你可以在下一章中期待的内容。
摘要
本章向你展示了如何轻松使用 Jetpack Compose 通过动画和过渡来丰富你的应用。我们首先使用简单的动画来可视化状态变化。例如,我向你介绍了animateColorAsState()
。然后我们使用updateTransition()
来获取Transition
实例,并调用扩展函数如animateDp()
和animateFloat()
,以根据状态变化同时动画化多个值。
在使用动画显示和隐藏 UI 元素部分,你了解了AnimatedVisibility()
组合函数,它允许你应用进入和退出过渡。它们在内容出现或消失时播放。你还学习了如何使用animateContentSize()
修饰符来动画化大小变化。
在最终的主部分,通过视觉效果增强过渡,我们使用了Crossfade()
组合函数在两个布局之间切换,并使用交叉淡入淡出动画。此外,你还了解了AnimationSpec
和相关类和接口。我在本节结束时对无限动画进行了总结。
在第九章,探索互操作性 API,你将学习如何混合传统的视图和可组合函数。我们还将再次回到 ViewModels,作为在两个世界之间共享数据的一种手段。并且我会向你展示如何将第三方库集成到你的 Compose 应用中。
第九章:探索互操作性 API
本书的目标是向你展示如何开发美观、快速且易于维护的 Jetpack Compose 应用。前几章帮助你熟悉核心技术和原则,以及重要的接口、类、包,当然还有可组合函数。接下来的章节将涵盖 Android 新声明式用户界面工具包的成功采用之外的主题。
在本章中,我们将探讨 AndroidView()
、AndroidViewBinding()
和 ComposeView
作为 Jetpack Compose 的互操作性 应用程序编程接口 (API)。主要部分如下:
-
在 Compose 应用中显示视图
-
在视图和可组合函数之间共享数据
-
在视图层次结构中嵌入可组合函数
我们首先看看如何在 Compose 应用中显示传统的视图层次结构。想象一下,你编写了一个自定义组件(在底层由几个 UI 元素组成),例如图片选择器、颜色选择器或相机预览器。你不必用 Jetpack Compose 重新编写你的组件,只需简单地重用它即可节省你的投资。许多第三方库仍然是用视图编写的,所以我将向你展示如何在 Compose 应用中使用它们。
一旦你在 Compose 应用中嵌入了一个视图,你需要在视图和你的可组合函数之间共享数据。在视图和可组合函数之间共享数据 部分解释了如何使用 ViewModels 来实现这一点。
通常,你可能不想从头开始重写一个应用,而是逐步将其迁移到 Jetpack Compose,逐步用可组合函数替换视图层次结构。最后一部分主要部分,在视图层次结构中嵌入可组合函数,讨论了如何在现有的基于视图的应用中包含一个 Compose 层次结构。
技术要求
本章基于 ZxingDemo
和 InteropDemo
示例。请参考 第一章 的 技术要求 部分,构建你的第一个 Compose 应用,了解如何安装和设置 Android Studio,以及如何获取本书的配套仓库。
本章的所有代码文件都可以在 GitHub 上找到,地址为 github.com/PacktPublishing/Android-UI-Development-with-Jetpack-Compose/tree/main/chapter_09
。
在 Compose 应用中显示视图
想象一下,你为之前的一个应用编写了一个基于视图的自定义组件——例如,一个图片选择器、颜色选择器或相机预览器——或者你想要包含一个第三方库,如 Zebra Crossing (ZXing) 来扫描 快速响应 (QR) 码和条形码。要将它们集成到 Compose 应用中,你需要将视图(或视图层次结构的根)添加到你的可组合函数中。
让我们看看这是如何工作的。
将自定义组件添加到 Compose 应用中
下面的屏幕截图显示了ZxingDemo
示例,它使用基于 ZXing 解码器的 Android ZXing Android Embedded 条形码扫描库。它根据 Apache License 2.0 条款发布,并托管在 GitHub 上(github.com/journeyapps/zxing-android-embedded
):
图 9.1 – ZxingDemo 示例
我的示例持续扫描条形码和二维码。装饰过的条形码视图由库提供。如果扫描引擎提供了结果,相应的文本将使用Text()
作为叠加显示。要使用ZXing Android Embedded,您需要将实现依赖项添加到您的模块级build.gradle
文件中,如下所示:
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
扫描器访问相机和(可选)设备振动器。应用必须在清单中请求至少android.permission.WAKE_LOCK
和android.permission.CAMERA
权限,并在运行时请求android.permission.CAMERA
权限。我的实现基于ActivityResultContracts.RequestPermission
,它取代了传统的覆盖onRequestPermissionsResult()
的方法。此外,根据活动的生命周期,扫描器必须暂停和恢复。为了简化,我使用了一个名为barcodeView
的lateinit
变量,并在需要时调用barcodeView.pause()
和barcodeView.resume()
。请参阅项目的源代码以获取详细信息。接下来,我将向您展示如何初始化扫描库。这涉及到填充一个布局文件(命名为layout.xml
),如下所示:
<?xml version="1.0" encoding="utf-8"?>
<com.journeyapps.barcodescanner.DecoratedBarcodeView
android:id="@+id/barcode_scanner"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_alignParentTop="true" />
布局仅包含一个元素,DecoratedBarcodeView
。它被配置为填充所有可用空间。以下代码片段是onCreate()
方法的一部分。请记住,barcodeView
在onPause()
等生命周期函数中被访问,因此是一个lateinit
属性:
val root = layoutInflater.inflate(R.layout.layout, null)
barcodeView = root.findViewById(R.id.barcode_scanner)
val formats = listOf(BarcodeFormat.QR_CODE,
BarcodeFormat.CODE_39)
barcodeView.barcodeView.decoderFactory =
DefaultDecoderFactory(formats)
barcodeView.initializeFromIntent(intent)
val callback = object : BarcodeCallback {
override fun barcodeResult(result: BarcodeResult) {
if (result.text == null || result.text == text.value) {
return
}
text.value = result.text
}
}
barcodeView.decodeContinuous(callback)
首先,layout.xml
被填充并分配给root
。然后,barcodeView
被初始化(initializeFromIntent()
)并配置(通过设置解码器工厂)。最后,使用decodeContinuous()
启动连续扫描过程。每当有新的扫描结果可用时,都会调用callback
lambda 表达式。text
变量定义如下:
private val text = MutableLiveData("")
我使用MutableLiveData
,因为它可以很容易地作为状态被观察。在我向您展示如何在组合函数内部访问它之前,让我们简要回顾如下:
-
我们已设置并激活了扫描库。
-
当它检测到条形码或二维码时,它会更新一个
MutableLiveData
实例的值。 -
我们定义并初始化了两个
View
实例——root
和barcodeView
。
接下来,我将向您展示如何在组合函数内部访问从 ViewModel 获取的状态,如下所示:
setContent {
val state = text.observeAsState()
state.value?.let {
ZxingDemo(root, it)
}
}
状态和root
的值传递给ZxingDemo()
可组合函数。我们使用Text()
显示value
。root
参数用于在 Compose UI 中包含视图层次结构。代码在以下代码片段中说明:
@Composable
fun ZxingDemo(root: View, value: String) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopCenter
) {
AndroidView(modifier = Modifier.fillMaxSize(),
factory = {
root
})
if (value.isNotBlank()) {
Text(
modifier = Modifier.padding(16.dp),
text = value,
color = Color.White,
style = MaterialTheme.typography.h4
)
}
}
}
UI 由一个Box()
可组合组件组成,包含两个子组件,AndroidView()
和Text()
。AndroidView()
接收一个factory
块,该块仅返回root
(包含扫描视图的视图层次结构)。Text()
可组合组件显示最后扫描的结果。
factory
块恰好调用一次,以获取要组合的视图。它总是在 UI 线程上调用,因此您可以按需设置视图属性。在我的示例中,这并不需要,因为所有初始化已经在onCreate()
中完成。配置条形码扫描器不应在可组合组件中完成,因为准备相机和预览可能需要消耗时间。此外,组件树的部分在活动级别被访问,因此需要子组件(barcodeView
)的引用。
在本节中,我向您展示了如何使用AndroidView()
将视图层次结构包含在您的 Compose 应用中。这个可组合函数是 Jetpack Compose 互操作性 API 的重要部分之一。我们使用了layoutInflater.inflate()
来膨胀组件树,并使用findViewById()
来访问其子组件之一。现代基于视图的应用尝试避免使用findViewById()
,而是使用视图绑定。在下一节中,您将学习如何结合视图绑定和可组合函数。
使用 AndroidViewBinding()膨胀视图层次结构
传统上,活动在lateinit
属性中持有对视图的引用,如果需要在不同的函数中修改相应的组件。第二章的膨胀布局文件部分,理解声明性范式讨论了这种方法的某些问题,并介绍了视图绑定作为解决方案。它被许多应用采用。因此,如果您想将现有应用迁移到 Jetpack Compose,您可能需要结合视图绑定和可组合函数。本节解释了如何实现这一点。
以下截图显示了InteropDemo
示例:
图 9.2 – InteropDemo 示例
InteropDemo
示例包含两个活动。一个(ViewActivity
)在视图层次结构中集成一个可组合函数。我们将在在视图层次结构中嵌入可组合函数部分转向这一点。第二个,ComposeActivity
做相反的事情:使用视图绑定膨胀一个视图层次结构,并在Column()
可组合组件内显示组件树。让我们看看这里:
class ComposeActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val viewModel: MyViewModel by viewModels()
…
setContent {
ViewIntegrationDemo(viewModel) {
val i = Intent(
this,
ViewActivity::class.java
)
i.putExtra(KEY, viewModel.sliderValue.value)
startActivity(i)
}
}
}
}
根组合器被称作 ViewIntegrationDemo()
。它接收一个 ViewModel 和一个 lambda 表达式。ViewModel 用于在 Compose 和 View
层次结构之间共享数据,这将在 在视图和组合函数之间共享数据 部分进行讨论。lambda 表达式启动 ViewActivity
并传递从 ViewModel(sliderValue
)中获取的值。代码在下面的代码片段中展示:
@Composable
fun ViewIntegrationDemo(viewModel: MyViewModel,
onClick: () -> Unit) {
val sliderValueState =
viewModel.sliderValue.observeAsState()
Scaffold( ... ) {
Column( ... ) {
Slider( … )
AndroidViewBinding(
modifier = Modifier.fillMaxWidth(),
factory = CustomBinding::inflate
) {
// Here Views will be updated
}
}
}
}
Scaffold()
是一个重要的集成组合器函数。它结构化了一个 Compose 屏幕。除了顶部和底部栏之外,它还包含一些内容——在这个例子中,是一个有两个子项的 Column()
组合器,分别是 Slider()
和 AndroidViewBinding()
。滑块从 ViewModel 获取其当前值并将更改传播回它。你将在 重新审视 ViewModels 部分了解更多关于这一点。
AndroidViewBinding()
与 AndroidView()
类似。一个 factory
块创建一个要组合的 View 层次结构。CustomBinding::inflate
从 custom.xml
文件中填充布局,并返回该类型的实例。该类在构建过程中创建和更新。它提供了反映名为 custom.xml
的布局文件内容的常量。以下是简化的版本:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/textView"
... />
<com.google.android.material.button.MaterialButton
android:id="@+id/button"
…
android:text="@string/view_activity"
…
app:layout_constraintTop_toBottomOf="@id/textView" />
</androidx.constraintlayout.widget.ConstraintLayout>
这个 ConstraintLayout
有两个子项,一个 MaterialTextView
和一个 MaterialButton
。按钮点击会调用传递给 ViewIntegrationDemo()
的 lambda 表达式。文本字段接收当前的滑块值。这是在 update
块中完成的。以下代码属于 ViewIntegrationDemo()
中 // Here Views will be updated
之下:
textView.text = sliderValueState.value.toString()
button.setOnClickListener {
onClick()
}
你可能想知道 textView
和 button
是在哪里定义的,以及为什么它们可以立即访问。update
块在布局被填充之后立即被调用。它是一个扩展函数,其实例由 inflate
返回——在我的例子中,是 CustomBinding
。因为 custom.xml
中有 button
和 textView
,所以在 CustomBinding
中有相应的变量。
当它所使用的值(sliderValueState.value
)发生变化时,也会调用 update
块。在下一节中,我们将探讨这种变化何时以及在哪里被触发。
在视图和组合函数之间共享数据
状态是可能随时间变化的应用数据。当被组合使用的状态发生变化时,就会发生重组。为了在传统的视图世界中实现类似的功能,我们需要以某种方式存储数据,以便可以观察到对其的更改。存在许多可观察模式的实现。Android 架构组件(以及随后的 Jetpack 版本)包括 LiveData
和 MutableLiveData
。这两个都在 ViewModels 中被频繁使用,用于在活动之外存储状态。
重新审视 ViewModels
在 第五章 的 Surviving configuration changes 部分、第七章](B17505_07_ePub.xhtml#_idTextAnchor119) 的 Persisting and retrieving state 部分以及 Tips, Tricks, and Best Practices 部分中,我向您介绍了 ViewModels。在我们查看如何使用 ViewModels 在视图和 composable 函数之间同步数据之前,让我们简要回顾以下关键技术:
-
要创建或获取 ViewModel 的实例,使用属于
androidx.activity
包的顶级viewModels()
函数。 -
要将
LiveData
实例作为 composable 状态观察,请在 composable 函数内部对 ViewModel 属性调用observeAsState()
扩展函数。 -
要在 composable 函数外部观察
LiveData
实例,调用observe()
。此函数属于androidx.lifecycle.LiveData
。 -
要更改 ViewModel 属性,调用相应的设置器。
重要提示
请确保在模块级别的
build.gradle
文件中根据需要添加androidx.compose.runtime:runtime-livedata
、androidx.lifecycle:lifecycle-runtime-ktx
和androidx.lifecycle:lifecycle-viewmodel-compose
的实现依赖项。
现在,我们已经熟悉了与 ViewModels 相关的关键技术,让我们看看视图和 composable 函数之间的同步是如何工作的。同步 意味着 composable 函数和与视图相关的代码观察相同的 ViewModel 属性,并且可能触发该属性的变化。触发变化通常是通过调用设置器来完成的。对于一个 Slider()
composable,它可能看起来像这样:
Slider(
modifier = Modifier.fillMaxWidth(),
onValueChange = {
viewModel.setSliderValue(it)
},
value = sliderValueState.value ?: 0F
)
此示例还展示了 composable 内部的读数(sliderValueState.value
)。以下是 sliderValueState
的定义方式:
val sliderValueState = viewModel.sliderValue.observeAsState()
接下来,让我们看看使用 View Binding 的传统(非 Compose)代码。以下示例是 ViewActivity
的一部分,它也属于 InteropDemo
示例。
结合 View Binding 和 ViewModels
利用 View Binding 的 Activity 通常有一个名为 binding
的 lateinit
属性,如下代码片段所示:
binding = LayoutBinding.inflate(layoutInflater)
LayoutBinding.inflate()
返回一个 LayoutBinding
实例。Binding.root
代表组件树的根。它被传递给 setContentView()
。以下是相应的布局文件(layout.xml
)的简略版本:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
…
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.slider.Slider
android:id="@+id/slider"
... />
<androidx.compose.ui.platform.ComposeView
android:id="@+id/compose_view"
...
app:layout_constraintTop_toBottomOf="@id/slider" />
</androidx.constraintlayout.widget.ConstraintLayout>
ConstraintLayout
包含一个 com.google.android.material.slider.Slider
和一个 ComposeView
(将在下一节中详细讨论)。滑动条的 ID 是 slider
,因此 LayoutBinding
包含一个同名的变量。因此,我们可以将滑动条链接到 ViewModel,如下所示:
viewModel.sliderValue.observe(this) {
binding.slider.value = it
}
当 sliderValue
中存储的值发生变化时,传递给 observe()
的块会被调用。通过更新 binding.slider.value
,我们改变滑动条手柄的位置,这意味着我们更新了滑动条。代码如下所示:
binding.slider.addOnChangeListener { _, value, _ ->
viewModel.setSliderValue(value) }
当用户拖动滑块手柄时,传递给addOnChangeListener()
的块会被调用。通过调用setSliderValue()
,我们更新 ViewModel,这反过来又触发了观察者的更新——例如,我们的可组合函数。
在本节中,我向您介绍了将可组合函数和传统视图绑定到 ViewModel 属性所需的步骤。当属性发生变化时,所有观察者都会被调用,这会导致可组合和视图的更新。在下一节中,我们将继续探讨InteropDemo
示例。这次,我将向您展示如何在视图层次结构中嵌入可组合函数。如果要将现有应用程序逐步迁移到 Jetpack Compose,这一点非常重要。
在视图层次结构中嵌入可组合函数
正如您所看到的,使用AndroidView()
和AndroidViewBinding()
,在可组合函数中集成视图非常简单直接。但反过来呢?通常,您可能不想从头开始重写现有的(基于视图的)应用程序,而是逐步将其迁移到 Jetpack Compose,逐步用可组合函数替换视图层次结构。根据活动的复杂性,从反映 UI 部分的小型可组合函数开始,并将它们整合到剩余布局中,可能是有意义的。
Androidx.compose.ui.platform.ComposeView
使可组合函数在经典布局中可用。该类扩展了AbstractComposeView
,其父类为ViewGroup
。一旦包含 ComposeView 的布局被填充,以下是您如何配置它的方法:
binding.composeView.run {
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnDetachedFromWindow)
setContent {
val sliderValue =
viewModel.sliderValue.observeAsState()
sliderValue.value?.let {
ComposeDemo(it) {
val I = Intent(
context,
ComposeActivity::class.java
)
i.putExtra(KEY, it)
startActivity(i)
}
}
}
}
setContent()
设置此视图的内容。当视图附加到窗口或调用createComposition()
时,将发生初始组合。虽然setContent()
在ComposeView
中定义,但createComposition()
属于AbstractComposeView
。它为此视图执行初始组合。通常,您不需要直接调用此函数。
setViewCompositionStrategy()
配置如何管理视图内部组合的释放。ViewCompositionStrategy.DisposeOnDetachedFromWindow
(默认值)表示每当视图从窗口分离时,组合就会被释放。在像我示例中的简单场景中,这是首选的。如果您的视图在片段或具有已知LifecycleOwner
的组件中显示,您应使用DisposeOnViewTreeLifecycleDestroyed
或DisposeOnLifecycleDestroyed
。然而,这些内容超出了本书的范围。以下行基于 ViewModel 的sliderValue
属性创建状态,并将值传递给ComposeDemo()
:
val sliderValue = viewModel.sliderValue.observeAsState()
此可组合函数还接收一个启动ComposeActivity
并传递当前滑块值的块,如下面的代码片段所示:
@Composable
fun ComposeDemo(value: Float, onClick: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colors.secondary)
.height(64.dp),
contentAlignment = Alignment.Center
) {
Text(
text = value.toString()
)
}
Button(
onClick = onClick,
modifier = Modifier.padding(top = 16.dp)
) {
Text(text = stringResource(id =
R.string.compose_activity))
}
}
}
ComposeDemo()
,如图下截图所示,在Column()
中放置一个Box()
(其中包含一个Text()
)和一个Button()
,以模仿ViewActivity
。将Text()
包裹在Box()
中是为了在具有特定高度的区域内垂直居中文本。点击按钮将调用onClick
lambda 表达式。Text()
仅显示value
参数:
图 9.3 – InteropDemo 示例展示了 ViewActivity
在结束本章之前,让我回顾一下您需要采取的重要步骤,以便在布局中包含 Compose 层次结构,如下所示:
-
将
androidx.compose.ui.platform.ComposeView
添加到布局中。 -
根据布局显示的位置(活动、片段等),决定一个
ViewCompositionStrategy
。 -
使用
setContent {}
设置内容。 -
通过调用
viewModels()
获取ViewModel
的引用。 -
将监听器注册到相关视图,并在更改时更新
ViewModel
。 -
在可组合函数内部,根据需要通过在 ViewModel 属性上调用
observeAsState()
来创建状态。 -
在可组合函数内部,通过调用相应的 setter 来更新 ViewModel。
Jetpack Compose 互操作 API 允许无缝双向集成可组合函数和View
层次结构。它们帮助您使用依赖于视图的库,并通过实现逐步、细粒度的迁移来简化向 Composable 的过渡。
概述
在本章中,我们探讨了 Jetpack Compose 的互操作 API,这些 API 允许您混合可组合函数和传统视图。我们首先通过使用AndroidView()
在 Compose 应用程序中集成第三方库的传统视图层次结构,然后展示了如何使用 View Binding 和AndroidViewBinding()
将布局嵌入到可组合函数中。一旦您在 Compose UI 中嵌入了一个View
,您就需要在这两个世界之间共享数据。视图和可组合函数之间的数据共享部分解释了如何使用 ViewModel 实现这一点。最后一章主要讨论了如何在现有应用程序中使用ComposeView
将 Compose UI 嵌入。
第十章,测试和调试 Compose 应用程序,专注于测试您的 Compose 应用程序。您将学习如何使用ComposeTestRule
和AndroidComposeTestRule
。此外,我将向您介绍语义树。
第十章:测试和调试 Compose 应用
编程是一个非常富有创造性的过程。使用 Jetpack Compose 实现看起来很棒的 用户界面(UI)和流畅的动画是纯粹的乐趣。然而,打造一个出色的应用不仅仅需要编写代码。测试和调试同样重要,因为无论您如何精心设计和实现您的应用,错误和故障都是不可避免的,至少在非*凡程序中是这样。但无需害怕,因为您可以使用强大的工具来检查您的代码是否按预期运行。
本章向您介绍这些工具。其主要部分如下所示:
-
设置和编写测试
-
理解语义
-
调试 Compose 应用
在第一部分主要章节中,我将向您介绍有关测试的重要术语和技术。我们将设置基础设施,编写一个简单的单元测试,然后转向 Compose 特定内容——例如,createComposeRule()
和 createAndroidComposeRule()
。
理解语义 部分建立在这些基础之上。我们将探讨如何在测试中选择或找到可组合函数,以及为什么使您的应用可访问也有助于编写更好的测试。您还将了解操作和断言。
失败的测试通常表明存在错误,除非当然,失败是故意的。如果您怀疑测试正在检查的代码有错误,那么就需要进行调试会话。最后一部分主要章节,调试 Compose 应用,解释了如何检查您的 Compose 代码。我们将回顾在 理解语义 部分中讨论的语义树。最后,我将向您展示如何利用 InspectorInfo
和 InspectorValueInfo
。
技术要求
本章基于 TestingAndDebuggingDemo
示例。请参考 第一章 的 技术要求 部分,了解如何安装和设置 Android Studio 以及如何获取本书配套的代码库。
本章的所有代码文件都可以在 GitHub 上找到:github.com/PacktPublishing/Android-UI-Development-with-Jetpack-Compose/tree/main/chapter_10
。
设置和编写测试
作为一名软件开发人员,您可能喜欢编写代码。看到应用增加功能会感到非常满足,可能比编写测试——或者更糟糕的是,发现错误——更有成就感。然而,测试和调试是必不可少的。最终,您的代码将包含错误,因为所有非*凡程序都会这样。为了使您作为开发者的生活更轻松,您需要熟悉编写测试以及调试您自己的和别人的代码。测试一个应用有多个方面,对应着不同类型的测试,如下所述:
-
单元测试:你需要确保业务逻辑按预期工作。例如,这意味着公式和计算总是产生正确的结果。
-
集成测试:应用的所有构建块是否正确集成?根据应用的功能,这可能包括访问远程服务、与数据库通信或在设备上读取和写入文件。
-
UI 测试:UI 是否准确?所有 UI 元素是否在所有支持的屏幕尺寸上都可见?它们是否总是显示正确的值?按钮点击或滑块移动等交互是否触发了预期的功能?还有非常重要的一点:应用的所有部分是否都易于访问?
测试的数量因类型而异。长期以来,人们一直声称,理想情况下,你大部分的测试应该是单元测试,其次是集成测试。这导致了一种测试金字塔的概念,其中单元测试是其基础,UI 测试是顶端。就像所有的隐喻一样,测试金字塔也经历了支持和严厉的批评。如果你想了解更多关于它以及一般测试策略的信息,请参阅本章末尾的进一步阅读部分。Jetpack Compose 测试是 UI 测试。因此,虽然你可能编写了许多相应的测试用例,但使用单元测试测试底层业务逻辑可能更为重要。
为了使测试可靠、易懂和可重复,使用了自动化。在下一节中,我将向你展示如何使用JUnit 4测试框架编写单元测试。
实现单元测试
单位是小的、独立的代码片段——通常是函数、方法、子程序或属性,具体取决于编程语言。让我们看看以下代码片段中的简单 Kotlin 函数:
fun isEven(num: Int): Boolean {
val div2 = num / 2
return (div2 * 2) == num
}
isEven()
确定传递的Int
值是否为偶数。如果是这样,函数返回true
;否则,返回false
。该算法基于这样一个事实:只有偶数Int
值才能被2
整除而没有余数。假设我们经常使用这个函数,我们当然想确保结果总是正确的。但我们如何做到这一点(如何测试这一点)?为了彻底验证isEven()
,我们需要检查从Int.MIN_VALUE
到Int.MAX_VALUE
的所有可能的输入值。即使在快速的计算机上,这也可能需要一些时间。编写良好单元测试的艺术部分在于识别所有重要的边界和转换。关于isEven()
,以下可能是一些:
-
Int.MIN_VALUE
和Int.MAX_VALUE
-
一个负偶数和一个负奇数
Int
值 -
一个正偶数和一个正奇数
Int
值
要编写和执行单元测试,你应该将以下依赖项添加到你的模块级build.gradle
属性文件中:
androidTestImplementation "androidx.test.ext:junit:1.1.3"
androidTestImplementation "androidx.compose.ui:ui-test-
junit4:$compose_version"
debugImplementation "androidx.compose.ui:ui-test-
manifest:$compose_version"
testImplementation 'junit:junit:4.13.2'
androidTestImplementation "androidx.test.espresso:espresso-
core:3.4.0"
根据你将添加到你的应用程序项目中的测试类型,一些前面的依赖项可能是可选的。例如,androidx.test.espresso
只在你的应用程序也包含你希望测试的旧式视图(例如在互操作性场景中)时需要。
单元测试是在你的开发机器上执行的。测试类放置在app/src/test/java
目录中,并通过SimpleUnitTest
可用:
![图 10.1 – Android Studio 项目工具窗口中的单元测试]
![img/B17075_10_1.jpg]
图 10.1 – Android Studio 项目工具窗口中的单元测试
让我们看看以下代码片段中的类:
Package
eu.thomaskuenneth.composebook.testinganddebuggingdemo
import org.junit.*
import org.junit.Assert.assertEquals
class SimpleUnitTest {
companion object {
@BeforeClass
@JvmStatic
fun setupAll() {
println("Setting things up")
}
}
@Before
fun setup() {
println("Setup test")
}
@After
fun teardown() {
println("Clean up test")
}
@Test
fun testListOfInts() {
val nums = listOf(Int.MIN_VALUE, -3, -2, 2, 3,
Int.MAX_VALUE)
val results = listOf(true, false, true, true, false,
false)
nums.forEachIndexed { index, num ->
val result = isEven(num)
println("isEven($num) returns $result")
assertEquals(result, results[index])
}
}
}
一个测试类包含一个或多个测试。一个@Test
。它检查某些定义明确的情景、条件或标准。测试应该是独立的,这意味着它们不应该依赖于之前的测试。我的例子是测试isEven()
对于六个输入值是否返回正确的结果。这样的检查基于断言。断言定义了期望的行为。如果断言未满足,则测试失败。
如果你需要在每个测试之前或之后执行某些操作,你可以实现函数并用@Before
或@After
注解它们。你也可以使用@Rule
实现类似的功能。我们将在下一节中探讨这个问题。要在所有测试之前运行代码,你需要实现一个带有@BeforeClass
和@JvmStatic
注解的伴生对象中的函数。@AfterClass
在所有测试运行完毕后用于清理。
你可以通过在项目工具窗口中右键单击测试类并选择运行 '…'来运行单元测试。一旦为测试类创建了一个启动配置,你也可以通过菜单栏和工具栏来运行测试。测试结果在运行工具窗口中显示,如下面的截图所示:
![图 10.2 – Android Studio 运行工具窗口中的测试结果]
![img/B17075_10_2.jpg]
图 10.2 – Android Studio 运行工具窗口中的测试结果
尽管测试通过了,但我的isEven()
实现可能仍然不完美。虽然测试检查了上下限,但它没有测试负数和正数之间的转换。让我们纠正这个问题并添加另一个测试,如下所示:
@Test
fun testIsEvenZero() {
assertEquals(true, isEven(0))
}
幸运的是,这个测试也通过了。
重要提示
请密切关注单元测试接收的参数和它产生的结果。始终测试边界和转换。确保覆盖所有代码路径(如果可能的话),并注意由于无效参数(例如,除以零或错误的数字格式)引起的异常等陷阱。
请记住,可组合函数是顶级 Kotlin 函数,因此它们是单元测试的理想候选者。让我们看看这是如何工作的。在下一节中,你将学习如何测试一个简单的 Compose UI。
测试可组合函数
SimpleButtonDemo()
可组合组件(属于 TestingAndDebuggingDemo
示例)显示一个带有按钮的框,按钮居中。第一次点击按钮会将文本从 A 更改为 B。后续点击会在 B 和 A 之间切换。代码如下所示:
@Composable
fun SimpleButtonDemo() {
val a = stringResource(id = R.string.a)
val b = stringResource(id = R.string.b)
var text by remember { mutableStateOf(a) }
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Button(onClick = {
text = if (text == a) b else a
}) {
Text(text = text)
}
}
}
文本存储为可变的 String
状态。它在 onClick
块内部更改,并用作 Text()
可组合组件的参数。如果我们想测试 SimpleButtonDemo()
,我们可能需要检查的一些方面包括这些:
-
UI 的初始状态:初始按钮文本是否为 A?
-
行为:第一次按钮点击是否会将文本更改为 B?
后续点击是否会切换到 B 和 A 之间?
下面是一个简单的测试类的样子:
@RunWith(AndroidJUnit4::class)
class SimpleInstrumentedTest {
@get:Rule
val rule = createComposeRule()
@Before
fun setup() {
rule.setContent {
SimpleButtonDemo()
}
}
@Test
fun testInitialLetterIsA() {
rule.onNodeWithText("A").assertExists()
}
}
与 实现单元测试 部分的 SimpleUnitTest
类不同,它的源代码存储在 app/src/androidTest/java
目录中(与普通单元测试的 …/test/…
相反)。SimpleInstrumentedTest
是一个 仪器化测试。与普通单元测试不同,它们不是在开发机上本地执行,而是在 Android 模拟器或真实设备上执行,因为它们需要 Android 特定的功能来运行。仪器化测试可以通过 项目 工具窗口访问,如下面的截图所示:
图 10.3 – Android Studio 项目工具窗口中的仪器化测试
您可以通过在 项目 工具窗口中右键单击测试类并选择 运行 '…' 来运行仪器化测试。一旦为测试类创建了启动配置,您也可以使用菜单栏和工具栏来运行测试。测试结果在 运行 工具窗口中显示,如下面的截图所示:
图 10.4 – Android Studio 运行工具窗口中的仪器化测试结果
在你的测试类中,JUnit 的 @Before
和 @After
注解。有几个预定义的规则——例如,TestName
规则可以在测试方法内部提供当前测试名称,如下所示:
@get:Rule
var name = TestName()
...
@Test
fun testPrintMethodName() {
println(name.methodName)
}
当 testPrintMethodName()
函数运行时,它会打印其名称。您可以在通过添加 get:
到属性获取器的 @Rule
注解中看到输出。如果不这样做,将导致执行期间出现 ValidationError
(The @Rule '…' must be public
)消息。
Compose 测试基于规则。createComposeRule()
返回 ComposeContentTestRule
接口的一个实现,它扩展了 ComposeTestRule
。此接口反过来又扩展了 org.junit.rules.TestRule
。每个 TestRule
实例实现 apply()
方法。此方法接收 Statement
并返回相同的、修改后的或完全新的 Statement
。然而,编写自己的测试规则超出了本书的范围。要了解更多信息,请参阅本章末尾的“进一步阅读”部分。
createComposeRule()
返回的 ComposeContentTestRule
接口实现取决于*台。在 Android 上,它是 AndroidComposeTestRule<ComponentActivity>
。这就是为什么你应该在模块级别的 build.gradle
文件中添加对 androidx.compose.ui:ui-test-manifest
的依赖。否则,你可能需要在清单文件中手动添加对 ComponentActivity
的引用。
createAndroidComposeRule()
允许你为除了 ComponentActivity
之外的活动类创建 AndroidComposeTestRule
。如果你需要在测试中使用此活动的功能,这很有用。在桌面或 Web 的 Compose 中,createComposeRule()
可能会返回 ComposeContentTestRule
的不同实现,具体取决于 Compose UI 的托管位置。为了使你的测试*台无关,尽可能使用 createComposeRule()
。
你的测试用例使用(包括其他)由 ComposeContentTestRule
实现提供的方法。例如,setContent()
将可组合函数设置为当前屏幕的内容——即要测试的 UI。setContent()
应该在每个测试中恰好调用一次。为了实现这一点,只需在带有 @Before
注解的函数中调用它。
重要提示
如果你想在多个*台上重用你的测试,尽量只依赖 ComposeContentTestRule
、ComposeContentTestRule
和 TestRule
接口中定义的方法。避免调用实现中的函数。
接下来,让我们看看 testInitialLetterIsA()
。这个测试用例检查初始按钮文本是否为 "A"
。这个比较是通过调用 assertExists()
和 onNodeWithText()
来完成的,后者被称为 finder。Finder 在 semantics nodes 上工作,你将在“理解语义”部分了解更多关于这些内容。但首先:为什么我们还需要 找到 要测试的可组合元素呢?
与传统的视图系统不同,Jetpack Compose 不使用引用来识别单个 UI 元素。请记住,在命令式方法中,在运行时修改组件树需要这样的引用。但 Compose 并不是这样工作的——相反,我们根据状态声明 UI 应该是什么样子。然而,为了测试某个可组合元素是否看起来和表现如预期,我们需要在 Compose 层级的所有其他子元素中找到它。
这就是 Role
、Text
和 Actions
的作用。它用于可访问性和测试。
在我们继续之前,让我们简要回顾一下:onNodeWithText()
尝试查找具有给定文本的复合元素(更准确地说,是一个语义节点)。assertExists()
检查当前 UI 中是否存在匹配的节点。如果是,则测试通过。否则,测试失败。
理解语义
在上一节中,我向您展示了一个简单的测试用例,用于检查按钮文本是否与给定的字符串匹配。这里还有一个测试用例。它点击按钮以查看按钮文本是否按预期更改:
@Test
fun testLetterAfterButtonClickIsB() {
rule.onNodeWithText("A")
.performClick()
.assert(hasText("B"))
}
再次,我们首先查找按钮。performClick()
(这被称为 Assert(hasText("B"))
)检查按钮文本是否为 B。断言确定测试是否通过或失败。
onNodeWithText()
(SemanticsNodeInteractions Provider
的扩展函数)返回一个 SemanticsNodeInteraction
语义节点。SemanticsNodeInteractionsProvider
接口是测试的主要入口点,通常由测试规则实现。它定义了两个方法,如下所示:
-
onNode()
查找并返回一个与给定条件匹配的语义节点(SemanticsNodeInteraction
)。 -
onAllNodes()
查找所有与给定条件匹配的语义节点。它返回一个SemanticsNodeInteractionCollection
实例。
它们都被称为 查找器,因为它们返回匹配特定条件的语义节点。
与语义节点一起工作
要查看上一节中用 testLetterAfterButtonClickIsB()
测试的语义节点的外观,您可以在 .assert(…)
之后添加以下表达式:
.printToLog("SimpleInstrumentedTest")
结果在 Logcat 中可见,如下截图所示:
图 10.5 – Logcat 中的语义节点
SemanticsNodeInteraction
代表一个语义节点。您可以通过执行 performClick()
或断言 assertHasClickAction()
等操作与节点交互,或者导航到其他节点,如 onChildren()
。这些函数是 SemanticsNodeInteraction
的扩展函数。SemanticsNodeInteractionCollection
是语义节点的一个集合。
让我们看看另一个查找函数,onNodeWithContentDescription()
。我们将使用它来测试 Image()
是否是当前 UI 的一部分。代码如下所示:
@Composable
fun ImageDemo() {
Image(
painter = painterResource(id =
R.drawable.ic_baseline_airport_shuttle_24),
contentDescription = stringResource(id =
R.string.airport_shuttle),
contentScale = ContentScale.FillBounds,
modifier = Modifier
.size(width = 128.dp, height = 128.dp)
.background(Color.Blue)
)
}
如果您的应用 UI 包含图像,您通常应该为它们添加内容描述。内容描述用于,例如,由辅助软件向视觉障碍人士描述当前屏幕上显示的内容。因此,通过添加它们,您可以大大提高可用性。此外,内容描述有助于查找复合元素。您可以在以下代码片段中看到这些用法:
@RunWith(AndroidJUnit4::class)
class AnotherInstrumentedTest {
@get:Rule
val rule = createComposeRule()
@Test
fun testImage() {
var contentDescription = ""
rule.setContent {
ImageDemo()
contentDescription = stringResource(id =
R.string.airport_shuttle)
}
rule.onNodeWithContentDescription(contentDescription)
.assertWidthIsEqualTo(128.dp)
}
}
testImage()
首先设置内容(ImageDemo()
)。然后查找具有给定内容描述的语义节点。最后,assertWidthIsEqualTo()
检查由该节点表示的 UI 元素的宽度是否为 128 密度无关像素宽。
小贴士
你有没有注意到我使用了 stringResource()
来获取内容描述?硬编码的值可能导致测试中产生微妙的错误(例如,拼写错误或打字错误)。为了避免这些错误,请尝试以编写测试的方式,让它们访问与被测试代码相同的值。但请记住,在底层,stringResource()
依赖于 Android 资源。因此,测试用例是*台特定的。
使用 onNodeWithText()
和 onNodeWithContentDescription()
可以轻松找到包含文本和图像的可组合函数。但如果你需要找到其他内容的语义节点——例如,一个 Box()
?以下示例 BoxButtonDemo()
展示了一个在内部居中的 Button()
的 Box()
。点击按钮会切换框的背景颜色,从白色变为浅灰色,然后再变回白色:
val COLOR1 = Color.White
val COLOR2 = Color.LightGray
@Composable
fun BoxButtonDemo() {
var color by remember { mutableStateOf(COLOR1) }
Box(
modifier = Modifier
.fillMaxSize()
.background(color = color),
contentAlignment = Alignment.Center
) {
Button(onClick = {
color = if (color == COLOR1)
COLOR2
else
COLOR1
}) {
Text(text = stringResource(id = R.string.toggle))
}
}
}
测试 BoxButtonDemo()
意味着找到框,检查其初始背景颜色,点击按钮,然后再次检查颜色。为了能够找到框,我们使用 testTag()
修饰符对其进行标记,如下面的代码片段所示。应用标签允许我们在测试中找到修改后的元素:
val TAG1 = "BoxButtonDemo"
Box(
modifier = ...
.testTag(TAG1)
...
我们可以检查框是否存在,如下所示:
@Test
fun testBoxInitialBackgroundColorIsColor1() {
rule.setContent {
BoxButtonDemo()
}
rule.onNode(hasTestTag(TAG1)).assertExists()
}
onNode()
查找器接收一个 hasTestTag()
参数。hasTestTag()
查找具有给定测试标签的节点。有几个预定义的匹配器。例如,isEnabled()
返回节点是否启用,而 isToggleable()
如果节点可以被切换则返回 true
。
小贴士
Google 在 developer.android.com/jetpack/compose/testing-cheatsheet
提供了一份测试速查表。它很好地将查找器、匹配器、操作和断言分组。
要完成测试代码,我们需要检查框的背景颜色。但我们该如何做呢?根据之前的示例,你可能期望有一个 hasBackgroundColor()
匹配器。不幸的是,目前还没有。测试只能依赖于语义树中可用的内容,但如果它不包含我们所需的信息,我们可以轻松地添加它。我将在下一节中向你展示如何操作。
添加自定义语义属性
如果你想要向测试暴露更多信息,你可以创建自定义语义属性。这需要以下步骤:
-
定义
SemanticsPropertyKey
-
通过使用
SemanticsPropertyReceiver
使其可用
你可以在以下代码片段中看到这些用法:
val BackgroundColorKey =
SemanticsPropertyKey<Color>("BackgroundColor")
var SemanticsPropertyReceiver.backgroundColor by
BackgroundColorKey
@Composable
fun BoxButtonDemo() {
...
Box(
modifier = ...
.semantics { backgroundColor = color }
.background(color = color),
...
使用 SemanticsPropertyKey
,你可以以类型安全的方式在语义块中设置键值对。每个键都有一个静态定义的值类型——在我的例子中,这是 Color
。SemanticsPropertyReceiver
是由 semantics {}
块提供的范围。它旨在通过扩展函数设置键值对。
这是如何在测试用例中访问自定义语义属性的方法:
@Test
fun testBoxInitialBackgroundColorIsColor1() {
rule.setContent {
BoxButtonDemo()
}
rule.onNode(SemanticsMatcher.expectValue
(BackgroundColorKey,COLOR1))
.assertExists()
}
expectValue()
检查给定键的值是否等于预期值。
在编写测试时,向语义树添加自定义值可能会有很大帮助。然而,请仔细考虑你是否真的需要依赖 SemanticsPropertyKey
。语义树还由辅助功能框架和工具使用,因此,避免将无关信息污染语义树至关重要。一种解决方案是重新思考测试策略。我们可能不是测试“盒子的初始背景颜色是否为白色”,而是测试我们传递给 background()
函数的值是否代表白色。
这部分内容结束了关于测试可组合函数的章节。在下一节中,我们将探讨调试 Compose 应用。
调试 Compose 应用
本节的标题 调试 Compose 应用 可能表明与调试传统的基于视图的应用存在重大差异。幸运的是,情况并非如此。在 Android 上,所有可组合层次结构都封装在 androidx.compose.ui.platform.ComposeView
中。如果你调用了 ComponentActivity
的 setContent {}
扩展函数,或者你故意在一个布局中包含可组合层次结构(参见 第九章,探索互操作性 API),这会间接发生。无论如何,最终 ComposeView
都会在屏幕上显示——例如,在 Activity 或 Fragment 中。因此,关于 Android 应用基本构建块(活动、片段、服务、广播接收器、意图和内容提供者)的所有方面都保持不变。
当然,任何 UI 框架都提倡特定的调试习惯。例如,视图系统需要关注 null
引用。此外,你还需要确保状态的变化能够可靠地触发组件树的更新。幸运的是,这些都不适用于 Jetpack Compose。由于可组合项是 Kotlin 函数,你可以在需要时通过逐步执行代码来跟踪可组合层次结构的创建,并检查 State
。
为了在运行时仔细检查你的可组合函数的视觉表示,你可以使用 Android Studio 的 布局检查器,如下面的截图所示。一旦你在模拟器或真实设备上部署了你的应用,请使用 工具 菜单中的 布局检查器 打开此工具:
图 10.6 – Android Studio 中的布局检查器
你可以使用 Android Studio 主窗口左侧的树来选择要检查的可组合项。重要属性显示在右侧。工具窗口的中心包含一个可配置、可缩放的预览。你还可以启用 三维 (3D)模式。这允许你通过点击和拖动来旋转布局,从而通过视觉检查层次结构。
如果你想要为了调试目的记录可组合项的重要值,你可以通过修饰符轻松实现这一点。下一节将展示如何进行操作。
使用自定义修饰符进行日志记录和调试
正如我在 第三章 的“修改行为”部分中解释的,探索 Compose 的关键原则,修饰符是一个有序的不可变修饰符元素集合。修饰符可以改变 Compose UI 元素的看起来和行为。您通过实现 Modifier
的扩展函数来创建自定义修饰符。以下代码片段使用 DrawScope
接口打印可组合的大小:
fun Modifier.simpleDebug() = then(object : DrawModifier {
override fun ContentDrawScope.draw() {
println("width=${size.width}, height=${size.height}")
drawContent()
}
})
根据您选择哪个接口,您可以记录不同的方面。例如,使用 LayoutModifier
,您可以访问与布局相关的信息。
重要提示
虽然这可能是一个巧妙的技巧,但它绝对不是修饰符的主要用途。因此,如果您仅为了调试目的实现自定义修饰符,您应该仅在调试时将其添加到修饰符链中。
此外,还有一个内置功能可以提供用于调试目的的附加信息。几个修饰符可以接收一个 inspectorInfo
参数,这是 InspectorInfo
的扩展函数。这个类是 InspectableValue
接口的构建器(该接口定义了一个可由工具检查的值,从而可以访问值的私有部分)。InspectorInfo
有三个属性,如下所示:
-
name
(为InspectableValue
提供nameFallback
) -
value
(为InspectableValue
提供valueOverride
) -
properties
(为InspectableValue
提供inspectableElements
)
要了解 inspectorInfo
的使用方法,请查看以下截图中的 semantics {}
修饰符的实现,该修饰符为测试和可访问性添加了语义键值对。请参阅“添加自定义语义属性”部分以获取详细信息:
图 10.7 – semantics {}
修饰符的源代码
semantics {}
调用 composed {}
修饰符,该修饰符接收两个参数,inspectorInfo
和 factory
(要组合的修饰符)。inspectorInfo
参数获取 debugInspectorInfo {}
工厂方法的结果(该方法接收一个 name
实例和两个用于 properties
的元素作为参数)。
composed {}
将 ComposedModifier
类添加到修饰符链中。这个私有类实现了 Modifier.Element
接口,并扩展了 InspectorValueInfo
,后者反过来实现了 InspectorValueInfo
。inspectableElements
属性保持 Sequence
的 ValueElements
。
要启用调试检查器信息,您必须将 androidx.compose.ui.platform
包中的全局顶级变量 isDebugInspectorInfoEnabled
设置为 true
。然后,您可以使用反射访问和打印调试检查器信息。以下是您需要的代码:
.semantics { backgroundColor = color }.also {
(it as CombinedModifier).run {
val inner = this.javaClass.getDeclaredField("inner")
inner.isAccessible = true
val value = inner.get(this) as InspectorValueInfo
value.inspectableElements.forEach {
println(it)
}
}
}
semantics {}
返回的修饰符类型为 CombinedModifier
,因为 composed {}
调用了 then()
,它底层使用 CombinedModifier
。你不仅可以打印原始的可检查元素,还可以根据需要自定义输出。
摘要
在本章中,我们探讨了与测试相关的重要术语和技术。在第一个主要部分,我们在开发机上本地设置基础设施,编写并运行了一个简单的单元测试,然后转向 Compose 特定内容。我向你介绍了 createComposeRule()
和 createAndroidComposeRule()
。
接下来,我们探讨了如何在 Compose 层级中找到可组合函数,以及为什么使你的应用可访问也有助于编写更好的测试。你还学习了动作和断言。最后,我们在语义树中添加了自定义条目。
最后一个主要部分解释了如何调试 Compose 应用。我们回顾了语义树,并展示了如何利用 InspectorInfo
和 InspectorValueInfo
来调试自定义修饰符。
第十一章,结论和下一步,总结了本书内容。我们展望未来,看看 Jetpack Compose 的未来版本可能会添加什么。例如,我们预览了 Compose 的 Material 3,它将 Material You 设计概念引入 Compose 应用。我们还超越了 Android,考察了其他*台上的 Compose。
进一步阅读
-
本书假设读者对如何测试 Android 应用有基本的了解。要了解更多信息,请参阅在 Android 上测试应用。
-
Catalin Tudose 的《JUnit in Action》(Manning Publications,2020,ISBN 978-1617297045)是 JUnit 测试框架最新版本的全面介绍。
-
如果你想要了解更多关于测试自动化的信息,你可能想查看 Arnon Axelrod 的《Complete Guide to Test Automation: Techniques, Practices, and Patterns for Building and Maintaining Effective Software Projects》(Apress,2018,ISBN 978-1484238318)。
-
要深入了解测试金字塔隐喻,你可能想参考 Ham Vocke 的《实用测试金字塔》。
第十一章:结论和下一步行动
本书向您展示了如何编写美观、快速且易于维护的 Jetpack Compose 应用程序。在第一章到第三章中,我向您介绍了 Jetpack Compose 的基础知识,解释了核心技术和原则,以及重要的接口、类、包,当然还有可组合函数。第四章到第七章专注于构建 Compose UI。您学习了如何管理状态并导航到不同的屏幕。我们还探讨了 ViewModel 和 Repository 模式。第八章到第十章涵盖了高级主题,例如动画、互操作性、测试和调试。
这最后一章完全关于您接下来可以做什么。我们将探讨 Jetpack Compose 的*期未来,并探索邻*的*台,因为您也可以在那里应用您的 Compose 知识。本章的主要部分如下:
-
探索未来
-
迁移到 Material You
-
超越 Android
我们将首先看看 Jetpack Compose 的下一个版本 1.1,当本书进入生产阶段时,这个版本还不稳定。这次迭代将带来错误修复、性能改进和新功能,例如ExposedDropdownMenuBox()
,一个暴露的下拉菜单,以及NavigationRail()
。这个垂直导航栏旨在用于可折叠设备和大型屏幕设备。
第二个主要部分,迁移到 Material You,向您介绍了为 Compose 设计的 Material 3。这个包包含了Material You,这是谷歌美丽设计语言的最新迭代,用于 Jetpack Compose 应用程序。我们将看看 Material 2 和 Material 3 之间的某些差异,例如简化的字体和配色方案。
超越 Android部分向您展示了如何将您的 Jetpack Compose 知识应用于其他*台,例如桌面和网页。我将简要解释如何将我的一个示例可组合函数带到桌面。
技术要求
本章基于ExposedDropdownMenuBoxDemo
和NavigationRailDemo
示例。请参考第一章,构建您的第一个 Compose 应用程序,了解有关如何安装和设置 Android Studio 以及如何获取本书附带的存储库的信息。
本章所有代码文件都可以在 GitHub 上找到,地址为github.com/PacktPublishing/Android-UI-Development-with-Jetpack-Compose/tree/main/chapter_11
。
探索未来
本书基于 Jetpack Compose 1.0,这是库的第一个稳定版本,于 2021 年 7 月发布。就像所有其他 Jetpack 组件一样,谷歌不断改进和更新 Compose。在完成手稿时,1.1 版本处于测试阶段。当它变得稳定时,我将更新本书附带的存储库以反映这些变化。您可以在github.com/PacktPublishing/Android-UI-Development-with-Jetpack-Compose
找到本书的样本的最新版本。
Jetpack Compose 1.1 将提供错误修复、新功能和性能改进。新功能包括以下内容:
-
Compose 编译器将支持较旧的 Compose 运行时版本。这允许您使用最新的工具,同时仍然针对较旧的 Compose 版本。
-
触摸目标尺寸(UI 元素可能获得额外的间距以使其更易于访问)。
-
ImageVector
缓存。 -
支持 Android 12 扩展滚动。
几个之前实验性的 API(例如,AnimatedVisibility
、EnterTransition
和ExitTransition
)将变为稳定。此外,Jetpack Compose 1.1 将支持 Kotlin 的新版本。不幸的是,您还将面临一些破坏性变化。例如,EnterTransition
和ExitTransition
工厂中的 lambda 表达式可能被移动到参数列表的最后一个位置。
显示暴露的下拉菜单
此外,还有新的 Material UI 元素。例如,ExposedDropdownMenuBox()
显示一个暴露的下拉菜单,它将当前选中的菜单项显示在选项列表上方。ExposedDropdownMenuBoxDemo
样本说明了 composable 函数(图 11.1)的用法。
图 11.1 – ExposedDropdownMenuBoxDemo 样本
目前,ExposedDropdownMenuBox()
被标记为实验性。因此,您必须添加@ExperimentalMaterialApi
注解:
@ExperimentalMaterialApi
@Composable
fun ExposedDropdownMenuBoxDemo() {
val titles = List(3) { i ->
stringResource(id = R.string.item, i + 1)
}
var expanded by remember { mutableStateOf(false) }
var selectedTxt by remember { mutableStateOf(titles[0]) }
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.TopCenter
) {
...
}
}
ExposedDropdownMenuBoxDemo()
将ExposedDropdownMenuBox()
放入Box()
中,并将菜单水*居中于顶部。菜单项存储在列表(titles
)中。expanded
状态反映了菜单项的可见性。selectedTxt
代表当前选中的文本。以下是它们的用法:
ExposedDropdownMenuBox(expanded = expanded,
onExpandedChange = {
expanded = !expanded
}) {
TextField(value = selectedTxt,
onValueChange = { },
readOnly = true,
label = {
Text(text = stringResource(id = R.string.label))
},
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(
expanded = expanded
)
}
)
ExposedDropdownMenu(expanded = expanded,
onDismissRequest = {
expanded = false
}) {
for (title in titles) {
DropdownMenuItem(onClick = {
expanded = false
selectedTxt = title
}) {
Text(text = title)
}
}
}
}
ExposedDropdownMenuBox()
有两个子元素,只读的TextField()
和ExposedDropdownMenu()
。文本字段显示selectedTxt
。由于readOnly
设置为true
,onValueChange
块可以是空的。expanded
控制尾随图标,它反映了菜单项的可见性。传递给ExposedDropdownMenuBox()
的onExpandedChange
lambda 表达式在用户点击暴露的下拉菜单时执行。通常,您会否定expanded
。
ExposedDropdownMenu()
至少包含一个 DropdownMenuItem()
作为其内容。通常,您会希望隐藏菜单(expanded = false
)并更新文本字段(selectedTxt = title
)。传递给 ExposedDropdownMenu()
的 onDismissRequest
块也应该关闭菜单,但不要更新文本字段。
因此,ExposedDropdownMenuBox()
是显示一系列项目并允许用户选择其中之一的一种非常紧凑的方式。在下一节中,我将向您展示 Compose 1.1 中首次亮相的另一个 Material UI 元素。NavigationRail()
以垂直方式呈现顶级导航目的地。
使用 NavigationRail()
Compose 提供了多种方式在您的应用中导航到顶级目的地。例如,您可以使用 BottomNavigation()
在屏幕底部放置一个导航栏。我在 第六章 的 添加导航 部分向您展示了如何使用它,将部件组合在一起。Jetpack Compose 1.1 包含另一个用于顶级导航的 UI 元素。NavigationRail()
实现了 导航栏 交互模式,这是一个专门为*板电脑和可折叠大屏设计的垂直导航栏。
如果屏幕不够大,或者可折叠设备关闭,则应显示标准底部导航栏。NavigationRailDemo
示例展示了如何实现这一点。在 图 11.2 中,您可以看到应用在竖屏模式下的样子。
![图 11.2 – NavigationRailDemo 示例在竖屏模式下的截图
图 11.2 – NavigationRailDemo 示例在竖屏模式下的截图
要继续,一个详细的方法是使用 Jetpack WindowManager
库,但这超出了本书的范围。相反,我们将使用 NavigationRailDemo()
以简化起见,它通过比较当前屏幕宽度与最小尺寸(600 密度无关像素)来确定是否应使用导航栏:
@Composable
fun NavigationRailDemo() {
val showNavigationRail =
LocalConfiguration.current.screenWidthDp >= 600
val index = rememberSaveable { mutableStateOf(0) }
Scaffold(topBar = {
TopAppBar(title = {
Text(text = stringResource(id = R.string.app_name))
})
},
bottomBar = {
if (!showNavigationRail)
BottomBar(index)
}) {
Content(showNavigationRail, index)
}
}
Scaffold()
通过 bottomBar
独占表达式接收底部栏。如果不应显示导航栏(showNavigationRail
为 false
),则调用我的 BottomBar()
可组合组件。否则,不添加底部栏。当前活动屏幕存储在一个可变的 Int
状态(index
)中。它传递给 BottomBar()
和 Content()
。接下来,让我们简要回顾一下 BottomNavigation()
的工作原理,通过查看我的 BottomBar()
可组合组件:
@Composable
fun BottomBar(index: MutableState<Int>) {
BottomNavigation {
for (i in 0..2)
BottomNavigationItem(selected = i == index.value,
onClick = { index.value = i },
icon = {
Icon(
painter = painterResource(id =
R.drawable.ic_baseline_android_24),
contentDescription = null
)
},
label = {
MyText(index = i)
}
)
}
}
BottomNavigation()
的内容由几个带有图标、标签和 onClick
块的 BottomNavigationItem()
元素组成。我的实现只是更新了 index
状态,该状态也在 Content()
中使用。这个可组合组件在需要时显示导航栏,以及主要内容(屏幕),它只是一个内部文本居中的盒子:
@Composable
fun Content(showNavigationRail: Boolean, index:
MutableState<Int>) {
Row(
modifier = Modifier.fillMaxSize()
) {
if (showNavigationRail) {
NavigationRail {
for (i in 0..2)
NavigationRailItem(selected = i == index.value,
onClick = {
index.value = i
},
icon = {
Icon(
painter = painterResource(id =
R.drawable.ic_baseline_android_24),
contentDescription = null
)
},
label = {
MyText(index = i)
})
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(color = MaterialTheme.colors.surface),
contentAlignment = Alignment.Center
) {
MyText(
index = index.value,
style = MaterialTheme.typography.h3
)
}
}
}
导航栏和屏幕在 Row()
中水*排列。与 BottomNavigation()
类似,NavigationRail()
获取一个或多个子元素,这些子元素代表导航目的地。子元素(NavigationRailItem()
)有一个标签、一个图标和一个 onClick
块。图 11.3 展示了横屏模式下的 NavigationRailDemo
示例。
图 11.3 – 横屏模式下的 NavigationRailDemo 示例
虽然 Jetpack Compose 1.1 将添加一些 Material UI 元素并润色现有的元素,但它仍然实现了与之前 Android 版本(包括 11,有时称为 Material 2)中存在的 Material Design。随着 Android 12 的推出,Material You 也将对 Compose 可用。然而,它不是现有包的就地更新,而是一个新的库。在接下来的章节中,我们将探讨 Jetpack Compose 的 Material 3,当时这个章节正在编写时,它还处于早期 alpha 版本。
注意
您可能想知道 Material You 和 Material 3 之间的区别。我指的是 Material 3 作为 Material Design 规范的最新版本,而 Material You 是 Android 12 上的实现。
迁移到 Material You
Material You 是谷歌设计语言 Material Design 的最新迭代。它是在 2021 年的 Google I/O 上宣布的,并首次在运行 Android 12 的 Pixel 智能手机上可用。最终,它将扩展到其他设备、形态和框架。像其前辈一样,Material You 基于排版、动画和层级。但它强调个性化:根据*台,Material You 的实现可能使用来自系统壁纸的颜色调色板。
比较 Material 2 和 Material 3 的差异
要在您的 Compose 应用中使用 Material You,您必须在模块级别的 build.gradle
文件中添加对 androidx.compose.material3:material3
的实现依赖。可组合对象、类和接口的基本包更改为 androidx.compose.material3
。如果您想将现有的 Compose 应用迁移到这个新版本,至少需要更改导入。不幸的是,相当多的可组合函数的名称也将改变。为了了解差异,我已经为 Material You 重新实现了 NavigationRailDemo
。该项目命名为 NavigationRailDemo_Material3
。这样,您可以通过比较重要文件轻松地检查更改。
图 11.4 – 横屏模式下的 NavigationRailDemo_Material3 示例
具体来说,需要将 TopAppBar()
替换为 SmallTopAppBar()
或其更大的兄弟之一,MediumTopAppBar()
和 LargeTopAppBar()
。其他更改包括以下内容:
-
BottomNavigation()
将被泛化为NavigationBar()
。 -
BottomNavigationItem()
现在被称为NavigationBarItem()
。 -
NavigationRailItem()
保持不变。
最后一个要点很有趣:由于 NavigationRailItem()
元素与 NavigationBarItem()
非常相似,我想知道这两个是否可能在将来被泛化。
几个控制 UI 元素视觉表示的属性将发生显著变化。例如,Material 颜色属于 MaterialTheme.colorScheme
而不是之前的 MaterialTheme.colors
。有关 Material 3 中颜色的更多信息,请参阅官方文档m3.material.io/styles/color/dynamic-color/overview
。
样式化文本可能也需要一些调整,因为Typography
类的成员将被简化。例如,你将不再使用h1
、h2
、h3
等,而是使用headlineLarge
、headlineMedium
或headlineSmall
。
这就结束了我们对 Material 3 和 Jetpack Compose *期未来的简要探讨。你知道你还可以为网页和桌面编写 Compose 应用吗?在接下来的部分,我们将尝试一下。
超越 Android
虽然 Jetpack Compose 是 Android 上的新 UI 工具包,但其底层思想和原则也使其对其他*台具有吸引力。让我们看看这是为什么:
-
声明式方法最初是在网页上实现的。
-
SwiftUI,苹果对声明式 UI 框架的实现,在 iPhone、iPad、手表和 macOS 设备上运行良好。
-
Jetpack Compose UI 元素使用 Material Design,它为不同的*台、设备类别和形态设计。
最重要的是,核心概念如状态和可组合函数并非 Android 特有的。因此,如果有人提供了工具链(例如,Kotlin 编译器和 Compose 编译器),任何能够显示图形的*台 可能 能够执行 Compose 应用。当然,还有大量的工作要做。
例如,Compose UI 必须托管在 某处。在 Android 上,使用活动(activities)。在网页上,这将是一个浏览器窗口。在桌面上,它将是某个 UI 工具包提供的窗口。任何其他功能(例如,网络和文件 I/O、连接性、内存管理、线程)必须由其他库或框架处理。
JetBrains,Kotlin 和 IntelliJ 的发明者,决定解决这个问题。*年来,该公司在针对多个*台和在这些*台间共享代码方面积累了大量经验。例如,使用 Kotlin Multiplatform Mobile,你可以为 iOS 和 Android 应用使用单个代码库进行业务逻辑。Compose Multiplatform 的目标是简化并加快桌面和网页 UI 的开发,并在它们之间共享 UI 代码以及 Android。
在接下来的部分,我将简要展示如何使用 IntelliJ IDE 创建一个简单的 Compose 桌面应用程序。
设置示例项目
创建 Compose for Desktop 项目的最简单方法是使用 IntelliJ IDEA 的项目向导。这需要 IntelliJ IDEA Community Edition 或 Ultimate Edition 2020.3 或更高版本。设置 IntelliJ 超出了本书的范围,这里没有详细说明。图 11.5 展示了如何填写项目向导对话框。
图 11.5 – IntelliJ 项目向导
JetBrains 在 GitHub 上维护了一个 使用 Compose Multiplatform 入门 教程,网址为 github.com/JetBrains/compose-jb/blob/master/tutorials/Getting_Started/README.md
。请参阅此链接获取更多信息。
项目向导在 src/main/kotlin
内部添加了一个简单的 Main.kt
文件。您可以通过双击 Gradle 工具窗口中的 Tasks | compose desktop | run 来运行它 (图 11.6)。
图 11.6 – IntelliJ Gradle 工具窗口
源代码包含一个名为 App()
的可组合组件。它从 main()
函数中被调用。让我们用我的一个示例来替换 App()
的主体,例如,从 第八章,与动画一起工作 中的 StateChangeDemo()
:
@Composable
@Preview
fun App() {
var toggled by remember {
mutableStateOf(false)
}
val color = if (toggled)
Color.White
else
Color.Red
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(onClick = {
toggled = !toggled
}) {
Text(text = "Toggle")
}
Box(
modifier = Modifier
.padding(top = 32.dp)
.background(color = color)
.size(128.dp)
)
}
}
你有没有注意到我改变了一行?原始版本使用 stringResource()
可组合组件。然而,桌面上的 Android 资源不可用,因此您必须用不同的东西替换调用。一个简单的解决方案是将文本硬编码。现实世界的应用程序可能希望选择支持多种语言的机制。Compose for Desktop 依赖于 Java 虚拟机,因此您可以使用 Java 的国际化支持。
在 macOS 上运行的应用程序显示在 图 11.7 中。
图 11.7 – 一个简单的桌面 Compose 应用
这就结束了我们对 Compose for Desktop 和 Compose Multiplatform 的简要探讨。要了解更多信息,请访问产品页面 www.jetbrains.com/de-de/lp/compose-mpp/
。
摘要
在本章的最后,我们探讨了 Jetpack Compose 的*期未来,并瞥见了邻*的*台。Jetpack Compose 1.1 将带来错误修复、性能改进和新功能,例如,ExposedDropdownMenuBox()
和 NavigationRail()
。两个示例 (ExposedDropdownMenuBoxDemo
和 NavigationRailDemo
) 展示了如何使用它们。
第二个主要部分,迁移到 Material You,向您介绍了 Compose 的 Material 3。这个包将 Google 美丽设计语言的最新迭代 Material You 带到 Jetpack Compose 应用中。我们查看了一些 Material 2 和 Material 3 之间的差异,例如,简化的字体和配色方案。
超越 Android 向您展示了如何将您的 Jetpack Compose 知识应用于其他*台。我解释了如何将我的一个示例可组合函数带到桌面。
我真诚地希望您喜欢阅读这本书。您现在对 Jetpack Compose 的核心原则有了全面的理解,以及相对于传统 Android 视图系统的重大优势。使用声明式方法使得编写外观出色的应用程序比以往任何时候都更容易。我迫不及待地想看看您将哪些美好的想法转化为代码。
订阅我们的在线数字图书馆,以获得对超过 7,000 本书和视频的完全访问权限,以及帮助您规划个人发展和提升职业的业界领先工具。欲了解更多信息,请访问我们的网站。
第十二章:为什么订阅?
-
使用来自 4,000 多位行业专业人士的实用电子书和视频,节省学习时间,增加编码时间
-
通过为您量身定制的技能计划提高学习效果
-
每月免费获得一本电子书或视频
-
完全可搜索,便于轻松访问关键信息
-
复制粘贴、打印和收藏内容
您知道 Packt 为每本书都提供电子书版本,包括 PDF 和 ePub 文件吗?您可以在 packt.com 升级到电子书版本,并且作为印刷版书籍的顾客,您有权获得电子书副本的折扣。有关更多信息,请联系我们 customercare@packtpub.com
。
在 www.packt.com,您还可以阅读一系列免费技术文章,注册各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。
您可能还喜欢的其他书籍
如果您喜欢这本书,您可能对 Packt 的其他书籍也感兴趣:
精通 Kotlin
Nate Ebel
ISBN: 978-1-83855-572-6
-
使用接口、类和数据类建模数据
-
使用 Java 解决实际互操作性挑战和解决方案
-
使用并发解决方案(如协程)构建并行应用程序
-
探索函数式、响应式和命令式编程以构建灵活的应用程序
-
发现如何构建您自己的领域特定语言
-
使用标准库和 Arrow 接受函数式编程
-
深入研究 Kotlin 在前端 JavaScript 开发中的应用
-
使用 Kotlin 和 Ktor 构建服务器端服务
《Kotlin 入门 Android 编程》
John Horton
ISBN: 978-1-78961-540-1
-
学习 Kotlin 和 Android 如何协同工作
-
使用面向对象编程(OOP)原则构建图形绘制应用程序
-
使用 ScrollView、RecyclerView、NavigationView、ViewPager 和 CardView 构建美观实用的布局
-
使用包括 JSON 和内置的 Android SQLite 数据库在内的不同策略编写 Kotlin 代码来管理应用程序数据
-
向您的应用程序添加用户交互、数据捕获、声音和动画
-
实现对话框以捕获用户输入
-
构建一个简单的数据库应用程序,用于排序和存储用户数据
Packt 正在寻找像您这样的作者
如果您有兴趣成为 Packt 的作者,请访问authors.packtpub.com并今天申请。我们已与成千上万的开发者和技术专业人士合作,就像您一样,帮助他们将见解分享给全球科技社区。您可以提交一般申请,申请我们正在招募作者的特定热门话题,或者提交您自己的想法。
分享您的想法
现在您已经完成了使用 Jetpack Compose 的 Android UI 开发,我们非常想听听您的想法!如果您在亚马逊购买了这本书,请选择www.amazon.in/review/create-review/error?asin=1801812160
为这本书提供反馈或在该购买网站上留下评论。
您的评论对我们和科技社区都非常重要,它将帮助我们确保我们提供的是高质量的内容。