什么是副作用?

Compose 最大的优势就是它的声明式编程。而掌握声明式 UI 的关键在于处理好副作用。

简单来说,副作用就是在可组合函数的执行,影响了函数外部的应用状态发生的变化。可组合函的重组非常频繁,如果在 Composable 里直接写从网络取数据、查数据库这种业务逻辑,因为说不定啥时候就重组了,这些操作就可能会多跑好几遍,搞出一堆 bug,或者把性能也给搞崩了。

为了解决这这类问题,Jetpack Compose 提供了一套副作用 API让我们安全地处理副作用。今天重点盘点最常用的三个 API : LaunchedEffectDisposableEffectSideEffect 。带大家从底层原理了解它们的功能,以后写代码心里会更有底气。

LaunchedEffect

LaunchedEffect 是使用频率最高的 API,它能让我们用一种能感知 Composable 生命周期(注意哈,不是 Activity 的生命周期)的方式去启动协程,而且只要指定的关键参数不变,里头的代码块就不会再执行。

这特性让它在处理一次性事件,像弹个 Toast、Snackbar,或者打点日志,再或者触发点业务逻辑的时候,特别好使。就像
Now in Android 项目里的代码示例这样:

val snackbarHostState = remember { SnackbarHostState() }
val isOffline by appState.isOffline.collectAsStateWithLifecycle()

// 如果用户没联网,就弹个Snackbar通知一下
val notConnectedMessage = stringResource(R.string.not_connected)
LaunchedEffect(isOffline) {
    if (isOffline) {
        snackbarHostState.showSnackbar(
            message = notConnectedMessage,
            duration = Indefinite
        )
    }
}

需要注意,LaunchedEffect 在底层会搞出一个新的协程作用域。这就意味着它主要是为了在可组合函数的作用域里,执行那些基于协程的任务设计的。而且一旦可组合函数从组合(Composition)里退出来,它的协程就会自动取消。

所以说,LaunchedEffect 特别适合用来搞数据获取、延迟效果,或者事件处理这些跟协程相关的活儿,要是就想执行个非挂起函数,用它就有点杀鸡用牛刀了。下面咱就深入看看它内部到底咋运作的:

@Composable
fun LaunchedEffect(
    key1: Any?,
    block: suspend CoroutineScope.() -> Unit
) {
    val applyContext = currentComposer.applyCoroutineContext
    remember(key1) { LaunchedEffectImpl(applyContext, block) }
}

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("LaunchedEffect already running!")
        job = scope.launch(block = task)
    }

    override fun onForgotten() {
        job?.cancel(LeftCompositionCancellationException())
        job = null
    }

    override fun onAbandoned() {
        job?.cancel(LeftCompositionCancellationException())
        job = null
    }
}

从它的内部实现能看出来,LaunchedEffect 会创建一个 LaunchedEffectImpl,然后把给定的键值存到内存里。要是键值变了,就用这参数再重新创建一个 LaunchedEffectImpl 实例。

再看 LaunchedEffectImpl 这个类,它实现了 RememberObserver,一开始会弄出个新的协程作用域。等可组合函数进入组合阶段,传进去的 lambda 表达式就在这个作用域里跑起来。等可组合函数离开组合的时候,协程作用域就自动取消了,这样就能把资源好好清理干净,也不用担心内存泄漏或者性能问题。

要是你的任务跟协程没啥关系,只是键值变了需要重新执行一下,用 LaunchedEffect 就有点浪费了。虽说创建协程作用域的开销一般不大,但要是根本用不着协程,那这开销就是多余的。这种情况,咱可以考虑用个轻量级的副作用处理库,像 RememberedEffect 处理非挂起任务可能就更合适。

还有个常见误区值得一提,有人觉得 LaunchedEffect 能感知 Android 的生命周期,其实不是那么回事儿。从内部实现能清楚看到,它只跟 Composable 生命周期挂钩,它不能感知 Activity、Fragment 的 onStop()onDestroy() 这些生命周期事件。这就意味着,要是在 LaunchedEffect 里启动个协程,结果 Android 组件(比如 Activity)被销毁了,除非你专门把这协程跟安卓生命周期绑定起来,不然这协程可能还在执行。

DisposableEffect

DisposableEffect 是另一个副作用处理 API。它适合把一些设置和清理逻辑,跟可组合函数的生命周期同步起来。一旦可组合函数离开组合,它就会提供一个清理的代码块。这特性让它在管理像监听器、回调,或者得手动销毁的广播接收器这些外部资源的时候,特别方便。

