深入理解 Compose 副作用

各位 Compose 爱好者们,你们好。

如果你去打开任何一个 Compose 页面源码,几乎总能看到各种 Effect API。

可能我们自己也会这样写代码:

  • userId 发生变化时,我们会用 LaunchedEffect(userId) { viewModel.load(userId) } 发起加载;
  • 需要注册并注销监听器时,会写下 DisposableEffect(owner) { owner.register(cb); onDispose { owner.unregister(cb) } }
  • 按钮点击后需要启动协程,就调用 rememberCoroutineScope();需要把 Compose 中的状态同步到外部对象,则可能使用 SideEffect { analytics.setCurrentScreen(name) }

这些 Effect API 实在太常用了,但是你们有没有想过:它们内部究竟做了什么?这么做,又付出了什么代价?

大多数开发者对 LaunchedEffect 的理解都是:当 key 发生变化时,重新执行这段代码。

这个理解不能算错,但并没有说明 Runtime 真正做的事情。

每当 key 改变,Compose 都会取消一个正在运行的协程,再启动一个全新的协程。每个 LaunchedEffect 背后都有真实存在的 CoroutineScopeJob 和协程------即便传入的 block 从来不会挂起,也是这样。

假设 Effect 中只有一次同步调用,只是希望在 key 变化时执行,那么我们其实为一个根本不需要协程的任务创建了协程。

要理解为什么会这样,以及什么时候应该换成更轻量的方案,就需要继续深挖这些 Effect API。

最后你会发现,它们中的大多数都建立在同一个接口之上:RememberObserver

这篇文章会从 RememberObserver 出发,依次看看下面这些问题:

  • Effect 为什么需要在组合生命周期中拥有一个明确的位置;
  • remember 如何在 key 变化后创建新对象;
  • LaunchedEffectImpl 如何在对象进入、离开 Composition 时启动和取消协程;
  • Effect 协程中的 Job 和帧时钟从哪里来;
  • DisposableEffectSideEffectrememberCoroutineScope 在实现上有什么区别;
  • onRememberedonForgottenonAbandoned 分别对应怎样的组合生命周期;
  • 当任务不需要协程时,RememberedEffect 为什么能够省掉这部分开销。

声明式 UI 中执行命令式代码

Composition 是声明式的,也允许重复执行。一个 Composable 函数可能先执行一次,下一次重组时再执行一次,之后还会继续执行,并没有固定的调用次数。

只用它来描述 UI 没什么问题,但如果要执行一些必须控制次数的命令式任务,那就会碰到问题。

例如,我们可以直接在 Composable 中发起一次加载:

kotlin 复制代码
@Composable
fun UserScreen(userId: String, viewModel: UserViewModel) {
    viewModel.load(userId) // 每次重组都会执行
    val user by viewModel.user.collectAsStateWithLifecycle()
    UserContent(user)
}

只要 UserScreen 发生重组,viewModel.load(userId) 就会再次执行。如果页面的其他位置正在播放动画,它甚至可能在一秒内被调用几十次。

我们肯定不希望这段代码这样执行,其实我们只想:每个 userId 只加载一次,而不是每次重组都加载一次。

当然了,这里还暴露了另一个问题:清理逻辑应该写在哪里?如果页面离开组合树,我们没有任何机会可以取消仍在进行的请求。

Effect API 的作用,就是让命令式代码在组合生命周期中拥有一个明确的位置。它解决了直接调用无法回答的三个问题:

  1. 这段代码应该执行多少次?
  2. 什么时候清理?
  3. 如果本次 Composition 在提交前被丢弃,已经安排的任务应该怎么办?

这些问题最终都落在同一套机制上。我们先从这套机制的入口开始看。

四种 Effect API

Compose 中有四种经常使用的 Effect API:

  • LaunchedEffect:在协程中执行 suspend block;当 key 改变时重启(也就是会重新执行代码)。
  • DisposableEffect:执行一段带 key 的初始化代码;key 变化或 Effect 离开 Composition 时,执行与之对应的清理逻辑。
  • SideEffect:每次 Composition 成功提交后执行一个普通 block
  • rememberCoroutineScope:返回一个 CoroutineScope,允许我们从组合过程之外启动协程,例如在点击事件中启动。

其中三种 API 的底层结构几乎相同:调用 remember 创建一个实现了 RememberObserver 的对象,真正的生命周期逻辑就写在这个接口中。

kotlin 复制代码
public interface RememberObserver {
    public fun onRemembered()
    public fun onForgotten()
    public fun onAbandoned()
}

接口很小,我们先来看下:

  • onRemembered:对象已经通过某次 Composition 提交到 Slot Table 后,在 Composition 的 apply 线程上调用。
  • onForgotten:对象不再被任何位置记住时调用,同样运行在 apply 线程上。可能是它所在的 Group 离开了组合树,也可能是整个 Composition 被销毁。
  • onAbandoned:如果对象是在一次从未 apply 的 Composition 中,由 remember 创建出来的,那么不会调用 onRemembered,而是调用 onAbandoned

它的 KDoc 还给出了两个很重要的保证:

  1. 一个对象在同一处被 remember 后,只会收到 onRememberedonAbandoned 中的一个,绝不会两个都收到。
  2. 多个对象一起被记住时,onForgotten 的调用顺序与 onRemembered 相反。

SideEffect 是这里的例外,稍后单独分析。

剩下三种 API 都可以归纳成同一种思路:用 remember 把一个 RememberObserver 放进 Slot Table,再通过三个回调完成真正的工作。

这里针对 onAbandoned,我需要特别说明一下,这个可能比较混淆:

  • onForgotten:对象已经成功进入过 Composition,现在被移除了。
  • onAbandoned:对象虽然被 remember 创建出来了,但这次 Composition 没有成功提交。

什么意思?虽然有点残忍,我用入职离职来举例子可能最贴切:

  • onForgotten:你已经入职了,现在离职了,这个就是 onForgotten
  • onAbandoned:你正在办理入职手续,此时公司说不要这个岗位了,那咋整?onAbandoned

也就是说,如果已经 onRemembered 了(入职了),那么后续只会走 onForgotten(离职)。当然,对于还没有 onRemembered 的,只能走 onAbandoned(还没签合同,岗位就撤了) 了。

所以,实际上 onAbandonedonRemembered + onForgotten 是互斥的!

remember 才是真正的基础

如果看源码,会发现 Effect 函数本身几乎没做什么。

下面就是 LaunchedEffect(key1) 的完整实现:

kotlin 复制代码
@Composable
@NonRestartableComposable
public fun LaunchedEffect(
    key1: Any?,
    block: suspend CoroutineScope.() -> Unit
) {
    val applyContext = currentComposer.applyCoroutineContext
    remember(key1) { LaunchedEffectImpl(applyContext, block) }
}

只有两步:

  1. 获取一个协程上下文;
  2. 调用 remember(key1),创建并保存 LaunchedEffectImpl

我们平时认为属于 LaunchedEffect 的行为,包括"key 变化时重新启动",实际上都来自 remember(key1)

所以问题变成了:key 发生变化时,remember 到底做了什么?

remember 底层会调用 Composer.cache。它先读取当前 Slot 中保存的值,再决定继续使用旧值,还是重新计算:

kotlin 复制代码
public inline fun <T> Composer.cache(
    invalid: Boolean,
    block: () -> T
): T {
    return rememberedValue().let {
        if (invalid || it === Composer.Empty) {
            val value = block()
            updateRememberedValue(value)
            value
        } else it
    } as T
}

remember(key1) { ... } 会把 currentComposer.changed(key1) 的结果作为 invalid 传进来。如果本次 key 与上一次保存的 key 不同,invalid 就是 true

整个判断可以概括为:

  • key 已经改变,或者 Slot 中还没有值:执行 factory,保存新值;
  • key 没有改变:直接返回原来的缓存值。

key 改变后,remember 会创建一个全新的对象,而原来保存在 Slot 中的对象则会被安排离开 Composition。

