五、rememberCoroutineScope
在此步骤中,我们将使抽屉式单行栏正常工作。目前,如果宁尝试点汉堡式菜单,什么都不会发生。
打开home/CraneHome.kt
文件,并查看CraneHome
可组合项,看看我们需要在何处打开抽屉式导航栏:在openDrawer
回调中!
在CraneHome
中,有一个包含DrawerState
的scaffoldState
。DrawerState
具有以程序化方式打开和关闭抽屉式导航栏的方法。不过,如果您尝试在openDrawer
回调中编写scaffoldState.drawerState.open()
,您会收到一条错误消息!这是因为,open
函数是一个挂起函数。我们再次进入协程的领域。
除了可让你从界面层安全调用协程的API之外,某些Compose API是挂起函数。用于打开抽屉式导航栏的API就是一个这样的例子。挂起函数除了能够运行异步代码之外,还可以帮助表示随着时间的推移出现的概念。由于打开抽屉式导航栏需要一些时间和移动,而且还有可能需要动画,这可以通过挂起函数完美地反映出来,挂起函数将在被调用的地方暂停协程的执行,直到它完成,然后再继续执行。
必须在协程中调用scaffoldState.drawerState.open()
。我们能做些什么呢?openDrawer
是一个简单的回调函数,因此:
- 我们不能简单地在其中调用挂起函数,因为
openDrawer
不在携程的上下文中执行。 - 我们不能像之前一样使用
LaunchedEffect
,因为我们不能在openDrawer
中调用可组合项。我们并不在组合中。
我们希望启动一个协程;我们应使用哪个作用域呢?理想情况下,我们希望CoroutineScope
能够遵循其调用点的生命周期。如果使用rememberCoroutineScope
API,则会返回一个CoroutineScope
,该CoroutineScope会绑定到它在组合中的调用点。一旦退出组合,作用域将自动取消。有了这个作用域,即使您不在组合中(例如,在openDrawer
回调中),也可以启动协程。
ini
// home/CraneHome.kt file
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
@Composable
fun CraneHome(
onExploreItemClicked: OnExploreItemClicked,
modifier: Modifier = Modifier
) {
val scaffoldState = remeberScaffoldState()
Scaffold(
scaffoldState = scaffoldState,
modifier = Modifier.statusBarsPadding(),
drawerContent = {
CraneDrawer()
}
) {
val scope = rememberCoroutineScope()
CraneHomeContent(
modifier = modifier,
onExploreItemClickec = onExploreItemClicked,
openDrawer = {
scope.launch {
scaffoldState.drawerState.open()
}
}
)
}
}
如果您运行应用,您会看到当您点击汉堡菜单图标时,系统会打开抽屉式导航栏。
5.1、LaunchedEffect与rememberCoroutineScope
在这种情况下无法使用LaunchedEffect
,因为我们需要触发调用以在组合之外的常规回调中创建协程。
回顾一下使用LaunchedEffect
的着陆屏幕步骤,您可以使用rememberCoroutineScope
并调用scope.launch { delay(); onTImeout(); }
而不使用LaunchedEffect
吗?
您本来可以这样做,而且似乎可行,但这样并不正确。如"Compose编程思想"所述,Compose可以随时调用可组合项。LaunchedEffecrt
可以保证当对该组合项的调用使其进入组合时将会执行附带效应。如果您在LandingScreen
的主体中使用rememberCoroutineScope
和scope.launch
,则每次Compose调用LandingScreen
时都会执行协程,而不管该调用是否使用其进入组合。因此,您会浪费资源,而且不会在受控环境中执行此附带效应。
六、创建状态容器
您注意到了吗?如果您点按"Choose Destination",您可以修改该字段,并根据搜索输入过滤城市。此外,您或许还注意到了,每当您修改"Choose Destination"时,文本样式都会发生改变。
打开base/EditableUserInput.kt
文件。CraneEditableUserInput
有状态可组合项接受一些参数,如hint
和caption
,后者对应图标旁边的可选文本。例如,当您搜索目的地时,会出现caption
"To"。
kotlin
// 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 :Encapsulate this state in a state holder
var textState by remember { mutableStateOf(hint) }
val isHint = { textState == hint }
...
}
6.1、为什么?
用于更新textState
以及确定显示的内容是否对于提示的逻辑全部都在CraneEditableUserInput
可组合项的主体中。这就带来了一些缺点:
TextField
的值未提升,因而无法从外部进行控制,这使得测试更加困难。- 此可组合项的逻辑可能会变得更加复杂,并且内部状态可能会更容易不同步。
通过创建负责此可组合项的内部状态的状态容器,您可以将所有状态变化集中在一个位置。这样,状态不同步就更难了,并且相关的逻辑全部跪在一个类中。此外,此状态很容易向上提升,并且可以从此可组合项的调用方使用。
在这种情况下,提升状态是一种不错的做法,因为这是一个低级界面组件,可能会在应用的其他部分中重复使用。因此,它越灵活越可控,就越好。
6.2、创建状态容器
由于CraneEditableUserInput
是一个可重复使用的组件,让我们在同一文件中创建一个名为EditableUserInputState
的常规作为状态容器,如下所示:
kotlin
// 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, initlalText: 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
是具有私有set
的var
,因此无法直接从类外部改变它。您可以公开updateText
事件来对此变量进行修改,从而将该类设为单一可信来源,而不是公开此变量。- 该类将
initialText
作为用于初始化text
的依赖项。 - 用于判断
text
是否为提示的逻辑在按需执行检查的isHint
属性中。
如果将来逻辑变得更加复杂,我们只需要对一个类进行更改:EditableUserInputState
。
6.3、记住状态容器
始终需要记住状态容器,以使其留在组合中,而不是每次都创建一个新的。最好在同一文件中创建一个执行此操作的方法,以移除样板并避免可能发生的任何错误。在base/EditableUserInput.kt
文件中,添加以下代码:
kotlin
// 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
保存和恢复此类的实例。
6.4、创建自定义保存器
Saver
描述了如何将对象转换为Saveable
(可保存)的内容。Saver
的实现需要替换两个函数:
save
-将原始值转换为可保存的值。restore
-将恢复的值转换为原始类的实例。
在本例中,我们可以使用一些现有的Compose API,如listSaver
或mapSaver
(用于存储要保存在List
或Map
中的值),以减少我们需要编写的代码量,而不是为EditableUserInputState
类创建Saver
的自定义实现。
最好将Saver
定义放置在与其一起使用的类附近。由于需要被静态访问,因此让我们在companion object
中为EditableUserInputState
添加Saver
。在base/EditableUserInput.kt
文件中,添加Saver
的实现:
kotlin
// 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
)中使用此保存器:
kotlin
// base/EditableUserInput.kt file
import androidx.compose.runtime.saveable.rememberSaveable
@Composable
fun rememberEdiableUserInputState(hint: String): EditableUserInputState =
rememberSaveable(hint, saver = EditableUserInputState.Saver) {
EditableUserInputState(hint, hint)
}
这样,EditableUserInput
记住的状态就会在进程和activity重新创建后继续留存。
6.5、使用状态容器
我们将要使用EditableUserInputState
而不是text
和isHint
,但我们不希望只将其用作CraneEditableUserInput
中的内部状态,因为调用方可组合项无法控制状态。箱单,我们希望提升EditableUserInputState
,以便调用方可以控制CraneEditableUserInput
的状态。如果我们提升状态,那么可组合项就可以在预览中使用,并且更容易进行测试,因为您能够从调用方修改器状态。
为此,我们需要更改可组合函数的参数,并在需要时为其提供默认值。由于我们可能希望允许CraneEditableUserInput
带有空提示,因此我们添加一个默认参数:
less
@Composable
fun CraneEditableUserInput(
state: EditableUserInputState = rememberEditableUserInputState(""),
caption: String? = null,
@DrawableRes vectorImageId: Int? = null
) { /* ... */ }
您或许已经注意到,onInputChanged
参数不存在了!由于状态可以提升,因此如果调用方想要知道输入是否发生了更改,它们可以控制状态并将该状态传入此函数。
接下来,我们需要调整函数主题,以使用提升的状态,而不是之前使用的内部状态。重构后,函数应如下所示:
ini
@Composable
fun CraneEditableUserInput(
state: EditableUserInputState = rememberEditabelUserInputState(""),
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 {
MaterilTheme.typography.body1.copy(color = LocalContentColor.current)
},
cursorBrush = SolidColor(LocalContentColor.current)
)
}
}
6.6、状态容器调用方
由于我们更改了CraneEditableUserInput
的API,因此需要再调用它的所有位置进行检查,以确保传入适当的参数。
在项目中,我们旨在一个位置调用此API,那就是在home/SearchUserInput.kt
文件中。打开该文件并转到ToDestinationUserInput
可组合函数;您应该会在该位置看到一个构建错误。由于提示现在是状态容器的一部分,并且我们希望在组合中设置此CraneEditableUserInput
实例的自定义提示,因此我们需要记住ToDestinationUserInput
级别的状态,并将其传入CraneEditableUserInput
:
kotlin
// home/SearchUserInput.kt file
import androidx.compose.sample.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
)
}
6.7、snapshotFlow
上面的代码缺少在输入更改时通知ToDestinationUserInput
的调用方的功能。由于应用的结构,我们不希望在层次结构中将EditableUserInputState
提升到任何更高的界别。而且,我们也不希望将其它可组合项(如FlySearchContent
)与此状态项结合。我们如何从ToDestinationUserInput
调用onToDestinationChanged
lambda并且仍使此可组合项可重复使用呢?
我们可以在每次输入更改时使用LaunchedEffect
出发附带效应,并调用onToDestinationChanged
lambda:
kotlin
// home/SearchUserInput.kt file
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter
@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
CraneEditableUserInput(
state = editableUserInputState,
caption = "To",
vectorImageId = R.drawable.ic_plane
)
val currentOnDestinationChanged by rememberUpdatedState(onToDestinationChanged)
LaunchedEffect(editableUserInputState) {
snapshotFlow { editableUserInputState.text }
.filter { !editableUserInputState.isHint }
.collect {
currentOnDestinationChanged(editableUserInputState.text)
}
}
}
我们之前已经使用LaunchedEffect
和rememberUpdatedState
,但上面的代码还使用了一个新的API!我们使用snapshotFlow
API将ComposeState<T>
对象转换为Flow。当在snapshotFlow
内读取的状态发生变化时,Flow会向收集器发出新值。在本例中,我们将状态转换为Flow,以使用Flow运算符的强大功能。这样,我们就可以在text
不是hint
时使用filter
进行过滤,并使用collect
收集发出的项,以通知父级当前的目的地发生了比那换。
七、DisposableEffect
当您点按某个目的地,系统会打开详情屏幕,您可以看到相应的城市在地图上的位置。该代码位于details/DetailsActivity.kt
文件中。在CityMapView
可组合项中,我们调用rememberMapViewWithLifecycle
函数。如果您打开此函数,您会看到它未关联到任何生命周期!它只是记住MapView
并对其调用onCreate
:
kotlin
// details/MapViewUtils.kt file
@Composable
fun rememberMapViewWithLifecycle(): MapView {
val context = LocalContext.current
return remember {
MapView(context).apply {
id = R.id.map
onCreate(Bundle())
}
}
}
虽然应用运行良好,但这也是一个问题,因为MapView
未遵循正确的生命周期。因此,它不知道应用何时转至后台,View何时应暂停,等等。让我们来解决这一问题!
由于MapView
是View而不是可组合项,因此我们希望它遵循使用它的Activity的生命周期,以及组合的生命周期。这意味着,我们需要创建一个LifecycleEventObserver
来监听生命周期事件并在MapView
上调用正确的方法。然后,我们需要将此观察期添加到当前activity的生命周期。
我们首先创建一个函数,该函数返回LifecycleEventObserver
,在给定某个事件的情况下,它会在MapView
中调用相应的方法:
kotlin
// details/MapViewUtils.kt file
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
private fun getMapLigecycleObserver(mapView: MapView): LifecycleEventObserver =
LifecycleEventObserver { _, event ->
when (evenr) {
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()
}
}
现在,我们需要将次观察器添加到当前的生命周期,沃恩可以使用当前的LifecycleOwner
与LocalLifecycleOwner
组合局部函数来获取该生命周期。不过,仅仅添加观察器是不够的;我们还需要能够将其移出!我们需要一种附带效应,可以在效应退出组合时告知我们,以便我们可以执行一些清理代码。我们寻找的附带效应API是DisposableEffect
。
DisposableEffect
适用于在键发生变化或可组合项退出组合后需要清理的附带效应。最终的rememberMapViewWithLifecycle
代码正好起到这种作用。在项目中实现以下代码行:
kotlin
// 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
仍能正确呈现。这一步骤没有任何视觉变化。
八、produceState
在本部分,我们将改进详情屏幕的启动方式。details/DetailsActivity.kt
文件中的DetailsScreen
可组合项会从ViewModel同步获取cityDetails
,并在结果成功时调用DetailsContent
。
不过,cityDetails
在界面线程上的加载成本可能会变得越来越高,它可以使用协程将数据的加载工作移至其他线程。让我们来改进此代码,添加一个加载屏幕,并在数据准备就绪时显示DetailsContent
。
为屏幕状态建模的一种方法是使用以下类,它涵盖了所有的可能性:要在屏幕上显示的数据,以及加载和错误信号。将DetailsUiState
类添加到DetailsActivity.kt
文件:
kotlin
// 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状态。它会启动一个作用域限定为组合的协程,该协程可使用value
属性将值推送到返回的State
。与LaunchedEffect
一样,produceState
也采用键来取消和重新开始计算。
用于我们的用例,我们可以使用produceState
发出初始值为DetailsUiState(isLoading = true)
的uiState
更新,如下所示:
kotlin
// details/DetailsActivity.kt file
import androidx.compose.runtime.produceState
@Composable
fun DetailScreen(
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(citiDetailsResult.data)
} else {
DetailsUiState(throwError = true)
}
}
}
接下来,根据uiState
,我们会显示数据、显示加载屏幕或报告错误。下面是DetailsScreen
可组合项的完整代码:
kotlin
// 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() }
}
}
如果您运行应用,您会看到在现实城市详情之前如何出现了指示"正在加载"的旋转图标。
九、derivedStateOf
我们要对Crane做最后一项改进是,每当您在航班目的地列表中滚动时,经过屏幕的第一个元素之后,就会显示一个用于"滚动至顶部"的按钮。点按该按钮会使您前往列表中的第一个元素。
打开包含此代码的base/ExploreSection.kt
文件。ExploreSection
可组合项对应于您在Scaffold的背景屏中看到的内容。
为了计算用户是否已经过第一项,我们可以使用LazyColumn
的LazyListState
并检查listState.firstVisibleItemIndex > 0
。
简单实现如下所示:
vbnet
// DO NOT DO THIS - It's executed on every recomposition
val showButton = listState.firstVisibleItemIndex > 0
该解决方案的效率并不高,因为每当fitstVisibleItemIndex
发生变化时,读取showButton
的可组合函数都会重组,这种情况经常会在滚动时发生。不过,我们希望该函数仅在条件在true
和false
之间变化时重组。
有一个API可让我们做到这一点,那就是derivedStateOf
API.
listState
是一个可观察的ComposeState
。我们的计算showButton
也需要是一个ComposeState
,因为我们希望界面在其值发生变化时重组,并显示或隐藏按钮。
当您想要的某个ComposeState
衍生自另一个State
时,请使用derivedStateOf
。每当内部状态发生变化时,系统都会执行derivedStateOf
计算块,但只有当计算结果与上一项不同时,可组合函数才会重组。这样可以最大限度地减少读取showButton
的函数的重组次数。
在这种情况下,使用derivedStateOf
API是一种更好且更高效的替代方案。我们还会使用remember
API来封装调用,因此计算得出的值在重组后继续有效。
kotlin
// 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
挂起函数。
scss
// 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.coroutine.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 compositons
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
if (showButton) {
val coroutineScope = rememberCoroutineScope()
FloatingActionButton(
background = MaterialTheme.colors.primary,
modifier = Modifier
.align(Alignment.BottomEnd)
.navigationBarsPadding(),
padding(bottom = 8.dp),
onClick = {
coroutineScope.launch {
listState.scrollToItem(0)
}
}
) {
Text("Up!")
}
}
}
}
}
}