Jetpack Compose 中的高级状态和副作用

1. 简介

在本 Codelab 中,您将学习与 状态副作用 API 在 Jetpack Compose 中相关的概念。您将了解如何为状态化可组合项创建状态持有者(其逻辑不简单),如何创建协程以及如何从 Compose 代码中调用挂起函数,以及如何触发副作用以完成不同的用例。

在您完成此 Codelab 的过程中,如需更多支持,请查看以下代码随附内容

您将学到什么

您需要什么

您将构建什么

在本 Codelab 中,您将从一个未完成的应用程序(Crane 材料学习 应用)开始,并添加功能以改进该应用。

b2c6b8989f4332bb.gif

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 过程中,您将看到一些代码片段,您需要将其添加到项目中。在某些地方,您还需要删除代码片段注释中明确提到的代码。

熟悉代码并运行示例应用

花点时间浏览项目结构并运行应用。

162c42b19dafa701.png

当您从 main 分支运行应用时,您会发现某些功能(例如抽屉或加载航班目的地)无法使用!这就是您将在 Codelab 的后续步骤中要做的工作。

b2c6b8989f4332bb.gif

UI 测试

该应用覆盖了 androidTest 文件夹中提供的非常基本的 UI 测试。它们始终应该在 mainend 分支上通过。

[可选] 在详情屏幕上显示地图

在详情屏幕上显示城市地图对于跟随操作完全没有必要。但是,如果您想查看它,则需要根据 地图文档 中的说明获取个人 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. UI 状态生成管道

您可能在从 main 分支运行应用时已经注意到,航班目的地的列表为空!

要解决此问题,您需要完成两个步骤

  • ViewModel 中添加逻辑以生成 UI 状态。在本例中,这是建议目的地的列表。
  • 从 UI 中使用 UI 状态,这将在屏幕上显示 UI。

在本节中,您将完成第一步。

一个良好的应用架构是分层组织的,以遵循基本良好的系统设计实践,例如关注点分离和可测试性。

UI 状态生成 指的是应用访问数据层、根据需要应用业务规则并将 UI 状态公开以供 UI 使用的过程。

此应用中的数据层已实现。现在,您将生成状态(建议目的地的列表),以便 UI 可以使用它。

有一些 API 可用于生成 UI 状态。这些替代方案在 状态生成管道中的输出类型 文档中进行了总结。通常,最好使用 Kotlin 的 StateFlow 生成 UI 状态。

要生成 UI 状态,请按照以下步骤操作

  1. 打开 home/MainViewModel.kt
  2. 定义一个名为 _suggestedDestinations 的私有变量,其类型为 MutableStateFlow,以表示建议目的地的列表,并将空列表设置为起始值。
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
  1. 定义第二个不可变变量 suggestedDestinations,其类型为 StateFlow。这是可供 UI 使用的公共只读变量。公开只读变量并在内部使用可变变量是一种良好的实践。通过这样做,您可以确保 UI 状态不会被修改,除非它通过 ViewModel,这使其成为单一事实来源。扩展函数 asStateFlow 将流从可变转换为不可变。
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())

val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()
  1. ViewModel 的 init 块中,从 destinationsRepository 添加一个调用以从数据层获取目的地。
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())

val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()

init {
    _suggestedDestinations.value = destinationsRepository.destinations
}
  1. 最后,取消注释您在此类中找到的内部变量 _suggestedDestinations 的用法,以便它可以通过来自 UI 的事件正确更新。

就是这样 - 第一步完成了!现在,ViewModel 能够生成 UI 状态。在下一步中,您将从 UI 中使用此状态。

4. 安全地从 ViewModel 中使用 Flow

航班目的地的列表仍然为空。在上一步骤中,您在 MainViewModel 中生成了 UI 状态。现在,您将使用 MainViewModel 公开的 UI 状态在 UI 中显示。

打开 home/CraneHome.kt 文件并查看 CraneHomeContent 可组合项。

suggestedDestinations 的定义上方有一个 TODO 注释,它被分配给一个已记住的空列表。这就是屏幕上显示的内容:一个空列表!在此步骤中,您将修复此问题并显示 MainViewModel 公开的建议目的地。

66ae2543faaf2e91.png

打开home/MainViewModel.kt,查看suggestedDestinations StateFlow,它初始化为destinationsRepository.destinations,并在调用updatePeopletoDestinationChanged 函数时更新。

您希望CraneHomeContent 可组合项中的 UI 在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 的行替换为对ViewModelsuggestedDestinations 属性上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()
    // ...
}

如果运行该应用,您将看到目的地列表已填充,并且在您点击出行人数时它们会发生变化。

d656748c7c583eb8.gif

5. LaunchedEffect 和 rememberUpdatedState

在项目中,有一个home/LandingScreen.kt 文件,目前未使用。您希望向应用添加一个登录屏幕,该屏幕可能会用于在后台加载所有必要的数据。

登录屏幕将占据整个屏幕,并在屏幕中间显示应用的徽标。理想情况下,您将显示屏幕,并在所有数据加载完成后,使用onTimeout 回调通知调用方可以关闭登录屏幕。

Kotlin 协程是在 Android 中执行异步操作的推荐方法。应用通常使用协程在启动时在后台加载内容。Jetpack Compose 提供了可在 UI 层安全地使用协程的 API。由于此应用不与后端通信,因此您将使用协程的delay 函数来模拟在后台加载内容。

Compose 中的副作用是在可组合函数作用域之外发生的应用状态更改。更改状态以显示/隐藏登录屏幕将在onTimeout 回调中发生,并且在调用onTimeout 之前需要使用协程加载内容,因此状态更改需要在协程的上下文中发生!

要在可组合项内部安全地调用挂起函数,请使用LaunchedEffect API,该 API 在 Compose 中触发协程作用域的副作用。

LaunchedEffect 进入 Composition 时,它会使用作为参数传递的代码块启动一个协程。如果LaunchedEffect 离开组合,则协程将被取消。

尽管下一段代码不正确,但让我们看看如何使用此 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 或对象表达式引用在组合期间计算的参数或值时,您应该使用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 秒后出现和消失。

e3fd932a5b95faa0.gif

6. rememberCoroutineScope

在本步骤中,您将使导航抽屉正常工作。目前,如果您尝试点击汉堡包菜单,则不会发生任何事情。

打开home/CraneHome.kt 文件并查看CraneHome 可组合项以查看您需要在何处打开导航抽屉:在openDrawer 回调中!

CraneHome 中,您有一个scaffoldState,其中包含一个DrawerStateDrawerState 具有以编程方式打开和关闭导航抽屉的方法。但是,如果您尝试在openDrawer 回调中编写scaffoldState.drawerState.open(),您将收到错误!这是因为open 函数是一个挂起函数。我们再次处于协程的领域。

除了使从 UI 层调用协程安全的 API 之外,某些 Compose API 也是挂起函数。其中一个示例是打开导航抽屉的 API。挂起函数除了能够运行异步代码外,还有助于表示随时间推移发生的概

scaffoldState.drawerState.open() 必须在协程内调用。你能做什么?openDrawer 是一个简单的回调函数,因此

  • 您不能在其中简单地调用挂起函数,因为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()
                }
            }
        )
    }
}

如果运行该应用,您将看到在您点击汉堡包菜单图标时导航抽屉会打开。

92957c04a35e91e3.gif

LaunchedEffect 与 rememberCoroutineScope

在这种情况下,无法使用LaunchedEffect,因为您需要在 Composition 之外的常规回调中触发创建协程的调用。

回顾使用LaunchedEffect 的登录屏幕步骤,您能否使用rememberCoroutineScope 并调用scope.launch { delay(); onTimeout(); } 而不是使用LaunchedEffect

您可以这样做,它似乎可以工作,但它是不正确的。如Compose 思维模型文档中所述,Compose 可以随时调用可组合项。LaunchedEffect 保证副作用将在对该可组合项的调用进入 Composition 时执行。如果您在LandingScreen 的主体中使用rememberCoroutineScopescope.launch,则协程将在 Compose 调用LandingScreen 的每次时执行,无论该调用是否进入 Composition。因此,您将浪费资源,并且不会在受控环境中执行此副作用。

7. 创建状态持有者

您是否注意到,如果您点击选择目的地,您可以编辑字段并根据您的搜索输入过滤城市?您可能还注意到,每当您修改选择目的地时,文本样式都会发生变化。

dde9ef06ca4e5191.gif

打开base/EditableUserInput.kt 文件。CraneEditableUserInput 有状态的可组合项采用一些参数,例如hintcaption,后者对应于图标旁边的可选文本。例如,当您搜索目的地时,会显示caption

// 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 的值没有提升(hoisted),因此无法从外部控制,这使得测试变得更加困难。
  • 此组合函数的逻辑可能会变得更加复杂,并且内部状态更容易出现不同步的情况。

通过创建一个状态持有者来负责此组合函数的内部状态,您可以将所有状态更改集中在一个地方。这样,状态就更不容易出现不同步的情况,并且相关逻辑都集中在一个类中。此外,此状态可以轻松地提升到上层,并且可以被此组合函数的调用者使用。

在这种情况下,提升状态是一个好主意,因为这是一个可能在应用程序的其他部分重用的低级 UI 组件。因此,它越灵活、越可控越好。

创建状态持有者

由于 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

记住状态持有者

状态持有者始终需要被记住,以便将其保留在组合中,并且每次都不创建新的状态持有者。最好在同一个文件中创建一个执行此操作的方法,以消除样板代码并避免可能发生的任何错误。在 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 描述了如何将对象转换为 Saveable 的内容。 Saver 的实现需要重写两个函数

  • save 用于将原始值转换为可保存的值。
  • restore 用于将恢复的值转换为原始类的实例。

在本例中,您可以使用一些现有的 Compose API(例如 listSavermapSaver(将要保存的值存储在 ListMap 中))来减少需要编写的代码量,而不是为 EditableUserInputState 类创建 Saver 的自定义实现。

最好将 Saver 定义放在与其相关的类附近。因为它需要静态访问,所以在 companion object 中添加 EditableUserInputStateSaver。在 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 的实例。

现在,您可以在之前创建的 rememberEditableUserInputState 方法中使用此保存器(而不是 remember)在 rememberSaveable 中。

// 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 而不是 textisHint,但您不希望将其仅仅用作 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 组合函数;您应该在那里看到一个构建错误。由于提示现在是状态持有者的一部分,并且您希望在此组合中的 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)
            }
    }
}

您之前已经使用过 LaunchedEffectrememberUpdatedState,但上面的代码还使用了一个新的 API!snapshotFlow **API** 将 Compose State<T> 对象转换为 Flow。当 snapshotFlow 内部读取的状态发生变异时,Flow 将向收集器发出新值。在本例中,您将状态转换为 Flow 以利用 Flow 运算符的功能。这样,您就可以 filter text 不等于 hint 的情况,并 collect 发出的项目以通知父级当前目的地已更改。

在本步骤的代码实验室中,没有视觉上的变化,但您提高了代码这一部分的质量。如果您现在运行应用程序,您应该会看到一切都在按预期工作。

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 的生命周期以及组合体的生命周期。这意味着您需要创建一个LifecycleEventObserver 来监听生命周期事件,并在MapView 上调用正确的方法。然后,您需要将此观察者添加到当前 Activity 的生命周期中。

