Jetpack Compose 中的高级状态和附带效应

在此 Codelab 中,您将学习与 Jetpack Compose 中的状态附带效应 API 相关的高级概念。我们将了解如何为逻辑并不简单的有状态可组合项创建状态容器,如何从 Compose 代码创建协程并调用挂起函数,以及如何触发附带效应以完成不同的用例。

学习内容

所需条件

构建内容

在此 Codelab 中,我们将从一个未完成的应用(即 Crane 材料研究应用)着手,我们将添加一些功能来改进该应用。

1fb85e2ed0b8b592.gif

获取代码

此 Codelab 的代码可以在 android-compose-codelabs GitHub 代码库中找到。如需克隆该代码库,请运行以下命令:

$ git clone https://github.com/googlecodelabs/android-compose-codelabs

或者,您也能以 Zip 文件的形式下载该代码库:

下载 Zip 文件

查看示例应用

您刚刚下载的代码包含提供的所有 Compose Codelab 的代码。为了完成此 Codelab,请在 Android Studio Arctic Fox 中打开 AdvancedStateAndSideEffectsCodelab 项目。

我们建议您从 main 分支中的代码着手,按照自己的节奏逐步完成此 Codelab。

在此 Codelab 的学习过程中,我们会为您提供需要添加到项目的代码段。在某些地方,您还需要移除在代码段的注释中明确提及的代码。

熟悉代码并运行示例应用

先花点时间浏览一下项目结构,然后再运行应用。

37d39b9ac4a9d2fa.png

从 main 分支运行应用时,您会看到某些功能(如抽屉式导航栏或加载航班目的地的功能)不起作用!这就是我们在此 Codelab 的后续步骤中要改进的地方。

1fb85e2ed0b8b592.gif

界面测试

系统会对应用执行 androidTest 文件夹中提供的非常基本的界面测试。对于 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/googlecodelabs/android-compose-codelabs

或者,您也可以在此处下载解决方案代码:

下载最终代码

常见问题解答

您可能已经注意到,从 main 分支运行应用时,航班目的地列表为空!为了看看发生了什么情况,请打开 home/CraneHome.kt 文件,并查看 CraneHomeContent 可组合项。

被分配给一个记住的空列表的 suggestedDestinations 的定义上面有一条 TODO 注释。这就是屏幕上显示的内容:一个空列表!在此步骤中,我们将解决该问题,并显示 MainViewModel 公开的推荐目的地。

9cadb1fd5f4ced3c.png

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

我们希望每当有新项被发送到 suggestedDestinations 数据流时 CraneHomeContent 可组合项中的界面都会更新。我们可以使用 StateFlow.collectAsState() 函数。在可组合函数中使用时,collectAsState() 会从 StateFlow 收集值,并通过 Compose 的状态 API 表示最新值。这样会使读取该状态值的 Compose 代码在发出新项时重组。

返回 CraneHomeContent 可组合项,并将分配 suggestedDestinations 的代码行替换为 ViewModel 的 suggestedDestinations 属性上的 collectAsState 调用:

import androidx.compose.runtime.collectAsState

@Composable
fun CraneHomeContent(
    onExploreItemClicked: OnExploreItemClicked,
    openDrawer: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: MainViewModel = viewModel(),
) {
    val suggestedDestinations by viewModel.suggestedDestinations.collectAsState()
    // ...
}

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

4ec666a2d1ac0903.gif

在该项目中,有一个目前未使用的 home/LandingScreen.kt 文件。我们想要向应用添加一个着陆屏幕,它有可能会用于在后台加载需要的所有数据。

着陆屏幕将占据整个屏幕,并在屏幕中间显示应用的徽标。理想情况下,我们会显示该屏幕,在所有数据加载完毕之后,我们会通知调用方可以使用 onTimeout 回调关闭着陆屏幕。

建议使用 Kotlin 协程在 Android 中执行异步操作。应用在启动时通常会使用协程在后台加载内容。Jetpack Compose 提供了可让您在界面层中安全使用协程的 API。由于此应用不与后端进行通信,因此我们将使用协程的 delay 函数来模拟在后台加载内容。

Compose 中的附带效应是指发生在可组合函数作用域之外的应用状态的变化。将状态更改为显示/隐藏着陆屏幕的操作将发生在 onTimeout 回调中,由于在调用 onTimeout 之前我们需要先使用协程加载内容,因此状态变化必须发生在协程的上下文中!

