主要翻译自官方文档Side-effects in Compose,并不是直译,有些细微调整。
一个副作用是指发生在composable函数范围之外的应用状态的一个变化。由于composable函数的生命周期和诸如不可预测的重组,以不同的顺序执行composable的重组,或者重组可能会被跳过等性质,理论上composable应该要是无副作用的。
然而,有些时候副作用是必要的,例如,触发一些诸如显示一个非干扰性提示(snackbar)或者在一定状态条件下跳转到另一个页面,等的一次性的事件时。这些行为应该在一个能够感知composable生命周期的可控的环境中调用。在本文中,你将学习Jetpack Compose提供的几种不同的副作用函数(side effect APIs)。
副作用的具体使用场景
如在文章降Compose十八掌之『潜龙勿用』| Thinking in Compose中提到的,composables应该尽可能的做到无副作用。当需要对应用状态进行修改时,应该使用副作用API,以便副作用函数以可预测的方式运行。
关键点: 一个作用(effect)是指一个composable函数不会生成UI元素,而是当组合完成时生成副作用。
由于Compose中有多种作用,很容易被滥用。要确保在副作用中做的事情是UI相关的并且没有违反『单一数据流原则』。
注意: 一个可响应的UI应该是异步的,Jetpack Compose解决异步的办法是在API级别结合协程而不是使用回调。想要了解更多的协程知识,可以参看之前的文章。
LaunchedEffect:在composable的作用域内运行挂起函数
想要在一个composable的生命周期中执行操作并且需要调用挂起函数,就可以使用LaunchedEffect。当LaunchedEffect进入组合时,它会使用作为参数传入的代码块来启动一个协程。如果LaucnhedEffect离开了组合协程会被取消。如果因不同的key LaunchedEffect被重组了(副作用的重启机制会在后面进行讲解),运行中的协程会被取消掉,一个新的协程会被启动以运行新的挂起函数。
例如,一个可调节延迟的脉冲式透明度的动画:
// 变化的速率可以调节,可以加快动画(减少间隔)
var pulseRateMs by remember { mutableStateOf(3000L) }
val alpha = remember { Animatable(1f) }
LaunchedEffect(pulseRateMs) { // 速度作为key,这样速度变化时,会重启副作用,动画也会重启
while (isActive) {
delay(pulseRateMs) // 一定间隔之后显示脉冲动画
alpha.animateTo(0f)
alpha.animateTo(1f)
}
}
在上面的代码中,动画使用了挂起函数delay来等待一定的时间。然后,它依次使用animateTo展现动画到不可见,再到可见。并在composable的生命周期中不断重复。
rememberCoroutineScope:获取一个可以在composable之外启动协程的可感知组合的协程作用域
因为LaunchedEffect是一个composable函数,所以它只能在其他composable函数中调用。如果想要在composable作用域之外启动协程,但又希望限制协程在一定的范围内,以便能在离开组合时协程自动被取消,可以使用rememberCoroutineScope。在任何需要手动操控一个或者多个协程的生命周期的时候都应该使用rememberCorountineScope,比如说当用户事件发生时需要取消动画。
rememberCoroutineScope是一个composable函数,返回一个协程作用域(CoroutineScope),这个作用域会被绑定到经组合中它被调用的地点。当离开组合时,这个协程作用域会被取消。
译注: 如果对协程作用域不太熟悉的同学,可以参看之前的文章。
例如,可以用下面的代码,当点击按扭时显示一个Snackbar(译注:非干扰式提示,类似于Toast):
@Composable
fun MoviesScreen(snackbarHostState: SnackbarHostState) {
// 创建一个绑定到MoviesScreen生命周期的协程作用域
val scope = rememberCoroutineScope()
Scaffold(
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
}
) { contentPadding ->
Column(Modifier.padding(contentPadding)) {
Button(
onClick = {
// 在作用域中创建一个协程来显示提示。
scope.launch {
snackbarHostState.showSnackbar("Something happened!")
}
}
) {
Text("Press me")
}
}
}
}
rememberUpdateState:指向即使值发生变化也不会重启的作用中的一个值
当参数key中的任何一个发生变化时LaunchedEffect就会重启。然而,在有些情况下我们希望捕获这样作用中的一个值,这个作用不会随着值变化而重启。为了达到这样的效果,需要使用rememberUpdatedState创建一个能被捕获和更新的值的引用。这个方式对于那些含有长时间运行,且重新创建或者重启都非常昂贵之类的操作的副作用是很有用的。
例如,假设你的应用有一个一段时间内消失的加载页面(LandingScreen)。即使这个加载页面被重组了,等待时间副作用和通知已过去了多少时间副作用都不应该被重新启动:
@Composable
fun LandingScreen(onTimeout: () -> Unit) {
// 这个状态永远指向LandingScreen重组后最新的onTimeout函数
val currentOnTimeout by rememberUpdatedState(onTimeout)
// 创建一个与LandingScreen生命周期一致的副作用。即使LandingScreen被重组了,这里的延迟不应该重新开始.
LaunchedEffect(true) {
delay(SplashWaitTimeMillis)
currentOnTimeout()
}
/* 加载页面的内容 */
}
想要创建一个与调用点生命周期一至的副作用,可以把像Unit或者true等永不会改变的常量当作参数。在上面的例子中,使用了LaunchedEffect(true)。为了保证lambda onTimeout中总是包含LandingScreen被重组后的最新值,onTimeout需要使用rememberUpdatedState来包装。返回值,就是代码中的currentOnTimeout应该在副作用中使用。
注意: LaunchedEffect(true)就像while(true)一样诡异。即使有具体的使用场景,也要三思后行,确保确实需要这样做。
DisposableEffect:需要清理工作的副作用
对于当离开组合或者key发生变化时需要清朝工作的副作用,使用DisposableEffect。如果DisposableEffect的key发生变化,调用的composable需要进行清理副作用,并且重新调用。
作为一个示例,通过使用LifecycleObserver,你也许想要发送基于平台生命周期事件(Lifecycle events)统计事件数据(译注:这里的生命周期是指Android平台组件的生命周期)。想要在Compose中监听这些事件,使用一个DisposableEffect来在需要时注册和反注册观察者:
@Composable
fun HomeScreen(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
onStart: () -> Unit, // 发送'started' 事件
onStop: () -> Unit // 发磅 'stopped' 事件
) {
// 保证当前的lambda是最新的
val currentOnStart by rememberUpdatedState(onStart)
val currentOnStop by rememberUpdatedState(onStop)
// 如果 `lifecycleOwner` 发生变化,清理并重置副作用函数
DisposableEffect(lifecycleOwner) {
// 创建一个观察者以触发我们的事件发送回调lambda
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) {
currentOnStart()
} else if (event == Lifecycle.Event.ON_STOP) {
currentOnStop()
}
}
// 把观察者添加到生命周期观察者列表里
lifecycleOwner.lifecycle.addObserver(observer)
// 当离开组合时,进行清理工作,即把观察者从其列表中移除
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
/* Home screen content */
}
在上面的代码中,副作用会添加一个观察者observer到lifecycleOwner上。如果lifecycleOwner有变动,副作用函数会被清理并且使用新的lifecycleOwner重启。
一个DispoableEffect必须要包含一个onDispose语句作为其代码块的最后一个语句。否则会有编译错误。
注意: 使用一个空的onDispose并不是一个好的做法。要多思考一下是否有更加符合使用场景的副作用函数。
SideEffect:向非Comopse代码发布Compose的状态
要与非Compose管理的对象共享Compose状态时,使用composable SideEffect。使用SideEffect能够保证副作用在每次成功重组后都能得到执行。另一方面,在一个重组保证成功之前执行一个副作用是不正确的,这种场景就会直接在composable中写入副作用。
例如,你的分析库也许允许你通过给后面的统计数据添加自定义的meta data(在此例中是『user properties』)的方式来给用户数据分段。为了建立当前用户的用户类型与统计库之间的联系,可以使用SideEffect来更新此值:
@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
val analytics: FirebaseAnalytics = remember {
FirebaseAnalytics()
}
// 每次成功组合,用当前用户的用户类型来更新 FirebaseAnalytics,保证
// 后面的统计事件能带上userType
SideEffect {
analytics.setUserProperty("userType", user.userType)
}
return analytics
}
produceState:把非Compose状态转化为Compose状态
produceState启动一个受组合管控的协程作用域,其中可以把值转化为状态。用它可以把非Compose状态转化为Compose状态,例如把外部由订阅驱动 的值如Flow,LiveData或者RxJava转化到组合中。
当produceState进入组合时生产者就会被启动,然后当离开组合时被取消。返回的状态会合并:就是说相同的值不会再次触发重组。
尽管produceState会创建协程,它也能用来监听非挂起数据。想要移除对数据的订阅,使用awaitDispose函数。
下面的例子展示如何使用produceState来从网络加载图片。Composable函数loadNextworkImage返回一个可以用在其他composables中的状态:
@Composable
fun loadNetworkImage(
url: String,
imageRepository: ImageRepository = ImageRepository()
): State<Result<Image>> {
// 用 Result.Loading 作为初值,创建一个状态 State<T>
// 如果 「url」或者「imageRepository」任何一个发生变化,运行中的生产者会被取消
// 使用新的输入被重启。
return produceState<Result<Image>>(initialValue = Result.Loading, url, imageRepository) {
// 在协程里,可以调用挂起函数
val image = imageRepository.load(url)
// 使用成功或者失败作为结果 来更新状态
// 这会触发读取此状态的composable的重组
value = if (image == null) {
Result.Error
} else {
Result.Success(image)
}
}
}
注意: 带有返回值的composable函数应该以常规的Kotlin函数命名规范进行命名,以小写字符开头的驼峰式。
关键点: 进一步的了解,produceState使用了其他的副作用函数!它使用remember { mutableStateOf(initialValue) }来持有返回结果,然后在一个LaunchedEffect中触发生产者代码块。每当生产者代码块中更新了value的值,相应的状态也会被更新。
开发者也可以基于现有的API来创造想要的副作用函数
derivedStateOf:把一个或者多个状态对象转化为另一个状态
在Compose中,每次当被观察的状态对象发生变化或者composable的输入有变化时重组就会发生。状态对象或者输入可能变化次数的超过了UI实际的需要,导致了不必要的重组。
当一个composable的输入变化超过了重组所需要时就应该使用函数deriveStateOf。比较觉的场景是当有些变量频繁的变动,比如滚动位置,但composable仅需要变动超过一定阈值时才需要对其响应。derivedStateOf创建一个新的仅在需要时更新的可观察Compose状态。这样,它就类似于Kotlin Flow中的操作符distinctUntilChanged。
注意: derivedStateOf是比较昂贵的(也即性能开销比较大),应该仅用来减少结果未变化时的不必要的重组。
正确的使用
下面的代码片段展示一个恰当的使用derivedStateOf的场景:
@Composable
// 当参数 messages 变化时, MessageList会被重组. derivedStateOf不会影响这个重组
fun MessageList(messages: List<Message>) {
Box {
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
// 当第一个可见的元素不是第一个元素时显示按扭。用一个被记忆的衍生状态来最小化不必要的重组
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
AnimatedVisibility(visible = showButton) {
ScrollToTopButton()
}
}
}
在这个代码片段中,每次第一个可见的元素变化时firstVisibleItemIndex都会变化。当滚动时,它的值会变成0,1,2,3,4,5等等。然而,仅当值大于0时才需要重组。这个更新频次的不匹配意味着是一个使用derivedStateOf的好的场景。
错误的使用
一个常见的错误是想当然的认为当需要合并两个状态时,就需要使用derivedStateOf,因为在创建衍生状态。然而这完全是凭空想像的也不是必须的,如下面代码所展示:
注意: 下面的代码展示derivedStateOf不正确的用例。不要这样使用。
// 不要这样用,不正确的derivedStateOf的用法
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }
val fullNameBad by remember { derivedStateOf { "$firstName $lastName" } } // 很糟糕
val fullNameCorrect = "$firstName $lastName" // 不正确
这段代码中,fullName的更新频次与firstName和lastName是一样的。因此,不会有不必要的重组发生,使用derivedStateOf是多余的。
snapshotFlow
使用snapshotFlow能把状态对象(State objects)转化为一个冷流(cold Flow)。当被订阅(collected)时snapshotFlow会运行代码块在其中发送它读取到的状态对象。当在snapshotFlow代码块中读取的状态对象发生变化时,Flow会发送新的数据给它的订阅者,如果这个新的数据没有被发送过(这个行为与Flow.distinctUntilChanged是类似的)。
译注: 对Flow不熟悉的同学可以参看之前的文章。
下面的例子展示一个副作用,用以记录当用户滑动超过列表中首个元素时的统计信息:
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.map { index -> index > 0 }
.distinctUntilChanged()
.filter { it == true }
.collect {
MyAnalyticsService.sendScrolledPastFirstItemEvent()
}
}
在上面的代码中,listState.firstVisibleItemIndex会被转化为一个Flow,方便使用Flow操作符带来的便利。
副作用的重启
Compose中的一些副作用,像LaunchedEffect,produceState,DispoableEffect,接收不定数量的参数keys,这些参数用于取消正在进行中的副作用和使用新的参数启动新的副作用。
这些API的典型形式是:
EffectName(restartIfThisKeyChanges, orThisKey, orThisKey, ...) { block }
由于这个行为的细微性质,如果用于重启副作用的参数并不是正确的参数时,就会产生问题:
- 副作用重启次数少于预期可能会产生bug
- 重启次数多于预期是冗余的,影响性能
首要的原则是,在副作用代码块中使用的可变的和不可变的变量都应该当成composable函数的参数。此外,也可以添加更多的参数以强制重启副作用。如果一个变量的变化不应该导致重启副作用,那这个变量应该用rememberUpdateState包裹起来。如果一个变量因为包裹在remember时没有key而永远不会变化,那么这个变量就应该作为key传给副作用函数。
关键点: 在副作用函数中使用的变量应该添加为函数的参数,或者使用rememberUpdateState包裹起来。
在上面展示的DisposbaleEffect代码中,在代码块中使用的变量lifecycleOwner作为副作用函数的一个参数,因为它们的任何变化都会引发重启副作用。
@Composable
fun HomeScreen(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
onStart: () -> Unit, // Send the 'started' analytics event
onStop: () -> Unit // Send the 'stopped' analytics event
) {
// 这些值在组合中永不会变化,因此用remember包裹起来,以免引发冗余重启
val currentOnStart by rememberUpdatedState(onStart)
val currentOnStop by rememberUpdatedState(onStop)
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
/* ... */
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}
currentOnState和currentOnStop不需要作为DisposableEffect的参数key,因为使用了rememberUpdateState它们在组合中始终不会变化。如果不把lifecycleOwner作为参数传递,然后它又发生了变化,HomeScreen会重组,但DisposableEffect不会被清理和重启。这会导致之后使用的都是错误的lifecyleOwner(译注:因为lifecycleOwner可能会发生变化)。
常量作为key
可以使用像true这样的常量当作副作用的key,以让它跟它所在的调用点有一样的生命周期。有一些场景适合这样用:比如前面例子中的LaunchedEffect。但是,在这样做之前还是要三思是否真的要这样。
总结
函数式编程的理想情况是每个函数都没有副作用,但实际情况副作用却是必须的。通过本文我们学习了如何使用Jetpack Compose提供的副作用函数以解决修改composable范围以外的状态的问题。副作用问题比较难以实现且容易产生难以debug的问题,要仔细分析问题的场景,根据场景选择合适的副作用函数。
欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!
保护原创,请勿转载!