ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Compose 副作用(Side Effects)等API 详解

Compose 副作用(Side Effects)等API 详解

官网: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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进