如需从可组合项内安全地调用挂起函数,请使用 LaunchedEffect API,该 API 会在 Compose 中触发协程作用域限定的附带效应。

LaunchedEffect 进入组合时,它会启动一个协程,并将代码块作为参数传递。如果 LaunchedEffect 退出组合,协程将取消。

虽然接下来的代码不正确,但让我们看看如何使用此 API,并探讨为什么下面的代码是错误的。我们将在此步骤的后面调用 LandingScreen 可组合项。

// home/LandingScreen.kt file

import androidx.compose.runtime.LaunchedEffect
import kotlinx.coroutines.delay

@Composable
fun LandingScreen(modifier: Modifier = Modifier, onTimeout: () -> Unit) {
    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 发生更改时重新开始执行效应!

如需在此可组合项的生命周期内仅触发一次附带效应,请将常量用作键,例如 LaunchedEffect(true) { ... }。不过,我们现在并没有防止 onTimeout 发生更改!

如果 onTimeout 在附带效应正在进行时发生更改,不能保证在效应结束时会调用最后一个 onTimeout。如需通过捕获和更新到新值来保证这一点,请使用 rememberUpdatedState API:

// home/LandingScreen.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState

@Composable
fun LandingScreen(modifier: Modifier = Modifier, onTimeout: () -> Unit) {
    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(true) {
            delay(SplashWaitTime)
            currentOnTimeout()
        }

        Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
    }
}

显示着陆屏幕

现在,我们需要在应用打开后显示着陆屏幕。打开 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 秒后消失。

fda616dda280aa3e.gif

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

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

CraneHome 中,有一个包含 DrawerStatescaffoldStateDrawerState 具有以编程方式打开和关闭抽屉式导航栏的方法。不过,如果您尝试在 openDrawer 回调中编写 scaffoldState.drawerState.open(),您会收到一条错误消息!这是因为,open 函数是一个挂起函数。我们再次进入协程的领域。

除了可让您从界面层安全调用协程的 API 之外,某些 Compose API 是挂起函数。用于打开抽屉式导航栏的 API 就是一个这样的例子。挂起函数除了能够运行异步代码之外,还可以帮助表示随着时间的推移出现的概念。由于打开抽屉式导航栏需要一些时间和移动,而且还有可能需要动画,这可以通过挂起函数完美地反映出来,挂起函数将在被调用的地方暂停协程的执行,直到它完成,然后再继续执行。

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

  • 我们不能简单地在其中调用挂起函数,因为 openDrawer 不在协程的上下文中执行。
  • 我们不能像之前一样使用 LaunchedEffect,因为我们不能在 openDrawer 中调用可组合项。我们不在组合中。

我们希望能够启动一个协程,我们应使用哪个作用域呢?理想情况下,我们希望 CoroutineScope 能够遵循其调用点的生命周期。为此,请使用 rememberCoroutineScope API。一旦退出组合,作用域将自动取消。利用该作用域,不在组合中(例如,在 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()
                }
            }
        )
    }
}

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

ad44883754b14efe.gif

LaunchedEffect 与 rememberCoroutineScope

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

回顾一下使用 LaunchedEffect 的着陆屏幕步骤,您可以使用 rememberCoroutineScope 并调用 scope.launch { delay(); onTimeout(); } 而不使用 LaunchedEffect 吗?

您本来可以这样做,而且似乎可行,但这样并不正确。如“Compose 编程思想”文档中所述,Compose 可以随时调用可组合项。LaunchedEffect 可以保证当对该可组合项的调用使其进入组合时将会执行附带效应。如果您在 LandingScreen 的主体中使用 rememberCoroutineScopescope.launch,则每次 Compose 调用 LandingScreen 时都会执行协程,而不管该调用是否使其进入组合。因此,您会浪费资源,而且不会在受控环境中执行此附带效应。

您注意到了吗?如果您点按“Choose Destination”,您可以修改该字段,并根据搜索输入过滤城市。此外,您或许还注意到,每当您修改“Choose Destination”时,文本样式都会发生变化。

99dec71d23aef084.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)

    val isHint: Boolean
        get() = text == hint
}

