Jetpack Compose 中的副作用(side effects)
什么是副作用?
side effects,中文翻译为“副作用”。Jetpack Compose 中的 side effects 并没有特殊的释义,它就是副作用的意思。如果你此前并未曾了解过相关知识,你大概率会对此感到困惑,副作用这个词和编程怎么看都不搭边。
的确,我们对“副作用”的第一印象,往往是它在医学上的意义,指药品往往有多种作用,作用于不同身体部位受体,治疗时利用其一种或一部分受体作用,其他作用或是受体产生作用即变成为副作用。
虽然副作用一词常被用来形容不良反应,但事实上副作用也可以指那些“有益处、意料之外”的效果。例如:X辐射线/X光一直被用做医学影像用途,人们原本把它的辐射线对人体产生的效果当成是副作用。但自从人们发现X辐射线/X光能够用来治疗肿瘤后,辐射线被应用为放射线疗法。在医学影像领域中被当成副作用的辐射线效果,在癌症治疗上反而成了消灭赘生物的正作用了。
在计算机科学邻域,副作用指的是一个函数的执行对函数外部状态产生的影响。
class Coo {
var number = 0
fun foo(user: User) {
// 副作用: 修改了函数外部的变量
number++
// 副作用: 修改了参数
user.name = "side effects"
// 副作用: 向调用方的终端/管道输出字符
println("End")
}
}
维基百科上的定义是:
函数副作用(side effect)指当调用函数时,除了返回可能的函数值之外,还对主调用函数产生附加的影响。例如修改全局变量(函数外的变量),修改参数,向主调方的终端、管道输出字符或改变外部存储信息等。
而 Compose 中的副作用,指的是发生在 Composable 函数作用域之外的应用状态的变化。
为什么要避免副作用?
Compose 团队建议开发者在编写 Composable 函数时应遵循最佳实践:
- fast: 快速
- idempotent: 幂等
- side-effect free: 无副作用
第一,快速,即避免在 Composable 函数中执行耗时操作,例如从共享偏好设置读取数据。
第二,幂等函数,指的是函数的执行结果只与输入参数有关,而与函数的执行次数无关。换句话说,使用相同参数重复执行幂等函数,总是能获得相同结果。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。
第三,无副作用,避免在 Composable 函数中执行副作用操作。可以简单理解为在 Composable 函数中不要包含和 UI 无关的代码操作。
虽然函数副作用可能会给程序设计带来不必要的麻烦,给程序带来难以查找的错误,并降低程序的可读性与可移植性,但是吧,前面也说了,副作用不一定是负面的,尤其在编程中,副作用往往是充当“正作用”的角色而存在,出现频率不亚于生活中人每天要吃饭喝水。
想象一下,在一个函数里面修改外部变量、打印字符、保存文件、执行网络请求... 这些副作用难道不是基本操作吗?那么,为什么 Compose 团队要将无副作用作为编写 Composable 函数的最佳实践呢?
我们来观察一下以下代码片段,这个 Composable 函数接收一个字符串列表,然后用 for 循环遍历将内容显示出来,每次遍历时,将外部变量 items 加 1,遍历结束后,将 items 的值显示在屏幕上。
@Composable
@Deprecated("Example with bug")
fun ListWithBug(myList: List<String>) {
var items = 0
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Column {
for (item in myList) {
Text("Item: $item")
items++ // ⚠️ Avoid! Side-effect of the column recomposing.
}
}
Text("Count: $items")
}
}
我们预期的结果是,每次执行该函数时,里面的代码会按顺序从头到尾执行一遍,显示出列表的内容和列表的长度。但实际上由于 Composable 的生命周期和重组特征,Composable 函数的执行结果很可能不如我们所愿。
-
不可预测的重组:
一次重组中,任一个 Composable 函数都可能会被多次执行。如果例子中的Column()
函数在一次重组中被执行了两次,那么副作用items++
将导致程序出错。 -
以不同顺序执行 Composable 函数的重组:
你也许一直都以为 Composable 函数里的代码会按编写的顺序运行。但其实未必如此。如果某个 Composable 函数包含对其他 Composable 函数的调用,这些函数可以按任何顺序运行。Compose 可以选择识别出某些界面元素的优先级高于其他界面元素,从而首先绘制这些元素。 -
可以舍弃的重组
如果界面的某些部分失效,Compose 会尽力只重组需要更新的部分。这意味着任一个 Composable 函数或者 lambda 表达式都可能在重组时被跳过执行。其中的副作用代码可能会被跳过,这是非常危险的,这导致程序行为变得不可预期。
再多提一点,重组时 Composable 函数是会被放在后台线程并行执行的。这意味着,如果你在 Composable 函数中调用 viewModel 等方法,还会有线程安全隐患。
综上所述,由于 Composable 的生命周期和重组特征,Composable 函数中的副作用会导致程序的行为变得不可预期。 这就是为什么要避免在 Composable 函数中执行副作用操作的原因。
Effect API 三剑客
但是,又要说但是了,编程里的副作用是不可能完全避免的。如果我们就是要在 Composable 函数中执行副作用操作,该怎么办呢?比如有一个 Composable 界面,现需要对该界面进行埋点统计,那么显然需要在 Composable 函数中执行副作用操作。
@Composable
fun AnnualSummaryScreen(user: User) {
Column {
// UI ...
}
val analytics: FirebaseAnalytics = remember {
FirebaseAnalytics()
}
analytics.logEvent("访问年度总结页面")
}
正因为副作用不可避免,Compose 提供了一系列的 Effect API,帮助开发者以可预测的方式在 Composable 函数中执行副作用操作。
SideEffect
使用 SideEffect()
,可以确保副作用代码在每次成功重组之后被执行 1 遍。
@Composable
@NonRestartableComposable
@ExplicitGroupsComposable
fun SideEffect(effect: () -> Unit): Unit

