【翻译】深入 Compose 副作用:RememberObserver 与组合生命周期

0 阅读20分钟

深入 Compose 副作用:RememberObserver 与组合生命周期

原文链接:doveletter.dev/articles/co…

原文作者:Jaewoong Eum (skydoves)

文章头图

Jaewoong Eum (skydoves) | 2026 年 7 月 5 日(星期日) | 阅读约 23 分钟

打开几乎任意一个 Compose 界面,副作用处理函数早就在那里了。你会写 LaunchedEffect(userId) { viewModel.load(userId) },在 id 变化时启动一次加载。你会写 DisposableEffect(owner) { owner.register(cb); onDispose { owner.unregister(cb) } },来挂上并清理监听器。你会调用 rememberCoroutineScope(),好让按钮的 onClick 能启动协程。你会落下一行 SideEffect { analytics.setCurrentScreen(name) },好让 Compose 之外的某样东西保持同步。这四个已经是肌肉记忆。你会顺手拿来用,几乎不会再多想它们各自要付出什么代价。

多数开发者把 LaunchedEffect 想象成「键变化时就跑这段」。这个心智模型并不错,但它掩盖了运行时实际在做什么。每次键变化,Compose 都会取消正在运行的协程,再启动一个全新的。每一个 LaunchedEffect 背后都有一个真实的 CoroutineScope、一个真实的 Job,以及一个真实的协程——无论你的块有没有真正挂起过。如果你的副作用只是一次需要响应键的同步调用,那你就在为从未要求过的协程买单。要弄清为什么会这样,以及何时更轻量的工具才是更好的选择,你得顺着这些函数往下走到它们几乎共同依赖的同一个接口:RememberObserver

在这篇文章里,你会深入了解 Compose 副作用处理函数如何工作,包括:为什么副作用需要在组合生命周期中有一个定义清楚的位置,remember 如何把键变化变成一个全新对象,LaunchedEffectImpl 如何在该对象进入与离开组合时启动与取消协程,副作用协程的 Job 与帧时钟从何而来,DisposableEffectSideEffectrememberCoroutineScope 各自如何不同,RememberEventDispatcher 如何在 applyChanges 期间触发 onRememberedonForgottenonAbandoned,以及何时像 compose-effects 库的 RememberedEffect 这样的无协程副作用可以彻底避开这些代价。

本文基于 imageJetpack Compose Mechanisms 一书,该书完整梳理了这些内部机制。

根本问题:在声明式树中运行命令式代码

组合是声明式的、可重复的。一个可组合函数可以运行一次,然后在下一次重组时再跑,然后再跑,并没有固定时间表。这对描述 UI 来说没问题,但会与必须受控次数发生的命令式工作相冲突。想想最直接的启动加载方式:

@Composable
fun UserScreen(userId: String, viewModel: UserViewModel) {
    viewModel.load(userId)   // 在每一次重组上都会执行
    val user by viewModel.user.collectAsStateWithLifecycle()
    UserContent(user)
}

viewModel.load(userId) 会在每次 UserScreen 重组时运行——别处动画在跑时,这可能是每秒几十次。你本想让它每个 userId 只跑一次,而不是每次重组都跑一次。你也无处安放清理逻辑:如果界面离开树,就没有钩子去取消进行中的请求。

副作用处理函数的存在,就是为了给命令式代码在组合生命周期中一个明确位置。它们回答了 naive 调用无法回答的三个问题:运行频率如何、何时清理,以及若调度了这项工作的组合在提交前就被丢掉会发生什么。所有答案都来自同一个地方,所以值得先认识那个地方。

四个处理函数,以及其下的同一个接口

日常你会伸手去用四个副作用处理函数。LaunchedEffect 在协程中运行挂起块,带键,并在键变化时重启。DisposableEffect 运行建立代码,带键,并在键变化或副作用离开时运行匹配的拆除逻辑。SideEffect 在每次成功组合之后运行一段普通块。rememberCoroutineScope 交给你一个 CoroutineScope,你可以在组合之外(例如点击处理)向它 launch

四个里有三个底层形态相同。它们都是一次 remember 调用,产出实现了 RememberObserver 的对象,而生命周期真正落脚的地方就是这个接口:

public interface RememberObserver {
    public fun onRemembered()
    public fun onForgotten()
    public fun onAbandoned()
}

契约很小且精确。onRemembered 在组合的 apply 线程上调用,表示该对象已提交进一次组合的 slot table。onForgotten 同样在 apply 线程上调用,表示对象不再被任何地方 remember——因为它的 group 离开了树,或组合被 dispose。当对象由某次从未被 apply 的组合中的 remember 计算产生时,会改调 onAbandoned,而不是 onRemembered。KDoc 还补了两条后文会用到的保证:在一处被 remember 的对象要么收到 onRemembered,要么收到 onAbandoned,绝不同时两者都有;当若干对象一起被 remember 时,它们的 onForgotten 调用按各自 onRemembered 的逆序执行。

SideEffect 是稍后会让这条规则更清楚的例外。另外三个是同一主题的变体:用 remember 把一个 RememberObserver 放进 slot table,再让它的三个回调去做工作。

remember 才是真正的原语

下面这个观察会重塑一切。副作用函数自身几乎不带逻辑。完整的 LaunchedEffect(key1) 就是:

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

整段函数就这些。它捕获一个协程上下文,然后用工厂调用 remember(key1) 去构建 LaunchedEffectImpl。你把所有与 LaunchedEffect 联系在一起的行为——包括「键变化时重启」——其实都是 remember(key1) 的行为。所以你需要知道 remember 对键做了什么。

在底层,remember 就是 Composer.cache:它读取当前存进这个 slot 的值,并决定保留还是重算:

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) { ... } 调用它时传入 invalid = currentComposer.changed(key1);当键与上次存储的值不同时该值为 true。所以逻辑是:若键变了,或尚未存任何东西,就跑工厂并存储新值;否则原样返回缓存值。变化的键会产出一个全新对象,而先前存储的对象会被调度离开组合。这一事实是全文的枢纽。键变化并不是「重新跑你的副作用」。它产出一个新的 RememberObserver 并 forget 旧的,而副作用行为就从这两个对象在 onRememberedonForgotten 里做什么推导出来。

解剖 LaunchedEffect:帷幕后的协程

现在看 remember 真正存的对象。LaunchedEffectImpl 是一个 RememberObserver,协程正是出现在它的回调里:

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。当组合 remember 它时,onRememberedtask launch 进该作用域并保存得到的 Job。当组合 forget 或 abandon 它时,用 LeftCompositionCancellationException 取消该 Job 并清掉。onRemembered 里的 job?.cancel("Old job was still running!") 是防御性的,通常是空操作,因为全新的 impl 还没有 job。该类还实现了 CoroutineExceptionHandler 并把自身加进自己的作用域上下文;源码注明这样做是为了节省一次分配,而不是再单独创建一个 handler 对象。

把这与上一节的 remember 行为合在一起,键变化的故事就完整了。假设 LaunchedEffect(userId) { load(userId) },且 userIdA 变为 B

  1. 在重组时,remember(B) 比较已存的键 AB,发现不同,于是跑工厂。这会产出一个带全新作用域的新 LaunchedEffectImpl。旧的 impl 被调度为即将被 forget。
  2. 当组合 apply 时,运行时对旧 impl 调用 onForgotten,取消仍在跑 load(A) 的协程;并对新 impl 调用 onRemembered,启动跑 load(B) 的新协程。

因此「键变化时重启副作用」在机制上就是「取消旧协程并启动新协程」,完全由 remember 产出不同的对象身份驱动。LaunchedEffect 函数本身从不查看键。当界面整棵离开树时,只跑 onForgotten,协程被取消,不会再 relaunch。这就是为何进行中的 LaunchedEffect 会在其 composable 消失时自动拆除。

Job 与帧时钟从何而来

作用域由 parentCoroutineContext + this 构建,而 impl 从不自己加一个 Job()。这就引出问题:被 launch 的协程由谁做父级,以及为什么你可以在 LaunchedEffect 里调用 withFrameNanosanimate* 而不做额外设置?答案在副作用用 currentComposer.applyCoroutineContext 捕获的那个上下文里。

该上下文由 Recomposer 组装。它的 effect 上下文是用户提供的上下文,再加上它追加的两个元素:

override val effectCoroutineContext: CoroutineContext =
    effectCoroutineContext + broadcastFrameClock + effectJob

broadcastFrameClock 是一个 MonotonicFrameClock,而 effectJob 是作为所有副作用工作父级的 Job。因为 applyCoroutineContext 已经包含这个 Job,标准 CoroutineScope(context) 工厂不会再追加另一个,于是副作用协程的 Job 成为 recomposer 的 effectJob 的子级。由此产生两个后果。取消整个 recomposer(组合拆除时会发生)会通过这条父子链路取消每一个副作用协程。又因为帧时钟是上下文的一个元素,像 withFrameNanos 这样的挂起调用会自动找到它:

public suspend fun <R> withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R =
    coroutineContext.monotonicFrameClock.withFrameNanos(onFrame)

这就是为何写在 LaunchedEffect 里的动画会与显示器对齐,而你这边不必接线。帧时钟搭乘的同一个上下文,也承载着取消用的 Job

隐藏的代价,以及你何时不需要它

当副作用是异步的时候,上面这一切正是你想要的。收集 flow、调用挂起网络函数、跑 delay,或做动画,都需要协程、取消用的 Job,以及帧时钟,而 LaunchedEffect 免费提供它们。只有当块永不挂起时,代价才变成浪费。看看一种非常常见的写法:

LaunchedEffect(count) {
    analytics.log("count changed to $count")   // 同步,永不挂起
}

你想要的是「每当 count 变化就跑这一行」。你实际得到的却是一次 LaunchedEffectImpl 分配、一个 CoroutineScope,并且每次键变化都是一次协程取消再跟一次协程 launch——所有这些只为跑一条同步语句。协程启动,块跑完且从不挂起,协程结束。这些机械没有一项赚回了自己的成本。

更轻的工具是一个 RememberObserver,它只是在 onRemembered 里跑你的块,没有作用域,也没有 Job。这正是 compose-effects 库用 RememberedEffect 提供的东西。它的 README 勾勒了同样的取舍:LaunchedEffect 适合协程任务,因为它会创建新的协程作用域,并在每次键变化时重新 launch 任务;而 RememberedEffect 不会在每次键变化时创建或 launch 作用域,因此对记住副作用的执行来说更高效。用法镜像 LaunchedEffect

var count by remember { mutableIntStateOf(0) }
RememberedEffect(key1 = count) {
    Log.d(tag, "$count")
}

这一设计自然承接你已见过的一切。它就是一次 remember(key1),存下一个在 onRemembered 中跑你块的 RememberObserver,复用前面那套键变化机制,但不引入协程。决策规则很简单。若你的块会挂起,就用 LaunchedEffect,因为你确实需要协程、它的取消能力,以及帧时钟。若你的块是同步的,且只需要响应键,无协程副作用可避免分配,以及取消再 relaunch 的 churn。正如下一节所示,运行时其实早已自带一种大多数代码库用得不够的无协程副作用。

DisposableEffect:无协程的建立与拆除

DisposableEffect 就是一直存在于运行时中的那种无协程副作用。它的 impl 又是一个 RememberObserver,但其回调跑的是普通代码,而不触及协程:

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]。
    }
}

在 remember 时,它跑你的 effect 并保留返回的 DisposableEffectResult。在 forget 时,它对那个结果调用 dispose()DisposableEffectScope 的存在只是为了让 onDispose { } 块返回一个结果对象:

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

有两点值得停一下。第一,这条路径上没有任何协程,所以 DisposableEffect 才是为按某个值注册与注销 listener、observer 或回调的正确工具。第二,注意 onAbandoned 什么也不做,这与 LaunchedEffectImpl 不同——后者的 onAbandoned 仍会取消其 Job。原因是与 onRemembered 的对称。DisposableEffectImpl 只在 onRemembered 里获取资源,因此若副作用在从未被 remember 之前就被 abandon,就没有什么可释放。LaunchedEffectImplonAbandoned 里防御性取消,即便其 job 通常为 null,因为取消 null job 无害,而这种对称让回调保持安全。

还有一个值得点名的人体工学褶皱。DisposableEffect 总要求你返回一个 onDispose { } 结果,即便没什么要拆除,因此若纯粹用来响应键,就意味着要写一个空的 onDispose { }。这正是专用 RememberedEffect 填补的缺口:同样的无协程、对键的反应,却没有拆除仪式。

SideEffect:那个另类

SideEffect 打破了这个模式,而看到它如何打破,会让该模式更清楚。它不是 RememberObserver,也不在 slot table 里存任何东西:

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

没有 SideEffectImpl 类。recordSideEffect 把块追加到组合的 change list,并在 apply 阶段、在 remember 回调之后运行。因为什么都没有被 remember,所以没有键,也没有清理钩子;而无键的 SideEffect 会在每次成功的组合与重组之后运行。它的工作是把 Compose 的某个值发布给必须在每一趟保持同步的外部对象,例如设置 view 或系统服务上的某个属性。也存在带键的 SideEffect 重载,会在 currentComposer.changed(keyN) 之后才门控记录,但常见的无键形式故意在每次 apply 时触发,而不是只跑一次。

对比才是有用之处。LaunchedEffectDisposableEffect 是被 remembered 的,所以它们有身份、有键、有离开回调。SideEffect 是被 recorded 的,所以它没有那些,只是在每次 apply 末尾跑。如果你曾困惑为何 SideEffect 没有清理、也没有键,而其他的有,答案就是:它从未进入 slot table。

rememberCoroutineScope:由你自己驱动的作用域

最后一个处理函数给你一个协程作用域,却不向其中 launch 任何东西。它的定义是一次不带键的 remember

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

因为没有键,作用域在首次组合时创建一次,并一直保留到调用点离开树。它返回的对象是 RememberedCoroutineScope,同时是 CoroutineScopeRememberObserver。其协程上下文在首次访问时惰性构建,因此你从未真正向其 launch 的作用域几乎零成本;而它的 onForgottenonAbandoned 会取消已创建的任何东西。它的 Job 是你早前见过的同一个 apply 上下文 Job 的子级,因此它也会随组合一起被拆除。

rememberCoroutineScope 的要点在于它不做什么。它自己不 launch 任何东西。你从诸如 onClick 的事件回调里自己调用 scope.launch { },那里没有组合可把工作挂靠上去。相比之下,LaunchedEffect 会作为组合的副作用去 launch。源码在文档里直接陈述了规则:绝不应当把 job 作为组合本身的副作用 launch 进某个作用域;对由组合启动的持续 job,应使用 LaunchedEffect。因此二者之间的选择不是风格问题。LaunchedEffect 用于生命周期绑定到某个键仍存在于组合中的协程,而 rememberCoroutineScope 用于由用户事件启动、生命周期只绑定到调用点仍留在组合中的协程。

组合生命周期:回调实际上如何触发

你现在知道每个副作用在其 RememberObserver 回调里做什么。剩下的问题是:这些回调如何在正确时机被调用。这就是组合生命周期机械装置,它以三个乐章运行:组合期间的记录(recording)、apply 期间的派发(dispatching),以及清理——清理本身又拆成:abandon 从未提交的组合,以及 dispose 被拆除的组合。

记录:remember 入队 holder 与 abandon 候选

当可组合函数调用 remember 且工厂产出一个值时,composer 通过 updateCachedValue 存储它。若该值是 RememberObserver,会同时发生三件事:

internal fun updateCachedValue(value: Any?) {
    val toStore =
        if (value is RememberObserver) {
            val holder = GapRememberObserverHolder(value, rememberObserverGroupIndex())
            if (inserting) {
                changeListWriter.remember(holder)
            }
            abandonSet.add(value)
            holder
        } else value
    updateValue(toStore)
}

观察者被包进 RememberObserverHolder,一次 remember 操作被追加到 change list;而且重要的是,原始观察者会立即加入组合的 abandonSet。最后一步把每个新创建的副作用登记为 abandon 候选——在运行时尚未知道这次组合是否真的会被 apply 之前。当某个 group 被移除时,slot 遍历会通过调用 forgetting(holder) 记录离开的观察者。副作用则用 recordSideEffect 单独记录,把副作用操作入队到同一份 change list。

派发:逆序 forgotten,正序 remembered,然后是 side effects

在 apply 时,CompositionImpl 把记录的变更交给 RememberEventDispatcher(下面代码里的 rememberManager),由其应用变更,再派发生命周期回调。顺序是刻意的:

applier.onBeginChanges()
changes.execute(slotStorage, applier, rememberManager, errorContext)
applier.onEndChanges()

rememberManager.dispatchRememberObservers()
rememberManager.dispatchSideEffects()

changes.execute 应用节点与 slot 变更,并在过程中把每个观察者记进 dispatcher:被 remember 的进 remembering 列表,被 forget 的进统一的 leaving 列表,副作用进 sideEffects 列表。然后 dispatchRememberObservers 触发回调。forget 先跑,且按逆序;随后 remember 按正序跑:

fun dispatchRememberObservers() {
    // 发送 forget,按逆序
    for (i in leaving.size - 1 downTo 0) {
        val instance = leaving[i]
        if (instance is RememberObserverHolder) {
            abandoning.remove(instance.wrapped)
            instance.wrapped.onForgotten()
        }
    }
    // 发送 remember,按正序
    remembering.forEach { instance ->
        abandoning.remove(instance.wrapped)
        instance.wrapped.onRemembered()
    }
}

这里能看见两种顺序选择。forget 按 remember 的逆序跑,好让拆除镜像建立:子副作用会在包围它的父 group 之前被 forget。并且两个循环都在调用回调之前先 abandoning.remove(...),这就是真正被 remember 或 forget 的观察者如何从 abandon 列表上拿掉。在此之后,dispatchSideEffects 跑记录的副作用。源码注释解释了为何副作用最后来:捕获了某个被 remember 对象的副作用,必须在该对象收到 onRemembered 之后再跑,这样副作用触碰它时对象已完全初始化。

Abandonment:从未成真的组合

在 remember 与 forget 派发之后仍留在 abandonSet 里的,是在一次从未提交的组合中创建的。那些观察者会收到 onAbandoned

fun dispatchAbandons() {
    val iterator = abandoning.iterator()
    while (iterator.hasNext()) {
        val instance = iterator.next()
        iterator.remove()
        instance.onAbandoned()
    }
}

这是组合中途失败,或某棵子树被算出来又在 apply 前被丢弃时的路径。在这种组合里创建的 LaunchedEffectImpl 从未被调用 onRemembered,因此从未启动协程,而 onAbandoned 取消的 job 几乎总是 null。abandon 派发从 apply 路径在组合被丢弃时运行,也从围绕 composeContentrecompose 的守卫处运行,好让抛错的组合仍能清理它创建的观察者。被 abandon 的观察者既收不到 onRemembered,也收不到 onForgotten,只有 onAbandoned——这正是 RememberObserver KDoc 承诺的保证。

Disposal:一切都被 forgotten

当一次组合被 dispose 时,运行时遍历整张 slot table,并 forget 每一个被 remember 的观察者:

if (nonEmptySlotTable || abandonSet.isNotEmpty()) {
    rememberManager.use(abandonSet, errorContext) {
        if (nonEmptySlotTable) {
            applier.onBeginChanges()
            slotStorage.clear(rememberManager)
            applier.clear()
            applier.onEndChanges()
            dispatchRememberObservers()
        }
        dispatchAbandons()
    }
}

slotStorage.clear(rememberManager) 访问每个 slot,并把每个 RememberObserver 记为 forgetting,然后 dispatchRememberObservers 按逆序触发它们全部的 onForgotten 回调,没有 remember 来与之平衡。正是这套机制保证:离开返回栈的界面,会取消它启动的每一个 LaunchedEffect 协程,并 dispose 它获取的每一个 DisposableEffect 资源。不会剩下仍在运行的东西。

完整追踪:一次键变化,端到端

早前你看过这个故事的短版。既然 dispatcher 已摆上台面,这里是同一次键变化,把 apply 阶段的每一步都填上。取 LaunchedEffect(userId) { load(userId) },其中 userId 在重组期间从 A 变为 B

在组合期间,LaunchedEffect(B, block) 运行。它调用 remember(B),后者比较已存键 AB,发现不同,于是调用工厂,产出 impl_B——带自有作用域的新 LaunchedEffectImpl。旧的 impl_A 被调度离开其 slot,而 impl_B 被加入 abandonSet 并包进 holder,同时入队一次 remember 操作。

在 apply 期间,changes.execute 写入 slot 变更,并把 impl_B 记进 dispatcher 的 remembering 列表、把 impl_A 记进 leaving 列表。然后 dispatchRememberObservers 运行。forget 循环调用 impl_A.onForgotten(),取消仍在跑 load(A) 的协程。remember 循环调用 impl_B.onRemembered(),启动跑 load(B) 的新协程,并把 impl_Babandoning 移除。dispatchSideEffects 发现没什么可跑,dispatchAbandons 发现 abandoning 为空。净结果是旧加载被取消、新加载被启动——纯粹因为 remember 为新键产出了不同对象,而 dispatcher forget 了一个观察者又 remember 了另一个。

每个副作用处理函数都是这条追踪的变体。DisposableEffect 走完全相同的路径,只是 onForgotten 是 dispose 资源而不是取消协程。rememberCoroutineScope 也走它,但没有键,因此作用域被 remember 一次,并且只会被 forget。SideEffect 站在这条路径之外:被 recorded 而非 remembered,并在每次 apply 末尾运行。

现实世界中的模式与坑

这些内部机制会把若干常见 bug 与选择,变成一眼可见的后果。

  • 不稳定的键会在每次重组时重启副作用。 把刚刚分配的对象或 lambda 当作键传入,意味着 rememberchanged 检查每次都为 true,于是运行时在每次重组都 forget 并 remember 一个新 impl,不断取消再 relaunch 你的协程。键必须是具有有意义相等性的稳定值。
  • LaunchedEffect(Unit) 只跑一次且永不重启。 无键重载只作为已弃用的错误阴影存在,编译会失败,因此 Unit 是「跑一次」的惯用法。因为它从未看到新键,块也绝不会看到更新后的值——除非通过它读取的状态——所以要小心捕获的值变陈旧。
  • 同步反应优先用无协程副作用。 若块只响应键而不挂起,DisposableEffectRememberedEffect 风格的副作用会完全跳过协程。把 LaunchedEffect 留给真正会挂起的块。
  • 不要从组合里向 rememberCoroutineScope launch。 把作为组合副作用的 launch 打进那个作用域,会产出不绑定任何键的工作,并很容易在重组时重复。组合驱动的协程用 LaunchedEffect,事件驱动的用 rememberCoroutineScope
  • 副作用在 apply 线程上、在变更之后运行。 onRememberedonForgotten 与副作用都在树已为该帧更新之后触发,而不是在你的 composable 执行期间,这就是为何副作用看到的是一致且已提交的树。

用 Jetpack Compose Mechanisms 走得更深

本文追踪的一切——从 RememberObserver,到 remember 与 forget 循环,再到为回调排序的 dispatcher——都来自 imageJetpack Compose Mechanisms,这是一本从编译器插件一路读到运行时与> UI 层的书与课程,始终从真实源码出发。副作用处理函数只是其中一章。同一套源码优先的路径还覆盖 slot table、重组、快照状态系统,以及三相渲染流水线;所以如果你喜欢这样学内部机制,这本书会把同一方法带到整个框架。

结论

在这篇文章里,你探索了 Compose 副作用处理函数在熟悉表面之下如何工作。LaunchedEffectDisposableEffectrememberCoroutineScope 各自都是一次存下 RememberObserverremember,而「键变化时重启」的行为,其实是 remember 产出新对象,同时运行时 forget 旧对象。LaunchedEffectImplonRemembered 里启动协程,在 onForgotten 里取消它,其 Job 以 recomposer 为父,帧时钟搭乘同一上下文。DisposableEffect 在无协程的情况下跑建立与拆除,SideEffect 是被 recorded 而非 remembered,并在每次 apply 之后运行;而 RememberEventDispatcher 逆序触发 forget、正序触发 remember,然后是副作用,并为从未提交的组合准备 abandon 路径。

理解这些内部机制能帮你选对工具,并避开不易察觉的浪费。你能看清为何不稳定键会让协程每帧 relaunch,为何 LaunchedEffect(Unit) 永不重启,以及为何把同步副作用包进 LaunchedEffect 会为从未使用的协程买单。当块并不挂起时,像 DisposableEffectcompose-effects 库的 RememberedEffect 这样的无协程副作用,能给你同样的键变化语义,却没有作用域、没有 Job、也没有 launch。

无论你是在收集 flow、注册监听器、把值同步到 view,还是从点击里 launch,底下都是同一套小机械装置。副作用处理函数是盖在 rememberRememberObserver 之上的薄函数,组合生命周期完成其余工作。一旦你能看见藏在 LaunchedEffect 里的协程,你就能刻意决定:自己是否真的需要它。

一如既往,编码愉快!

imageJaewoong (skydoves)