该类应具有以下特征:

  • textString 类型的可变状态,就像在 CraneEditableUserInput 中一样。请务必使用 mutableStateOf,以便 Compose 跟踪值的更改,并在发生更改时重组。
  • text 是一个 var,这样就可以直接从该类外部改变它。
  • 该类将 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 和进程重新创建后会继续留存。在内部,它使用保存的实例状态机制。

对于可以存储在 Bundle 内的对象,rememberSaveable 可以做所有这些工作,而无需任何额外的操作。对于我们在项目中创建的 EditableUserInputState 类,却并非如此。因此,我们需要告知 rememberSaveable 如何使用 Saver 保存和恢复此类的实例。

创建自定义保存器

Saver 描述了如何将对象转换为 Saveable(可保存)的内容。Saver 的实现需要替换两个函数:

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

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

最好将 Saver 定义放置在与其一起使用的类附近。由于需要被静态访问,因此让我们在 companion object 中为 EditableUserInputState 添加 Saver。在 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 方法的 rememberSaveable(而不是 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.text = 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 运算符的强大功能。这样,我们就可以在 text 不是 hint 时使用 filter 进行过滤,并使用 collect 收集发出的项,以通知父级当前的目的地发生了变化。

在此 Codelab 的这一步骤中没有任何视觉变化,但我们改进了这部分代码的质量。如果您现在运行应用,您应该会看到一切都像以前一样运作。

当您点按某个目的地时,系统会打开详情屏幕,您可以看到相应的城市在地图上的位置。该代码位于 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 仍能正确呈现。这一步骤中没有任何视觉变化。

在本部分中,我们将改进详情屏幕的启动方式。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 会使用您已了解的 collectAsState() API 收集该数据流。

不过,为了本练习,我们将实现一种替代方案。如果我们希望将 uiState 映射逻辑移至 Compose 环境,我们可以使用 produceState API。

produceState 可让您将非 Compose 状态转换为 Compose 状态。它会启动一个作用域限定为组合的协程,该协程可使用 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() }
    }
}

如果您运行应用,您会看到在显示城市详情之前如何出现了指示“正在加载”的旋转图标。

18956feb88725ca5.gif

我们要对 Crane 做的最后一项改进是,每当您在航班目的地列表中滚动时,经过屏幕的第一个元素之后,都会显示一个用于“滚动至顶部”的按钮。点按该按钮会使您转到列表中的第一个元素。

59d2d10bd334bdb.gif

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

实现在视频中看到的行为的解决方案应该不会让您感到惊讶。不过,有一个我们还没有看到过的新 API,它在此用例中非常重要:derivedStateOf API。

当您想要的某个 Compose State 衍生自另一个 State 时,会使用 derivedStateOf。使用此函数可保证仅当计算中使用的状态之一发生变化时才会进行计算。

使用 listState 计算用户是否已经过第一项就像检查是否符合 listState.firstVisibleItemIndex > 0 条件一样简单。不过,firstVisibleItemIndex 被封装在 mutableStateOf API 中,这就使其成为一个可观察的 Compose 状态。我们的计算结果也需要是一个 Compose 状态,因为我们希望重组界面以显示该按钮!

一种简略而低效的实现如以下示例所示。请勿将其复制到您的项目中;稍后会将正确的实现连同屏幕的其余逻辑一起复制到您的项目中:

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

一种更好且更高效的替代方案是使用 derivedStateOf API,该 API 仅在 listState.firstVisibleItemIndex 发生变化时计算 showButton

// 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
    }
}

您应该已经熟悉 ExploreSection 可组合项的新代码。再来看看我们如何使用 rememberCoroutineScopeButtononClick 回调内调用 listState.scrollToItem 挂起函数。我们使用 Box 将有条件地显示的 Button 放置在 ExploreList 的顶部:

// 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 com.google.accompanist.insets.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!")
                    }
                }
            }
        }
    }
}

如果您运行应用,您会看到一旦您滚动并经过屏幕的第一个元素,该按钮就会出现在底部。

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

您学习了如何创建状态容器、附带效应 API(如 LaunchedEffectrememberUpdatedStateDisposableEffectproduceStatederivedStateOf),以及如何在 Jetpack Compose 中使用协程。

后续操作

请查看 Compose 衔接课程中的其他 Codelab,以及其他代码示例,包括 Crane。

文档

如需获得关于上述主题的更多信息和指导,请参阅以下文档: