理解Jetpack Compose中副作用函数的内部原理

本文译自「Understanding the Internals of Side-Effect Handlers in Jetpack Compose」,原文链接proandroiddev.com/understandi...,由Jaewoong Eum发布于2025年4月10日。

近年来,Jetpack Compose生态呈指数级增长,现已被广泛用于构建 Android 应用的产品级UI。现在,我们可以说 Jetpack Compose 代表了 Android UI 开发的未来。

Compose 最大的优势之一是其声明式(declarative)方法------它允许开发者描述 UI 应该显示的内容,而框架则负责处理 UI 在底层状态发生变化时应如何更新。这种模型将焦点从命令式(imperative)UI 转移到更直观、更具响应式的思维方式。

然而,尽管声明式 UI有很多优势,但妥善管理副作用也至关重要。可组合函数可能会因各种原因(例如状态或参数的变化)而被重组,如果副作用处理不当,应用的行为可能会变得不可预测。

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

副作用(Side Effect)是啥?

副作用(Side Effect)是指发生在可组合函数作用域之外的应用状态变化。在 Jetpack Compose 中,由于状态变化、参数更新或其他事件触发的重组,可组合函数可能会频繁且不可预测地重新执行(译注:也就是说Compose的重组是不受开发者控制的)。因此,你不能假设一个可组合函数只会被调用一次。

换句话说,在可组合函数内部直接调用业务逻辑(例如从网络获取数据或查询数据库)是有风险的。由于潜在的重组,这些操作可能会无意中运行多次,从而导致错误或性能问题。

为了解决这个问题,Jetpack Compose 提供了一组专门用于以安全可控的方式管理副作用的API。这些 API包括 LaunchedEffect 、 DisposableEffect 、 SideEffect 、 rememberCoroutineScope 等等。在本文中,你将重点介绍三个最常用的处理程序------ LaunchedEffect 、 DisposableEffect 和 SideEffect ------并仔细研究它们的内部实现,以便更好地理解它们的底层工作原理。

LaunchedEffect

LaunchedEffect是Jetpack Compose中最常用的副作用处理 API之一。它允许你以可组合生命周期感知的方式(而非 Android 生命周期)启动协程,并确保除非指定的关键参数(keys)之一发生变化,否则不会重新执行提供的代码块。这种行为使得 LaunchedEffect 特别适合执行与特定状态相关的一次性事件,例如显示 Toast或Snackbar、记录事件或触发业务逻辑,正如你在 Now in Android 项目中的示例代码中所见:

Kotlin 复制代码
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 会在底层创建一个新的协程作用域。这意味着它主要用于在可组合函数作用域内执行基于协程的任务,并在可组合函数离开组合时自动取消其协程。因此,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 会在此范围内启动。当可组合项离开组合阶段时,协程范围会自动取消,从而确保资源得到正确清理,并避免潜在的内存泄漏或性能问题。

话虽如此,如果你的任务不涉及任何与协程相关的操作,而只是需要在键发生变化时重新执行,那么使用 LaunchedEffect 可能略显多余。虽然创建协程作用域的开销通常很小,但在实际不使用协程的情况下,它仍然是不必要的。在这种情况下,你可以考虑使用更轻量级的副作用处理程序库 (RememberedEffect),它更适合非暂停任务。

另一个常见的误解是LaunchedEffect能够感知 Android生命周期------但事实并非如此。从内部实现可以看出,LaunchedEffect的作用域完全限定于Jetpack Compose组合生命周期,与 Android组件(Activity和Fragment)的生命周期没有直接关联。

换句话说,它本身并不了解任何有关 Activity、Fragment 或 onStop()或 onDestroy()等生命周期事件的信息。这意味着,如果你在 LaunchedEffect 中启动一个协程,并且 Android 组件(例如 Activity)在可组合项未离开组合的情况下被停止或销毁,则该协程可能会继续运行,除非它明确与Android组件生命周期绑定。

DisposableEffect

DisposableEffect是Jetpack Compose 运行时提供的另一个副作用处理API。它允许你与可组合项的生命周期同步执行设置和清理逻辑。与LaunchedEffect不同,它提供了一个 DisposableEffectScope 作为接收器(receiver),使你能够定义一个清理代码块(clean-up code block),该代码块在可组合项离开组合时自动运行。这使得它非常适合管理需要显式卸载的外部资源,例如监听器、回调或广播接收器。

Kotlin 复制代码
val lifecycleOwner = LocalLifecycleOwner.current

// 如果 `lifecycleOwner` 发生变化,则释放并重置效果
DisposableEffect(lifecycleOwner) {
  // 创建一个观察者,触发我们记住的回调以发送分析事件
  val observer = LifecycleEventObserver { _, event ->
    if (event == Lifecycle.Event.ON_RESUME) {
      // do something
    } else if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) {
      // do something
    }
  }

  // Add the observer to the lifecycle
  lifecycleOwner.lifecycle.addObserver(observer)

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

上面的示例使用 DisposableEffect 将 LifecycleEventObserver注册到lifecycleOwner,使其能够观察生命周期变化并根据当前状态执行特定逻辑。观察者会在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 中启动。退出组合时,会自动调用 DisposableEffectResult的onDispose 函数,以确保在可组合项完全从组合中移除之前正确清理资源并防止内存泄漏或性能问题。

SideEffect

Jetpack Compose中的SideEffect API 用于安全地将可组合项内发生的状态变化通知给外部非 Compose 管理的对象。它确保效果在重组成功后运行,使其成为触发依赖于界面最终稳定状态的副作用的理想选择。

使用 SideEffect可以避免在重组阶段执行的操作可能会被丢弃的风险,如果你在未采取此保护措施的情况下直接在可组合项中编写效果,则可能会发生这种情况。因此,当你需要将 Compose 状态与外部系统(例如日志记录工具、分析工具或命令式界面组件)同步时,SideEffect 至关重要,如下例所示:

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

    // 每次成功组合后,使用当前用户的用户类型更新 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 API与 Compose运行时底层内部机制紧密相关,尤其是 ChangeList,它用于跟踪和管理用于更新渲染UI的状态驱动变更列表。

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

安排效果在当前合成成功完成并应用更改时运行。SideEffect 可用于将副作用应用于合成管理的、未受快照支持的对象,以便在当前合成操作失败时避免这些对象处于不一致的状态。

副作用将始终在合成的应用调度程序上运行,并且应用器永远不会与自身、彼此并发运行,也不会将更改应用于合成树或运行 RememberObserver 事件回调。SideEffect 始终在 RememberObserver 事件回调之后运行。

因此,SideEffect API 会在每次成功重组后运行。

结论

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

本主题最初在Dove Letter(译注:链接是github.com/doveletter/... Android 和 Kotlin 的每日见解,涵盖 Compose、架构、行业面试问题和实用代码技巧等主题。自上线以来的短短 37 周内,Dove Letter 已拥有超过 700 名个人订阅者和 20 名企业/终身订阅者。如果你渴望深入了解 Android、Kotlin 和 Compose,请务必查看"通过 Dove Letter 学习 Kotlin 和 Android"(译注:链接是medium.com/@skydoves/l...

欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

保护原创,请勿转载!

相关推荐
阿巴斯甜20 小时前
Android 报错:Zip file '/Users/lyy/develop/repoAndroidLapp/l-app-android-ble/app/bu
android
Kapaseker21 小时前
实战 Compose 中的 IntrinsicSize
android·kotlin
xq95271 天前
Andorid Google 登录接入文档
android
黄林晴1 天前
告别 Modifier 地狱,Compose 样式系统要变天了
android·android jetpack
冬奇Lab1 天前
Android触摸事件分发、手势识别与输入优化实战
android·源码阅读
城东米粉儿2 天前
Android MediaPlayer 笔记
android
Jony_2 天前
Android 启动优化方案
android
阿巴斯甜2 天前
Android studio 报错:Cause: error=86, Bad CPU type in executable
android
张小潇2 天前
AOSP15 Input专题InputReader源码分析
android
_小马快跑_2 天前
Kotlin | 协程调度器选择:何时用CoroutineScope配置,何时用launch指定?
android