官网:Compose 中的附带效应 | Jetpack Compose | Android Developers
前置概念:什么是副作用?
Compose 的核心理念是声明式 UI + 重组(Recomposition)。重组可能随时发生、可能部分执行、可能被取消。因此,任何「修改 Compose 状态之外的世界」的操作(网络请求、订阅、日志、回调等)都属于副作用,必须放在合适的 API 中托管,否则会出现泄漏、重复执行、读到过期数据等问题。
官方把副作用 API 分成两大类:
一次性副作用:
LaunchedEffect、DisposableEffect、SideEffect状态转换副作用:
produceState、derivedStateOf、snapshotFlow辅助工具:
rememberCoroutineScope、rememberUpdatedState
1. LaunchedEffect
作用:在组合(Composition)中启动一个协程,当key变化或组合离开时自动取消协程。
适用场景:进入组合时执行一次性异步任务(加载数据、动画、跳转),且希望在重组时不重复执行。
kotlin
@Composable fun HomeScreen(viewModel: HomeViewModel) { LaunchedEffect(Unit) { // Unit 表示只执行一次 viewModel.loadData() } LaunchedEffect(viewModel.userId) { // userId 变化时重新执行,旧的协程被取消 val detail = fetchUserDetail(viewModel.userId) viewModel.onDetailLoaded(detail) } }要点:
key相等 → 不重启;key变化 → 取消旧协程、启动新协程;离开组合 → 自动取消。常见错误:用
viewModel当 key 导致每次重组重启协程;或在该协程里做不该随状态变化重做的重活。
2. rememberCoroutineScope
作用:获取一个与组合生命周期绑定的CoroutineScope,允许你在回调中(而非组合体内)手动启动协程。
适用场景:事件回调触发的异步操作,如按钮点击刷新、滚动到底部加载更多。此时不能用LaunchedEffect(它只在进入组合时触发)。
kotlin
@Composable fun RefreshButton(viewModel: HomeViewModel) { val scope = rememberCoroutineScope() Button(onClick = { scope.launch { viewModel.refresh() // 例如弹一个 Snackbar,需要 Compose 作用域 // snackbarHostState.showSnackbar("已刷新") } }) { Text("刷新") } }与 LaunchedEffect 的区别:
| 触发时机 | 取消时机 | |
|---|---|---|
LaunchedEffect | 进入组合 / key 变化自动触发 | 离开组合自动取消 |
rememberCoroutineScope | 手动launch触发 | 离开组合自动取消 |
要点:不要remember { CoroutineScope(...) },那样离开组合不会取消,会造成泄漏;必须用rememberCoroutineScope。
3. rememberUpdatedState
作用:保存对某个最新状态的引用,使得长时间运行的副作用协程里读到的是最新的值,而不是启动时捕获的旧值。
解决问题:LaunchedEffect(Unit)的协程只启动一次,里面直接捕获的变量是旧的。用rememberUpdatedState包装后,协程每次读取都是新值。
经典例子:屏幕上有倒计时,期间用户修改了timeout,协程应使用最新的timeout。
kotlin
@Composable fun TimeoutEffect( timeout: Long, onTimeout: () -> Unit ) { val currentOnTimeout by rememberUpdatedState(onTimeout) val currentTimeout by rememberUpdatedState(timeout) LaunchedEffect(Unit) { delay(currentTimeout) // 每次读取都是调用方传入的最新值 currentOnTimeout() // 回调也是最新的 } }调用方不断传新回调时,副作用仍然用最新的回调执行——避免捕获旧的 lambda 造成 bug。
一句话:rememberUpdatedState把「值快照」变成「值的引用快照」。
4. DisposableEffect
作用:进入组合执行一次初始化,离开组合时执行清理(onDispose)。可带 key,key 变化时先清理旧资源再重新初始化。
适用场景:需要配对「注册/注销」「订阅/退订」「添加监听/移除监听」的资源。
kotlin
@Composable fun BackHandlerEffect(onBack: () -> Unit) { val currentOnBack by rememberUpdatedState(onBack) DisposableEffect(Unit) { val callback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() = currentOnBack() } backDispatcher.addCallback(callback) onDispose { callback.remove() // 离开组合时移除监听,防止泄漏 } } }要点:
必须调用
onDispose(代码里必须出现它),否则编译期有 lint 检查提醒。onDispose中不能读组合里的状态(组合已开始卸载);要把需要的最新状态用rememberUpdatedState捕获后传进去(如上例)。
5. SideEffect
作用:每次成功重组后都会执行,把 Compose 状态同步到非 Compose 世界。
适用场景:Compose → 外部系统的单向同步:Analytics 上报、日志、把重组后的值写入 Android View。
kotlin
@Composable fun AnalyticsScreen(pageName: String) { SideEffect { // 每次重组成功后上报;不是进入组合时才上报 Analytics.log("screen_shown", pageName) } }与 LaunchedEffect 对比:
LaunchedEffect:进入组合/key 变化时跑一次(面向「事件」)。SideEffect:每次重组后跑一次(面向「状态同步」)。
注意:SideEffect 中不要做重活/异步,它跑在主线程组合提交阶段,应保持轻量。
6. produceState
作用:把一个非 Compose 状态源(异步回调、Flow、网络请求)转换成 Compose 的State<T>,并托管其生命周期。
返回值:一个State,可以直接用于 UI。
kotlin
@Composable fun produceUserData(userId: String): State<User?> { return produceState<User?>(initialValue = null, userId) { val data = repository.fetchUser(userId) // 异步加载 value = data // 赋值即更新 UI // 也可以收集 Flow: // repository.userFlow(userId).collect { value = it } } }要点:
value的初始值由initialValue指定。key(这里是
userId)变化 → 取消旧的 producer 协程,重启。离开组合 → 自动取消。适合替代 ViewModel 中很多样板代码(简单场景)。
组合体内不能有网络请求?
produceState内部的协程是合法的,因为状态转换是被托管的。
7. derivedStateOf
作用:把一个或多个 Compose 状态派生成一个新的状态,且只有结果真正变化时才触发重组(缓存计算 + 精确通知)。
适用场景:昂贵或高频的状态计算,避免每次源状态变化都连锁重组。
kotlin
@Composable fun LazyColumnWithButton(list: List<Item>) { val listState = rememberLazyListState() // 只有「是否显示按钮」这个结果变化时才重组,而不是每滚动一像素就重组 val showBackToTop by remember { derivedStateOf { listState.firstVisibleItemIndex > 5 } } AnimatedVisibility(visible = showBackToTop) { FloatingActionButton(onClick = { scope.launch { listState.animateScrollToItem(0) } }) { Icon(Icons.Default.ArrowUpward, null) } } }为什么不用普通计算?
kotlin
// ❌ 错误:每次重组都执行,且状态对象每次新建,失去缓存意义 val show = listState.firstVisibleItemIndex > 5 // ❌ 错误:普通 remember 返回普通 Boolean,首次计算后不再更新 val show = remember { listState.firstVisibleItemIndex > 5 } // ✅ derivedStateOf:状态变化→重新计算→只有结果变了才通知重组要点:remember { derivedStateOf { ... } }内读的 State 被订阅;常用于滚动位置、文本是否超长、筛选计数等。
8. snapshotFlow
作用:把 Compose 的State 转换成 Flow,以便在协程里使用 Flow 的操作符(debounce、filter、distinctUntilChanged等)。
与 derivedStateOf 方向相反:
derivedStateOf:State → State(留在 Compose 世界)snapshotFlow:State → Flow(流向协程/Flow 世界)
kotlin
@Composable fun SearchBar(viewModel: SearchViewModel) { val query = remember { mutableStateOf("") } LaunchedEffect(Unit) { snapshotFlow { query.value } // 把 Compose 状态转成 Flow .debounce(300) // 300ms 防抖 .distinctUntilChanged() .filter { it.isNotBlank() } .collect { q -> viewModel.search(q) } } TextField(value = query.value, onValueChange = { query.value = it }) }要点:
snapshotFlow块内读的 State 变化时发送新值到 Flow。必须在协程中收集(常配合
LaunchedEffect)。典型场景:搜索防抖、输入校验、表单状态上报。
汇总对比表
| API | 类型 | 触发时机 | 核心用途 | 一句话记忆 |
|---|---|---|---|---|
| LaunchedEffect | 一次性 | 进入组合 / key 变化 | 启动托管协程 | 进组合干活 |
| rememberCoroutineScope | 工具 | 手动 launch | 回调里启动协程 | 点击等事件里干活 |
| rememberUpdatedState | 工具 | 重组即更新引用 | 让旧协程读到新值 | 给旧协程"换血" |
| DisposableEffect | 一次性 | 进入组合 / key 变化 | 初始化 + 清理资源 | 记得打扫现场 |
| SideEffect | 一次性 | 每次重组后 | 状态同步到外部 | 汇报最新状态 |
| produceState | 状态转换 | key 变化 / 收集源 | 外部异步 → State | 把异步装进 State |
| derivedStateOf | 状态转换 | 源状态变化 | State → State,结果变了才重组 | 计算缓存 |
| snapshotFlow | 状态转换 | 源状态变化 | State → Flow | 把状态接进 Flow 管道 |
选型口诀
进来做一次异步任务 →
LaunchedEffect点击/回调里启动协程 →
rememberCoroutineScope长协程要用最新参数 →
rememberUpdatedState注册/订阅要配对清理 →
DisposableEffect每次重组后同步给外部 →
SideEffect异步数据要变成 UI 状态 →
produceState状态推导、避免连锁重组 →
derivedStateOf状态要用 Flow 操作符处理 →
snapshotFlow