首先创建一个函数,该函数返回一个LifecycleEventObserver,该观察者在给定特定事件的情况下调用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()
        }
    }

现在,您需要将此观察者添加到当前生命周期中,您可以使用当前的LifecycleOwnerLocalLifecycleOwner 组合本地获取它。但是,仅仅添加观察者是不够的;您还需要能够将其删除!您需要一个副作用来告诉您效果何时离开组合,以便您可以执行一些清理代码。您正在寻找的副作用 API 是DisposableEffect

当键更改或可组合项离开组合时,`DisposableEffect` 用于需要清理的副作用。最终的`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
}

观察者被添加到当前`lifecycle` 中,并且在当前生命周期发生变化或此可组合项离开组合时将被移除。使用`DisposableEffect` 中的`key`,如果`lifecycle` 或`mapView` 发生变化,则观察者将被移除并再次添加到正确的`lifecycle` 中。

通过您刚刚做出的更改,`MapView` 将始终遵循当前`LifecycleOwner` 的`lifecycle`,并且其行为就像在 View 世界中使用一样。

随意运行应用程序并打开详细信息屏幕,以确保`MapView` 仍然可以正确渲染。此步骤中没有视觉变化。

9. produceState

在本节中,您将改进详细信息屏幕的启动方式。`details/DetailsActivity.kt` 文件中的`DetailsScreen` 可组合项会从 ViewModel 同步获取`cityDetails`,如果结果成功,则调用`DetailsContent`。

但是,`cityDetails` 可能会演变成在 UI 线程上加载成本更高,并且它可以使用协程将数据的加载移动到不同的线程。您将改进此代码以添加加载屏幕并在数据准备就绪时显示`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。它启动一个作用域为组合体的协程,该协程可以使用`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() }
    }
}

如果运行应用程序,您将看到在显示城市详细信息之前加载微调器是如何出现的。

aa8fd1ac660266e9.gif

10. derivedStateOf

您要对 Crane 做出的最后一个改进是在您在航班目的地列表中滚动并超过屏幕的第一个元素后,始终显示一个“滚动到顶部”按钮。点击该按钮将带您到列表中的第一个元素。

2c112d73f48335e0.gif

打开包含此代码的`base/ExploreSection.kt` 文件。`ExploreSection` 可组合项对应于您在脚手架背景中看到的内容。

要计算用户是否已超过第一个项目,请使用LazyColumnLazyListState 并检查`listState.firstVisibleItemIndex > 0` 是否成立。

一个简单的实现如下所示。

// DO NOT DO THIS - It's executed on every recomposition
val showButton = listState.firstVisibleItemIndex > 0

此解决方案效率不高,因为读取`showButton` 的可组合函数会随着`firstVisibleItemIndex` 的更改而频繁重新组合,而这在滚动时会频繁发生。相反,您希望该函数仅在条件在`true` 和`false` 之间更改时重新组合。

有一个 API 可以让您做到这一点:derivedStateOf API。

`listState` 是一个可观察的 Compose `State`。您的计算`showButton` 也需要是一个 Compose `State`,因为您希望 UI 在其值更改时重新组合,并显示或隐藏按钮。

当您想要一个从另一个`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` 将有条件显示的`Button` 放在`ExploreList` 的顶部。并且您使用`rememberCoroutineScope` 在`Button` 的`onClick` 回调中调用`listState.scrollToItem` 暂停函数。

// 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. 祝贺!

恭喜您已成功完成此代码实验室,并学习了 Jetpack Compose 应用程序中状态和副作用 API 的高级概念!

您了解了如何创建状态持有者、副作用 API(例如LaunchedEffectrememberUpdatedStateDisposableEffectproduceStatederivedStateOf)以及如何在 Jetpack Compose 中使用协程。

接下来是什么?

查看Compose 学习路径 上的其他代码实验室以及其他代码示例(包括 Crane)。

文档

有关这些主题的更多信息和指导,请查看以下文档。