val lifecycleOwner = LocalLifecycleOwner.current
if (lifecycleOwner.lifecycle.currentState == Lifecycle.State.STARTED) {
    DisposableEffect(lifecycleOwner) {
        // 创建一个观察者,在我们记住的回调上触发事件
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) {
                // 干点啥事儿
            }
        }
        // 把观察者加到生命周期里
        lifecycleOwner.lifecycle.addObserver(observer)
        // 可组合函数离开组合的时候,把观察者去掉
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }
}

上面这例子就是用 DisposableEffectlifecycleOwner 注册了一个 LifecycleEventObserver,保证可组合函数离开组合的时候,能把这观察者安全移除,把相关资源清理得明明白白。下面再深挖一下它的内部原理:

@Composable
fun DisposableEffect(
    key1: Any?,
    effect: DisposableEffectScope.() -> DisposableEffectResult
) {
    remember(key1) { DisposableEffectImpl(effect) }
}

private class DisposableEffectImpl(
    private val effect: DisposableEffectScope.() -> DisposableEffectResult
) : RememberObserver {
    private var onDispose: DisposableEffectScope.() -> Unit = {}
    private var done: Boolean = false

    override fun onRemembered() {
        val result = DisposableEffectScope().effect()
        onDispose = result.onDispose
        done = result.done
    }

    override fun onForgotten() {
        if (!done) {
            onDispose()
        }
        onDispose = {}
    }

    override fun onAbandoned() {
        if (!done) {
            onDispose()
        }
        onDispose = {}
    }
}

class DisposableEffectScope {
    inline fun onDispose(
        crossinline disposable: () -> Unit
    ): DisposableEffectResult {
        return DisposableEffectResult(
            onDispose = disposable,
            done = false
        )
    }

    fun disposable(
        crossinline disposable: () -> Unit
    ): DisposableEffectResult {
        disposable()
        return DisposableEffectResult(
            onDispose = {},
            done = true
        )
    }
}

data class DisposableEffectResult(
    val onDispose: () -> Unit,
    val done: Boolean
)

DisposableEffect 的内部实现能看出,它会用传进去的效果创建一个 DisposableEffectImpl 实例,然后存到内存里。DisposableEffectImpl 类实现了 RememberObserver,当可组合函数进入组合阶段,它会在 DisposableEffectScope 里先创建一个效果。等可组合函数离开组合的时候,onDispose 函数就会自动调用,保证在可组合函数完全从组合里移除之前,把资源清理得妥妥当当。

SideEffect

SideEffect 主要是用来安全地通知那些在组合之外、不受组合管理的对象,告诉它们组合里状态发生变化了。这在触发那些依赖于 UI 最终稳定状态的副作用时,特别有用。

SideEffect 能避免在组合过程中执行操作带来的风险。要是直接在可组合函数里写效果,一旦状态变了,组合过程可能就会被打断。这就使得 SideEffect 在跟日志工具、分析工具,或者命令式 UI 组件这些外部系统打交道的时候,能派上大用场。

看下面这例子:

@Composable
fun FirebaseAnalytics(user: User) {
    val firebaseAnalytics = remember { FirebaseAnalytics.getInstance(context) }
    SideEffect {
        // 每次成功组合之后,更新Firebase Analytics
        if (user.hasThisMergedDataAttached) {
            firebaseAnalytics.setUserProperty("user_type", user.userType)
        }
    }
}

接下来看看内部是如何运作的:

@Composable
fun SideEffect(
    effect: () -> Unit
) {
    currentComposer.recordSideEffect(effect)
}

fun <T> changeSideEffect(
    effect: () -> T
): T {
    return currentComposer.recordSideEffect(effect)
}

乍一看这代码好像挺简单,但实际上 SideEffect 跟 Compose 运行时的底层联系非常紧密,通过 Compose 源码的注释可知,SideEffect 会在当前组合尝试成功,而且组合状态没被快照操作改掉的时候才运行。这就能防止在当前组合操作失败的时候,让相关对象处于不一致的状态。

副作用在重新组合的过程中是绝对不会运行的,它们都是在成功重新组合之后才会跑起来。也就是说,SideEffect 仅在每次成功组合之后才执行。

总结

本文带大家详细研究了 Jetpack Compose 里最常用的三个主要副作用处理 API。在声明式 UI 的世界里,状态能影响到执行行为的方方面面,所以把副作用管理好,对保证任务能正确、可预测地执行,是非常关键的。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