1、SideEffect
Google 官方文档对 side effect 有两种翻译,简体中文翻译为附带效应,繁体中文翻译为副作用。这两个翻译我们用哪个都行,关键是如何理解它的含义。
1.1 什么是副作用
我们在日常生活中听到的副作用大多是医学领域中的,药物的副作用是指除了预期治疗效果外,在使用过程中可能产生的额外不良反应或不良影响。可以理解为,副作用是目标作用为了生效而附带而来的作用,它并不是“负作用”那种完全不好的、负面的作用。
在 Compose 中,副作用(或附带效应,后续出现两个词都认为是 side effect)通常指的是对界面以外的状态进行更改或者操作的行为。在函数式编程中,强调函数的纯粹性,即函数的输出仅依赖于输入,不会对外部状态产生影响。然而,在 UI 开发中,通常需要与外部环境进行交互,比如修改变量、进行网络请求等,这些会导致“副作用”。
比如说:
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)
// 在重组之后确定 Column 仍会显示才执行代码块的内容,但此时
// 最下面的 Text 要显示的内容已经确定了,无法再更改
SideEffect {
seasonCount++
}
}
Text("Total season count: $seasonCount")
}
}
按照设想,最后 Text 展示的 seasonCount 应该为 4,但假如代码运行过程中出现多次重组,那么 seasonCount 就不会是 4 了,结果不在预期中。
正是因为种种的不可预期性,Compose 建议开发者不要在 @Composable 函数中引入副作用代码。但很多时候,业务需求使得我们无法遵守这个建议,比如在代码中埋点统计函数的执行数据,那难道因为 Compose 的建议就无法完成这些业务需求吗?当然不是,任何时候,业务都是优先的,没有商业和业务支撑的代码创造不出任何价值,也就一文不值。
1.2 SideEffect 函数
Compose 提供了一些函数来满足业务需求(比如埋点),最直接与最简单的就是 SideEffect()。
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")
}
}
2、DisposableEffect
DisposableEffect 是 SideEffect 的升级版,增加了对离开界面的监听。比如:
Button(onClick = { /*TODO*/ }) {
DisposableEffect(Unit) {
// Button 进入页面监听
println("Button 进入页面")
// 该 lambda 表达式必须返回一个 DisposableEffectResult,可以通过
// 主动调用组件离开页面的监听函数 onDispose() 得到
onDispose { println("Button 离开页面") }
}
}
通过主动调用 onDispose() 设置对组件离开页面的监听内容,这样在所属组件离开页面(准确点说是离开组合 Composition)时就会回调 onDispose(),同理进入页面会回调 DisposableEffect() 的内容。
有两种场景适用 DisposableEffect:
- 埋点,统计用户进入以及退出了哪些界面
- 组件进入页面时在 DisposableEffect() 内为该组件设置监听器,组件离开页面时在 onDispose() 内取消监听器
此外,我们要看一下 DisposableEffect 的第一个参数 key1:
@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() 回调,再回调自己内部的代码。
3、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
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 秒钟后消失(如刚进入视频播放页面后,视频播放的控制面板会显示几秒后消失)。
4、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()
}
}
}
5、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 { } })
6、协程或其他状态向 Compose 状态的转换
本节主要讲如何将非 Compose 状态转换为 Compose 状态。
6.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
}
6.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
}
}
}
6.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()。
7、把 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)
}
}
}
在Compose中,副作用通常发生在LaunchedEffect
、DisposableEffect
、SideEffect
等函数中。这些函数用于处理可能会引起副作用的操作,如启动协程、订阅数据、修改可变状态等。需要注意的是,在Compose中,副作用应该尽量被限制在特定的作用域内,以保持代码的可维护性和可预测性。
总的来说,Compose中的“副作用”指的是对外部状态进行更改或操作的行为,通过合适的方式管理和控制副作用的产生,可以帮助确保应用的正确性和性能。