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 代码,都要问:
- 启动条件是什么? 首次进入,还是每次成功重组?
- 重启条件是什么? 哪些 key 改变会取消旧任务并启动新任务?
- 清理条件是什么? 离开组合时如何取消协程或注销监听?
只要能回答这三个问题,大部分 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 离开状态时清理 |
rememberUpdatedState 和 derivedStateOf 严格说不是"执行副作用"的 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)
}
}
userId 从 A 变为 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)
}
当 userId 或 filter 变化时重启,因为旧订阅已经不再对应当前输入。
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 可以理解为 remember 与 LaunchedEffect 的组合:
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 什么时候值得使用
同时满足两个条件时再考虑:
- 输入变化频率高;
- 派生结果变化频率明显更低,且结果用于组合。
它类似 Flow 的 distinctUntilChanged():关注的是派生结果是否变化,而不是输入经历了多少次变化。
10.4 remember 与 derivedStateOf
通常要记住派生状态对象:
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 snapshotFlow 与 derivedStateOf
| 目标 | 选择 |
|---|---|
| 派生值用于渲染 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. remember、key 和 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 生命周期约束,可能在页面销毁后继续访问旧对象。使用 rememberCoroutineScope 或 viewModelScope。
错误八:在 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 选择速查表
遇到需求时按下面顺序判断:
- 只是根据状态描述 UI? 直接写 Composable,不需要 Effect。
- 每次成功组合后同步非 Compose 对象?
SideEffect。 - 进入组合或 key 变化时运行挂起任务?
LaunchedEffect。 - 点击等事件发生时启动 UI 协程?
rememberCoroutineScope。 - 注册资源且必须注销?
DisposableEffect。 - Effect 不重启但要使用最新回调?
rememberUpdatedState。 - 把外部异步源封装成 Compose State?
produceState。 - 高频 State 派生为低频 UI 值?
derivedStateOf。 - 把 Compose State 送入 Flow 操作链?
snapshotFlow。 - 工作只应在 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 声明式模型与真实外部世界之间的一组明确边界。