JetpackCompose笔记(8) - SideEffect 与协程及状态转换
副作用是什么
SideEffect 官方翻译成「副作用」或「附带效应」。
首先应该明确的是,副作用只是除了目标效果之外,附带的效果,不见得是不好的。不要把副作用和坏作用混为一谈。大概官方翻译成「附带效应」也是这个用意吧。
在编程上副作用普遍指,一个函数修改了外部状态(比如修改全局变量或打印输出)。更加精准的定义是:如果把一个函数删掉,用它的返回值替代过来,是否影响逻辑?对于没有返回值的函数更简单:能不能直接把它删掉?如果这种操作影响了程序,那么两者的差别就是它的副作用。显然,打印标准输出是一种副作用。
Compose 要求所有组件函数都是无副作用的。 在这个语境中意思是,组件函数只应该执行这个组件相关的代码,不要对外界产生影响(比如修改全局变量、启动协程、启动 Service 等)。因为 Compose 会重组,组件函数的调用时机与次数都是不可预期的。
如果确实有副作用的需求呢? 比如埋点分析经常要和 UI 绑定记录用户的操作。这就需要用到 Compose 提供的副作用 API。
SideEffect
是最基本的副作用函数。它保证里面的 lambda 只有在组合/重组完成后才被执行,若界面重组则再次执行。
DisposableEffect
DisposableEffect
是 SideEffect
的升级版。后者在 UI 尘埃落定后执行。我们知道如果在打开页面时启动了一些东西,最好退出页面时及时关闭,不然轻则浪费资源,重则内存泄漏。
DisposableEffect
提供了这样一个释放资源的渠道,除了进入界面时回调一次之外,如果 Compose 组件不复存在也会有回调。
用法如下:
Column {
var show by remember { mutableStateOf(true) }
Button(onClick = { show = !show }) {
Text(text = "切换")
}
if (show) {
Text(text = "啦啦啦啦")
DisposableEffect(Unit) {
// 进入界面的回调
Log.i("Compose", "显示文本")
onDispose {
// 离开界面的回调
Log.i("Compose", "隐藏文本")
}
}
}
}
除此之外 DisposableEffect()
还提供 key
参数,其效果和 remember()
的参数一样。如果 key 发生变化就重启这个副作用。「重启」有两个含义:1. 先关闭当前的副作用,也就是回调上一次的 onDispose
代码块。 2. 启动一个新的,即再次执行 lambda。若 key 没有变化即使重组也不会重启。
rememberUpdatedState()
刚刚说到 DisposableEffect()
如果 key 不变,即使重组也不会重启 lambda,这样可以减少重复计算提高性能。于是极左主义分子想,要不追求极致性能,即使 key 改变也不重启?这显然不行,会影响程序的正确性。梦想家提出,要不即使不重启副作用,也让 key 的修改生效? 猛一听似乎不可能。其实在某些情况下,的确可以实现。
一般来说,UI 依赖数据,如果数据变了就得更新 UI。还有一种情况,如果这个数据不是立即被使用,比如...在协程中。举个例子:
var message by remember { mutableStateOf("Hello") }
Text(text = "啦啦啦啦")
LaunchedEffect(message) { // 可以改成 LaunchedEffect(Unit)
delay(3000)
println(message)
}
message
数据的确有被使用到,但不是立即使用。这种情况下,如果 message
改变,即使协程不重启,也可以输出最新的值。所以这里把 message
作为 key 有点浪费了,完全可以改成 Unit。
但是这种写法不适用于参数:
@Composable
fun test() {
var message by remember { mutableStateOf("Hello") }
Text(text = "啦啦啦啦")
delayPrint(text = message)
}
@Composable
fun delayPrint(text: String) {
LaunchedEffect(Unit) {
delay(3000)
println(text)
}
}
上面这样 message 的变更不会反映到输出。核心原因是 text 是 message 的一个拷贝,之前启动的协程使用的还是旧的参数。
解决方案如下:
@Composable
fun delayPrint(text: String) {
var rememberedText by remember { mutableStateOf(text) }
rememberedText = text // 这一行不能漏
LaunchedEffect(key1 = Unit) {
delay(3000)
println(rememberedText)
}
}
如此一来,rememberedText
总是同一个对象,而它的内容会保持最新。好像有一点麻烦呀...终于进入正题,Compose 针对这种场景推出了简化写法:rememberUpdatedState
! 这个函数完全等价于 remember state + 赋值操作。用法如下:
@Composable
fun delayPrint(text: String) {
val rememberedText by rememberUpdatedState(newValue = text) // 注意这一行
LaunchedEffect(key1 = Unit) {
delay(3000)
println(rememberedText)
}
}
用法很简单,关键是理解这个 API 的意义、它解决的是什么问题。
总结:rememberUpdatedState
用于包装稍后使用的参数,从而无需重启副作用。一般来说,「稍后使用」的场景有两个:
- LaunchedEffect 中的协程
- DisposableEffect 中的
onDispose
回调
协程相关
LaunchedEffect
前面介绍的两个函数用于一般的副作用业务需求,LaunchedEffect
则更专注与协程,它是特殊的 DisposableEffect
,以相同的触发逻辑启动(重启)一个协程,在协程中执行 lambda。而退出 Compose 的回调也封装成了取消协程。
LaunchedEffect
的使用场景也和 DisposableEffect
一样:当需要把组件的显示作为触发器时使用,唯一的区别是要触发的工作是否需要协程。
比如希望组件显示后的 3 秒自动消失就能这么写:
var show by remember { mutableStateOf(true) }
if (show) {
Text(text = "三秒后消失")
LaunchedEffect(key1 = Unit) {
delay(3000)
show = false
}
}
默认情况下 3 秒后文字消失,但如果因为其他原因 show
被提前置为 false,相应的协程会自动取消。
rememberCoroutineScope
在 Compose 中使用协程有两个途径,一个是刚刚说的最常用的 LaunchedEffect{}
第二个就是这一节的 rememberCoroutineScope
。
研究这个函数之前,先来思考为什么 Compose 中不能用原生的写法启动协程?(其实编译上是可以的)宏观上一开始就提到了,Compose 的调用不可预期,所以不应该产生副作用。微观上,kotlin 要求协程必须属于一个 Scope,Scope 结束时协程能够被及时取消。在 Compose 中,每一个组件都有自己的生命周期,这一点和传统 View 不太一样。View 中我们更习惯绑定 Activity/Fragment 的生命周期,经常说「Fragment 销毁了」。虽然 View 本身也有 OnAttach 事件,有 Visibitity 属性,但开发中并不把它视为生命周期。Compose 中就不同了,组件没有 Visibility 的概念,没有了就是没有了,在内存中也「没有了」。所以各个组件自己的生命周期不再可忽略。
所以 Compose 中协程的 Scope 应该和它所处的组件绑定,而不是 Activity/Fragment 的 lifecycleScope
。rememberCoroutineScope
正是提供了这样一个 Scope!
机智的小伙伴可能立即尝试这么写:
@Composable
fun test() {
val scope = rememberCoroutineScope()
// Calls to launch should happen inside a LaunchedEffect and not composition
scope.launch { }
}
可惜还是报错。因为 rememberCoroutineScope()
虽然可以在这个组件销毁时取消协程,但不能防止重组时多次启动协程。怎么防止重组时重复执行代码?remember
嘛!改一下代码:
@Composable
fun test() {
val scope = rememberCoroutineScope()
remember { scope.launch { } }
}
这就可以了!LaunchedEffect
内部也是这么实现的。
那... rememberCoroutineScope()
有啥用?有用的!有时候启动协程的代码不是直接写在 Compose 组件内,而是一个回调内,比如 clickable 回调。
显然 clickable
调用时机是确定的,即使发生重组也不会有意外的触发。并且希望这个协程在组件销毁时能及时取消,就得手动拿到 Scope 了。
写法如下:
@Composable
fun TestBox() {
val scope = rememberCoroutineScope()
Box(modifier = Modifier.clickable {
scope.launch { /*...*/ }
})
}
Modifier.clickable{}
的回调没有 @Composable 注解,其内部无法使用LaunchedEffect
等函数。也因此如果在 clickable 回调中使用原生的启动协程 API IDE 不会报错。要根据需求衡量应该用哪种写法。即:这个协程与谁的生命周期绑定?
非 Compose 的状态转换
在 Compose 出现之前就已经有很多可以被订阅的对象,这些对象改变时需要通知订阅者刷新。显然这些对象不能直接适应 Compose 的机制。所以需要状态转换。
普通状态
普通状态指不需要协程的状态,比如 LiveData
。传统 View 中应该监听 LiveData,在数值改变回调中更新 UI,界面销毁时会自动取消监听。对应到 Compose 中就是:在组件创建的时候监听,在回调中修改 MutableState 的状态来更新 UI,组件销毁时取消监听。
明显,这里的「监听」和「取消监听」是一次性的操作,应该用副作用函数实现。如下:
val messageData: LiveData<String>
@Composable
fun TestBox() {
var message by remember { mutableStateOf(messageData.value) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(Unit) {
val observer = Observer<String> { message = it }
messageData.observe(lifecycleOwner, observer)
onDispose { messageData.removeObserver(observer) }
}
}
事实上便捷函数 Observer.observeAsState()
也是这么写的。
协程状态
StateFlow
是典型的协程状态。对它的订阅必须在协程环境下才能完成。
@Composable
fun TestBox() {
var message by remember { mutableStateOf(messageFlow.value) }
LaunchedEffect(Unit) {
messageFlow.collect { message = it }
}
}
与普通状态转换唯一的区别就是需要 LaunchedEffect
提供协程环境,用起来反而更简单了,因为协程会自动取消。
协程状态到 Compose 的转换自然也有便捷写法,就是 produceState()
,它内部使用了上面的手动实现。State
需要有默认值,Flow
当前可能是没有值的。手动实现中我们自己创建的 State,顺便也就给出默认值了。produceState()
函数内部创建 State,默认值自然需要通过参数给出,这就是它第一个参数的意义。另外 produceState()
用于从协程读取值,而不仅仅是 Flow
,所以具体怎么取值也需要我们来实现,这就是第二个参数。用法如下:
@Composable
fun TestBox() {
val message by produceState("") {
// value 是 produceState() 内部创建的 state
messageFlow.collect { value = it }
}
}
在 lambda 内部,还提供了一个 awaitDispose{}
函数,作用是立即挂起这个协程,直到被取消的时候恢复并回调。在 Flow
的这个用法中没有什么意义,因为 Flow.collect{}
本来就挂起了,之后的代码不会执行。这个奇怪的函数适用于协程取消不会自动取消订阅的场景,比如用 produceState()
去转换 LiveData。...? 恩...比较奇怪,真正的使用场景有待探索。
produceState()
针对的是协程状态转换通用场景,如果明确了是 Flow
,也有更便捷的函数:Flow.collectAsState()
。
snapshotFlow()
snapshotFlow()
用于把 Compose 的状态转换成 Flow
。每当 Compose State 的值发生改变都会在这个 Flow 中生成一个新值,从而让外界也能感知 Compose 的状态。
var message by remember { mutableStateOf("Hello") }
val messageFlow = snapshotFlow { message }
// 每次 message 更新,messageFlow 都会有新值
仔细看看,snapshotFlow()
接收的不是一个 State,而是 lambda。它可以把多个 State 转换成一个 Flow! 比如这样:
var name by remember { mutableStateOf("Chenhe") }
var message by remember { mutableStateOf("Hello") }
val messageFlow = snapshotFlow { "$message $name" }
// name 或 message 更新都会触发 messageFlow
使用场景呢?snapshotFlow()
是给外部用的,也就是说写 UI 的时候用不到这个东西。当需要从外部读取 Compose 内部状态的时候自然就派上用场了。