可以把 SideEffect 简单的理解为是一个重组完成后的回调。
var text by remember { mutableStateOf("") }
Button(onClick = { text += "#" }) {
SideEffect {
Log.d(TAG, "SideEffect")
}
Text(text)
}
text 被更新后,Text 组件所在的 lambda 作用域会被重组,从而触发 SideEffect 的执行。
DisposableEffect
@Composable
@NonRestartableComposable
fun DisposableEffect(key1: Any?, effect: DisposableEffectScope.() -> DisposableEffectResult): Unit
// 注意函数类型参数 `effect` 要求返回值为 DisposableEffectResult
如果说 SideEffect()
是为重组添加回调,那 DisposableEffect()
就是为进入组合和退出组合添加回调。

先来看看怎么使用 DisposableEffect()
:
var bool by remember { mutableStateOf(true) }
Switch(
checked = bool,
onCheckedChange = { bool = it }
)
if (bool) { // 通过条件判断控制 DisposableEffect 进入或退出组合
DisposableEffect(key1 = Unit) {
// 这里的代码会在进入组合时执行(注意是进入组合,不是重组)
Log.d(TAG, "Enter Composition")
onDispose {
// 这里的代码会在退出组合时执行
Log.d(TAG, "Leave Composition")
}
}
}
调用 DisposableEffect()
时要传入一个 lambda(要求返回值为 DisposableEffectResult),它会在进入组合时执行,在该 lambda 的最后一行,必须要调用 onDispose()
,因为它的返回值类型就是 DisposableEffectResult。传递给 onDispose()
的 lambda 会在退出组合时被执行。
DisposableEffect()
函数有一个 key
参数,在上面的例子里只是简单地传入了 Unit
,那这个参数到是用来干嘛的呢?当参数 key 发生变化时,即使 DisposableEffect()
并没有进入或退出组合,也会触发一遍 DisposableEffect 重启,所谓重启,就是先执行退出组合的回调代码,然后再执行进入组合的回调代码。
我们把上面的例子改造一下:
var bool by remember { mutableStateOf(true) }
Switch(
checked = bool,
onCheckedChange = { bool = it }
)
- if (bool) {
- DisposableEffect(key1 = Unit) {
+ DisposableEffect(key1 = bool) {
Log.d(TAG, "Enter Composition, bool = $bool")
onDispose {
Log.d(TAG, "Leave Composition, bool = $bool")
}
}
-}
现在 Boolean 值不再控制 DisposableEffect 进入或退出组合了,只是作为 key 参数传递给 DisposableEffect()
函数
第一行 log 是由 DisposableEffect 进入组合触发的,后面两行 log 是由 key 参数变化而触发的。
关于 DisposableEffect()
的用法我们已经掌握了,它的用途很明显,就是让我们可以在进入界面或退出界面时执行某些代码,比如:路由统计(记录用户打开或关闭了哪些界面)、订阅可观察对象(进入界面时订阅,退出界面时取消订阅)...
// If lifecycleOwner changes, dispose and reset the effect
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) {
/* TODO */
} else if (event == Lifecycle.Event.ON_STOP) {
/* TODO */
}
}
// Add the observer to the lifecycle
lifecycleOwner.lifecycle.addObserver(observer)
// When the effect leaves the Composition, remove the observer
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
LaunchedEffect
@Composable
@NonRestartableComposable
fun LaunchedEffect(key1: Any?, block: suspend CoroutineScope.() -> Unit): Unit
// 因为函数类型参数 block 拥有协程上下文,所以可以在里面调用各种协程代码。
LaunchedEffect()
可以简单地理解为是一个协程版本的 DisposableEffect()
。当进入组合时,便会启动一个新协程用于执行 block 代码块,当退出组合时,协程将会被取消。
至于参数 key
,作用和 DisposableEffect()
函数的参数 key
一样都是用于触发重启,当其发生改变时,会将上一个协程取消,再启动一个新协程执行 block 代码块。

