Jetpack Compose 中的副作用(side effects)

Jetpack Compose 中的副作用(side effects)

什么是副作用?

side effects,中文翻译为"副作用"。Jetpack Compose 中的 side effects 并没有特殊的释义,它就是副作用的意思。如果你此前并未曾了解过相关知识,你大概率会对此感到困惑,副作用这个词和编程怎么看都不搭边。

的确,我们对"副作用"的第一印象,往往是它在医学上的意义,指药品往往有多种作用,作用于不同身体部位受体,治疗时利用其一种或一部分受体作用,其他作用或是受体产生作用即变成为副作用。

虽然副作用一词常被用来形容不良反应,但事实上副作用也可以指那些"有益处、意料之外"的效果。例如:X辐射线/X光一直被用做医学影像用途,人们原本把它的辐射线对人体产生的效果当成是副作用。但自从人们发现X辐射线/X光能够用来治疗肿瘤后,辐射线被应用为放射线疗法。在医学影像领域中被当成副作用的辐射线效果,在癌症治疗上反而成了消灭赘生物的正作用了。

在计算机科学邻域,副作用指的是一个函数的执行对函数外部状态产生的影响。

kotlin 复制代码
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 的值显示在屏幕上。

kotlin 复制代码
@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++ 将导致程序出错。

    kotlin 复制代码
    @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++               |            X2 次
        |     }                         |
        | }                             |
        ---------------------------------
          Text("Count: $items")
      }
    }
  2. 以不同顺序执行 Composable 函数的重组:

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

    kotlin 复制代码
    @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++               |     2️⃣
        |     }                         |
        | }                             |
        ---------------------------------     🔼
        -------------------------
        | Text("Count: $items") |             1️⃣
        -------------------------
      }                                  可能的运行顺序
    }
  3. 可以舍弃的重组

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

    kotlin 复制代码
    @Composable
    @Deprecated("Example with bug")
    fun ListWithBug(header: String, myList: List<String>) {
      var items = 0
    
      Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
          Text(header, style = MaterialTheme.typography.bodyLarge)
          Divider()
        ------------------------------
        | LazyColumn {               |
        |   items(myList) { item ->  |
        |     Text("Item: $item")    | 当仅 header 被更新时,
        |     items++                | 可能会被跳过执行
        |   }                        |
        | }                          |
        ------------------------------
          Text("Count: $items")
        }    
      }
    }

再多提一点,重组时 Composable 函数是会被放在后台线程并行执行的。这意味着,如果你在 Composable 函数中调用 viewModel 等方法,还会有线程安全隐患。

综上所述,由于 Composable 的生命周期和重组特征,Composable 函数中的副作用会导致程序的行为变得不可预期。 这就是为什么要避免在 Composable 函数中执行副作用操作的原因。

Effect API 三剑客

但是,又要说但是了,编程里的副作用是不可能完全避免的。如果我们就是要在 Composable 函数中执行副作用操作,该怎么办呢?比如有一个 Composable 界面,现需要对该界面进行埋点统计,那么显然需要在 Composable 函数中执行副作用操作。

kotlin 复制代码
@Composable
fun AnnualSummaryScreen(user: User) {
    Column {
        // UI ...
    }
    val analytics: FirebaseAnalytics = remember {
        FirebaseAnalytics()
    }
    analytics.logEvent("访问年度总结页面")
}

正因为副作用不可避免,Compose 提供了一系列的 Effect API,以帮助开发者以可预测的方式在 Composable 函数中执行副作用操作。

SideEffect

使用 SideEffect(),可以确保副作用代码在每次成功重组完成之后被执行 1 遍。

kotlin 复制代码
@Composable
@NonRestartableComposable
@ExplicitGroupsComposable
fun SideEffect(effect: () -> Unit): Unit

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

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

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

DisposableEffect

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

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

先来看看怎么使用 DisposableEffectResult()

kotlin 复制代码
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 重启,所谓重启,就是先执行退出组合的回调代码,然后再执行进入组合的回调代码。

我们把上面的例子改造一下:

kotlin 复制代码
var bool by remember { mutableStateOf(true) }
Switch(
    checked = bool,
    onCheckedChange = { bool = it }
)

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() 的用法基本讲清楚了,至于用途,很明显,就是让我们可以在进入界面或退出界面时执行某些代码,比如:路由统计(记录用户打开或关闭了哪些界面)、订阅的可观察对象(进入界面时订阅,退出界面时取消订阅)...

LaunchedEffect

kotlin 复制代码
@Composable
@NonRestartableComposable
fun LaunchedEffect(key1: Any?, block: suspend CoroutineScope.() -> Unit): Unit
// 因为函数类型参数 block 拥有协程上下文,所以可以在里面调用各种协程代码。

LaunchedEffect() 可以简单地理解为是一个协程版本的 DisposableEffect()。当进入组合时,便会启动一个新协程用于执行 block 代码块,当退出组合时,协程将会被取消。

至于参数 key,作用和 DisposableEffect() 函数的参数 key一样都是用于触发重启,当其发生改变时,会将上一个协程取消,再启动一个新协程执行 block 代码块。

kotlin 复制代码
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

kotlin 复制代码
@Composable
fun <T> rememberUpdatedState(newValue: T): State<T> = remember {
    mutableStateOf(newValue)
}.apply { value = newValue }

rememberUpdatedState() 这个 API 很简单,只有两行,代码也不难。简单得甚至让人有些摸不着头脑,这有啥用啊?

创建一个被记住的 State 对象,然后赋值... 拿来干嘛?为什么要专门封装这么个函数?

我们来看一个特殊场景:

kotlin 复制代码
// Landing Screen 通常指 app 首次启动后,用户看到的闪屏页,比如微信的地球启动页
@Composable
fun LandingScreen(onFinish: () -> Unit) {
    // 使用 LaunchedEffect 从网络加载数据,加载完成后调用 onFinish()
    LaunchedEffect(key1 = onFinish) {
        delay(SplashWaitTime) // 模拟从网络上加载数据
        onFinish()
    }
    /* Landing screen content */
}

上面的代码问题在于,我们不该把 onFinish 作为 key 传递给 LaunchedEffect,因为一旦 key 发生变化,LaunchedEffect 就会重启,delay() 又得从头再来一遍,这根本没必要,因为不管 LaunchedEffect 是否重启,执行效果是一样的。而且重启还白白浪费时机和资源。

在这种特殊情况下,因为重启 LaunchedEffect 或 DisposableEffect 的成本太高,而且重启和不重启的结果都一样,我们希望函数参数 onFinish 不要触发重启,那么我们可能会这么改:

kotlin 复制代码
@Composable
fun LandingScreen(onFinish: () -> Unit) {
    LaunchedEffect(key1 = Unit) { // 🆕 把 key 改为 Unit
        delay(SplashWaitTime)
        onFinish()
    }
    /* Landing screen content */
}

但这样又引发了一个新的问题:当正在 delay 时,如果函数被新的 onFinish 值调用,虽然 LaunchedEffect 不会重启,但 delay() 执行完之后的 onFinish() 是上一次的旧值!⚠

那怎么办呢?是不是只能妥协,牺牲时间和资源,选择重启?不是的,我们还可以这么写:

kotlin 复制代码
@Composable
fun LandingScreen(onFinish: () -> Unit) {
    var currentOnFinish by remember { mutableStateOf(onFinish) } // 🆕 记住参数
    currentOnFinish = onFinish // 🆕 在有新的 onFinish 调用函数时,更新记住的值
    
    LaunchedEffect(key1 = Unit) {
        delay(SplashWaitTime)
        currentOnFinish() // 🆕 使用最新值
    }
    /* Landing screen content */
}

哎,新增的两行好像有点眼熟,不就是 rememberUpdatedState() 吗?原来是这么用的啊

kotlin 复制代码
@Composable
fun LandingScreen(onFinish: () -> Unit) {
    val currentOnFinish by rememberUpdatedState(onFinish) // 🆕
    
    LaunchedEffect(key1 = Unit) {
        delay(SplashWaitTime)
        currentOnFinish()
    }
    /* Landing screen content */
}

rememberUpdatedState() 函数的使用场景实在有些特殊,让我们来总结一下:

  • 在长生命周期的 lambda 里引用某个外部变量(常见于 LaunchedEffectDisposableEffect );
  • 这个变量的值会在重组中被更新;
  • 希望使用这个变量的最新值;
  • 不希望新值导致长生命周期 lambda 的重新执行(例如导致 LaunchedEffectDisposableEffect 的重启)。

rememberCoroutineScope

相信大家都用过 lifecycleScope.launch { ... },通过这种方式创建出来的协程,会和 activity 的生命周期绑定。当退出 activity 时,与其生命周期绑定的所有协程将被取消。 Composable 组件拥有自己的生命周期,有什么办法能创造出和 Composable 组件生命周期绑定的协程吗?什么?你说 LaunchedEffect?是,绝大部分情况下,使用 LaunchedEffect() 足矣,但 LaunchedEffect() 是一个 Composable 函数,它只能在 Composable 环境中被调用。有时候,我们需要在非 Composable 环境中使用协程,并且让这个协程和 Composable 组件的生命周期绑定,听上去有点离谱,我们来看以下例子:

kotlin 复制代码
@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 { } 开启新的协程了:

kotlin 复制代码
@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() 来实现,进入组合时订阅回调,退出组合时取消回调:

kotlin 复制代码
@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(),它的实现原理和上面的例子是一样的:

kotlin 复制代码
@Composable
fun <T : Any?> LiveData<T>.observeAsState(): State<T?>
// 需要引入 androidx.compose.runtime:runtime-livedata

LiveData 坟头草 🪦 比人还要高了... 算了不提它,那 Kotlin Flow 的能转换成 Compose State 吗?当然可以!但因为 Flow 要通过挂起函数 collect() 函数来收集,所以不能像上面那样用 DisposableEffect() 了,因为它没有协程环境。要协程,我们可以用 LaunchedEffect() 嘛:

kotlin 复制代码
@Composable
fun FlowToComposeState(viewModel: MyViewModel = viewModel()) {
    var text by remember { mutableStateOf("") } 

    Text(text)
    LaunchedEffect(Unit) {
        viewModel.stateFlowString.collect {
            text = it
        }
    }
}
// Flow 不需要在退出组合时手动关闭收集,退出组合后协程会自动关闭

通过以上两种方式,我们可以将非 Compose 状态转换成 Compose 状态,其实还有第三种方式,那就是使用 produceState()

kotlin 复制代码
@Composable
fun <T> produceState(
    initialValue: T,
    producer: suspend ProduceStateScope<T>.() -> Unit // 挂起函数类型参数
): State<T>

来看看怎么用 produceState() 将 StateFlow 转换成 Compose State:

kotlin 复制代码
@Composable
fun FlowToComposeState(viewModel: MyViewModel = viewModel()) {
    val text by produceState(initialValue = "") {
        viewModel.stateFlowString.collect {
            value = it // 新值赋值给 value
        }
    }
    
    Text(text)
}

produceState() 的源码可以看出来它就是利用 LaunchedEffect 实现的。

kotlin 复制代码
@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() 里实现退出组合时取消订阅的需求。

kotlin 复制代码
@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

kotlin 复制代码
fun <T> snapshotFlow(block: () -> T): Flow<T>

snapshotFlow() 可用于将 Compose 的 State 转换为冷 Flow。

这很容易让人联想它是一个 produceState() 的完全镜像 API,这种想法并不正确,首先 produceState() 可以将任意非 Compose State 转换成 Compose State,但 snapshotFlow() 只能将 Compose State 转换成 Flow,目标类型不能是 LiveData 或其他 State。其次 produceState() 的转换是一对一,但 snapshotFlow() 支持将一个或多个 Compose State 转换成一个 Flow,也就是一对一或多对一。

kotlin 复制代码
// Define Snapshot state objects
var greeting = mutableStateOf("Hello")
var person = mutableStateOf("Adam")

// 重组时,两个 Compose State 中的任一个发生变化,都会发射新的 Flow
val greetPersonFlow: Flow<String> = snapshotFlow { "$greeting, $person" }

到这里,关于 Compose 中的副作用以及和副作用相关的 API 终于讲完了,内容可真是不少,学了这么多,好好犒劳一下自己吧。

有非常多的人(包括我)在往 Compose 迁移的路上,总是遇到些莫名其妙的 bug,而且找不到问题在哪,其中很大一部分都和副作用有关,所以我要恭喜你,啃下了这块硬骨头,多多练习,相信你以后需要在 Compose 里写副作用代码,应该是信手拈来。


参考:

相关推荐
simplepeng5 小时前
我的天,我真是和androidx的字体加载杠上了
android
l软件定制开发工作室5 小时前
Jetpack Architecture系列教程之(一)——Jetpack介绍
android jetpack
小猫猫猫◍˃ᵕ˂◍6 小时前
备忘录模式:快速恢复原始数据
android·java·备忘录模式
CYRUS_STUDIO8 小时前
使用 AndroidNativeEmu 调用 JNI 函数
android·逆向·汇编语言
梦否8 小时前
【Android】类加载器&热修复-随记
android
徒步青云8 小时前
Java内存模型
android
今阳9 小时前
鸿蒙开发笔记-6-装饰器之@Require装饰器,@Reusable装饰器
android·app·harmonyos
-优势在我13 小时前
Android TabLayout 实现随意控制item之间的间距
android·java·ui
hedalei14 小时前
android13修改系统Launcher不跟随重力感应旋转
android·launcher
Indoraptor15 小时前
Android Fence 同步框架
android