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

1. 简介

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

在您完成本 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

UI 测试

该应用包含非常基本的 UI 测试,位于 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. 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 可组合项中,每当有新项目发出到 suggestedDestinations 数据流时,UI 都会更新。您可以使用 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 保证副作用将在调用该可组合项并将其纳入组合时执行。如果你在 LandingScreen 的主体中使用 rememberCoroutineScopescope.launch,则无论该调用是否进入组合,协程都会在每次 Compose 调用 LandingScreen 时执行。因此,你会浪费资源,并且不会在受控环境中执行此副作用。

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

通过创建一个负责此可组合项内部状态的状态持有者,你可以在一个地方集中所有状态更改。这样,状态就更难不同步,并且相关逻辑都集中在一个类中。此外,此状态可以轻松地提升到更高层级,并且可以由此可组合项的调用者使用。

在这种情况下,提升状态是一个好主意,因为这是一个可能在应用程序的其他部分重用的低级 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 此状态,它将无法在活动重建时继续存在。为了实现这一点,你可以使用 rememberSaveable API,它的行为类似于 remember,但存储的值也会在活动和进程重建时继续存在。在内部,它使用保存的实例状态机制。

rememberSaveable 对于可以存储在 Bundle 中的对象来说,无需额外工作即可完成所有这些操作。但你项目中创建的 EditableUserInputState 类并非如此。因此,你需要告诉 rememberSaveable 如何使用 Saver 保存和恢复此类的实例。

创建自定义保存器

Saver 描述了如何将对象转换为 Saveable 的内容。 Saver 的实现需要重写两个函数

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

对于这种情况,与其为 EditableUserInputState 类创建 Saver 的自定义实现,不如使用一些现有的 Compose API,例如 listSavermapSaver(将要保存的值存储在 ListMap 中)以减少需要编写的代码量。

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 记住的状态将继续存在于进程和活动重建中。

使用状态持有者

你将使用 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 将会向收集器发出新值。在本例中,你将状态转换为流以利用流操作符的强大功能。通过这样做,你可以 filtertext 不等于 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,如果 lifecyclemapView 发生变化,则观察者将被移除并重新添加到正确的 lifecycle 中。

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

你可以使用数据流,即类型为 DetailsUiStateStateFlow,在 ViewModel 层映射屏幕需要显示的内容和 UiState,ViewModel 在信息准备就绪时更新此数据流,Compose 使用你已经了解的 collectAsStateWithLifecycle() API 收集此数据流。

但是,为了本练习的目的,你将实现另一种方法。如果要将 uiState 映射逻辑移动到 Compose 世界,可以使用 produceState API。

produceState 允许你将非 Compose 状态转换为 Compose State。它启动一个作用域为组合体的协程,该协程可以使用 value 属性将值推送到返回的 State 中。与 LaunchedEffect 一样,produceState 也使用键来取消和重新启动计算。

对于你的用例,你可以使用 produceState 发出 uiState 更新,初始值为 DetailsUiState(isLoading = true),如下所示

// 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 的变化而频繁重新组合,而 firstVisibleItemIndex 在滚动时会频繁变化。相反,你希望该函数仅在条件在 truefalse 之间变化时重新组合。

有一个 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 的顶部。并且你使用 rememberCoroutineScopeButtononClick 回调中调用 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. 恭喜!

恭喜您已成功完成此 Codelab,并学习了 Jetpack Compose 应用中状态和副作用 API 的高级概念!

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

接下来做什么?

查看 Compose 学习路径 上的其他 Codelab,以及其他 代码示例,包括 Crane。

文档

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