这一点至关重要。

key 变化并不是简单地把原来的 Effect 重新执行一遍。

更准确地说,是 Runtime 创建了一个新的 RememberObserver,同时忘记旧的 RememberObserver。Effect 最终表现出什么行为,取决于新旧两个对象在 onRememberedonForgotten 中分别做了什么。

拆解 LaunchedEffect:藏在背后的协程

接着看 remember 保存的对象。

LaunchedEffectImpl 实现了 RememberObserver,协程正是在它的回调中出现的:

kotlin 复制代码
internal class LaunchedEffectImpl(
    private val parentCoroutineContext: CoroutineContext,
    private val task: suspend CoroutineScope.() -> Unit,
) : RememberObserver, CoroutineExceptionHandler {
    private val scope = CoroutineScope(parentCoroutineContext + this)
    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
    }
}

这个类在构造时创建了一个 CoroutineScope,并在内部保存一个可空的 Job

当它被 Composition 记住时,onRemembered 会在这个 Scope 中启动 task,并保存返回的 Job。当它被忘记或被放弃时,则使用 LeftCompositionCancellationException 取消 Job,然后把引用清空。

onRemembered 中还有一行:

kotlin 复制代码
job?.cancel("Old job was still running!")

这是一层防御性处理。正常情况下,新创建的 LaunchedEffectImpl 还没有启动过任务,job 应该是 null,所以这行代码通常什么也不会做。

这个类还实现了 CoroutineExceptionHandler,并把自己放进了 Scope 的上下文。源码中的解释是:这样可以少创建一个单独的 Handler 对象,省掉一次对象分配。

把这部分逻辑和上一节的 remember 合起来,key 变化时的逻辑我们一下就清楚了。

假设我们写下:

kotlin 复制代码
LaunchedEffect(userId) {
    load(userId)
}

现在 userIdA 变成 B

  1. 重组时,remember(B) 会比较已经保存的 key A 和新 key B。两者不同,于是执行 factory,创建一个新的 LaunchedEffectImpl,它拥有自己的 Scope;旧的 Impl 则被安排进入 forgotten 流程。
  2. Composition 进入 apply 阶段后,Runtime 先调用旧 Impl 的 onForgotten,取消仍在执行 load(A) 的协程;再调用新 Impl 的 onRemembered,启动一个新协程执行 load(B)

所以,"key 改变后重启 Effect"在底层的真正含义是:

取消旧协程,再启动一个新协程。

整个过程由 remember 创建不同的对象实例来驱动。LaunchedEffect 函数本身甚至没有直接判断 key

当页面彻底离开组合树时,只会发生 onForgotten:协程被取消,也不会再启动新的任务。这就是 Composable 消失后,仍在执行的 LaunchedEffect 为什么能够自动停止。

Job 从哪里来

LaunchedEffectImpl 使用 parentCoroutineContext + this 创建 Scope,却没有自己添加一个 Job()

那么,最终启动的协程以谁作为父 Job

为什么我们可以直接在 LaunchedEffect 中调用 withFrameNanos 或各种 animate* API,而不需要做任何额外配置?

答案就在 LaunchedEffectcurrentComposer.applyCoroutineContext 中取得的上下文里。

这个上下文由 Recomposer 组装。它会在外部传入的 Effect Context 上,再添加两个元素:

kotlin 复制代码
override val effectCoroutineContext: CoroutineContext =
    coroutineContext + broadcastFrameClock + effectJob

broadcastFrameClock 是一个 MonotonicFrameClockeffectJob 则是所有 Effect 任务的父 Job

由于 applyCoroutineContext 已经包含 Job,标准的 CoroutineScope(context) 工厂不会再额外添加一个。因此,Effect 协程的 Job 会成为 Recomposer.effectJob 的子 Job。

这样会产生两个结果:

  1. 当整个 Recomposer 被取消时------通常意味着 Composition 正在被销毁------所有 Effect 协程都会通过父子 Job 关系一起被取消。

  2. 帧时钟也在同一个协程上下文中,所以 withFrameNanos 可以直接找到它:

    kotlin 复制代码
    public suspend fun <R> withFrameNanos(
        onFrame: (frameTimeNanos: Long) -> R
    ): R = coroutineContext.monotonicFrameClock.withFrameNanos(onFrame)

这就是为什么写在 LaunchedEffect 中的动画能够自然地与屏幕刷新同步。负责取消的 Job 和负责提供帧时间的 Frame Clock,都是通过同一份上下文传进来的。

隐藏的开销

如果 Effect 中执行的本来就是异步任务,那么前面这些机制正是我们需要的。

收集 Flow、调用 suspend 网络请求、执行 delay 或运行动画,都离不开协程、用于取消的 Job 和帧时钟。

LaunchedEffect 把这些东西都准备好了。

那么,如果 block 从不挂起呢?

例如下面这种写法很常见:

kotlin 复制代码
LaunchedEffect(count) {
    analytics.log("count changed to $count") // 同步调用,不会挂起
}

我们的需求只是:每当 count 改变时,执行一次这行代码。

但实际得到的是:创建一个 LaunchedEffectImpl 和一个 CoroutineScope;每次 key 变化时,先取消协程,再启动一个新协程,只为执行一句同步代码。

协程启动后,block 从头运行到结束,中间一次都没有挂起,然后协程结束。前面的那一整套机制并没有发挥作用。

更轻量的做法,是使用一个 RememberObserver,直接在 onRemembered 中执行 block,不创建 Scope,也不需要 Job

这里给出一个最简单的实现:

kotlin 复制代码
@Composable
@NonRestartableComposable
public fun RememberedEffect(vararg keys: Any?, effect: () -> Unit) {
    remember(*keys) { RememberedEffectImpl(effect = effect) }
}

/**
 * Launches the provided [effect] lambda when it enters the composition.
 */
internal class RememberedEffectImpl(private val effect: () -> Unit) : RememberObserver {

    override fun onRemembered() {
        effect.invoke()
    }

    override fun onAbandoned() {
        // no-op
    }

    override fun onForgotten() {
        // no-op
    }
}
  • LaunchedEffect 适合协程任务,因为每次 key 改变后,它都会创建新的 Effect 对象并重新启动任务;
  • RememberedEffect 不需要在 key 改变后创建并启动协程,因此更适合只想在 key 变化时执行同步副作用的场景。

它的使用方式与 LaunchedEffect 几乎是一样的:

kotlin 复制代码
var count by remember { mutableIntStateOf(0) }

RememberedEffect(count) {
    Log.d(tag, "$count")
}

RememberedEffect 内部仍然通过 remember(key1) 保存一个 RememberObserver,并在 onRemembered 中执行 blockkey 变化的处理方式完全没有变,只是去掉了协程。

这样,你就可以按照如下的规则进行选择了:

  • block 中需要调用 suspend 函数:使用 LaunchedEffect,因为确实需要协程、取消机制和帧时钟。
  • block 只执行同步任务,并且只需要响应 key 变化:无协程的 Effect 可以避免创建、取消和重新启动协程的开销。

其实 Compose Runtime 本身就提供了一种不依赖协程的 Effect,也就是接下来要讲的 DisposableEffect,只不过它在很多项目中没有得到充分使用。

DisposableEffect:不依赖协程

DisposableEffect 很早就存在于 Compose Runtime 中,它本身不需要协程。

它的 Impl 同样是一个 RememberObserver,只是回调里执行的是普通代码:

kotlin 复制代码
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 未执行,因此不需要清理
    }
}

对象被记住时,onRemembered 会执行我们传入的 effect,并保存它返回的 DisposableEffectResult。对象被忘记时,onForgotten 则调用这个结果中的 dispose()

DisposableEffectScope 的主要作用,就是让 onDispose { } 返回一个 DisposableEffectResult

kotlin 复制代码
public class DisposableEffectScope {
    public inline fun onDispose(
        crossinline onDisposeEffect: () -> Unit
    ): DisposableEffectResult =
        object : DisposableEffectResult {
            override fun dispose() {
                onDisposeEffect()
            }
        }
}

注意:

  1. 整个流程中没有协程。因此,对于需要成对注册与注销 ListenerObserverCallback,并且生命周期又依赖某个 key 的场景,DisposableEffect 才是更合适的选择。
  2. DisposableEffectImpl.onAbandoned 什么都没做(因为没有异步问题);而在 LaunchedEffectImpl 中,onAbandoned 仍然会尝试取消 Job

这是因为 DisposableEffectImpl 只会在 onRemembered 中获取资源。如果 Effect 还没来得及被记住就被放弃,自然没有资源需要释放。

LaunchedEffectImplonAbandoned 中取消 Job 更多是一种防御性处理。此时 job 通常也是 null,但对空 Job 执行安全调用没有副作用,也能让回调本身保持安全。

不过,DisposableEffect 在使用上有一个小问题:即使根本没有清理逻辑,也必须返回一个 onDispose { }

如果只是希望在 key 变化时执行同步代码,就只能写下一个空的 onDispose { }。专门的 RememberedEffect 填补的正是这个空缺:拥有相同的无协程 key 响应能力,却不要求我们补上一段没有意义的清理代码。

SideEffect:不太一样的 Effect

SideEffect 和前面所有实现模式都不一样。

它不是 RememberObserver,也不会在 Slot Table 中保存任何对象:

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

源码中不存在 SideEffectImpl

recordSideEffect 只是把 block 添加到 Composition 的 Change List 中,等到 apply 阶段、所有 Remember 回调执行完毕后再运行。

由于没有对象被记住,SideEffect 也就没有对象身份、没有 key、没有清理回调。每当包含它的代码参与 Composition,并且这次结果成功提交后,它就会执行。

它适合把 Compose 中的值发布给外部对象,并且要求外部对象在每次提交后都与 Compose 保持同步。例如,把某个属性写入 View 或系统服务。

和前面的两类实现,区别如下:

  • LaunchedEffectDisposableEffect 会被 remember,所以拥有对象身份、key,以及离开 Composition 时的回调;
  • SideEffect 只会被记录,因此不具备这些能力,只在每次 apply 的末尾运行。

如果你曾经疑惑过,为什么 SideEffect 没有清理逻辑,也不像其他 Effect 那样依赖 key,原因就在这里:它从来没有进入 Slot Table。

一句话:SideEffect 会在包含它的 Composition 每次成功提交后执行,包括首次组合和后续重组。

rememberCoroutineScope:我们自己驱动的 Scope

最后一种 API 会返回一个 Coroutine Scope,却不会自动向其中启动任何任务。

它的定义是一次不带 keyremember

kotlin 复制代码
@Composable
public inline fun rememberCoroutineScope(
    crossinline getContext: () -> CoroutineContext = {
        EmptyCoroutineContext
    }
): CoroutineScope {
    val composer = currentComposer
    return remember {
        createCompositionCoroutineScope(getContext(), composer)
    }
}

因为没有 key,这个 Scope 会在首次 Composition 时创建一次,并一直保留到调用位置离开组合树。

返回的对象是 RememberedCoroutineScope

它同时实现了 CoroutineScopeRememberObserver。内部的协程上下文会在第一次访问时才创建,所以如果我们取得 Scope 后从未启动协程,几乎不会产生额外开销。它的 onForgottenonAbandoned 会取消已经创建的内容。

它的 Job 同样是 apply context 中 Job 的子 Job,所以当 Composition 被销毁时也会随之取消。

rememberCoroutineScope 的重点就是:它不会自己启动协程。

我们需要从类似 onClick 这样的事件回调中,主动调用 scope.launch { } 才会启动一个协程。事件回调发生在组合过程之外,因此无法直接使用 Composition 来启动并管理这次工作。

LaunchedEffect 则不同,它会把启动协程本身作为 Composition 的副作用。

所以,这两种 API 的区别是任务由谁触发、生命周期跟谁绑定:

  • LaunchedEffect:协程由 Composition 驱动,生命周期与某个 key 是否仍存在于 Composition 中绑定。
  • rememberCoroutineScope:协程由用户事件驱动,生命周期只与当前调用位置是否仍存在于 Composition 中绑定。

组合生命周期:我们真正需要知道什么

现在,我们已经知道了每个 Effect 会在 RememberObserver 的回调中做什么。

但这里还有一个问题:这些回调是在 Composable 执行到对应位置时,立刻触发的吗?

并不是。

Composition 负责计算 UI 应该变成什么样,Runtime 会先把这次计算产生的变化记录下来。只有当结果被成功提交后,它才会分发相应的生命周期回调。

这也是 Effect 不能直接写成普通函数调用的原因。Composable 执行过,不代表这一次计算一定会成为最终的 UI。假设 Runtime 还在计算时,我们就注册监听、启动协程或修改外部对象,那么即使这次结果最后被丢弃,副作用也已经发生了。

因此,RememberObserver 的回调可以简化成两条互斥的路径。

第一条,是对象成功进入了 Composition:

text 复制代码
onRemembered()
    ↓
onForgotten()

onRemembered 表示对象已经被成功记住。以后 key 改变、调用位置离开组合树,或者整个 Composition 被销毁时,它会收到 onForgotten

第二条,是对象虽然创建出来了,但这次 Composition 没有成功提交:

text 复制代码
onAbandoned()

这种情况下,对象从来没有真正进入过 Composition,所以不会收到 onRemembered,以后也不会再收到 onForgotten

换句话说,对于同一个位置创建的 RememberObserver,通常要么经历:

text 复制代码
onRemembered → onForgotten

要么只经历:

text 复制代码
onAbandoned

不会出现 onRemembered → onAbandoned

这里多次提到的 apply 线程,强调的是"应用 Composition 变化并分发生命周期回调的线程"。它并不意味着 Compose 固定创建了一条名为 apply 的后台线程。

SideEffect 不参与这套 remember 和 forget 生命周期。它只是被单独记录下来,并在本次 Composition 成功提交、Remember 回调处理完成后执行。

对于理解 Effect 来说,知道这些就够了。

完整走一遍:一次 key 变化经历了什么

现在回到 LaunchedEffect

kotlin 复制代码
LaunchedEffect(userId) {
    load(userId)
}

假设 userIdA 变成了 B

重组时,remember(B) 会发现 key 已经变化,于是创建一个新的 LaunchedEffectImpl。原来对应 A 的旧对象则会被安排离开 Composition。

如果本次 Composition 成功提交,接下来会发生两件事:

  1. 旧对象收到 onForgotten(),正在执行的 load(A) 协程被取消;
  2. 新对象收到 onRemembered(),启动一个新协程执行 load(B)

整个过程可以压缩成下面这样:

text 复制代码
userId:A → B
        ↓
remember 发现 key 变化
        ↓
旧 LaunchedEffectImpl 离开
新 LaunchedEffectImpl 进入
        ↓
旧对象 onForgotten:取消 load(A)
新对象 onRemembered:启动 load(B)

所以,LaunchedEffect 并不是把原来的 block 原地再执行一遍,而是结束旧 Effect 的生命周期,再创建一个新的 Effect。

如果本次 Composition 没有成功提交,情况又不一样。新创建的对象只会收到 onAbandoned(),不会启动新的协程;原来已经存在的旧对象也不会因为一次未提交的结果就完成替换。

其他几个 API 也可以放进这套模型中理解:

  • DisposableEffect 走相同的生命周期,只是 onForgotten 执行的是清理逻辑,而不是取消协程;
  • rememberCoroutineScope 没有 key,因此 Scope 通常只会创建一次,等调用位置离开 Composition 时再取消;
  • SideEffect 不参与 remember 流程,它会在包含它的 Composition 成功提交后执行。

实际开发中容易遇到的问题

到这里,Effect 的底层逻辑已经清楚了。回到日常开发,还有几个容易踩坑的地方值得单独说一下。

key 决定 Runtime 认为"这还是不是原来的 Effect"

我们经常说"key 变化后 Effect 会重启",但 key 并不只是一个普通参数,它实际上参与决定 Effect 的身份。

多个 key 会共同构成这个身份条件。只要其中任意一个被判断为发生变化,Runtime 就会忘记旧对象,再创建并记住新对象。

因此,不要随便把每次重组都会产生新身份的对象或 Lambda 作为 key。否则,Effect 可能在每次重组时都被取消并重新启动。

不过,"创建了新对象"也不等于 key 一定发生变化。Compose 会通过相等性检查判断新旧 key 是否相同。如果两个新旧对象具有相等语义,例如内容完全相同的 data class,它们未必会触发 Effect 重启。

真正需要避免的,是相等结果或对象身份不断变化,却又不能代表任务生命周期的 key

LaunchedEffect(Unit) 不是整个页面永远只执行一次

keyLaunchedEffect 重载只作为一个已经废弃的错误提示存在,直接调用无法通过编译。因此,我们经常写下:

kotlin 复制代码
LaunchedEffect(Unit) {
    load()
}

Unit 始终不会变化,所以普通重组不会让这段 Effect 重启。

但更准确地说,它是在当前调用位置每次进入 Composition 时执行一次 。如果这个调用位置离开 Composition,之后又重新进入,LaunchedEffect(Unit) 仍然会再次执行(再次进入该页面时通常会再执行一次)。

所以,Unit 表示的是"让 Effect 跟随当前调用位置的生命周期",并不是整个页面、整个 Activity,甚至整个进程永远只执行一次。

rememberUpdatedState 的神奇魔法

常量 key 还会带来另一个问题。

假设一个 LaunchedEffect(Unit) 中存在耗时操作,同时又捕获了外部传入的 Lambda:

kotlin 复制代码
@Composable
fun Timeout(onTimeout: () -> Unit) {
    LaunchedEffect(Unit) {
        delay(3_000)
        onTimeout()
    }
}

如果等待期间 onTimeout 发生变化,Effect 不会重启,因为 key 一直都是 Unit。这时,我们真正想要的不是重新等待三秒,而是在计时结束后调用最新的 onTimeout

这种情况可以使用 rememberUpdatedState

kotlin 复制代码
@Composable
fun Timeout(onTimeout: () -> Unit) {
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    LaunchedEffect(Unit) {
        delay(3_000)
        currentOnTimeout()
    }
}

rememberUpdatedState 会保存最新的 onTimeout,但不会因为它发生变化而重启 LaunchedEffect

因此,判断一个值应该怎样传给 Effect,可以先问自己一个问题:

  • 这个值变化后,旧任务已经没有意义,需要重新开始:把它作为 key;
  • 任务不需要重新开始,只需要在执行过程中读取最新值:使用 rememberUpdatedState

这两个场景看起来很像,但生命周期语义完全不同。

同步任务应该先看执行时机,再看有没有协程

如果 block 只执行同步代码,确实没有必要下意识地使用 LaunchedEffect。但也不能看到同步任务,就机械地替换成某一个 API。

关键是这段代码需要什么生命周期:

  • 每次成功提交 Composition 后都要执行:使用 SideEffect
  • key 变化时执行,并且离开时需要清理:使用 DisposableEffect
  • key 变化时执行,不需要协程,也没有清理逻辑:可以考虑前面介绍的 RememberedEffect

这里的 RememberedEffect 就是上面的源码,你可以直接用。

当然,我们也可以给 DisposableEffect 写一个空的 onDispose {}。代码可以正常工作,但空清理通常是在提醒我们:当前选择的 API,可能并不完全符合这段任务的语义。

不要在 Composition 中直接使用 rememberCoroutineScope 启动协程

下面这种代码看起来能够运行,却很容易随着重组重复启动任务:

kotlin 复制代码
@Composable
fun UserScreen() {
    val scope = rememberCoroutineScope()
    scope.launch {
        load()
    }
}

rememberCoroutineScope 只负责提供一个跟随调用位置生命周期的 Scope,并不会把 launch 变成安全的 Composition 副作用。

任务如果由 Composition 驱动,就使用 LaunchedEffect;如果由点击、滑动等事件驱动,再使用 rememberCoroutineScope

kotlin 复制代码
@Composable
fun UserScreen() {
    val scope = rememberCoroutineScope()

    Button(
        onClick = {
            scope.launch {
                load()
            }
        }
    ) {
        Text("Load")
    }
}

SideEffect 的"每次"也有范围

我们前面说过:

SideEffect 会在包含它的 Composition 每次成功提交后执行,包括首次组合和后续重组。

这里的"每次"并不表示组合树中任何位置发生重组,它都一定会执行。

只有当 SideEffect 所在的代码实际参与了本次 Composition,它才会重新记录一个待执行任务。如果它所在的作用域在这次重组中被跳过,就不会产生一次新的 SideEffect 调用。

另外,SideEffect 也不是在 Composable 函数执行到这一行时立刻运行。它会等本次变化成功应用后再执行,因此适合把 Compose 中已经确认的最新状态同步给外部对象。

一点想法

实际上,所有的知识点核心依然是 remember

remember 发现 key 变化后,会创建一个新的 RememberObserver,同时让旧对象离开 Composition。旧 LaunchedEffectImplonForgotten 中取消协程,新对象在 onRemembered 中启动协程,于是从外部看起来,就像 Effect 被"重新执行"了一次。

但底层发生的事情并不是重复调用,而是一次完整的对象替换和生命周期切换。

LaunchedEffectDisposableEffectrememberCoroutineScope 都建立在 rememberRememberObserver 之上,只是它们在回调中做的事情不同。SideEffect 则没有对象身份和 key,只会在成功提交后执行已经记录的普通代码。

至于 onAbandoned,我们只需要记住:它处理的是"对象已经创建,但这次 Composition 没有成功提交"的情况。它与 onRemembered → onForgotten 是两条不同的生命周期路径。

知道这些以后,选择 Effect API 就驾轻就熟了:

  • 需要执行 suspend 任务,用 LaunchedEffect
  • 需要成对初始化和清理资源,用 DisposableEffect
  • 需要在每次成功提交后同步外部对象,用 SideEffect
  • 需要从点击等事件中手动启动协程,用 rememberCoroutineScope
  • 只是响应 key 变化执行同步代码,可以考虑不依赖协程的 Effect。

下一次写下 LaunchedEffect 时,我们应该已经能够看到藏在它背后的 Scope、Job,以及那次由 remember 驱动的对象替换。

至于这个任务是不是真的需要一条协程,就要看它需要的究竟是 suspend 能力,还是一次跟随 Composition 生命周期执行的普通代码了。

相关推荐
雨白14 小时前
深入理解 Kotlin 协程 (八):拾遗补阙,探秘官方框架的调度细节与取消闭环
android·kotlin
GitLqr19 小时前
别被“Flutter 传感器延迟 150ms”带偏了:这可能只是你的实现方式错了
flutter·架构·kotlin
我命由我123451 天前
Android 在构建过程中,发现有两个依赖库都包含了相同路径的资源文件
android·java·开发语言·java-ee·kotlin·android-studio·android runtime
Coffeeee1 天前
谷歌的一个优化建议,让我重新学了一遍Android里面如何正确处理位图
android·google·kotlin
Android打工仔3 天前
Continuation 到底是谁创建的?
android·kotlin
Android-Flutter3 天前
android fragment 使用
android·kotlin
alexhilton3 天前
响应式的Android身份验证架构
android·kotlin·android jetpack
zzq77973 天前
Android 16 API 36 升级后 APP 加固兼容性问题解析
android·开发语言·安全·kotlin·安卓·安全架构
Sirens.4 天前
从参考 iCost 到做自己的 OneLedger:一个 Android 本地记账 App 的开发记录
android·kotlin·room·jetpack compose·记账 app