Jetpack Compose的副作用一览

近年来,Jetpack Compose 生态系统呈指数级增长,如今它已广泛应用于构建安卓应用的生产级用户界面。现在,我们可以说 Jetpack Compose 代表了安卓用户界面开发的未来。Compose 最大的优势之一在于其声明式方法 ------ 它允许开发者描述用户界面应显示的内容,而框架则负责处理底层状态变化时用户界面的更新方式。这种模式将关注点从命令式的用户界面逻辑转移到了一种更直观、响应式的思维方式上。

然而,尽管声明式用户界面有诸多好处,但正确管理副作用变得至关重要。由于状态或参数的变化等各种原因,可组合函数可能会被重新组合。如果不谨慎处理副作用,应用程序的行为可能会变得不可预测。

在本文中,我们将将探索 Jetpack Compose 默认提供的副作用处理 API 。你还将研究它们的内部工作流程,以便更好地理解 Compose 在底层是如何管理这些操作的。

什么是副作用(Side Effect)?

副作用(Side Effect)是指在可组合函数范围之外发生的应用程序状态变化。

可能光听这个描述依然不能理解什么是副作用,我这里做一个比较简单的解释。

Jetpack Compose 中,由于状态变化、参数更新或其他事件触发的 Recomposition,可组合函数可能会频繁且不可预测地重新执行。因此,你不能假设一个可组合函数只会被调用一次。

换句话说,在可组合函数内部直接调用业务逻辑,例如从网络获取数据或查询数据库,是有风险的。由于可能会发生 Recomposition,这些操作可能会意外地多次运行,从而导致错误或性能问题(其实我们开发者并不希望执行多次)。

为了解决这个问题,Jetpack Compose 提供了一组专门用于安全、可控地管理副作用的 API ,这些 API 可以让我们的某些过程或者逻辑脱离可组合函数的重组去独立执行。其中包括 LaunchedEffectDisposableEffectSideEffectrememberCoroutineScope 等等。在本文中,你将重点关注三个最常用的处理程序 ------ LaunchedEffectDisposableEffectSideEffect ------ 并仔细研究它们的内部实现,以便更好地理解它们在底层是如何工作的。当然我也相信,接下来的学习会加深对于副作用的理解。

LaunchedEffect

LaunchedEffect 通常是 Jetpack Compose 中最常用的副作用处理 API 之一。它允许你以与可组合生命周期(而非安卓生命周期)相关的方式启动协程,并确保提供的代码块不会重新执行,除非指定的 key 参数发生变化。

这种行为使得 LaunchedEffect 特别适用于执行与特定状态相关的一次性事件,例如显示 ToastSnackbar、记录事件或触发业务逻辑,就像你在 Now in Android 项目的示例代码中看到的那样:

Kotlin 复制代码
val snackbarHostState = remember { SnackbarHostState() }
val isOffline by appState.isOffline.collectAsStateWithLifecycle()

// 如果用户未连接到互联网,则显示一个 Snackbar 通知他们
val notConnectedMessage = stringResource(R.string.not_connected)
// 使用 isOffline 当做 key
LaunchedEffect(isOffline) {
    if (isOffline) {
        snackbarHostState.showSnackbar(
            message = notConnectedMessage,
            duration = Indefinite,
        )
    }
}

需要记住的一点是,LaunchedEffect 在底层会创建一个新的协程作用域。这意味着它主要用于在可组合函数的范围内执行基于协程的任务,并且当可组合函数离开组合时,它会自动取消其协程。

因此,LaunchedEffect 最适合用于与协程相关的操作,例如数据获取、延迟效果或事件处理,而不是简单地执行非挂起函数。现在,让我们深入了解一下 LaunchedEffect 在内部是如何工作的。

Kotlin 复制代码
@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("Old job was still 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 接口,并最初创建了一个新的 CoroutineScope。当可组合函数进入组合阶段时,提供的 lambda 表达式会在这个作用域内启动。当可组合函数离开组合时,协程作用域会自动取消,确保资源得到正确清理,避免潜在的内存泄漏或性能问题。

也就是说,如果你的任务不涉及任何与协程相关的操作,只是需要在 key 变化时重新执行,使用 LaunchedEffect 可能会有点大材小用。虽然创建协程作用域的开销通常很小,但在实际上不使用协程的情况下,这仍然是不必要的。在这种情况下,你可以考虑使用一个更轻量级的第三方副作用处理库(RememberedEffect),它更适合非挂起任务。

一个常见误解是------LaunchedEffect 了解安卓生命周期。但事实并非如此。从内部实现可以看出,LaunchedEffect 完全局限于 Jetpack Compose 组合生命周期,与安卓生命周期没有直接联系。

换句话说,它本身对 ActivityFragment 或像 onStop()onDestroy() 这样的生命周期事件一无所知。这意味着,如果你在 LaunchedEffect 内部启动一个协程,并且安卓组件(例如一个活动)停止或销毁,但可组合函数没有离开组合,那么协程可能会继续运行,除非它明确与安卓生命周期绑定。

LaunchedEffect 有一个更加常见的用法------LaunchedEffect(Unit){ },即在整个可组合函数的声明周期,他只执行一次。这个用法多用于可组合函数的第一次执行,当然,如果你关心可组合函数的生命周期,那就继续往下看吧。

DisposableEffect

DisposableEffectJetpack Compose 运行时提供的另一个副作用处理 API 。它允许你与可组合函数的生命周期同步并执行初始化和清理的逻辑。与 LaunchedEffect 不同,它提供了一个 DisposableEffectScope 作为接收器,使你能够定义一个清理过程,当可组合函数离开组合时,该清理过程会自动运行。这使得它非常适合管理需要显式释放的外部资源,如监听器、回调或广播接收器。

Kotlin 复制代码
val lifecycleOwner = LocalLifecycleOwner.current

// 如果 `lifecycleOwner` 发生变化,则处理并重置效果
DisposableEffect(lifecycleOwner) {
    // 创建一个观察者,触发我们记忆的回调
    // 用于发送分析事件
    val observer = LifecycleEventObserver { _, event ->
        if (event == Lifecycle.Event.ON_RESUME) {
            // 执行某些操作
        } else if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) {
            // 执行某些操作
        }
    }

    // 将观察者添加到生命周期中
    lifecycleOwner.lifecycle.addObserver(observer)

    // 当效果离开组合时,移除观察者
    onDispose {
        lifecycleOwner.lifecycle.removeObserver(observer)
    }
}

