Jetpack Compose 中的副作用(side effects)

2,875 阅读17分钟

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 函数时应遵循最佳实践:

第一,快速,即避免在 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 函数的执行结果很可能不如我们所愿。

  1. 不可预测的重组:
    一次重组中,任一个 Composable 函数都可能会被多次执行。如果例子中的 Column() 函数在一次重组中被执行了两次,那么副作用 items++ 将导致程序出错。

    bug1.png

  2. 以不同顺序执行 Composable 函数的重组:
    你也许一直都以为 Composable 函数里的代码会按编写的顺序运行。但其实未必如此。如果某个 Composable 函数包含对其他 Composable 函数的调用,这些函数可以按任何顺序运行。Compose 可以选择识别出某些界面元素的优先级高于其他界面元素,从而首先绘制这些元素。

    bug2.png

  3. 可以舍弃的重组
    如果界面的某些部分失效,Compose 会尽力只重组需要更新的部分。这意味着任一个 Composable 函数或者 lambda 表达式都可能在重组时被跳过执行。其中的副作用代码可能会被跳过,这是非常危险的,这导致程序行为变得不可预期。

    bug3.png

再多提一点,重组时 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
一个或多次重组.png

可以把 SideEffect 简单的理解为是一个重组完成后的回调。

var text by remember { mutableStateOf("") }
Button(onClick = { text += "#" }) {
    SideEffect {
        Log.d(TAG, "SideEffect")
    }
    Text(text)
}

text 被更新后,Text 组件所在的 lambda 作用域会被重组,从而触发 SideEffect 的执行。

SideEffect.gif


DisposableEffect

@Composable
@NonRestartableComposable
fun DisposableEffect(key1: Any?, effect: DisposableEffectScope.() -> DisposableEffectResult): Unit
// 注意函数类型参数 `effect` 要求返回值为 DisposableEffectResult

如果说 SideEffect() 是为重组添加回调,那 DisposableEffect() 就是为进入组合和退出组合添加回调。

进入退出组合.png

先来看看怎么使用 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_demo1.gif


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() 函数

DisposableEffect_demo2.gif

第一行 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 代码块。

进入组合.png
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

LaunchedEffect.gif


一些和副作用相关的其他 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> 的最新值(常见于 LaunchedEffectDisposableEffect );
  • 这个 State<T> 的值可能会在重组中被更新;
  • 不希望 State<T> 的更新触发长生命周期 lambda 的重新执行(例如导致 LaunchedEffectDisposableEffect 重启)。

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 里写副作用代码,应该是信手拈来。




参考: