Jetpack Compose Effect 完全指南

Compose 的核心思想是:UI 是状态的函数。

text 复制代码
UI = f(State)

理想的 Composable 只读取状态并描述界面,不修改外部世界。但真实应用还需要请求网络、启动动画、注册监听、记录埋点、控制 Snackbar、同步系统状态。这些行为不能当作普通 UI 描述执行,因为组合函数可能被反复执行、跳过,甚至执行后被放弃。

Compose Effect API 的作用,就是让副作用在可预测的时机启动、更新和清理,并与组合树中的生命周期绑定。

本文会回答四类问题:

  • 每种 Effect 到底解决什么问题;
  • 它何时启动、何时重启、何时取消;
  • key 应该如何选择;
  • 哪些写法看似能运行,实际上会产生重复请求、过期回调或资源泄漏。

1. 什么是副作用

副作用是函数除了计算返回值之外,对外部环境产生的可观察变化。例如:

  • 发起网络请求;
  • 写数据库或文件;
  • 修改非 Compose 对象;
  • 注册和注销监听器;
  • 启动协程、定时器或动画;
  • 显示 Snackbar、执行导航;
  • 上报页面曝光和埋点;
  • 调用系统服务。

下面这种代码是危险的:

kotlin 复制代码
@Composable
fun UserScreen(repository: UserRepository) {
    repository.refreshUser() // 错误:组合期间直接执行副作用
    Text("用户信息")
}

UserScreen 可能因为任意状态变化而重组,所以请求次数不等于"进入页面一次"。组合还可能被丢弃,这意味着副作用已经发生,但对应 UI 从未真正提交。

正确做法是把副作用放进合适的 Effect:

kotlin 复制代码
@Composable
fun UserScreen(repository: UserRepository) {
    LaunchedEffect(repository) {
        repository.refreshUser()
    }

    Text("用户信息")
}

2. 先理解 Compose 的执行模型

2.1 Composable 不是普通的"只执行一次"函数

Composable 可能:

  • 首次进入组合;
  • 因读取的状态变化而重组;
  • 被跳过,因为输入未改变;
  • 因条件分支消失而离开组合;
  • 因 key 改变而被视为新的组合实例;
  • 在组合结果提交前被放弃。

因此不能依赖"代码从上到下执行了一遍"来推断外部行为次数。

2.2 组合、应用变更与渲染不是同一阶段

可以用简化流程理解:
#mermaid-svg-r96zgOREBMVxF1Fo{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-r96zgOREBMVxF1Fo .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-r96zgOREBMVxF1Fo .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-r96zgOREBMVxF1Fo .error-icon{fill:#552222;}#mermaid-svg-r96zgOREBMVxF1Fo .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-r96zgOREBMVxF1Fo .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-r96zgOREBMVxF1Fo .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-r96zgOREBMVxF1Fo .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-r96zgOREBMVxF1Fo .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-r96zgOREBMVxF1Fo .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-r96zgOREBMVxF1Fo .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-r96zgOREBMVxF1Fo .marker{fill:#333333;stroke:#333333;}#mermaid-svg-r96zgOREBMVxF1Fo .marker.cross{stroke:#333333;}#mermaid-svg-r96zgOREBMVxF1Fo svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-r96zgOREBMVxF1Fo p{margin:0;}#mermaid-svg-r96zgOREBMVxF1Fo .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-r96zgOREBMVxF1Fo .cluster-label text{fill:#333;}#mermaid-svg-r96zgOREBMVxF1Fo .cluster-label span{color:#333;}#mermaid-svg-r96zgOREBMVxF1Fo .cluster-label span p{background-color:transparent;}#mermaid-svg-r96zgOREBMVxF1Fo .label text,#mermaid-svg-r96zgOREBMVxF1Fo span{fill:#333;color:#333;}#mermaid-svg-r96zgOREBMVxF1Fo .node rect,#mermaid-svg-r96zgOREBMVxF1Fo .node circle,#mermaid-svg-r96zgOREBMVxF1Fo .node ellipse,#mermaid-svg-r96zgOREBMVxF1Fo .node polygon,#mermaid-svg-r96zgOREBMVxF1Fo .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-r96zgOREBMVxF1Fo .rough-node .label text,#mermaid-svg-r96zgOREBMVxF1Fo .node .label text,#mermaid-svg-r96zgOREBMVxF1Fo .image-shape .label,#mermaid-svg-r96zgOREBMVxF1Fo .icon-shape .label{text-anchor:middle;}#mermaid-svg-r96zgOREBMVxF1Fo .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-r96zgOREBMVxF1Fo .rough-node .label,#mermaid-svg-r96zgOREBMVxF1Fo .node .label,#mermaid-svg-r96zgOREBMVxF1Fo .image-shape .label,#mermaid-svg-r96zgOREBMVxF1Fo .icon-shape .label{text-align:center;}#mermaid-svg-r96zgOREBMVxF1Fo .node.clickable{cursor:pointer;}#mermaid-svg-r96zgOREBMVxF1Fo .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-r96zgOREBMVxF1Fo .arrowheadPath{fill:#333333;}#mermaid-svg-r96zgOREBMVxF1Fo .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-r96zgOREBMVxF1Fo .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-r96zgOREBMVxF1Fo .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-r96zgOREBMVxF1Fo .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-r96zgOREBMVxF1Fo .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-r96zgOREBMVxF1Fo .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-r96zgOREBMVxF1Fo .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-r96zgOREBMVxF1Fo .cluster text{fill:#333;}#mermaid-svg-r96zgOREBMVxF1Fo .cluster span{color:#333;}#mermaid-svg-r96zgOREBMVxF1Fo div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-r96zgOREBMVxF1Fo .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-r96zgOREBMVxF1Fo rect.text{fill:none;stroke-width:0;}#mermaid-svg-r96zgOREBMVxF1Fo .icon-shape,#mermaid-svg-r96zgOREBMVxF1Fo .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-r96zgOREBMVxF1Fo .icon-shape p,#mermaid-svg-r96zgOREBMVxF1Fo .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-r96zgOREBMVxF1Fo .icon-shape .label rect,#mermaid-svg-r96zgOREBMVxF1Fo .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-r96zgOREBMVxF1Fo .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-r96zgOREBMVxF1Fo .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-r96zgOREBMVxF1Fo :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 成功
结果被放弃
State 变化
Composition

计算 UI 描述
Apply Changes

提交组合变更
运行对应 Effect
Layout / Draw
不应提交副作用

Effect API 让 Compose 知道副作用属于哪个组合节点,从而在节点进入、更新或离开时进行管理。

2.3 Effect 的三个核心问题

看到任何 Effect 代码,都要问:

  1. 启动条件是什么? 首次进入,还是每次成功重组?
  2. 重启条件是什么? 哪些 key 改变会取消旧任务并启动新任务?
  3. 清理条件是什么? 离开组合时如何取消协程或注销监听?

只要能回答这三个问题,大部分 Effect 代码就不再神秘。


3. Effect API 总览

API 主要作用 是否支持挂起函数 启动/执行时机 清理或取消
SideEffect 每次成功组合后同步外部对象 每次成功重组后 无专用清理
LaunchedEffect 启动与组合节点绑定的协程 进入组合;key 改变后重启 离开组合或 key 改变时取消
DisposableEffect 注册需要对称释放的资源 进入组合;key 改变后重建 必须在 onDispose 清理
rememberCoroutineScope 在事件回调中手动启动协程 调用 launch 调用点离开组合时取消 Scope
rememberUpdatedState Effect 不重启,但读取最新值 不直接执行任务 每次重组更新包装的值 随组合遗忘
produceState 把外部异步数据转换为 Compose State 进入组合;key 改变后重启 自动取消;回调源可用 awaitDispose
derivedStateOf 从 State 派生并减少无意义重组 依赖 State 变化时重新计算 remember 生命周期结束
snapshotFlow 把 Compose State 转成 Flow Flow 内支持异步操作 Flow 被收集时开始观察 收集协程取消时停止
Lifecycle Effects 绑定 Android Lifecycle 状态/事件 视 API 而定 START/RESUME/指定事件 对应 Lifecycle 离开状态时清理

rememberUpdatedStatederivedStateOf 严格说不是"执行副作用"的 API,但它们决定 Effect 是否重启、读取什么值、触发多少次,因此必须和 Effect 一起理解。


4. SideEffect:成功组合后同步外部状态

4.1 作用

SideEffect每次成功完成组合并提交变更后执行。它适合把 Compose 中的最新状态同步给一个不由 Compose 管理的对象。

kotlin 复制代码
@Composable
fun AnalyticsUser(
    user: User,
    analytics: Analytics,
    content: @Composable () -> Unit,
) {
    SideEffect {
        analytics.setUserId(user.id)
        analytics.setUserProperty("tier", user.tier.name)
    }

    content()
}

如果组合失败或结果被放弃,SideEffect 不会执行,这比直接在 Composable 函数体里调用安全。

4.2 执行特点

  • 首次成功组合后执行;
  • 后续每次成功重组后也执行;
  • 没有 key;
  • 不能调用挂起函数;
  • 没有 onDispose
  • 应该快速完成,不要阻塞主线程。

4.3 适合的场景

  • 给埋点 SDK 更新当前用户属性;
  • 把 Compose 状态同步给命令式 UI 对象;
  • 更新第三方 SDK 的非挂起配置;
  • 通知外部对象"当前最新值是什么"。

4.4 不适合的场景

不要用它做网络请求、数据库写入或只允许执行一次的操作。因为每次成功重组都可能再次执行。

kotlin 复制代码
// 错误:重组次数决定提交次数
SideEffect {
    orderRepository.submitOrder()
}

还要避免修改当前组合正在读取的状态,否则可能形成重组循环:

kotlin 复制代码
// 危险:每次成功组合后修改状态,又触发下一次重组
SideEffect {
    counter++
}

5. LaunchedEffect:启动组合感知的协程

5.1 作用

LaunchedEffect 在进入组合时启动一个协程。协程会在以下情况自动取消:

  • Effect 离开组合;
  • 任意 key 与上次相比发生变化。

key 改变后,旧协程先取消,然后使用新 key 启动新协程。

kotlin 复制代码
@Composable
fun UserRoute(
    userId: String,
    viewModel: UserViewModel,
) {
    LaunchedEffect(userId) {
        viewModel.loadUser(userId)
    }
}

userIdA 变为 B 时,加载 A 的 Effect 被取消,再启动加载 B 的任务。

5.2 生命周期示意

LaunchedEffect(userId) 组合树 LaunchedEffect(userId) 组合树 #mermaid-svg-cVKNqhNCJw767eqw{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-cVKNqhNCJw767eqw .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-cVKNqhNCJw767eqw .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-cVKNqhNCJw767eqw .error-icon{fill:#552222;}#mermaid-svg-cVKNqhNCJw767eqw .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-cVKNqhNCJw767eqw .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-cVKNqhNCJw767eqw .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-cVKNqhNCJw767eqw .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-cVKNqhNCJw767eqw .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-cVKNqhNCJw767eqw .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-cVKNqhNCJw767eqw .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-cVKNqhNCJw767eqw .marker{fill:#333333;stroke:#333333;}#mermaid-svg-cVKNqhNCJw767eqw .marker.cross{stroke:#333333;}#mermaid-svg-cVKNqhNCJw767eqw svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-cVKNqhNCJw767eqw p{margin:0;}#mermaid-svg-cVKNqhNCJw767eqw .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-cVKNqhNCJw767eqw text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-cVKNqhNCJw767eqw .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-cVKNqhNCJw767eqw .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-cVKNqhNCJw767eqw .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-cVKNqhNCJw767eqw .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-cVKNqhNCJw767eqw #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-cVKNqhNCJw767eqw .sequenceNumber{fill:white;}#mermaid-svg-cVKNqhNCJw767eqw #sequencenumber{fill:#333;}#mermaid-svg-cVKNqhNCJw767eqw #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-cVKNqhNCJw767eqw .messageText{fill:#333;stroke:none;}#mermaid-svg-cVKNqhNCJw767eqw .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-cVKNqhNCJw767eqw .labelText,#mermaid-svg-cVKNqhNCJw767eqw .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-cVKNqhNCJw767eqw .loopText,#mermaid-svg-cVKNqhNCJw767eqw .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-cVKNqhNCJw767eqw .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-cVKNqhNCJw767eqw .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-cVKNqhNCJw767eqw .noteText,#mermaid-svg-cVKNqhNCJw767eqw .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-cVKNqhNCJw767eqw .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-cVKNqhNCJw767eqw .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-cVKNqhNCJw767eqw .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-cVKNqhNCJw767eqw .actorPopupMenu{position:absolute;}#mermaid-svg-cVKNqhNCJw767eqw .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-cVKNqhNCJw767eqw .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-cVKNqhNCJw767eqw .actor-man circle,#mermaid-svg-cVKNqhNCJw767eqw line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-cVKNqhNCJw767eqw :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 协程 A 运行 不重启 启动协程 B 进入组合,userId=A重组,userId 仍为 A重组,userId 变为 B取消协程 A离开组合取消协程 B

5.3 常用场景

页面进入后加载数据
kotlin 复制代码
LaunchedEffect(productId) {
    viewModel.load(productId)
}

如果加载动作本来就是 ViewModel 初始化职责,也可以让 ViewModel 在 init 中加载。两者选择取决于输入是否来自组合参数,以及任务是否应严格随这个 UI 节点存在。

响应状态显示 Snackbar
kotlin 复制代码
@Composable
fun MessageHost(
    messages: Flow<String>,
    snackbarHostState: SnackbarHostState,
) {
    LaunchedEffect(messages, snackbarHostState) {
        messages.collect { message ->
            snackbarHostState.showSnackbar(message)
        }
    }
}
驱动动画
kotlin 复制代码
val alpha = remember { Animatable(0f) }

LaunchedEffect(Unit) {
    alpha.animateTo(
        targetValue = 1f,
        animationSpec = tween(500),
    )
}

5.4 key 的语义

LaunchedEffect(key1, key2) 不是把 key 传给协程,而是定义 Effect 的身份和重启边界。

kotlin 复制代码
LaunchedEffect(userId, filter) {
    repository.observe(userId, filter).collect(::render)
}

userIdfilter 变化时重启,因为旧订阅已经不再对应当前输入。

key 应该包含:

  • Effect 内使用、且变化后必须重新建立任务的值;
  • 订阅目标、资源 ID、查询条件等任务身份。

key 不应该随意包含:

  • 每次重组都会创建的新对象;
  • 只需要在任务内部读取最新值、无需重启的回调;
  • 与任务身份无关的纯展示状态。

5.5 LaunchedEffect(Unit)true 是否错误

它们表示"只要这个调用点留在组合中,就保持同一个 Effect 实例"。这不是天然错误,适用于页面生命周期内只启动一次的任务:

kotlin 复制代码
LaunchedEffect(Unit) {
    viewModel.effects.collect(::handleEffect)
}

但常量 key 应让你停下来确认:这个任务真的不应该因任何输入变化而重启吗?长时间循环尤其要谨慎。

kotlin 复制代码
LaunchedEffect(Unit) {
    while (isActive) {
        delay(5_000)
        refresh()
    }
}

循环必须可取消,不能捕获并吞掉 CancellationException

5.6 常见错误

把瞬时状态当 key,导致重复请求
kotlin 复制代码
// query 每输入一个字符,旧请求都会取消并重新启动
LaunchedEffect(query) {
    repository.search(query)
}

如果这是搜索联想,可以配合防抖;如果只有点击搜索才发请求,就不应把输入框变化直接作为启动条件。

kotlin 复制代码
LaunchedEffect(Unit) {
    snapshotFlow { query }
        .debounce(300)
        .distinctUntilChanged()
        .filter(String::isNotBlank)
        .collectLatest(viewModel::search)
}
吞掉取消异常
kotlin 复制代码
LaunchedEffect(id) {
    try {
        repository.load(id)
    } catch (error: Exception) {
        // 错误:可能把 CancellationException 当普通失败处理
        showError(error)
    }
}

更安全的做法是重新抛出取消异常,或者让只捕获业务异常的代码位于合适层级。

kotlin 复制代码
catch (error: CancellationException) {
    throw error
} catch (error: IOException) {
    showError(error)
}

6. DisposableEffect:建立并释放成对资源

6.1 作用

DisposableEffect 用于必须显式清理的非挂起副作用。代码块最后必须提供 onDispose

kotlin 复制代码
@Composable
fun LifecycleAnalytics(
    lifecycleOwner: LifecycleOwner,
    analytics: Analytics,
) {
    DisposableEffect(lifecycleOwner, analytics) {
        val observer = LifecycleEventObserver { _, event ->
            when (event) {
                Lifecycle.Event.ON_START -> analytics.screenVisible()
                Lifecycle.Event.ON_STOP -> analytics.screenHidden()
                else -> Unit
            }
        }

        lifecycleOwner.lifecycle.addObserver(observer)

        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }
}

6.2 什么时候执行清理

  • 调用点离开组合时;
  • 任意 key 改变,旧 Effect 被替换时。

更新 key 时顺序可以理解为:

text 复制代码
旧实例 onDispose -> 创建新实例 -> 注册新资源

6.3 典型场景

  • 注册/注销 BroadcastReceiver
  • 添加/移除 Lifecycle Observer;
  • 注册传感器、位置或窗口监听;
  • 给传统 View 添加/移除 listener;
  • 管理需要 close()release() 的资源。

6.4 onDispose 不能为空壳

如果没有任何清理工作,通常说明应该选择其他 Effect,或者副作用根本不应放在这里。

kotlin 复制代码
// 可疑写法
DisposableEffect(Unit) {
    analytics.logScreen()
    onDispose { }
}