上面的示例使用 DisposableEffectlifecycleOwner 注册了一个 LifecycleEventObserver,使其能够观察生命周期变化并根据当前状态执行特定逻辑。观察者在 onDispose 块中被安全地移除,确保在可组合函数离开组合时进行正确的清理。现在,让我们深入了解一下 DisposableEffect 在内部是如何工作的。

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

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() {
        // 由于 [onRemembered] 未被调用,所以无需执行任何操作
    }
}

class DisposableEffectScope {
    inline fun onDispose(
        crossinline onDisposeEffect: () -> Unit
    ): DisposableEffectResult = object : DisposableEffectResult {
        override fun dispose() {
            onDisposeEffect()
        }
    }
}

DisposableEffect 的内部实现可以看出,它创建了一个 DisposableEffectImpl 实例,并使用提供的键将其存储在内存中。每当键发生变化时,就会创建一个新的 DisposableEffectImpl 实例,从而允许相应地重新执行效果。

DisposableEffectImpl 类实现了 RememberObserver 接口,并最初创建了一个 DisposableEffectResult。当可组合函数进入组合阶段时,效果 lambda 表达式会在 DisposableEffectScope 内启动。当可组合函数离开组合时,DisposableEffectResultonDispose 函数会自动调用,确保在可组合函数完全从组合中移除之前进行正确的资源清理,防止内存泄漏或性能问题。

SideEffect

Jetpack Compose 中的 SideEffect API 用于安全地将可组合组件内发生的状态变化通知给外部非 Compose 管理的对象。它确保在成功 Recomposition 后运行该副作用,这使得它非常适合触发依赖于用户界面最终稳定状态的副作用。

使用 SideEffect 可以避免在 Recomposition 阶段执行可能随后被丢弃的操作的风险。如果你在可组合组件中直接编写副作用而没有这种保护措施,就可能出现这种情况。

如下例所示,当您需要将 Compose 状态与日志工具、分析工具或命令式用户界面组件等外部系统同步时,SideEffect 就非常重要:

Kotlin 复制代码
@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
    val analytics: FirebaseAnalytics = remember {
        FirebaseAnalytics()
    }

    // 在每次成功组合时,使用当前用户的 userType 更新 FirebaseAnalytics
    // 确保未来的分析事件附带此元数据
    SideEffect {
        analytics.setUserProperty("userType", user.userType)
    }
    return analytics
}

现在,让我们来探索一下 SideEffect API 在底层是如何工作的。

Kotlin 复制代码
@Composable
fun SideEffect(
    effect: () -> Unit
) {
    currentComposer.recordSideEffect(effect)
}

/** 安排在应用组合更改时运行副作用 */
override fun recordSideEffect(effect: () -> Unit) {
    changeListWriter.sideEffect(effect)
}

乍一看,上面的代码可能看起来简单却难以理解,这很正常。因为 SideEffect APICompose 运行时的底层内部机制密切相关,特别是 ChangeList,它跟踪和管理用于更新渲染用户界面的状态驱动变化列表。

根据 Compose 源代码中的内部注释,SideEffect API 的表示如下:

安排在当前组合成功完成并应用更改时运行副作用。SideEffect 可用于对由组合管理但没有快照支持的对象应用副作用,这样如果当前组合操作失败,就不会使这些对象处于不一致的状态。

副作用始终会在组合的调度器上运行,并且永远不会与自身、其他、组合树的应用更改、RememberObserver 事件回调产生并发。SideEffect 始终在 RememberObserver 事件回调之后运行。

因此,SideEffect API 在每次成功 Recomposition 后运行。

总结

在本文中,你探索了 Jetpack Compose 中常用的三个主要副作用处理 API。由于声明式用户界面的性质,状态会影响运行时行为的许多方面,因此正确处理副作用对于确保任务的正确和可预测执行至关重要。

相关推荐
CYRUS STUDIO21 分钟前
逆向某物 App 登录接口:还原 newSign 算法全流程
android·python·安全·app·逆向·app加固·脱壳
tom4i42 分钟前
Android13 Launcher3 桌面图标添加 HUAWEI HiCar 和 ICCOA 角标
android
kymjs张涛1 小时前
前沿技术周刊 2025-06-23
android·ios·app
Wgllss1 小时前
Kotlin+协程+FLow+Channel,实现生产消费者模式3种案例
android·架构·android jetpack
东风西巷3 小时前
MolyCamCCD复古胶片相机:复古质感,时尚出片
android·数码相机·智能手机·软件需求
恋猫de小郭4 小时前
Flutter 里的像素对齐问题,深入理解为什么界面有时候会出现诡异的细线?
android·前端·flutter
liang_jy4 小时前
Java 线程实现方式
android·java·面试
CYRUS_STUDIO4 小时前
逆向某物 App 登录接口:热修复逻辑挖掘隐藏参数、接口完整调用
android·app·逆向
BoomHe4 小时前
Android 源码两种执行脚本的区别
android·源码