Jetpack Compose 的 UI 由状态驱动:状态变化,函数重新执行,界面得到更新。但真实页面不只有“根据数据返回 UI”这一类工作。请求数据、显示 Toast、注册监听器、启动动画、写入日志,都可能需要和 Compose 外部的系统或对象交互,这些工作就是副作用。
副作用本身并不可怕,真正容易出问题的是把它放在错误的位置:在组合函数体里直接发请求、用 LaunchedEffect(true) 偷偷承载整个页面生命周期、在 DisposableEffect 中忘记注销监听,或者在点击回调里启动了没有归属的协程。本文通过一个搜索页面,梳理几种常用 API 的职责边界。
组合函数可能因为任意状态变化反复执行。下面的代码看起来直观,但每次重新组合都可能发起一次请求:
@Composable
fun SearchScreen(repository: SearchRepository) {
var keyword by remember { mutableStateOf("") }
repository.search(keyword) // 错误:组合过程不应该直接调用外部操作
TextField(
value = keyword,
onValueChange = { keyword = it }
)
}组合阶段应该尽量保持可重入、可取消和无副作用。把外部操作放进受 Compose 管理的 Effect 中,Compose 才能根据 key 决定何时启动、取消和重新启动它。
LaunchedEffect 适合“进入组合后启动协程,并在 key 变化或离开组合时取消”的工作。搜索页面可以把关键词作为 key:
@Composable
fun SearchScreen(
viewModel: SearchViewModel = viewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var keyword by rememberSaveable { mutableStateOf("") }
LaunchedEffect(keyword) {
if (keyword.isBlank()) {
viewModel.clearResult()
} else {
delay(300)
viewModel.search(keyword)
}
}
SearchContent(
keyword = keyword,
state = uiState,
onKeywordChange = { keyword = it }
)
}用户连续输入时,旧的 Effect 会被取消,新的 Effect 重新计时。这个行为适合防抖,但请求本身最好仍由 ViewModel 或仓库统一管理,因为网络请求、错误映射和缓存都属于业务层。Effect 负责把 UI 事件转换为挂起调用,不应逐渐膨胀成业务控制器。
可以把 key 理解为这段副作用依赖的输入:
LaunchedEffect(Unit) 或 LaunchedEffect(true) 常用于只执行一次的进入动作,例如首次加载或播放进入动画。但“只执行一次”是相对于当前组合实例而言的,并不等同于进程生命周期内只执行一次。页面重新进入组合后,它仍可能再次启动,因此不能用它替代持久化任务或业务去重。
如果 Effect 需要读取一个会变化、但不应该触发重启的回调或对象,可以使用 rememberUpdatedState:
@Composable
fun SessionTimeoutMessage(
onTimeout: () -> Unit
) {
val latestOnTimeout by rememberUpdatedState(onTimeout)
LaunchedEffect(Unit) {
delay(30_000)
latestOnTimeout()
}
}这样计时协程不会因为回调实例变化而重启,但执行时拿到的是最新回调。这里的重点不是“绕过 key”,而是明确区分“会改变副作用生命周期的依赖”和“只需要读取最新值的依赖”。
LaunchedEffect 由组合启动,rememberCoroutineScope 则适合由用户事件启动。例如点击按钮后弹出 Snackbar:
@Composable
fun SaveBar(snackbarHostState: SnackbarHostState) {
val scope = rememberCoroutineScope()
Button(
onClick = {
scope.launch {
snackbarHostState.showSnackbar("已保存")
}
}
) {
Text("保存")
}
}这个 Scope 的生命周期绑定到当前组合位置,Composable 离开组合后会取消其中的协程。它比在回调里直接创建 CoroutineScope(Dispatchers.Main) 更可靠,因为后者没有自动清理,页面销毁后仍可能继续持有引用。
它也不是 ViewModel Scope 的替代品。需要跨越配置变化、继续执行或保存业务结果的工作,应交给 ViewModel;只服务于当前界面交互的短任务,才适合放在 rememberCoroutineScope 中。
当副作用需要注册监听器、观察生命周期或订阅系统回调时,使用 DisposableEffect。例如记录页面可见状态:
@Composable
fun TrackScreenVisible(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
analytics: Analytics
) {
DisposableEffect(lifecycleOwner, analytics) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
analytics.log("screen_resume")
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}onDispose 必须位于 Effect 末尾,并且要撤销注册动作。key 变化时,Compose 会先执行旧实例的 onDispose,再创建新实例,所以 key 必须包含真正决定注册对象的依赖。只要 lifecycleOwner 可能改变,就不能把它漏掉。
如果只是向外部对象发送一次同步更新,不需要清理资源,SideEffect 通常更合适:它会在成功组合之后执行,适合把 Compose 状态同步给已有对象,但不能在里面启动挂起函数。
常见的生命周期监听写法是:
val currentOwner = LocalLifecycleOwner.current
DisposableEffect(currentOwner) {
val observer = LifecycleEventObserver { _, event ->
// 读取简单值可以直接使用;复杂回调要考虑最新值问题
}
currentOwner.lifecycle.addObserver(observer)
onDispose { currentOwner.lifecycle.removeObserver(observer) }
}如果 observer 存活期间需要使用外部变化的配置,例如最新的埋点参数,不要为了更新参数而反复注册 observer。可以让 observer 读取 rememberUpdatedState 保存的最新引用:
val latestScreenName by rememberUpdatedState(screenName)
DisposableEffect(currentOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) {
analytics.log("start:$latestScreenName")
}
}
currentOwner.lifecycle.addObserver(observer)
onDispose { currentOwner.lifecycle.removeObserver(observer) }
}这种组合可以同时满足两个要求:生命周期对象变化时重新注册,普通参数变化时只更新读取到的值。
页面加载、下拉刷新和重试如果各自维护一套请求状态,很容易出现竞态。建议让 UI 只发出事件,ViewModel 统一处理请求,状态通过 StateFlow 暴露。Effect 可以负责“进入页面时发送一次加载事件”,但不要再复制一套 loading、错误和结果状态。
把每次重组都会创建的新对象作为 key,会导致 Effect 反复取消和启动。优先使用稳定的 ID、值类型参数或明确的生命周期对象。若对象本身是业务实体,应确认其相等性和实例更新策略。
协程取消是正常控制流,不应该被宽泛的异常捕获转换成错误提示:
try {
repository.load()
} catch (error: CancellationException) {
throw error
} catch (error: IOException) {
showNetworkError()
}当用户离开页面或 key 变化时,及时传播取消信号可以避免旧请求继续更新界面。
导航、Toast、Snackbar 这类事件如果直接作为普通状态渲染,重组或旋转后可能重复消费。可以在 ViewModel 侧设计明确的事件流,或者让事件携带唯一 ID,并在消费后确认状态。选择哪种方式取决于事件是否需要可靠送达,但都比在组合函数体里直接调用更容易推理。
Effect 测试不应只验证“函数执行了”,还要验证生命周期边界。可以使用 Compose UI Test 和测试时钟覆盖这些场景:
DisposableEffect 注册监听,销毁时完成注销;业务请求本身则在 ViewModel 和仓库测试中验证,避免把网络依赖带入组合测试。对于 LaunchedEffect 中的 delay,使用测试调度器推进虚拟时间,可以让防抖测试稳定且快速。
场景 | 推荐 API | 生命周期重点 |
|---|---|---|
由状态变化触发挂起工作 | LaunchedEffect(key) | key 变化取消并重启 |
点击、手势触发短协程 | rememberCoroutineScope | 离开组合时取消 |
注册监听并需要注销 | DisposableEffect(key) | onDispose 成对清理 |
组合成功后同步外部对象 | SideEffect | 不能承载挂起操作 |
读取最新回调但不重启 Effect | rememberUpdatedState | 区分最新值与生命周期依赖 |
Compose 副作用 API 的差异,本质上是启动者和生命周期的差异。LaunchedEffect 适合状态驱动的挂起工作,rememberCoroutineScope 适合事件驱动的短任务,DisposableEffect 负责需要清理的外部资源,SideEffect 用来同步成功组合后的结果,rememberUpdatedState 则解决长任务读取最新值的问题。
使用前先回答三个问题:这项工作由谁触发?哪些输入变化应该重启它?离开组合时需要怎样清理?答案明确后,API 的选择通常就不会靠猜。把业务状态留在 ViewModel,把界面副作用限制在清晰的生命周期内,Compose 页面会更稳定,也更容易测试。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。