如果目标只是成功组合后同步值,考虑 SideEffect;如果只在进入组合时执行挂起工作,考虑 LaunchedEffect

6.5 与 LaunchedEffect 的区别

问题 LaunchedEffect DisposableEffect
工作类型 挂起、Flow、动画、延迟 同步注册与同步释放
清理方式 协程取消和 finally 显式 onDispose
典型资源 Job、Flow collect Listener、Observer、Receiver
key 改变 取消并重启协程 dispose 后重新建立资源

如果一个回调 API 可以用 callbackFlow 封装成 Flow,通常也可以在 LaunchedEffect 中收集;底层的注册释放则由 awaitClose 完成。


7. rememberCoroutineScope:从用户事件启动协程

7.1 为什么需要它

Effect 是声明式的:由"是否处于组合中"和 key 决定启动。点击、拖动、提交等事件是命令式的:只有事件发生时才启动任务。

rememberCoroutineScope 返回一个与当前组合调用点绑定的 Scope,让事件回调能安全启动协程。

kotlin 复制代码
@Composable
fun SettingsScreen(
    snackbarHostState: SnackbarHostState,
) {
    val scope = rememberCoroutineScope()

    Button(
        onClick = {
            scope.launch {
                snackbarHostState.showSnackbar("设置已保存")
            }
        },
    ) {
        Text("保存")
    }
}

页面离开组合后,Scope 会被取消,未完成的 Snackbar 任务也随之结束。

7.2 和 LaunchedEffect 如何选择

text 复制代码
状态/组合生命周期触发任务 → LaunchedEffect
点击等事件发生时触发任务 → rememberCoroutineScope

错误示例:

kotlin 复制代码
var shouldSave by remember { mutableStateOf(false) }

Button(onClick = { shouldSave = true }) { Text("保存") }

LaunchedEffect(shouldSave) {
    if (shouldSave) save()
}

如果 save() 是 UI 层的挂起动作,可直接在事件中 scope.launch { save() }。如果保存属于业务操作,更常见的是调用 viewModel.save(),由 viewModelScope 管理。

7.3 不要滥用 UI Scope 承担业务任务

UI Scope 离开组合就会取消。如果任务必须在页面关闭后继续,例如上传文件或提交关键交易,应放到 ViewModel、Repository、WorkManager 或其他合适生命周期中。


8. rememberUpdatedState:不重启 Effect,读取最新值

8.1 它解决的矛盾

有时 Effect 的生命周期应保持不变,但 Effect 内使用的回调或参数需要保持最新。

以启动页倒计时为例:

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

如果父组件重组并传入新的 onTimeout,协程可能仍捕获旧回调。把回调设为 key 又会让倒计时重新开始:

kotlin 复制代码
LaunchedEffect(onTimeout) { // 回调身份变化会重新计时
    delay(2_000)
    onTimeout()
}

正确写法:

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

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

8.2 实现原理

可以把它理解为一个稳定的 State 容器:容器本身在组合中被记住,每次重组把最新值写进去。Effect 持有稳定容器,执行时从容器读取最新内容。

text 复制代码
Effect 生命周期:不变
Effect 读取的 value:随重组更新

8.3 典型场景

  • 长期运行 Effect 中的最新回调;
  • Lifecycle Observer 中的最新 lambda;
  • 定时器不重新计时,但到点调用最新逻辑;
  • 长期订阅中使用最新配置,而订阅本身不应重建。

8.4 不要用它掩盖错误 key

如果参数变化后任务语义已经变了,就应该作为 key 重启,而不是用 rememberUpdatedState 偷偷换值。

例如订阅 userId 对应的数据:

kotlin 复制代码
// 正确:userId 改变意味着必须取消旧订阅
LaunchedEffect(userId) {
    repository.observeUser(userId).collect(::render)
}

不能把 userId 包进 rememberUpdatedState 后继续使用旧订阅。


9. produceState:把外部数据源转换成 Compose State

9.1 作用

produceState 创建一个 State<T>,并启动一个生产者协程。协程中通过 value = ... 更新状态。

kotlin 复制代码
@Composable
fun loadImageState(
    url: String,
    loader: ImageLoader,
): State<ImageLoadState> = produceState<ImageLoadState>(
    initialValue = ImageLoadState.Loading,
    key1 = url,
    key2 = loader,
) {
    value = runCatching { loader.load(url) }
        .fold(
            onSuccess = { ImageLoadState.Success(it) },
            onFailure = { ImageLoadState.Error(it) },
        )
}

使用方像读取普通状态一样读取:

kotlin 复制代码
val imageState by loadImageState(url, imageLoader)

9.2 生命周期

  • 进入组合时创建初始状态并启动生产者;
  • key 改变时取消旧生产者并启动新的;
  • 离开组合时取消;
  • 设置与当前值相等的值通常不会触发重组。

9.3 内部思路

概念上,produceState 可以理解为 rememberLaunchedEffect 的组合:

kotlin 复制代码
@Composable
fun <T> conceptualProduceState(
    initial: T,
    key: Any?,
    producer: suspend MutableState<T>.() -> Unit,
): State<T> {
    val state = remember { mutableStateOf(initial) }
    LaunchedEffect(key) {
        state.producer()
    }
    return state
}

这只是帮助理解的简化实现,不应替代官方 API。

9.4 适配非挂起回调源

使用 awaitDispose 保持生产者存活,并在结束时释放 listener:

kotlin 复制代码
@Composable
fun connectivityState(
    monitor: ConnectivityMonitor,
): State<Boolean> = produceState(
    initialValue = monitor.isOnline,
    key1 = monitor,
) {
    val listener = ConnectivityListener { online ->
        value = online
    }

    monitor.addListener(listener)
    awaitDispose {
        monitor.removeListener(listener)
    }
}

9.5 什么时候不需要它

  • 已经有 StateFlow:优先用生命周期感知的 collectAsStateWithLifecycle()
  • 只是从其他 Compose State 做同步计算:考虑普通表达式或 derivedStateOf
  • 数据应该由 ViewModel 跨配置变化保存:不要把业务加载全部塞进 Composable 的 produceState

produceState 更适合编写可复用的 UI 适配器,而不是替代业务层。


10. derivedStateOf:控制派生状态的更新频率

10.1 作用

当输入状态变化非常频繁,但 UI 只关心经过计算后的较少变化结果时,derivedStateOf 可以减少不必要的重组。

kotlin 复制代码
@Composable
fun MessageList(listState: LazyListState) {
    val showScrollToTop by remember {
        derivedStateOf {
            listState.firstVisibleItemIndex > 0
        }
    }

    Box {
        LazyColumn(state = listState) {
            // items...
        }

        AnimatedVisibility(showScrollToTop) {
            ScrollToTopButton()
        }
    }
}

滚动位置持续变化,但 showScrollToTop 只有跨越"是否为首项"的边界时才变化。

10.2 它不是普通计算的默认包装

下面没有必要:

kotlin 复制代码
val fullName by remember(firstName, lastName) {
    derivedStateOf { "$firstName $lastName" }
}

fullName 与输入几乎同频变化,直接计算更清晰:

kotlin 复制代码
val fullName = "$firstName $lastName"

10.3 什么时候值得使用

同时满足两个条件时再考虑:

  1. 输入变化频率高;
  2. 派生结果变化频率明显更低,且结果用于组合。

它类似 Flow 的 distinctUntilChanged():关注的是派生结果是否变化,而不是输入经历了多少次变化。

10.4 rememberderivedStateOf

通常要记住派生状态对象:

kotlin 复制代码
val isEnabled by remember {
    derivedStateOf { formState.isValid && !formState.isSubmitting }
}

如果计算依赖普通参数和 Compose State,可以把普通参数放入 remember key,或保证计算读取的是当前 State:

kotlin 复制代码
val visibleItems by remember(filter) {
    derivedStateOf {
        items.filter { filter.matches(it) }
    }
}

11. snapshotFlow:把 Compose State 变化变成 Flow

11.1 作用

snapshotFlow { ... } 记录代码块中读取的 Compose State。当其中任一状态变化时重新计算;如果结果与上次不相等,则向 Flow 发送新值。

kotlin 复制代码
@Composable
fun ListAnalytics(
    listState: LazyListState,
    analytics: Analytics,
) {
    LaunchedEffect(listState, analytics) {
        snapshotFlow { listState.firstVisibleItemIndex }
            .map { index -> index > 0 }
            .distinctUntilChanged()
            .filter { it }
            .collect {
                analytics.logScrolledPastFirstItem()
            }
    }
}

11.2 为什么不用直接在组合中读取

如果直接读取高频滚动状态,读取它的 Composable 可能随滚动持续重组。若目标只是执行埋点、存储或异步处理,并不需要改变 UI,可以转换为 Flow 后在 Effect 中处理。

11.3 特点

  • 返回冷 Flow,只有被收集时才工作;
  • 自动追踪代码块中读取的 Snapshot State;
  • 结果具有类似 distinctUntilChanged 的语义;
  • 代码块应是无副作用的读取与计算;
  • 通常在 LaunchedEffect 中收集。

11.4 snapshotFlowderivedStateOf

目标 选择
派生值用于渲染 UI derivedStateOf
状态变化要进入 Flow 操作链 snapshotFlow
防抖、采样、组合其他 Flow snapshotFlow
减少派生结果不变时的重组 derivedStateOf

12. Android 生命周期相关 Effect

核心 Compose Effect 绑定的是组合生命周期 。Android 页面还有 Lifecycle:Activity 可能仍在组合中,但已经进入后台。两者不是同一件事。

androidx.lifecycle:lifecycle-runtime-compose 提供了生命周期感知工具。具体可用 API 取决于项目依赖版本。

12.1 LifecycleEventEffect

监听一个特定生命周期事件:

kotlin 复制代码
@Composable
fun ScreenAnalytics(analytics: Analytics) {
    LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
        analytics.logScreenResumed()
    }
}

适合只响应某一个事件。不要用它处理 ON_DESTROY 清理,因为组合可能先于 Lifecycle 销毁而离开;需要清理时应使用有明确清理回调的 API。

12.2 LifecycleStartEffect

在 Lifecycle 进入 STARTED 时建立资源,离开 STARTED 时清理:

kotlin 复制代码
@Composable
fun LocationTracking(tracker: LocationTracker) {
    LifecycleStartEffect(tracker) {
        tracker.start()

        onStopOrDispose {
            tracker.stop()
        }
    }
}

它表达的是"只在界面可见期间持有资源"。key 改变或离开组合也会触发清理。

12.3 LifecycleResumeEffect

在 RESUMED 时建立资源,在 Pause 或离开组合时清理:

kotlin 复制代码
@Composable
fun CameraPreviewSession(camera: CameraSession) {
    LifecycleResumeEffect(camera) {
        camera.resumePreview()

        onPauseOrDispose {
            camera.pausePreview()
        }
    }
}

适合只应在页面真正处于前台交互状态时运行的资源。

12.4 collectAsStateWithLifecycle

它不是命名为 Effect 的 API,但在 Android UI 中非常重要:它只在 Lifecycle 达到指定活跃状态时收集 Flow,并把值转换成 Compose State。

kotlin 复制代码
val uiState by viewModel.uiState.collectAsStateWithLifecycle()

对于 ViewModel 暴露的 StateFlow,通常优先用它,而不是手写 LaunchedEffect + repeatOnLifecycle 再复制一份本地状态。

12.5 组合生命周期和 Android Lifecycle 如何选择

需求 应绑定的生命周期
这个 Composable 存在时运行动画 组合生命周期
这个组件存在时注册普通 listener 组合生命周期
页面可见时启动定位 Lifecycle STARTED
页面可交互时占用摄像头资源 Lifecycle RESUMED
收集 ViewModel UI State collectAsStateWithLifecycle
ViewModel 中持续保存业务状态 viewModelScope,不直接依赖 Composable

13. rememberkey 和 Effect 的关系

13.1 remember 不是副作用执行器

remember 用于在组合中保存对象或计算结果:

kotlin 复制代码
val snackbarHostState = remember { SnackbarHostState() }

计算 lambda 应避免产生需要清理的副作用。不要用 remember 注册监听:

kotlin 复制代码
// 错误:没有可靠的注销时机
val listener = remember {
    service.addListener { /* ... */ }
}

需要注册和释放时使用 DisposableEffect

13.2 remember(key)LaunchedEffect(key) 的区别

kotlin 复制代码
val formatter = remember(locale) {
    DateTimeFormatter.ofPattern("yyyy-MM-dd", locale)
}

LaunchedEffect(locale) {
    repository.reloadLocalizedContent(locale)
}
  • remember(key):key 变化时丢弃旧缓存并重新计算值;
  • LaunchedEffect(key):key 变化时取消旧协程并启动新协程。

13.3 key(...) 改变组合身份

列表项没有稳定身份时,重排可能让内部 remember 状态和 Effect 跟错对象:

kotlin 复制代码
items.forEach { item ->
    key(item.id) {
        ItemRow(item)
    }
}

key 不是 Effect,但它决定内部组合节点的身份,也会间接决定内部 Effect 是保留、移动还是销毁重建。使用 LazyColumn 时优先通过 items(items, key = { it.id }) 提供稳定 key。


14. 一次性事件、导航和 Snackbar

这是 Effect 使用中最容易产生争议的部分。

14.1 State 与 Event 的区别

  • "订单提交中"描述当前事实,是 State;
  • "订单提交成功"如果页面要持续展示,是 State;
  • "现在导航到详情页"是一次性行为;
  • "显示错误对话框直到用户关闭"通常是 State。

14.2 使用事件流

kotlin 复制代码
sealed interface CheckoutEffect {
    data class ShowSnackbar(val message: String) : CheckoutEffect
    data class NavigateToOrder(val orderId: String) : CheckoutEffect
}

@Composable
fun CheckoutRoute(
    viewModel: CheckoutViewModel,
    snackbarHostState: SnackbarHostState,
    navigateToOrder: (String) -> Unit,
) {
    val currentNavigate by rememberUpdatedState(navigateToOrder)

    LaunchedEffect(viewModel, snackbarHostState) {
        viewModel.effects.collect { effect ->
            when (effect) {
                is CheckoutEffect.ShowSnackbar ->
                    snackbarHostState.showSnackbar(effect.message)
                is CheckoutEffect.NavigateToOrder ->
                    currentNavigate(effect.orderId)
            }
        }
    }
}

14.3 必须定义交付语义

Channel/SharedFlow 不是"绝对可靠的一次性事件"魔法。团队要明确:

  • UI 暂时停止时,事件要丢弃、缓存还是转成状态;
  • 旋转屏幕后是否允许重复消费;
  • 进程被杀后是否需要恢复;
  • 导航成功后如何让业务状态进入下一阶段。

对不能丢失的业务结果,优先把它建模为可恢复 State,并在 UI 完成动作后发送确认事件,而不是依赖瞬时消息永不丢失。


15. Key 的系统选择方法

15.1 判断流程

#mermaid-svg-PRto0jDgG5xT7dX5{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-PRto0jDgG5xT7dX5 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-PRto0jDgG5xT7dX5 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-PRto0jDgG5xT7dX5 .error-icon{fill:#552222;}#mermaid-svg-PRto0jDgG5xT7dX5 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-PRto0jDgG5xT7dX5 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-PRto0jDgG5xT7dX5 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-PRto0jDgG5xT7dX5 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-PRto0jDgG5xT7dX5 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-PRto0jDgG5xT7dX5 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-PRto0jDgG5xT7dX5 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-PRto0jDgG5xT7dX5 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-PRto0jDgG5xT7dX5 .marker.cross{stroke:#333333;}#mermaid-svg-PRto0jDgG5xT7dX5 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-PRto0jDgG5xT7dX5 p{margin:0;}#mermaid-svg-PRto0jDgG5xT7dX5 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-PRto0jDgG5xT7dX5 .cluster-label text{fill:#333;}#mermaid-svg-PRto0jDgG5xT7dX5 .cluster-label span{color:#333;}#mermaid-svg-PRto0jDgG5xT7dX5 .cluster-label span p{background-color:transparent;}#mermaid-svg-PRto0jDgG5xT7dX5 .label text,#mermaid-svg-PRto0jDgG5xT7dX5 span{fill:#333;color:#333;}#mermaid-svg-PRto0jDgG5xT7dX5 .node rect,#mermaid-svg-PRto0jDgG5xT7dX5 .node circle,#mermaid-svg-PRto0jDgG5xT7dX5 .node ellipse,#mermaid-svg-PRto0jDgG5xT7dX5 .node polygon,#mermaid-svg-PRto0jDgG5xT7dX5 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-PRto0jDgG5xT7dX5 .rough-node .label text,#mermaid-svg-PRto0jDgG5xT7dX5 .node .label text,#mermaid-svg-PRto0jDgG5xT7dX5 .image-shape .label,#mermaid-svg-PRto0jDgG5xT7dX5 .icon-shape .label{text-anchor:middle;}#mermaid-svg-PRto0jDgG5xT7dX5 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-PRto0jDgG5xT7dX5 .rough-node .label,#mermaid-svg-PRto0jDgG5xT7dX5 .node .label,#mermaid-svg-PRto0jDgG5xT7dX5 .image-shape .label,#mermaid-svg-PRto0jDgG5xT7dX5 .icon-shape .label{text-align:center;}#mermaid-svg-PRto0jDgG5xT7dX5 .node.clickable{cursor:pointer;}#mermaid-svg-PRto0jDgG5xT7dX5 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-PRto0jDgG5xT7dX5 .arrowheadPath{fill:#333333;}#mermaid-svg-PRto0jDgG5xT7dX5 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-PRto0jDgG5xT7dX5 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-PRto0jDgG5xT7dX5 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-PRto0jDgG5xT7dX5 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-PRto0jDgG5xT7dX5 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-PRto0jDgG5xT7dX5 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-PRto0jDgG5xT7dX5 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-PRto0jDgG5xT7dX5 .cluster text{fill:#333;}#mermaid-svg-PRto0jDgG5xT7dX5 .cluster span{color:#333;}#mermaid-svg-PRto0jDgG5xT7dX5 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-PRto0jDgG5xT7dX5 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-PRto0jDgG5xT7dX5 rect.text{fill:none;stroke-width:0;}#mermaid-svg-PRto0jDgG5xT7dX5 .icon-shape,#mermaid-svg-PRto0jDgG5xT7dX5 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-PRto0jDgG5xT7dX5 .icon-shape p,#mermaid-svg-PRto0jDgG5xT7dX5 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-PRto0jDgG5xT7dX5 .icon-shape .label rect,#mermaid-svg-PRto0jDgG5xT7dX5 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-PRto0jDgG5xT7dX5 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-PRto0jDgG5xT7dX5 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-PRto0jDgG5xT7dX5 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 无效
仍有效


Effect 使用了某个变化值
值变化后,旧任务是否仍然有效?
把值作为 key

取消并重启
任务执行时是否必须读取最新值?
rememberUpdatedState
无需 key,也无需包装

15.2 好 key 的特征

  • 与任务身份直接相关;
  • equals 语义稳定;
  • 不会在每次重组无意义变化;
  • 变化时确实需要清理旧资源。

15.3 不稳定对象导致重启风暴

kotlin 复制代码
// options 每次重组都新建,若未正确实现 equals 可能不断重启
val options = SearchOptions(category, sort)

LaunchedEffect(options) {
    repository.search(options)
}

可以使用稳定值作为多个 key,或者记住对象:

kotlin 复制代码
LaunchedEffect(category, sort) {
    repository.search(SearchOptions(category, sort))
}

lambda 也经常在重组中产生新实例。如果 lambda 变化不应重启任务,使用 rememberUpdatedState


16. 高频错误清单

错误一:在 Composable 函数体直接请求

组合次数不可预测。改用 ViewModel 初始化、LaunchedEffect 或事件回调。

错误二:用 SideEffect 做一次性任务

SideEffect 每次成功重组都运行,不表示"只执行一次"。

错误三:LaunchedEffect 的 key 不完整

Effect 内订阅了 userId,却用 Unit 作为 key,切换用户后仍观察旧用户。

错误四:key 太多

把所有 UI State 都当 key,会让长任务因无关界面变化不断取消重启。

错误五:listener 没有释放

LaunchedEffect 注册回调却没有清理。改用 DisposableEffect,或者 callbackFlow { awaitClose { ... } }

错误六:用空 onDispose 凑 API

说明资源并不需要对称清理,重新选择 Effect。

错误七:事件协程放进 GlobalScope

任务失去 UI 生命周期约束,可能在页面销毁后继续访问旧对象。使用 rememberCoroutineScopeviewModelScope

错误八:在 Effect 中复制 ViewModel State

kotlin 复制代码
// 不必要地制造第二份状态和同步问题
var localState by remember { mutableStateOf(UiState()) }
LaunchedEffect(viewModel) {
    viewModel.uiState.collect { localState = it }
}

Android 上优先:

kotlin 复制代码
val state by viewModel.uiState.collectAsStateWithLifecycle()

错误九:把昂贵操作放在主线程 Effect

Effect 协程默认运行在组合提供的上下文中。CPU 密集工作应由合适层使用 withContext(Dispatchers.Default),I/O 由数据层正确调度。

错误十:认为协程取消一定能撤回外部操作

取消只发送协作式取消信号。已经提交的 HTTP 请求、数据库事务或第三方 SDK 操作是否能撤回,取决于底层实现。关键写操作仍要设计幂等、事务和重试策略。


17. Effect 选择速查表

遇到需求时按下面顺序判断:

  1. 只是根据状态描述 UI? 直接写 Composable,不需要 Effect。
  2. 每次成功组合后同步非 Compose 对象? SideEffect
  3. 进入组合或 key 变化时运行挂起任务? LaunchedEffect
  4. 点击等事件发生时启动 UI 协程? rememberCoroutineScope
  5. 注册资源且必须注销? DisposableEffect
  6. Effect 不重启但要使用最新回调? rememberUpdatedState
  7. 把外部异步源封装成 Compose State? produceState
  8. 高频 State 派生为低频 UI 值? derivedStateOf
  9. 把 Compose State 送入 Flow 操作链? snapshotFlow
  10. 工作只应在 Android 前台/可见时运行? Lifecycle Effect 或 lifecycle-aware 收集。

#mermaid-svg-GhdXJ2TpUCiAgRr0{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-GhdXJ2TpUCiAgRr0 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .error-icon{fill:#552222;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .marker.cross{stroke:#333333;}#mermaid-svg-GhdXJ2TpUCiAgRr0 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-GhdXJ2TpUCiAgRr0 p{margin:0;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .cluster-label text{fill:#333;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .cluster-label span{color:#333;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .cluster-label span p{background-color:transparent;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .label text,#mermaid-svg-GhdXJ2TpUCiAgRr0 span{fill:#333;color:#333;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .node rect,#mermaid-svg-GhdXJ2TpUCiAgRr0 .node circle,#mermaid-svg-GhdXJ2TpUCiAgRr0 .node ellipse,#mermaid-svg-GhdXJ2TpUCiAgRr0 .node polygon,#mermaid-svg-GhdXJ2TpUCiAgRr0 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .rough-node .label text,#mermaid-svg-GhdXJ2TpUCiAgRr0 .node .label text,#mermaid-svg-GhdXJ2TpUCiAgRr0 .image-shape .label,#mermaid-svg-GhdXJ2TpUCiAgRr0 .icon-shape .label{text-anchor:middle;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .rough-node .label,#mermaid-svg-GhdXJ2TpUCiAgRr0 .node .label,#mermaid-svg-GhdXJ2TpUCiAgRr0 .image-shape .label,#mermaid-svg-GhdXJ2TpUCiAgRr0 .icon-shape .label{text-align:center;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .node.clickable{cursor:pointer;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .arrowheadPath{fill:#333333;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-GhdXJ2TpUCiAgRr0 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-GhdXJ2TpUCiAgRr0 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-GhdXJ2TpUCiAgRr0 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .cluster text{fill:#333;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .cluster span{color:#333;}#mermaid-svg-GhdXJ2TpUCiAgRr0 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-GhdXJ2TpUCiAgRr0 rect.text{fill:none;stroke-width:0;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .icon-shape,#mermaid-svg-GhdXJ2TpUCiAgRr0 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .icon-shape p,#mermaid-svg-GhdXJ2TpUCiAgRr0 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .icon-shape .label rect,#mermaid-svg-GhdXJ2TpUCiAgRr0 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-GhdXJ2TpUCiAgRr0 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-GhdXJ2TpUCiAgRr0 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-GhdXJ2TpUCiAgRr0 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是

否,由点击触发





需要做 UI 描述之外的工作
挂起任务?
由状态/组合触发?
LaunchedEffect
rememberCoroutineScope
需要对称清理?
DisposableEffect
每次成功组合后同步?
SideEffect
重新检查:可能无需 Effect


18. 与 ViewModel 和 Clean Architecture 的边界

Effect 属于 UI 适配层,不应该吞掉所有业务职责。

text 复制代码
Composable
  ├─ 收集 UiState
  ├─ 渲染 UI
  ├─ 用 Effect 适配 UI 生命周期和平台行为
  └─ 把 Event 交给 ViewModel

ViewModel / Store
  ├─ 生产 UiState
  ├─ 管理页面级并发
  └─ 调用 Use Case

Use Case / Domain
  └─ 执行业务规则,不依赖 Compose

判断任务放哪里,可以问"页面离开后是否应该继续":

  • Snackbar、滚动、焦点、动画:通常属于 Composable Effect;
  • 页面数据加载和状态:通常属于 ViewModel;
  • 必须跨页面/进程可靠执行:Repository、WorkManager 或系统服务;
  • 业务规则:Use Case/Domain。

LaunchedEffect 调用 ViewModel 方法并不错误,但不要让每次重组或临时 UI 状态成为业务请求的隐式触发器。


19. 测试 Effect

19.1 测试 key 变化是否重启

kotlin 复制代码
@Test
fun effectRestartsWhenIdChanges() {
    val loaded = mutableListOf<String>()

    composeRule.setContent {
        var id by remember { mutableStateOf("A") }

        LaunchedEffect(id) {
            loaded += id
        }

        Button(onClick = { id = "B" }) {
            Text("Change")
        }
    }

    composeRule.waitForIdle()
    composeRule.onNodeWithText("Change").performClick()
    composeRule.waitForIdle()

    assertEquals(listOf("A", "B"), loaded)
}

19.2 测试资源清理

给 listener 容器提供 Fake,条件隐藏 Composable 后验证 observer 被移除:

kotlin 复制代码
@Test
fun listenerIsRemovedWhenComposableLeavesComposition() {
    val source = FakeEventSource()
    lateinit var hide: () -> Unit

    composeRule.setContent {
        var visible by remember { mutableStateOf(true) }
        hide = { visible = false }

        if (visible) {
            EventObserver(source)
        }
    }

    assertEquals(1, source.listenerCount)
    composeRule.runOnIdle { hide() }
    composeRule.waitForIdle()
    assertEquals(0, source.listenerCount)
}

19.3 把复杂逻辑移出 Effect

Effect 本身通常只需要测试生命周期协作。防抖、重试、状态转换等复杂逻辑应尽量移到普通 Flow、Reducer、ViewModel 或 Use Case 中,然后使用协程单元测试独立验证。

Effect 越薄,测试越稳定:

kotlin 复制代码
LaunchedEffect(viewModel) {
    viewModel.effects.collect(effectHandler)
}

20. 最佳实践总结

  • 保持 Composable 尽可能无副作用;
  • Effect 内只做与 UI 生命周期直接相关的适配;
  • 用 key 明确定义任务身份,不多也不少;
  • 需要最新值但不想重启时使用 rememberUpdatedState
  • 注册和释放必须成对,优先 DisposableEffect
  • 用户事件中的 UI 协程使用 rememberCoroutineScope
  • ViewModel State 使用生命周期感知收集;
  • 高频状态用于 UI 派生时考虑 derivedStateOf,用于异步流处理时考虑 snapshotFlow
  • 不要吞掉协程取消;
  • 对关键外部操作设计幂等和持久化,而不是只依赖 Effect 生命周期。

最终可以把所有 API 归纳成一句话:

先确定副作用应该依附哪个生命周期,再确定什么变化应该重启它,最后确保旧任务能够被正确取消或释放。

掌握这三个判断后,Effect 不再是一组需要背诵的 API,而是 Compose 声明式模型与真实外部世界之间的一组明确边界。

相关推荐
平头哥技术团队10 小时前
Day 10 | 工欲善其事:VS Code 配置与项目归档
android·开发语言·前端·javascript·html·交互
冬木家居11 小时前
40㎡客厅变形记,小家住出大自由[特殊字符]
android·经验分享·笔记·智能家居·微信公众平台
AFinalStone11 小时前
Android 7系统无障碍服务(六)输入事件拦截与 TouchExplorer
android·无障碍服务
天空之城--12 小时前
MT管理器Android逆向工程完全指南:从入门到实战
android
Anhty13 小时前
2026九月最新变声器测评:iOS安卓双端适配,低延迟运行更稳定
android·人工智能·功能测试·ios·智能手机
渡我白衣14 小时前
并查集:基础认识与模拟实现
android·java·javascript·数据结构·c++·算法·并查集
Android-Flutter16 小时前
flutter GetX 详解
android·flutter
MyFreeIT18 小时前
Android Manual
android
hai_android18 小时前
AIDL 实现 IPC 进程间通信
android·android studio