1、什么是附带效应
Google 官方文档对 side effect 有两种翻译,简体中文翻译为附带效应,繁体中文翻译为副作用。这三种叫法用哪个都行,后续不再做出声明。
我们在日常生活中听到的副作用大多是医学领域中的,药物的副作用是指除了预期治疗效果外,在使用过程中可能产生的额外的不良反应或不良影响。即,副作用是为了让目标作用生效,附带而来的作用,它并不是“负作用”那种完全不好的、负面的作用。
在 Compose 中,副作用通常指的是对界面以外的状态进行更改或者操作的行为。在函数式编程中,强调函数的纯粹性,即函数的输出仅依赖于输入,不会对外部状态产生影响。但是在 UI 开发中,难免会有对应用状态或外部环境进行交互,执行非 UI 操作的情况,如:
- 发起网络请求
- 修改全局变量
- 操作数据库
- 订阅事件(如传感器、广播)
- 更新 SharedPreferences
这些操作如果直接在可组合函数中调用,可能会因重组(Recomposition)被多次触发,导致性能问题或逻辑错误。
比如说:
fun a() {
var value = 0
value = 1
}
var flag = false
fun b() {
flag = true
}
函数 a 只修改了其内部的变量,因此没有副作用;而函数 b 修改了外部的变量 flag,因此它有副作用。
再看:
fun c() {
println("Compose")
}
想要确认函数 c 是否有副作用,通过判断是否修改函数内部变量的方式似乎不能确定,这里我们可以借助副作用的学术性定义:对于一个函数,如果用它的返回值替换函数本身,但不会对程序有任何影响,那么该函数就没有副作用,假如产生了影响,两种结果之间的差异就是副作用。
对于 println 这个函数而言,它本身没有返回值,或者说是一个 Unit,用返回值替换函数本身使得无法打印指定内容,这对程序产生了影响。因此 println 这个函数是有副作用的,导致函数 c 也是有副作用的。
副作用这个词可以很好地描述函数的纯净性,可以称一个函数是“有/无副作用的函数”。Compose 就要求所有的组件函数都是无副作用的函数。Compose 的 @Composable 函数是用来显示界面内容的,应该只包含界面显示工作,不应掺杂其他任何对外界有影响的工作,也就是不应该有副作用。因为 @Composable 函数的副作用会导致整个程序产生不可预期的结果,这是由于 @Composable 函数的调用就具有不可预期性。
由于 Compose 框架对于重组过程的优化,一个 @Composable 函数可能在运行过程中被终断甚至干脆就没被执行,这样可能会出现影响外界的代码,有一部分被执行了,剩余的部分由于终断而没被执行,从而产生不可预期的结果。
此外,由于重组的次数不确定,具有副作用的 @Composable 函数的执行结果也是不可预期的:
setContent {
var seasonCount = 0
Column {
val seasons = arrayOf("Spring", "Summer", "Autumn", "Winter")
seasons.forEach {
Text(it)
seasonCount++
}
Text("Total season count: $seasonCount")
}
}
按照设想,最后 Text 展示的 seasonCount 应该为 4,但假如 Column 出现多次重组,那么 seasonCount 就大于 4,无法预期具体的结果。
正是因为种种的不可预期性,Compose 建议开发者不要在 @Composable 函数中引入副作用代码。但很多时候,业务需求使得我们无法遵守这个建议,比如在代码中埋点统计函数的执行数据,那难道因为 Compose 的建议就无法完成这些业务需求吗?当然不是,Compose 提供了一系列附带效应 API 帮助我们完成业务需求,下面我们来逐个解读。
derivedStateOf() 也是附带效应 API 中的一员,但是由于前面在讲重组时已经用了单独章节来介绍该 API,因此本篇文章就不会再介绍它,有需求的可以去看 Compose 实践与探索三 —— 深入理解重组。
2、SideEffect
SideEffect 需要传入一个函数参数 effect:
/**
* 让 [effect] 在当前组合成功完成并应用更改后运行。[SideEffect] 可用于对组合(composition)管理的
* 对象施加副作用,这些对象不受 [快照][Snapshot] 的支持,以避免在当前组合操作失败时使这些对象处于
* 不一致的状态。
*
* [effect] 始终在组合的 apply 调度器上运行,并且应用器(appliers)永远不会与自身或其他应用器并发
* 执行,也不会与组合树的更改应用或 [RememberObserver] 事件回调并发执行。[SideEffect]始终在
* [RememberObserver] 事件回调之后运行。
*
* [SideEffect] 在每次重组后都会运行。要启动可能跨越多次重组的持续任务,请参阅 [LaunchedEffect]。
* 要管理事件订阅或其他对象生命周期,请参阅 [DisposableEffect]。
*/
@Composable
@NonRestartableComposable
@OptIn(InternalComposeApi::class)
fun SideEffect(effect: () -> Unit) {
currentComposer.recordSideEffect(effect)
}
effect 的代码不会在执行到 SideEffect 时被立即执行,而是先被保存起来进行等待,直到本轮重组过程完成,确定了 SideEffect 所在的组件会在界面上显示,才会执行其内部代码。这样可以保证没有执行完就被取消的 Composable 函数的副作用代码不会被执行,还可保证在一轮重组过程中被多次调用的 Composable 函数的代码只被执行一次。
那使用 SideEffect 是不是能解决所有的副作用相关的问题呢?当然不是,想通过 SideEffect 解决副作用问题有一个前提,就是引入副作用代码的这个需求必须是正常的,不能对外界造成不可预期影响的需求。比如还是上面的例子,由于对 seasonCount 的自增操作会对外界造成不可预期的影响,因此只是简单的为它包上一层 SideEffect 并不能达到预期的结果:
setContent {
var seasonCount = 0
Column {
val seasons = arrayOf("Spring", "Summer", "Autumn", "Winter")
seasons.forEach {
Text(it)
// 在重组之后确定 Column 仍会显示才执行代码块的内容,但此时最下面的 Text
// 要显示的内容已经确定了,无法再更改,再运行 seasonCount++ 已经晚了
SideEffect {
seasonCount++
}
}
Text("Total season count: $seasonCount")
}
}
真正的解决办法是你要把业务(可以简单的理解为数据处理)与界面显示分拆,在显示 UI 前把数据准备好,而不是滥用 SideEffect:
setContent {
var seasonCount = 0
Column {
val seasons = arrayOf("Spring", "Summer", "Autumn", "Winter")
seasonCount = seasons.size()
seasons.forEach {
Text(it)
/*SideEffect {
seasonCount++
}*/
}
Text("Total season count: $seasonCount")
}
}
3、DisposableEffect
DisposableEffect 是 SideEffect 的升级版,增加了对离开界面的监听。比如:
Button(onClick = { /*TODO*/ }) {
DisposableEffect(Unit) {
// Button 进入页面监听
println("Button 进入页面")
// 组件离开组合的监听函数,返回值为 DisposableEffectResult
onDispose { println("Button 离开页面") }
}
}
当组件进入页面(准确说是组合 Composition)时,会回调 DisposableEffect 内的代码。由于 DisposableEffect 的 effect 参数的返回值是 DisposableEffectResult 类型,因此可以通过在 effect 的最后一个语句调用 onDispose(),实现对组合离开页面(准确说是组合 Composition)的回调监听,同时满足返回值的要求。
DisposableEffect 常见的适用场景:
- 埋点,统计用户进入以及退出了哪些界面
- 组件进入页面时在 DisposableEffect 内为该组件设置监听器,组件离开页面时在 onDispose() 内取消监听器
- 在 DisposableEffect 的 onDispose() 内进行资源回收
此外,我们要看一下 DisposableEffect 的第一个参数 key1:
/**
* 一种组合(composition)的副作用,当 [key1] 发生变化或者 [DisposableEffect] 离开组合时
* 必须进行反向操作或清理。
*
* [DisposableEffect] 的 key 是 [DisposableEffect] 的身份标识,当 key 发生变化时,
* [DisposableEffect] 必须清理旧的副作用(dispose current effect),并且通过调用 effect 重启。
* keys 的示例包括:
* 1、effect 订阅的可观察对象
* 2、一个操作唯一的请求参数,如果这些参数发生变化,则必须取消并重试
*
* [DisposableEffect] 可用于初始化或订阅 key,并在提供不同的 key 时重新初始化,在初始化新操作
* 之前执行旧操作的清理工作。
*/
@Composable
@NonRestartableComposable
fun DisposableEffect(
key1: Any?,
effect: DisposableEffectScope.() -> DisposableEffectResult
) {
remember(key1) { DisposableEffectImpl(effect) }
}
它的作用是,当传入 DisposableEffect() 的 key1 发生变化时,整个 DisposableEffect() 会进行一次重启,重启动作包括两步:
- 先对老的 key1 值执行一次离开回调 onDispose()
- 然后对新的 key1 值执行一次 effect 参数函数中的进入回调
这样的顺序可以保证程序有一个合理的执行过程。比如 onDispose() 中会执行将对象 A 置为 null 的操作,而 DisposableEffect() 会为 A 赋值。那么当 DisposableEffect 因为 key1 的变化而重启时,就会先将 A 置为 null 然后再为它赋新值,而不是先为 A 赋了新值再置为 null。
反之,如果 key1 不变,不论 DisposableEffect 所在的组件如何进行重组,DisposableEffect 都不会重启(避免资源消耗):
@Composable
fun DisposableEffectSample1() {
var showText by remember { mutableStateOf(false) }
Button(onClick = { showText = !showText }) {
Text("点击")
if (showText) {
Text("Compose")
}
// 只要 Button 重组就会回调
SideEffect {
println("SideEffect")
}
// key1 不变不论 Button 如何重组,DisposableEffect 都不会重启
DisposableEffect(Unit) {
println("Button 进入页面")
onDispose { println("Button 离开页面") }
}
}
}
不断点击 Button 让其发生重组,但是只有 SideEffect() 会跟随重组进行重启,由于 DisposableEffect() 的 key1 参数不变,所以只有首次进入页面时的 log 被输出:
Button 进入页面
SideEffect
SideEffect
SideEffect
但将 DisposableEffect() 的 key1 参数换为 showText 之后,点击按钮会触发 DisposableEffect() 重启:
Button 进入页面
SideEffect
Button 离开页面
Button 进入页面
SideEffect
Button 离开页面
Button 进入页面
SideEffect
并且你能看到,DisposableEffect 会先触发 onDispose() 回调,再回调 effect 参数内的代码。
4、LaunchedEffect
LaunchedEffect 会在 Composable 组件完成显示之后启动协程,并在参数发生改变之后重启协程。
LaunchedEffect 从功能与底层实现上来讲是特殊形式的 DisposableEffect:
@Composable
@NonRestartableComposable
fun DisposableEffect(
key1: Any?,
effect: DisposableEffectScope.() -> DisposableEffectResult
) {
remember(key1) { DisposableEffectImpl(effect) }
}
key1 变化才会执行 DisposableEffectImpl():
private class DisposableEffectImpl(
private val effect: DisposableEffectScope.() -> DisposableEffectResult
) : RememberObserver {
private var onDispose: DisposableEffectResult? = null
override fun onRemembered() {
onDispose = InternalDisposableEffectScope.effect()
}
override fun onForgotten() {
onDispose?.dispose()
onDispose = null
}
override fun onAbandoned() {
// Nothing to do as [onRemembered] was not called.
}
}
而 LaunchedEffect 也是在 key1 变化时才执行 LaunchedEffectImpl():
@Composable
@NonRestartableComposable
@OptIn(InternalComposeApi::class)
fun LaunchedEffect(
key1: Any?,
block: suspend CoroutineScope.() -> Unit
) {
val applyContext = currentComposer.applyCoroutineContext
remember(key1) { LaunchedEffectImpl(applyContext, block) }
}
LaunchedEffectImpl 也实现了 RememberObserver,并且实现内容都是基于协程的:
internal class LaunchedEffectImpl(
parentCoroutineContext: CoroutineContext,
private val task: suspend CoroutineScope.() -> Unit
) : RememberObserver {
private val scope = CoroutineScope(parentCoroutineContext)
private var job: Job? = null
// 在协程中执行 LaunchedEffect 的 block 参数
override fun onRemembered() {
// 启动新协程之前先取消旧协程
job?.cancel("Old job was still running!")
job = scope.launch(block = task)
}
override fun onForgotten() {
job?.cancel()
job = null
}
override fun onAbandoned() {
job?.cancel()
job = null
}
}
何时会用到?实际上就是把组件显示到界面作为某种业务的触发逻辑时,实际上 DisposableEffect 也是这种逻辑,只不过 LaunchedEffect 是面向协程的。比如,某个组件在页面中显示 3 秒钟后消失(如刚进入视频播放页面后,视频播放的控制面板会在显示几秒后消失)。
5、rememberUpdatedState
上两节我们分别讲了 DisposableEffect 与 LaunchedEffect,它俩有一个共同的功能,就是根据参数上传入的 key 是否发生变化决定是否重启自身的执行。即参数 key 变了就重启,不变的话即便所在组件重组也不会重启,避免资源消耗。
以往我们遇到的大多数情况,都是组件依赖的状态发生变化会在同一帧中立即起到作用,比如以下这种极度简化过的代码:
var text by remember { mutableStateOf("Compose") }
...
Text(text)
但有一些场景下,我们希望被依赖的状态发生改变时,不去触发重组或 DisposableEffect 与 LaunchedEffect 的重启,也能在需要使用这个状态时拿到它最新的值:
@Composable
fun RememberUpdatedStateSample() {
var welcome by remember { mutableStateOf("Initial value.") }
Button(onClick = { welcome = "Jetpack Compose" }) {
Text("点击")
LaunchedEffect(Unit) {
delay(3000)
println("welcome: $welcome")
}
}
}
如果在 LaunchedEffect 的 3 秒延时之内点击按钮,那么 welcome 在打印时会输出更新后的 “Jetpack Compose”,而不是初始值。这种更新不需要将 welcome 作为 LaunchedEffect 的参数,在可以获取到 welcome 新值的同时,还避免了 LaunchedEffect 重启带来的性能损耗。
但假如将 LaunchedEffect 抽取到一个单独的函数中,即便在 3 秒内点击按钮,welcome 也只打印初始值:
@Composable
fun RememberUpdatedStateSample() {
var welcome by remember { mutableStateOf("Initial value.") }
Button(onClick = { welcome = "Jetpack Compose" }) {
Text("点击")
CustomLaunchedEffect(welcome)
}
}
@Composable
private fun CustomLaunchedEffect(welcome: String) {
LaunchedEffect(Unit) {
delay(3000)
println("welcome: $welcome")
}
}
为什么第一种情况可以,第二种情况不行呢?因为第一种情况的 welcome 通过 remember() + mutableStateOf() 实现了一个持久存储且可以将状态变化通知到所有使用处的变量,因此它可以跨越重组传递到 Button 的内部,在发生变化时可以同步给 LaunchedEffect()。而第二种情况,将 welcome 作为函数参数传递,那么 CustomLaunchedEffect() 中的 welcome 就是一个普通的变量,它的变化不会同步给 LaunchedEffect() 内的 welcome,因此即便给 CustomLaunchedEffect() 的传参发生了变化,但打印输出的 welcome 仍是协程最初拿到的初始值。
那如何解决呢?把 welcome 填到 LaunchedEffect() 的参数上?我们的要求是尽量避免让 LaunchedEffect() 重启,因此这样不行。所以还是效仿第一种情况,用 remember() + mutableStateOf() 构造一个状态变量,然后把参数 welcome 传给该状态变量:
@Composable
private fun CustomLaunchedEffect(welcome: String) {
var rememberedWelcome by remember { mutableStateOf(welcome) }
rememberedWelcome = welcome
LaunchedEffect(Unit) {
delay(3000)
println("welcome: $rememberedWelcome")
}
}
LaunchedEffect() 之前的两个语句可以用 rememberUpdatedState() 平替:
@Composable
fun <T> rememberUpdatedState(newValue: T): State<T> = remember {
mutableStateOf(newValue)
}.apply { value = newValue }
也就是:
@Composable
private fun CustomLaunchedEffect(welcome: String) {
val rememberedWelcome by rememberUpdatedState(welcome)
LaunchedEffect(Unit) {
delay(3000)
println("welcome: $rememberedWelcome")
}
}
rememberUpdatedState() 除了可以用于 LaunchedEffect,也可用于 DisposableEffect,比如:
@Composable
fun CustomDisposableEffect(user: User) {
DisposableEffect(Unit) {
// 拿不到 user 的新值
suscriber.subscribe(user)
onDispose {
suscriber.unsubscribe()
}
}
}
在 DisposableEffect() 进行订阅操作时,拿不到参数 user 的新值,因此还是要使用 rememberUpdatedState() 来解决:
@Composable
fun CustomDisposableEffect(user: User) {
val updatedUser by rememberUpdatedState(user)
DisposableEffect(Unit) {
suscriber.subscribe(user)
onDispose {
suscriber.unsubscribe()
}
}
}
6、rememberCoroutineScope
rememberCoroutineScope() 是在 Compose 中除了 LaunchedEffect 之外,另一种使用协程的方式。
在 Compose 中使用协程不能像通用的协程使用方法那样,比如不可以直接使用 lifecycleScope.launch(),因为 lifecycleScope 作为一个 CoroutineScope 是用来管理协程的,主要负责在与它绑定的具有生命周期的组件结束后,自动结束该组件中运行的协程。而 Composable 函数也是具有声明周期的,在 Composable 函数内启动的协程也应该在函数结束后自动结束,这意味着每个 Composable 函数都有自己的 CoroutineScope。
因此在 Composable 函数中应该使用相应的 CoroutineScope,而不是与 Activity 生命周期绑定的 lifecycleScope。使用 rememberCoroutineScope() 可以获取到与当前组合点绑定的 CoroutineScope,然后在 remember() 中可以直接用它启动协程:
val coroutineScope = rememberCoroutineScope()
// 不用 remember 包上 launch() 会报错,因为遇到重组时每次都会重新启动一次协程
val coroutine = remember { coroutineScope.launch { } }
同样是在 Compose 中启动一个协程,LaunchedEffect 的内部实际上已经为使用者完成了 CoroutineScope 的获取与 remember() 的使用:
@Composable
@NonRestartableComposable
@OptIn(InternalComposeApi::class)
fun LaunchedEffect(
key1: Any?,
block: suspend CoroutineScope.() -> Unit
) {
val applyContext = currentComposer.applyCoroutineContext
remember(key1) { LaunchedEffectImpl(applyContext, block) }
}
因此,通常我们使用 LaunchedEffect 启动协程就足够了。但如果想要在 Composable 组件的外面启动协程时,需要使用 rememberCoroutineScope():
val coroutineScope = rememberCoroutineScope()
// 点击 Box 触发 clickable 回调时才启动协程,这是在组件外部启动的协程
Box(Modifier.clickable { coroutineScope.launch { delay(1000) } })
所以可以将 rememberCoroutineScope() 看作是对 LaunchedEffect 的一种补充。因为 LaunchedEffect 是一个 Composable 函数,它只能在另一个 Composable 函数内被调用。当出现上面这种在 Composable 函数之外需要使用协程的场景,无法使用 LaunchedEffect,因此才提供了 rememberCoroutineScope() 来解决 LaunchedEffect 使用场景受限的问题。这样使用这两个函数基本上就可以满足 Compose 对协程的使用需求了。
7、协程或其他状态向 Compose 状态的转换
本节讲如何将非 Compose 状态转换为 Compose 状态。
7.1 DisposableEffect
之前说过 DisposableEffect 可以用来做一些订阅工作,并且可以在它的 onDispose() 回调中取消订阅。这种用法也可以用在订阅数据更新上,比如说地图上要显示一个坐标点,当坐标数据发生变化时 UI 应自动更新:
val geoManager: GeoManager = GeoManager()
@Composable
fun UpdatePoint() {
var position by remember { mutableStateOf(Point(0, 0)) }
DisposableEffect(Unit) {
// PositionCallback 提供最新的坐标数据 newPos
val callback = object : PositionCallback { newPos ->
position = newPos
}
// 注册回调与取消回调注册
geoManager.register(callback)
onDispose {
// 本组件不再显示时取消注册
geoManager.unregister(callback)
}
}
}
PositionCallback 可以提供更新后的坐标数据,而 GeoManager 在注册回调后可以接收到坐标变化,这个变化的坐标 newPos 原本是 Compose 无法识别的普通变量,经过赋值给 position 状态后,newPos 的变化可以自动应用到界面上,这就是一种将普通数据转换为 Compose 状态的简单示例。
此外,相同的套路也可用在 LiveData 转换为 State 上:
val positionData = MutableLiveData<Point>()
@Composable
fun UpdatePoint(owner: LifecycleOwner) {
var position by remember { mutableStateOf(Point(0, 0)) }
DisposableEffect(Unit) {
val observer = Observer<Point> { newPos ->
position = newPos
}
positionData.observe(owner, observer)
onDispose {
positionData.removeObserver(observer)
}
}
}
实际上,Compose 为 LiveData 提供了扩展函数 observeAsState() 就可以将 LiveData 转换为 State:
// 需依赖 androidx.compose.runtime:runtime-livedata 方可使用
@Composable
fun <T> LiveData<T>.observeAsState(): State<T?> = observeAsState(value)
@Composable
fun <R, T : R> LiveData<T>.observeAsState(initial: R): State<R> {
val lifecycleOwner = LocalLifecycleOwner.current
// 用初始值创建一个 State 对象
val state = remember { mutableStateOf(initial) }
DisposableEffect(this, lifecycleOwner) {
// 更新 state 值的 Observer
val observer = Observer<T> { state.value = it }
// 订阅
observe(lifecycleOwner, observer)
// 取消订阅
onDispose { removeObserver(observer) }
}
return state
}
7.2 LaunchedEffect
对于用到了协程的外部状态,如 Flow,就不能用 DisposableEffect 进行转换了,而是要换成 LaunchedEffect:
val positionState: StateFlow<Point> = TODO()
@Composable
fun UpdatePoint(owner: LifecycleOwner) {
var position by remember { mutableStateOf(Point(0, 0)) }
LaunchedEffect(Unit) {
positionState.collect { newPos ->
position = newPos
}
}
}
7.3 produceState
produceState() 创建一个 MutableState 对象并在协程中更新它的值:
@Composable
fun <T> produceState(
initialValue: T,
@BuilderInference producer: suspend ProduceStateScope<T>.() -> Unit
): State<T> {
val result = remember { mutableStateOf(initialValue) }
LaunchedEffect(Unit) {
ProduceStateScopeImpl(result, coroutineContext).producer()
}
return result
}
参数 producer 内定义获取状态值的代码,它会在协程中被执行用于获取最新的状态值:
val positionState: StateFlow<Point> = TODO()
@Composable
fun UpdatePoint(owner: LifecycleOwner) {
// 参数传入初始值
val produceState = produceState(Point(0, 0)) {
positionState.collect {
// Flow 传来的新数据 it 赋值给 State 的真实数据对象 value
value = it
}
}
}
相当于把 LaunchedEffect() 的写法封装到 produceState() 这个便捷函数中了。
produceState() 内还可以调用一个 awaitDispose(),它可以无限期挂起协程,主要用于转换不是协程提供的状态的情况。
最后要提一嘴,StateFlow 提供了扩展函数 collectAsState() 可以直接将一个 StateFlow 转换成 State:
@Suppress("StateFlowValueCalledInComposition")
@Composable
fun <T> StateFlow<T>.collectAsState(
context: CoroutineContext = EmptyCoroutineContext
): State<T> = collectAsState(value, context)
@Composable
fun <T : R, R> Flow<T>.collectAsState(
initial: R,
context: CoroutineContext = EmptyCoroutineContext
): State<R> = produceState(initial, this, context) {
if (context == EmptyCoroutineContext) {
collect { value = it }
} else withContext(context) {
collect { value = it }
}
}
它内部就是用到了 produceState()。
8、把 Compose 的 State 转换成协程的 Flow
snapshotFlow() 可以把 Compose 的 State 转换成协程 Flow:
setContent {
var name by remember { mutableStateOf("Jack") }
var age by remember { mutableStateOf(18) }
val flow = snapshotFlow { "$name $age" }
LaunchedEffect(Unit) {
// snapshotFlow() 内任何一个状态发生变化,都会以新值执行一次 collect
flow.collect { info ->
println(info)
}
}
}