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

1. 简介

在此 Codelab 中,您将学习 Jetpack Compose 中与状态副作用 API 相关的高级概念。您将了解如何为逻辑并非微不足道的状态化可组合项创建状态容器,如何创建协程并从 Compose 代码调用 suspend 函数,以及如何触发副作用以实现不同的用例。

如需在完成此 Codelab 的过程中获得更多支持,请查看以下随堂代码:

您将学到什么

您需要准备什么

您将构建什么

在此 Codelab 中,您将从一个未完成的应用(Crane Material Study 应用)开始,并添加功能来改进该应用。

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

界面测试

该应用涵盖了 androidTest 文件夹中提供的非常基础的界面测试。在 mainend 分支中,这些测试应该始终通过。

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

在详情屏幕上显示城市地图对于继续完成 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 分支的应用时可能已经注意到的,航班目的地列表是空的!

要解决此问题,您必须完成以下两个步骤:

  • ViewModel 中添加逻辑,以生成界面状态。在您的示例中,这代表建议的目的地列表。
  • 从界面消费界面状态,这将在屏幕上显示界面。

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

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

界面状态生成是指应用访问数据层,在需要时应用业务规则,并将界面状态暴露给界面消费的过程。

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

有几种可用于生成界面状态的 API。这些替代方案在状态生成管线中的输出类型文档中有所总结。通常,最好使用 Kotlin 的 StateFlow 来生成界面状态。

要生成界面状态,请按照以下步骤操作:

  1. 打开 home/MainViewModel.kt
  2. 定义一个类型为 MutableStateFlow 的私有变量 _suggestedDestinations,用于表示建议的目的地列表,并将空列表设为起始值。
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
  1. 定义第二个类型为 StateFlow 的不可变变量 suggestedDestinations。这是可供界面消费的公共只读变量。在内部使用可变变量的同时暴露只读变量是一个好的做法。通过这样做,您可以确保界面状态只能通过 ViewModel 进行修改,从而使其成为单一事实来源。asStateFlow 扩展函数将 Flow 从可变转换为不可变。
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 的用法,以便它可以根据来自界面的事件进行适当更新。

就是这样 – 第一个步骤完成了!现在,ViewModel 能够生成界面状态。在下一步中,您将从界面消费此状态。

4. 从 ViewModel 安全地消费 Flow

航班目的地列表仍然是空的。在前面的步骤中,您在 MainViewModel 中生成了界面状态。现在,您将消费 MainViewModel 暴露的界面状态以在界面中显示。

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

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

66ae2543faaf2e91.png

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

您希望 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()
    // ...
}

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

d656748c7c583eb8.gif

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 秒后消失。

e3fd932a5b95faa0.gif

6. rememberCoroutineScope

在此步骤中,您将使抽屉式导航栏正常工作。目前,如果您尝试点击汉堡菜单,什么都不会发生。

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

CraneHome 中,您有一个 scaffoldState,其中包含一个 DrawerStateDrawerState 具有以编程方式打开和关闭抽屉式导航栏的方法。但是,如果您尝试在 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()
                }
            }
        )
    }
}

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

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 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(例如 listSavermapSaver(将要保存的值存储在 ListMap 中)),而不是为您创建的 EditableUserInputState 类创建自定义 Saver 实现,以减少需要编写的代码量。

Saver 定义放在与其配合使用的类附近是一个好的做法。由于需要静态访问它,因此将 EditableUserInputStateSaver 添加到 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 而不是 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 可组合函数;您应该在那里看到一个构建错误。由于提示现在是状态容器的一部分,并且您希望在 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)
            }
    }
}

您之前已经使用过 LaunchedEffectrememberUpdatedState,但上面的代码还使用了新的 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,如果 lifecyclemapView 发生变化,observer 将被移除并再次添加到正确的 lifecycle 中。

通过您刚刚所做的更改,MapView 将始终遵循当前 LifecycleOwnerlifecycle,其行为就像在 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
)

您可以使用数据流(类型为 DetailsUiStateStateFlow)在 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() }
    }
}

如果您运行该应用,您会看到加载微调器在显示城市详情之前出现。

aa8fd1ac660266e9.gif

10. derivedStateOf

您将对 Crane 进行的最后一项改进是:当您在航班目的地列表中滚动并经过屏幕第一个元素后,显示一个“滚动到顶部”按钮。点击该按钮会将您带到列表的第一个元素。

2c112d73f48335e0.gif

打开包含此代码的 base/ExploreSection.kt 文件。ExploreSection 可组合项对应于您在 Scaffold 的 Backdrop 中看到的内容。

要计算用户是否已经经过第一个项目,请使用 LazyColumnLazyListState,并检查 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。并且您使用 rememberCoroutineScopeButtononClick 回调中调用 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,例如 LaunchedEffectrememberUpdatedStateDisposableEffectproduceStatederivedStateOf,以及如何在 Jetpack Compose 中使用协程。

下一步是什么?

查看Compose 路径中的其他 Codelab,以及包括 Crane 在内的其他代码示例

文档

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