var bool by remember { mutableStateOf(true) }
Button(onClick = { bool = false }) {
Text("Change Key")
}
LaunchedEffect(key1 = bool) {
Log.d(TAG, "Start")
delay(3000)
Log.d(TAG, "End")
}
打开应用时,因进入组合,启动了新协程,所以打印出 Start,在协程 delay 期间,点击改变了 LaunchedEffect 的 key,所以协程被取消,然后又启动了新的协程重新执行 block 代码块,所以 log 里面有两个 Start 但只有一个 End。
一些和副作用相关的其他 API
Effect 系列 API 上面我们已经逐个了解完了,不过和副作用相关的可不止 Effect API,还有一些其他 API,下面我们一起来看看!
rememberUpdatedState
// SnapshotState.kt
@Composable
fun <T> rememberUpdatedState(newValue: T): State<T> = remember {
mutableStateOf(newValue)
}.apply { value = newValue }
// 相当于
@Composable
// fun <T> rememberUpdatedState(newValue: T): State<T> {
// val currentValue = mutableStateOf(newValue)
// currentValue.value = newValue
// retuen currentValue
// }
rememberUpdatedState()
这个 API 很简单,只有两行,代码也不难,简单得甚至让人有些摸不着头脑:创建一个被记住的 State 对象,然后再赋值... 拿来有什么用?为什么要专门封装这么个函数?
我们来看一个常见的场景,假设现在有一个闪屏页,代码如下:
@Composable
fun SplashScreen(onFinish: () -> Unit) {
LaunchedEffect(key1 = onFinish) {
delay(1500)
onFinish()
}
/* Landing screen content */
}
这段代码其实存在一个潜在的问题,我们给 LaunchedEffect 传入了一个 lambda,它是一个长生命周期的 lambda,如果在 delay 期间,SplashScreen(onFinish)
被新的参数调用,LaunchedEffect 就会被重启(因为 onFinish
是它的 key
),从而导致 delay 重新计时,但我们并不希望新的 onFinish
会触发 LaunchedEffect 重启,所以可以这么改:
@Composable
fun SplashScreen(onFinish: () -> Unit) {
- LaunchedEffect(key1 = onFinish) {
+ LaunchedEffect(key1 = Unit) {
delay(1500)
onFinish()
}
/* Landing screen content */
}
问题被解决了吗?并没有,虽然此时 onFinish
的新值确实不会触发 LaunchedEffect 重启了,但这样改又引发了一个新的问题:如果在 delay 期间,SplashScreen(onFinish)
被新的参数调用,此时 LaunchedEffect 不会重启,但 delay 结束后执行的 onFinish()
是旧值而不是最新值!🚨 因为在 SplashScreen 内部启动的 LaunchedEffect 捕获的是来自 SplashScreen 外部的旧 State 值(指旧 onFinish),它无法感知到外部 State 的变化。
那怎么办呢?难道问题无解?不是的,其实问题很好解决:
@Composable
fun SplashScreen(onFinish: () -> Unit) {
+ var currentOnFinish by remember { mutableStateOf(onFinish) } // 创建一个被记住的 State 对象,注意这行只会被执行 1 次
+ currentOnFinish = onFinish // 保证 currentOnFinish 永远为最新值
LaunchedEffect(key1 = Unit) {
delay(1500)
- onFinish()
+ currentOnFinish() // 使用最新值
}
/* Landing screen content */
}
新增的两行好像有点眼熟,不就是 rememberUpdatedState()
吗?原来是这么用的啊:
@Composable
fun SplashScreen(onFinish: () -> Unit) {
- var currentOnFinish by remember { mutableStateOf(onFinish) }
- currentOnFinish = onFinish
+ val currentOnFinish by rememberUpdatedState(onFinish)
LaunchedEffect(key1 = Unit) {
delay(1500)
currentOnFinish()
}
/* Landing screen content */
}
rememberUpdatedState()
函数的使用场景实在有些特殊,让我们来总结一下:
- 要在长生命周期 lambda 里使用某个
State<T>
的最新值(常见于LaunchedEffect
或DisposableEffect
); - 这个
State<T>
的值可能会在重组中被更新; - 不希望
State<T>
的更新触发长生命周期 lambda 的重新执行(例如导致LaunchedEffect
或DisposableEffect
重启)。
rememberCoroutineScope
相信大家都用过 lifecycleScope.launch { ... }
,通过这种方式创建出来的协程,会和 activity 的生命周期绑定。当退出 activity 时,与其生命周期绑定的所有协程将被取消。 Composable 组件拥有自己的生命周期,有什么办法能创造出和 Composable 组件生命周期绑定的协程吗?什么?你说 LaunchedEffect?是,绝大部分情况下,使用 LaunchedEffect()
足矣,但 LaunchedEffect()
是一个 Composable 函数,它只能在 Composable 环境中被调用。有时候,我们需要在非 Composable 环境中使用协程,并且让这个协程和 Composable 组件的生命周期绑定,听上去有点离谱,我们来看以下例子:
@Composable
fun MyScreen(snackbarHostState: SnackbarHostState) {
Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
) { contentPadding ->
Column(Modifier.padding(contentPadding)) {
Button(
onClick = {
协程.launch {
snackbarHostState.showSnackbar("Hello!") // 挂起方法
}
}
) {
Text("Show Snackbar")
}
}
}
我们需要在点击回调中执行 showSnackbar()
方法,因为这是一个挂起方法,所以需要协程环境,而且协程要和 MyScreen 组件的生命周期绑定,因为我们希望协程在 MyScreen 退出组合后自动取消。
这时我们就可以使用 rememberCoroutineScope()
,它会返回一个与当前 Composable 组件生命周期绑定的 CoroutineScope 对象,随后我们就可以通过 CoroutineScope.launch { }
开启新的协程了:
@Composable
fun MyScreen(snackbarHostState: SnackbarHostState) {
// Creates a CoroutineScope bound to the MyScreen's lifecycle
val scope = rememberCoroutineScope()
Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
) { contentPadding ->
Column(Modifier.padding(contentPadding)) {
Button(
onClick = {
// Create a new coroutine in the event handler to show a snackbar
scope.launch {
snackbarHostState.showSnackbar("Hello!")
}
}
) {
Text("Show Snackbar")
}
}
}
另外,如果需要手动控制一个或多个协程的生命周期,我们也同样可以使用 rememberCoroutineScope()
。
produceState
在使用 Compose 时,很多时候我们会遇到这么一种情况:需要将非 Compose 状态转换成 Compose 状态。比如我们在使用高德地图定位 SDK,要将定位点信息显示在屏幕上,那么我们需要一个 State<AMapLocation>
对象,并在定位回调中去更新这个 State 对象,我们可以利用前面学过的 DisposableEffect()
来实现,进入组合时订阅回调,退出组合时取消回调:
@Composable
fun MyScrren() {
val context = LocalContext.current
val locationClient = remember { AMapLocationClient(context) }
var location by remember { mutableStateOf(AMapLocation(DefaultLocation)) }
Text(text = location.toStr())
DisposableEffect(Unit) {
val aMapLocationListener = AMapLocationListener { aMapLocation -> location = aMapLocation }
locationClient.setLocationListener(aMapLocationListener)
locationClient.startLocation()
onDispose {
locationClient.unRegisterLocationListener(aMapLocationListener)
locationClient.stopLocation()
}
}
}
包括 LiveData,同样也可以用这种方式将其转换为 Compose State,不过官方已经为我们封装了一个拓展函数 LiveData.observeAsState()
,它的实现原理和上面的例子是一样的:
@Composable
fun <T : Any?> LiveData<T>.observeAsState(): State<T?>
// 需要引入 "androidx.compose.runtime:runtime-livedata:<version>"
LiveData 坟头草 🪦 比人还要高了... 算了不提它,那 Kotlin Flow 的能转换成 Compose State 吗?当然可以!但因为 Flow 要通过挂起函数 collect()
函数来收集,所以不能像上面那样用 DisposableEffect()
了,因为它没有协程环境。要协程,我们可以用 LaunchedEffect()
嘛:
@Composable
fun FlowToComposeState(viewModel: MyViewModel = viewModel()) {
var text by remember { mutableStateOf("") }
Text(text)
LaunchedEffect(Unit) {
viewModel.stateFlowString.collect {
text = it
}
}
}
// Flow 不需要在退出组合时手动关闭收集,退出组合后协程会自动关闭
与 LiveData.observeAsState()
类似,Compose 团队为我们提供了两个便捷函数:Flow.collectAsState()
和 Flow.collectAsStateWithLifeCycle()
。Flow.collectAsState()
是全平台通用的,被定义在 compose-runtime
库,使用时不需要引入额外依赖。Flow.collectAsStateWithLifeCycle()
只适用于 Android 平台,它能够以生命周期感知型方式从 Flow 中收集值,从而节省一定的资源,使用时需要引入额外依赖 androidx.lifecycle:lifecycle-runtime-compose:<version>
。
通过以上两种方式,我们可以将非 Compose 状态转换成 Compose 状态,其实还有第三种方式,那就是使用 produceState()
:
@Composable
fun <T> produceState(
initialValue: T,
producer: suspend ProduceStateScope<T>.() -> Unit // 挂起函数类型参数
): State<T>
来看看怎么用 produceState()
手动将 StateFlow 转换成 Compose State:
@Composable
fun FlowToComposeState(viewModel: MyViewModel = viewModel()) {
val text by produceState(initialValue = "") {
viewModel.stateFlowString.collect {
value = it // 新值赋值给 value
}
}
Text(text)
}
由 produceState()
的源码可以看出来它就是利用 LaunchedEffect 实现的。
@Composable
fun <T> produceState(
initialValue: T,
producer: suspend ProduceStateScope<T>.() -> Unit
): State<T> {
val result = remember { mutableStateOf(initialValue) }
LaunchedEffect(Unit) {
ProduceStateScopeImpl(result, coroutineContext).producer()
}
return result
}
既然是通过 LaunchedEffect 实现的,那是不是意味着 produceState()
不能用来转换那些需要在退出组合时取消订阅的状态?因为 LaunchedEffect 不能设置退出组合的回调啊。这么想没错,但 Compose 为我们提供了一个 awaitDispose()
函数,用它可以让我们在 produceState()
里实现退出组合时取消订阅的需求。
@Composable
fun MyScrren() {
val context = LocalContext.current
val locationClient = remember { AMapLocationClient(context) }
val location by produceState(initialValue = AMapLocation("")) {
val aMapLocationListener = AMapLocationListener { aMapLocation -> value = aMapLocation }
locationClient.setLocationListener(aMapLocationListener)
locationClient.startLocation()
awaitDispose { // awaitDispose 是一个挂起函数,lambda 的代码会在退出协程时执行,也就是退出组合时
locationClient.unRegisterLocationListener(aMapLocationListener)
locationClient.stopLocation()
}
}
Text(text = location.toStr())
}
小小总结一下,需要将非 Compose 状态转换成 Compose 状态,无脑用 produceState()
。
snapshotFlow
fun <T> snapshotFlow(block: () -> T): Flow<T>
snapshotFlow()
可用于将 Compose 的 State 转换为冷 Flow。
这很容易让人联想它是一个 produceState()
的完全镜像 API,这种想法并不正确,首先 produceState()
可以将任意非 Compose State 转换成 Compose State,但 snapshotFlow()
只能将 Compose State 转换成 Flow。其次 produceState()
的转换是一对一,但 snapshotFlow()
支持将一个或多个 Compose State 转换成一个 Flow,也就是一对一或多对一。
// Define Snapshot state objects
var greeting by mutableStateOf("Hello")
var person by mutableStateOf("Adam")
// 任意一个在 snapshotFlow 块中读取的 State 对象发生变化,且导致新的计算结果,就会发射新的值
val greetPersonFlow: Flow<String> = snapshotFlow { "$greeting, $person" }
注意,如果在 snapshotFlow 块中读取的 State 对象发生变化,但并未导致 lambda 返回值改变,这时 Flow 是不会发射值的。换句话说,即使把下面代码中的 num
依次修改为 1、2、3,也不会触发任何打印,因为 lambda 返回值始终为 true。(这种行为类似于 Flow.distinctUntilChanged()
)
var num by mutableIntStateOf(0)
val flow = snapshotFlow { num >= 0 }
collectionScope.launch {
flow.collect { println(it) }
}
snapshotFlow()
的一个常见应用场景是:Compose 状态的部分变化需要触发某些业务逻辑代码。比如以下例子:
@Composable
fun ExamplePage() {
val listState = rememberLazyListState()
LazyColumn(state = listState) { /*...*/ }
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.map { index -> index > 0 }
.distinctUntilChanged()
.filter { it == true }
.collect {
MyAnalyticsService.sendScrolledPastFirstItemEvent()
}
}
}
每当列表的第一个 item 被用户从屏幕内滑动到屏幕外,我们就将该事件统计上报后台。由于该事件与 Compose 状态 LazyListState.firstVisibleItemIndex
的部分变化相关,所以利用 snapshotFlow()
将其转换为 Flow,然后利用 Flow 的各种操作符来进行过滤,最终在收集流的时候上报事件。
到这里,关于 Compose 中的副作用以及和副作用相关的 API 终于讲完了,内容可真是不少,学了这么多,好好犒劳一下自己吧。
有非常多的人(包括我)在往 Compose 迁移的路上,总是遇到些莫名其妙的 bug,某些代码的执行次数总是不符合预期,而且找不到问题在哪,其中绝大部分是由副作用引起的,所以我要恭喜你,啃下了这块硬骨头,多多练习,相信你以后需要在 Compose 里写副作用代码,应该是信手拈来。
参考: