1. 简介
在此 Codelab 中,您将学习 Jetpack Compose 中与状态和副作用 API 相关的高级概念。您将了解如何为逻辑并非微不足道的状态化可组合项创建状态容器,如何创建协程并从 Compose 代码调用 suspend 函数,以及如何触发副作用以实现不同的用例。
如需在完成此 Codelab 的过程中获得更多支持,请查看以下随堂代码:
您将学到什么
- 如何从 Compose 代码观察数据流以更新界面。
- 如何为状态化可组合项创建状态容器。
- 副作用 API,例如
LaunchedEffect
、rememberUpdatedState
、DisposableEffect
、produceState
和derivedStateOf
。 - 如何使用
rememberCoroutineScope
API 在可组合项中创建协程和调用 suspend 函数。
您需要准备什么
- 最新版本的 Android Studio
- 熟悉 Kotlin 语法,包括 Lambda 表达式。
- 具备 Compose 基础经验。建议在此 Codelab 之前完成Jetpack Compose 基础 Codelab。
- 熟悉 Compose 中的基本状态概念,例如单向数据流 (UDF)、ViewModel、状态提升、无状态/有状态可组合项、Slot API 以及
remember
和mutableStateOf
状态 API。如需获取这方面的知识,建议阅读状态和 Jetpack Compose 文档,或完成在 Jetpack Compose 中使用状态 Codelab。 - 具备 Kotlin 协程基础知识。
- 基本了解可组合项的生命周期。
您将构建什么
在此 Codelab 中,您将从一个未完成的应用(Crane Material Study 应用)开始,并添加功能来改进该应用。
2. 环境设置
获取代码
此 Codelab 的代码位于 android-compose-codelabs Github 仓库中。要克隆它,请运行
$ git clone https://github.com/android/codelab-android-compose
或者,您也可以将仓库下载为 zip 文件
查看示例应用
您刚刚下载的代码包含了所有可用 Compose Codelab 的代码。要完成此 Codelab,请在 Android Studio 中打开 AdvancedStateAndSideEffectsCodelab
项目。
建议您从 main 分支的代码开始,按照 Codelab 的步骤一步一步进行。
在 Codelab 期间,您将看到需要添加到项目中的代码片段。在某些地方,您还需要移除代码片段注释中明确提及的代码。
熟悉代码并运行示例应用
花点时间探索项目结构并运行该应用。
当您从 main 分支运行应用时,您会发现一些功能(例如抽屉式导航栏或加载航班目的地)无法正常工作!这正是您将在 Codelab 的后续步骤中完成的任务。
界面测试
该应用涵盖了 androidTest
文件夹中提供的非常基础的界面测试。在 main
和 end
分支中,这些测试应该始终通过。
[可选] 在详情屏幕上显示地图
在详情屏幕上显示城市地图对于继续完成 Codelab 来说并非必需。但是,如果您想查看它,则需要按照地图文档的说明获取个人 API 密钥。将该密钥包含在 local.properties
文件中,如下所示:
// local.properties file
google.maps.key={insert_your_api_key_here}
Codelab 解决方案
要使用 git 获取 end
分支,请使用以下命令
$ git clone -b end https://github.com/android/codelab-android-compose
或者,您也可以从这里下载解决方案代码
常见问题
3. 界面状态生成管线
正如您在运行 main
分支的应用时可能已经注意到的,航班目的地列表是空的!
要解决此问题,您必须完成以下两个步骤:
在本节中,您将完成第一个步骤。
一个好的应用架构按层组织,以遵循基本良好的系统设计实践,例如关注点分离和可测试性。
界面状态生成是指应用访问数据层,在需要时应用业务规则,并将界面状态暴露给界面消费的过程。
此应用中的数据层已实现。现在,您将生成状态(建议目的地列表),以便界面可以消费它。
有几种可用于生成界面状态的 API。这些替代方案在状态生成管线中的输出类型文档中有所总结。通常,最好使用 Kotlin 的 StateFlow
来生成界面状态。
要生成界面状态,请按照以下步骤操作:
- 打开
home/MainViewModel.kt
。 - 定义一个类型为
MutableStateFlow
的私有变量_suggestedDestinations
,用于表示建议的目的地列表,并将空列表设为起始值。
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
- 定义第二个类型为
StateFlow
的不可变变量suggestedDestinations
。这是可供界面消费的公共只读变量。在内部使用可变变量的同时暴露只读变量是一个好的做法。通过这样做,您可以确保界面状态只能通过ViewModel
进行修改,从而使其成为单一事实来源。asStateFlow
扩展函数将 Flow 从可变转换为不可变。
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()
- 在
ViewModel
的 init 块中,添加一个从destinationsRepository
调用以从数据层获取目的地。
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()
init {
_suggestedDestinations.value = destinationsRepository.destinations
}
- 最后,取消注释您在此类中找到的内部变量
_suggestedDestinations
的用法,以便它可以根据来自界面的事件进行适当更新。
就是这样 – 第一个步骤完成了!现在,ViewModel
能够生成界面状态。在下一步中,您将从界面消费此状态。
4. 从 ViewModel 安全地消费 Flow
航班目的地列表仍然是空的。在前面的步骤中,您在 MainViewModel
中生成了界面状态。现在,您将消费 MainViewModel
暴露的界面状态以在界面中显示。
打开 home/CraneHome.kt
文件,查看 CraneHomeContent
可组合项。
在 suggestedDestinations
的定义上方有一个 TODO 注释,该变量被分配给一个记住的空列表。这就是屏幕上显示的内容:一个空列表!在此步骤中,您将修复此问题并显示 MainViewModel
暴露的建议目的地。
打开 home/MainViewModel.kt
并查看初始化为 destinationsRepository.destinations
的 suggestedDestinations
StateFlow,当调用 updatePeople
或 toDestinationChanged
函数时,它会更新。
您希望 CraneHomeContent
可组合项中的界面在 suggestedDestinations
数据流中每次发射新项时更新。您可以使用 collectAsStateWithLifecycle()
函数。collectAsStateWithLifecycle()
从 StateFlow
收集值,并通过 Compose 的 State API 以感知生命周期的方式表示最新值。这将使读取该状态值的 Compose 代码在新的发射时重新组合。
要开始使用 collectAsStateWithLifecycle
API,首先在 app/build.gradle
中添加以下依赖项。变量 lifecycle_version
已在项目中定义,并具有适当的版本。
dependencies {
implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
}
返回到 CraneHomeContent
可组合项,将分配 suggestedDestinations
的行替换为对 ViewModel 的 suggestedDestinations
属性调用 collectAsStateWithLifecycle
:
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@Composable
fun CraneHomeContent(
onExploreItemClicked: OnExploreItemClicked,
openDrawer: () -> Unit,
modifier: Modifier = Modifier,
viewModel: MainViewModel = viewModel(),
) {
val suggestedDestinations by viewModel.suggestedDestinations.collectAsStateWithLifecycle()
// ...
}
如果运行该应用,您将看到目的地列表已填充,并且当您点击旅行人数时它们会发生变化。
5. LaunchedEffect 和 rememberUpdatedState
在该项目中,目前有一个未使用的 home/LandingScreen.kt
文件。您希望为应用添加一个启动屏幕,它可能用于在后台加载所有所需数据。
启动屏幕将占据整个屏幕并在屏幕中央显示应用标志。理想情况下,您会在显示屏幕后,并在所有数据加载完毕后,通过 onTimeout
回调通知调用者可以关闭启动屏幕。
Kotlin 协程是 Android 中执行异步操作的推荐方式。应用通常会在启动时使用协程在后台加载内容。Jetpack Compose 提供了 API,可在界面层内安全地使用协程。由于此应用不与后端通信,您将使用协程的 delay
函数模拟在后台加载内容。
Compose 中的副作用是指发生在可组合函数范围之外的应用状态更改。更改状态以显示/隐藏启动屏幕将发生在 onTimeout
回调中,并且由于在调用 onTimeout
之前您需要使用协程加载内容,状态更改需要发生在协程的上下文中!
要从可组合项内部安全地调用 suspend 函数,请使用 LaunchedEffect
API,该 API 会在 Compose 中触发协程范围的副作用。
当 LaunchedEffect
进入 Composition 时,它会启动一个带有作为参数传入的代码块的协程。如果 LaunchedEffect
离开 Composition,协程将被取消。
尽管下面的代码不正确,但让我们看看如何使用此 API 并讨论为什么以下代码是错误的。您稍后将在此步骤中调用 LandingScreen
可组合项。
// home/LandingScreen.kt file
import androidx.compose.runtime.LaunchedEffect
import kotlinx.coroutines.delay
@Composable
fun LandingScreen(onTimeout: () -> Unit, modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
// Start a side effect to load things in the background
// and call onTimeout() when finished.
// Passing onTimeout as a parameter to LaunchedEffect
// is wrong! Don't do this. We'll improve this code in a sec.
LaunchedEffect(onTimeout) {
delay(SplashWaitTime) // Simulates loading things
onTimeout()
}
Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
}
}
某些副作用 API(例如 LaunchedEffect
)接受可变数量的键作为参数,这些键用于在其中一个键发生变化时重新启动副作用。您发现错误了吗?我们不希望当此可组合函数的调用者传入不同的 onTimeout
lambda 值时重新启动 LaunchedEffect
。那样会让 delay
重新开始,您就无法满足要求了。
让我们修复它。要在此可组合项的生命周期中仅触发一次副作用,请使用常量作为键,例如 LaunchedEffect(Unit) { ... }
。但是,现在出现了另一个问题。
如果 onTimeout
在副作用进行期间发生变化,则无法保证在副作用完成时调用最新的 onTimeout
。为保证调用最新的 onTimeout
,请使用 rememberUpdatedState
API 记住 onTimeout
。此 API 捕获并更新最新值
// home/LandingScreen.kt file
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import kotlinx.coroutines.delay
@Composable
fun LandingScreen(onTimeout: () -> Unit, modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
// This will always refer to the latest onTimeout function that
// LandingScreen was recomposed with
val currentOnTimeout by rememberUpdatedState(onTimeout)
// Create an effect that matches the lifecycle of LandingScreen.
// If LandingScreen recomposes or onTimeout changes,
// the delay shouldn't start again.
LaunchedEffect(Unit) {
delay(SplashWaitTime)
currentOnTimeout()
}
Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
}
}
当长期存在的 lambda 或对象表达式引用在 Composition 期间计算的参数或值时,您应该使用 rememberUpdatedState
,这在使用 LaunchedEffect
时可能很常见。
显示启动屏幕
现在,您需要在应用打开时显示启动屏幕。打开 home/MainActivity.kt
文件并查看首先调用的 MainScreen
可组合项。
在 MainScreen
可组合项中,您可以简单地添加一个内部状态来跟踪是否应该显示启动屏幕:
// home/MainActivity.kt file
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@Composable
private fun MainScreen(onExploreItemClicked: OnExploreItemClicked) {
Surface(color = MaterialTheme.colors.primary) {
var showLandingScreen by remember { mutableStateOf(true) }
if (showLandingScreen) {
LandingScreen(onTimeout = { showLandingScreen = false })
} else {
CraneHome(onExploreItemClicked = onExploreItemClicked)
}
}
}
如果现在运行应用,您应该会看到 LandingScreen
出现并在 2 秒后消失。
6. rememberCoroutineScope
在此步骤中,您将使抽屉式导航栏正常工作。目前,如果您尝试点击汉堡菜单,什么都不会发生。
打开 home/CraneHome.kt
文件并查看 CraneHome
可组合项,了解您需要在何处打开抽屉式导航栏:在 openDrawer
回调中!
在 CraneHome
中,您有一个 scaffoldState
,其中包含一个 DrawerState
。DrawerState
具有以编程方式打开和关闭抽屉式导航栏的方法。但是,如果您尝试在 openDrawer
回调中写入 scaffoldState.drawerState.open()
,您会收到错误!这是因为 open
函数是一个 suspend 函数。我们又回到了协程的领域。
除了从界面层安全调用协程的 API 之外,一些 Compose API 也是 suspend 函数。一个例子是打开抽屉式导航栏的 API。Suspend 函数除了能够运行异步代码外,还有助于表示随时间发生的概念。由于打开抽屉需要一些时间、移动和可能的动画,这可以通过 suspend 函数完美地体现出来,它将暂停调用它的协程的执行,直到它完成并恢复执行。
scaffoldState.drawerState.open()
必须在协程内调用。您能做什么?openDrawer
是一个简单的回调函数,因此
- 您不能简单地在其内部调用 suspend 函数,因为
openDrawer
不在协程的上下文中执行。 - 您不能像以前那样使用
LaunchedEffect
,因为我们不能在openDrawer
中调用可组合项。我们不在 Composition 中。
您想启动一个协程;我们应该使用哪个作用域?理想情况下,您需要一个遵循其调用点生命周期的 CoroutineScope
。使用 rememberCoroutineScope
API 会返回一个绑定到您调用它的 Composition 点的 CoroutineScope
。一旦它离开 Composition,该作用域将自动取消。有了该作用域,您可以在不在 Composition 中时启动协程,例如,在 openDrawer
回调中。
// home/CraneHome.kt file
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
@Composable
fun CraneHome(
onExploreItemClicked: OnExploreItemClicked,
modifier: Modifier = Modifier,
) {
val scaffoldState = rememberScaffoldState()
Scaffold(
scaffoldState = scaffoldState,
modifier = Modifier.statusBarsPadding(),
drawerContent = {
CraneDrawer()
}
) {
val scope = rememberCoroutineScope()
CraneHomeContent(
modifier = modifier,
onExploreItemClicked = onExploreItemClicked,
openDrawer = {
scope.launch {
scaffoldState.drawerState.open()
}
}
)
}
}
如果运行该应用,您会看到当您点击汉堡菜单图标时,抽屉式导航栏会打开。
LaunchedEffect 与 rememberCoroutineScope
在这种情况下,无法使用 LaunchedEffect
,因为您需要在 Composition 之外的常规回调中触发创建协程的调用。
回顾使用 LaunchedEffect
的启动屏幕步骤,您是否可以使用 rememberCoroutineScope
并调用 scope.launch { delay(); onTimeout(); }
,而不是使用 LaunchedEffect
?
您本可以这样做,而且看起来会有效,但这不正确。正如在 Compose 中思考的文档中所解释的,Compose 可以在任何时刻调用可组合项。LaunchedEffect
保证当对该可组合项的调用进入 Composition 时,副作用将被执行。如果在 LandingScreen
的主体中使用 rememberCoroutineScope
和 scope.launch
,协程将在 Compose 每次调用 LandingScreen
时执行,无论该调用是否进入 Composition。因此,您会浪费资源,并且您不会在一个受控的环境中执行此副作用。
7. 创建状态容器
您是否注意到,如果您点击选择目的地,您可以编辑该字段并根据您的搜索输入过滤城市?您可能还注意到,每当您修改选择目的地时,文本样式都会发生变化。
打开 base/EditableUserInput.kt
文件。CraneEditableUserInput
有状态可组合项接受一些参数,例如 hint
和 caption
,对应于图标旁边的可选文本。例如,当您搜索目的地时,会出现 caption
To。
// base/EditableUserInput.kt file - code in the main branch
@Composable
fun CraneEditableUserInput(
hint: String,
caption: String? = null,
@DrawableRes vectorImageId: Int? = null,
onInputChanged: (String) -> Unit
) {
// TODO Codelab: Encapsulate this state in a state holder
var textState by remember { mutableStateOf(hint) }
val isHint = { textState == hint }
...
}
为什么会这样?
更新 textState
并确定显示的内容是否对应于提示的逻辑全部在 CraneEditableUserInput
可组合项的主体中。这带来了一些缺点:
TextField
的值未提升,因此无法从外部控制,从而使测试更加困难。- 此可组合项的逻辑可能会变得更加复杂,并且内部状态更容易不同步。
通过为此可组合项的内部状态创建状态容器,您可以将所有状态更改集中在一个地方。这样,状态不同步的情况更难发生,并且相关逻辑全部集中在一个类中。此外,此状态可以轻松提升,并可供此可组合项的调用者使用。
在这种情况下,提升状态是一个好主意,因为这是一个低级界面组件,可能会在应用的其他部分重用。因此,它越灵活、越可控越好。
创建状态容器
由于 CraneEditableUserInput
是一个可重用组件,请在同一文件中创建一个名为 EditableUserInputState
的常规类作为状态容器,如下所示:
// base/EditableUserInput.kt file
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
class EditableUserInputState(private val hint: String, initialText: String) {
var text by mutableStateOf(initialText)
private set
fun updateText(newText: String) {
text = newText
}
val isHint: Boolean
get() = text == hint
}
该类应具有以下特性:
text
是一个类型为String
的可变状态,正如您在CraneEditableUserInput
中所拥有的一样。使用mutableStateOf
很重要,这样 Compose 才能跟踪值的变化并在发生变化时重新组合。text
是一个var
,带有私有set
,因此无法从类外部直接更改。您可以暴露一个updateText
事件来修改它,而不是将此变量公开,这使该类成为单一事实来源。- 该类接受一个
initialText
作为依赖项,用于初始化text
。 - 知道
text
是否是提示的逻辑位于按需执行检查的isHint
属性中。
如果未来逻辑变得更复杂,您只需要更改一个类:EditableUserInputState
。
记住状态容器
状态容器始终需要被记住才能保留在 Composition 中,而不是每次都创建一个新的。在同一个文件中创建一个方法来执行此操作是一个好的做法,可以减少样板代码并避免可能发生的任何错误。在 base/EditableUserInput.kt
文件中,添加此代码
// base/EditableUserInput.kt file
@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
remember(hint) {
EditableUserInputState(hint, hint)
}
如果您仅 remember
此状态,它将无法在 Activity 重建中幸存。要实现这一点,您可以使用 rememberSaveable
API,它的行为类似于 remember
,但存储的值也能在 Activity 和进程重建中幸存。在内部,它使用保存的实例状态机制。
rememberSaveable
可以为可以存储在 Bundle
中的对象执行所有这些操作,而无需额外工作。这与您在项目中创建的 EditableUserInputState
类不同。因此,您需要告诉 rememberSaveable
如何使用 Saver
保存和恢复此类的实例。
创建自定义 Saver
一个 Saver
描述了如何将对象转换为可以 Saveable
的东西。实现 Saver
需要覆盖两个函数:
save
用于将原始值转换为可保存的值。restore
用于将恢复的值转换为原始类的实例。
对于此用例,您可以使用一些现有的 Compose API(例如 listSaver
或 mapSaver
(将要保存的值存储在 List
或 Map
中)),而不是为您创建的 EditableUserInputState
类创建自定义 Saver
实现,以减少需要编写的代码量。
将 Saver
定义放在与其配合使用的类附近是一个好的做法。由于需要静态访问它,因此将 EditableUserInputState
的 Saver
添加到 companion object
中。在 base/EditableUserInput.kt
文件中,添加 Saver
的实现:
// base/EditableUserInput.kt file
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver
class EditableUserInputState(private val hint: String, initialText: String) {
var text by mutableStateOf(initialText)
val isHint: Boolean
get() = text == hint
companion object {
val Saver: Saver<EditableUserInputState, *> = listSaver(
save = { listOf(it.hint, it.text) },
restore = {
EditableUserInputState(
hint = it[0],
initialText = it[1],
)
}
)
}
}
在这种情况下,您使用 listSaver
作为实现细节,将 EditableUserInputState
的实例存储和恢复到 saver 中。
现在,您可以在之前创建的 rememberEditableUserInputState
方法中使用此 saver(而不是 remember
):
// base/EditableUserInput.kt file
import androidx.compose.runtime.saveable.rememberSaveable
@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
rememberSaveable(hint, saver = EditableUserInputState.Saver) {
EditableUserInputState(hint, hint)
}
这样,EditableUserInput
记住的状态将在进程和 Activity 重建中幸存。
使用状态容器
您将使用 EditableUserInputState
而不是 text
和 isHint
,但您不想仅仅将其用作 CraneEditableUserInput
中的内部状态,因为调用者可组合项无法控制状态。相反,您希望提升 EditableUserInputState
,以便调用者可以控制 CraneEditableUserInput
的状态。如果您提升状态,则可以在预览中使用该可组合项,并且更容易测试,因为您可以从调用者处修改其状态。
要做到这一点,您需要更改可组合函数的参数并在需要时为其提供默认值。因为您可能希望允许 CraneEditableUserInput
具有空提示,请添加一个默认参数
@Composable
fun CraneEditableUserInput(
state: EditableUserInputState = rememberEditableUserInputState(""),
caption: String? = null,
@DrawableRes vectorImageId: Int? = null
) { /* ... */ }
您可能已经注意到 onInputChanged
参数不见了!由于状态可以提升,如果调用者想知道输入是否发生了变化,他们可以控制状态并将该状态传递给此函数。
接下来,您需要调整函数主体,使其使用提升的状态而不是之前使用的内部状态。重构后,函数应如下所示:
@Composable
fun CraneEditableUserInput(
state: EditableUserInputState = rememberEditableUserInputState(""),
caption: String? = null,
@DrawableRes vectorImageId: Int? = null
) {
CraneBaseUserInput(
caption = caption,
tintIcon = { !state.isHint },
showCaption = { !state.isHint },
vectorImageId = vectorImageId
) {
BasicTextField(
value = state.text,
onValueChange = { state.updateText(it) },
textStyle = if (state.isHint) {
captionTextStyle.copy(color = LocalContentColor.current)
} else {
MaterialTheme.typography.body1.copy(color = LocalContentColor.current)
},
cursorBrush = SolidColor(LocalContentColor.current)
)
}
}
状态容器调用者
由于您更改了 CraneEditableUserInput
的 API,因此您需要在所有调用它的地方检查,以确保传入适当的参数。
项目中唯一调用此 API 的地方是 home/SearchUserInput.kt
文件。打开它并转到 ToDestinationUserInput
可组合函数;您应该在那里看到一个构建错误。由于提示现在是状态容器的一部分,并且您希望在 Composition 中为 CraneEditableUserInput
的此实例提供自定义提示,您需要在 ToDestinationUserInput
级别记住状态并将其传递给 CraneEditableUserInput
// home/SearchUserInput.kt file
import androidx.compose.samples.crane.base.rememberEditableUserInputState
@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
CraneEditableUserInput(
state = editableUserInputState,
caption = "To",
vectorImageId = R.drawable.ic_plane
)
}
snapshotFlow
上面的代码缺少在输入更改时通知 ToDestinationUserInput
的调用者的功能。由于应用的结构方式,您不想将 EditableUserInputState
提升到层次结构的更高级别。您不想将 FlySearchContent
等其他可组合项与此状态耦合。如何从 ToDestinationUserInput
调用 onToDestinationChanged
lambda,同时仍然保持此可组合项的可重用性?
您可以在每次输入更改时使用 LaunchedEffect
触发副作用,并调用 onToDestinationChanged
lambda
// home/SearchUserInput.kt file
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter
@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
CraneEditableUserInput(
state = editableUserInputState,
caption = "To",
vectorImageId = R.drawable.ic_plane
)
val currentOnDestinationChanged by rememberUpdatedState(onToDestinationChanged)
LaunchedEffect(editableUserInputState) {
snapshotFlow { editableUserInputState.text }
.filter { !editableUserInputState.isHint }
.collect {
currentOnDestinationChanged(editableUserInputState.text)
}
}
}
您之前已经使用过 LaunchedEffect
和 rememberUpdatedState
,但上面的代码还使用了新的 API!snapshotFlow
API 将 Compose State<T>
对象转换为 Flow。当在 snapshotFlow
内部读取的状态发生变化时,Flow 将向收集器发射新值。在这种情况下,您将状态转换为 Flow 以利用 Flow 运算符的强大功能。通过这样做,您可以 filter
过滤掉 text
不是 hint
的情况,并 collect
收集发射的项以通知父级当前目的地已更改。
在此 Codelab 的此步骤中没有视觉变化,但您提高了代码此部分的质量。如果您现在运行应用,您应该会看到一切都像以前一样正常工作。
8. DisposableEffect
当您点击目的地时,详情屏幕会打开,您可以在地图上看到该城市的位置。该代码位于 details/DetailsActivity.kt
文件中。在 CityMapView
可组合项中,您正在调用 rememberMapViewWithLifecycle
函数。如果您打开此函数(位于 details/MapViewUtils.kt
文件中),您会发现它没有连接到任何生命周期!它只是记住了 MapView
并对其调用 onCreate
// details/MapViewUtils.kt file - code in the main branch
@Composable
fun rememberMapViewWithLifecycle(): MapView {
val context = LocalContext.current
// TODO Codelab: DisposableEffect step. Make MapView follow the lifecycle
return remember {
MapView(context).apply {
id = R.id.map
onCreate(Bundle())
}
}
}
尽管应用运行正常,但这是一个问题,因为 MapView
没有遵循正确的生命周期。因此,它不会知道应用何时移到后台,View 何时应该暂停等等。让我们修复此问题!
由于 MapView
是一个 View 而不是一个可组合项,您希望它遵循使用它的 Activity 的生命周期以及 Composition 的生命周期。这意味着您需要创建一个 LifecycleEventObserver
来监听生命周期事件并在给定事件时调用 MapView
上的正确方法。然后,您需要将此 observer 添加到当前 Activity 的生命周期中。
首先创建一个函数,该函数返回一个 LifecycleEventObserver
,该 observer 在给定事件时调用 MapView
中的相应方法
// details/MapViewUtils.kt file
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
private fun getMapLifecycleObserver(mapView: MapView): LifecycleEventObserver =
LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_CREATE -> mapView.onCreate(Bundle())
Lifecycle.Event.ON_START -> mapView.onStart()
Lifecycle.Event.ON_RESUME -> mapView.onResume()
Lifecycle.Event.ON_PAUSE -> mapView.onPause()
Lifecycle.Event.ON_STOP -> mapView.onStop()
Lifecycle.Event.ON_DESTROY -> mapView.onDestroy()
else -> throw IllegalStateException()
}
}
现在,您需要将此 observer 添加到当前生命周期,您可以使用当前的 LifecycleOwner
通过 LocalLifecycleOwner
组合局部变量获取该生命周期。但是,仅添加 observer 还不够;您还需要能够移除它!您需要一个副作用来告知您副作用何时离开 Composition,以便您可以执行一些清理代码。您正在寻找的副作用 API 是 DisposableEffect
。
DisposableEffect
用于需要在键发生变化或可组合项离开 Composition 后进行清理的副作用。最终的 rememberMapViewWithLifecycle
代码正好做了这一点。在您的项目中实现以下行:
// details/MapViewUtils.kt file
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalLifecycleOwner
@Composable
fun rememberMapViewWithLifecycle(): MapView {
val context = LocalContext.current
val mapView = remember {
MapView(context).apply {
id = R.id.map
}
}
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(key1 = lifecycle, key2 = mapView) {
// Make MapView follow the current lifecycle
val lifecycleObserver = getMapLifecycleObserver(mapView)
lifecycle.addObserver(lifecycleObserver)
onDispose {
lifecycle.removeObserver(lifecycleObserver)
}
}
return mapView
}
observer 被添加到当前的 lifecycle
中,并且当当前生命周期发生变化或此可组合项离开 Composition 时,它将被移除。使用 DisposableEffect
中的 key
,如果 lifecycle
或 mapView
发生变化,observer 将被移除并再次添加到正确的 lifecycle
中。
通过您刚刚所做的更改,MapView
将始终遵循当前 LifecycleOwner
的 lifecycle
,其行为就像在 View 世界中一样。
您可以随意运行应用并打开详情屏幕,确保 MapView
仍然正常渲染。在此步骤中没有视觉变化。
9. produceState
在本节中,您将改进详情屏幕的启动方式。details/DetailsActivity.kt
文件中的 DetailsScreen
可组合项从 ViewModel 同步获取 cityDetails
,如果结果成功,则调用 DetailsContent
。
但是,cityDetails
的加载可能会在界面线程上变得更加耗时,并且可以使用协程将数据的加载移动到另一个线程。您将改进此代码以添加一个加载屏幕,并在数据准备就绪时显示 DetailsContent
。
对屏幕状态进行建模的一种方法是使用以下类,该类涵盖所有可能性:要显示在屏幕上的数据、加载信号和错误信号。将 DetailsUiState
类添加到 DetailsActivity.kt
文件中
// details/DetailsActivity.kt file
data class DetailsUiState(
val cityDetails: ExploreModel? = null,
val isLoading: Boolean = false,
val throwError: Boolean = false
)
您可以使用数据流(类型为 DetailsUiState
的 StateFlow
)在 ViewModel 层映射屏幕需要显示的内容和 UiState
,当信息准备就绪时 ViewModel 会更新该数据流,并且 Compose 会使用您已经知道的 collectAsStateWithLifecycle()
API 收集该数据流。
但是,为了完成本练习,您将实现另一种方法。如果您想将 uiState
映射逻辑移动到 Compose 世界,您可以使用 produceState
API。
produceState
允许您将非 Compose 状态转换为 Compose State。它会启动一个作用域为 Composition 的协程,该协程可以使用 value
属性将值推送到返回的 State
中。与 LaunchedEffect
一样,produceState
也接受用于取消和重新启动计算的键。
对于您的用例,您可以使用 produceState
以初始值 DetailsUiState(isLoading = true)
发射 uiState
更新,如下所示:
// details/DetailsActivity.kt file
import androidx.compose.runtime.produceState
@Composable
fun DetailsScreen(
onErrorLoading: () -> Unit,
modifier: Modifier = Modifier,
viewModel: DetailsViewModel = viewModel()
) {
val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
// In a coroutine, this can call suspend functions or move
// the computation to different Dispatchers
val cityDetailsResult = viewModel.cityDetails
value = if (cityDetailsResult is Result.Success<ExploreModel>) {
DetailsUiState(cityDetailsResult.data)
} else {
DetailsUiState(throwError = true)
}
}
// TODO: ...
}
接下来,根据 uiState
,显示数据、显示加载屏幕或报告错误。以下是 DetailsScreen
可组合项的完整代码
// details/DetailsActivity.kt file
import androidx.compose.foundation.layout.Box
import androidx.compose.material.CircularProgressIndicator
@Composable
fun DetailsScreen(
onErrorLoading: () -> Unit,
modifier: Modifier = Modifier,
viewModel: DetailsViewModel = viewModel()
) {
val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
val cityDetailsResult = viewModel.cityDetails
value = if (cityDetailsResult is Result.Success<ExploreModel>) {
DetailsUiState(cityDetailsResult.data)
} else {
DetailsUiState(throwError = true)
}
}
when {
uiState.cityDetails != null -> {
DetailsContent(uiState.cityDetails!!, modifier.fillMaxSize())
}
uiState.isLoading -> {
Box(modifier.fillMaxSize()) {
CircularProgressIndicator(
color = MaterialTheme.colors.onSurface,
modifier = Modifier.align(Alignment.Center)
)
}
}
else -> { onErrorLoading() }
}
}
如果您运行该应用,您会看到加载微调器在显示城市详情之前出现。
10. derivedStateOf
您将对 Crane 进行的最后一项改进是:当您在航班目的地列表中滚动并经过屏幕第一个元素后,显示一个“滚动到顶部”按钮。点击该按钮会将您带到列表的第一个元素。
打开包含此代码的 base/ExploreSection.kt
文件。ExploreSection
可组合项对应于您在 Scaffold 的 Backdrop 中看到的内容。
要计算用户是否已经经过第一个项目,请使用 LazyColumn
的 LazyListState
,并检查 listState.firstVisibleItemIndex > 0
是否为 true。
一个朴素的实现可能如下所示
// DO NOT DO THIS - It's executed on every recomposition
val showButton = listState.firstVisibleItemIndex > 0
此解决方案的效率不如预期,因为读取 showButton
的可组合函数会随着 firstVisibleItemIndex
的更改而频繁地进行重新组合,而 firstVisibleItemIndex
在滚动时经常变化。相反,您希望函数仅在条件从 true
更改为 false
时进行重新组合。
有一个 API 可以实现这一点:derivedStateOf
API。
listState
是一个可观察的 Compose State
。您的计算 showButton
也需要是一个 Compose State
,因为您希望界面在其值更改时重新组合,并显示或隐藏按钮。
当您想要从另一个 State
派生出 Compose State
时,请使用 derivedStateOf
。每当内部状态发生变化时,都会执行 derivedStateOf
计算块,但只有当计算结果与上次不同时,可组合函数才会重新组合。这最大限度地减少了读取 showButton
的函数重新组合的次数。
在这种情况下使用 derivedStateOf
API 是更好、更高效的替代方案。您还将使用 remember
API 封装调用,以便计算的值在重新组合中幸存。
// Show the button if the first visible item is past
// the first item. We use a remembered derived state to
// minimize unnecessary recompositions
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
您应该已经熟悉 ExploreSection
可组合项的新代码。您正在使用 Box
来放置有条件地显示在 ExploreList
上方的 Button
。并且您使用 rememberCoroutineScope
在 Button
的 onClick
回调中调用 listState.scrollToItem
suspend 函数。
// base/ExploreSection.kt file
import androidx.compose.material.FloatingActionButton
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.foundation.layout.navigationBarsPadding
import kotlinx.coroutines.launch
@Composable
fun ExploreSection(
modifier: Modifier = Modifier,
title: String,
exploreList: List<ExploreModel>,
onItemClicked: OnExploreItemClicked
) {
Surface(modifier = modifier.fillMaxSize(), color = Color.White, shape = BottomSheetShape) {
Column(modifier = Modifier.padding(start = 24.dp, top = 20.dp, end = 24.dp)) {
Text(
text = title,
style = MaterialTheme.typography.caption.copy(color = crane_caption)
)
Spacer(Modifier.height(8.dp))
Box(Modifier.weight(1f)) {
val listState = rememberLazyListState()
ExploreList(exploreList, onItemClicked, listState = listState)
// Show the button if the first visible item is past
// the first item. We use a remembered derived state to
// minimize unnecessary compositions
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
if (showButton) {
val coroutineScope = rememberCoroutineScope()
FloatingActionButton(
backgroundColor = MaterialTheme.colors.primary,
modifier = Modifier
.align(Alignment.BottomEnd)
.navigationBarsPadding()
.padding(bottom = 8.dp),
onClick = {
coroutineScope.launch {
listState.scrollToItem(0)
}
}
) {
Text("Up!")
}
}
}
}
}
}
如果您运行该应用,您会看到当您滚动并经过屏幕第一个元素后,按钮会出现在底部。
11. 恭喜!
恭喜,您已成功完成此 Codelab,并学习了 Jetpack Compose 应用中状态和副作用 API 的高级概念!
您学习了如何创建状态容器、副作用 API,例如 LaunchedEffect
、rememberUpdatedState
、DisposableEffect
、produceState
和 derivedStateOf
,以及如何在 Jetpack Compose 中使用协程。
下一步是什么?
查看Compose 路径中的其他 Codelab,以及包括 Crane 在内的其他代码示例。
文档
有关这些主题的更多信息和指导,请查看以下文档: