一、前言
自2008年发布第一版Android开始,Android 已经发展了 18年之久,从 XML 到 ViewBinding,再到如今的 Jetpack Compose,最大的思维颠覆之一就是副作用管理 。早期的 Android View 时代,我们可以在 onCreate 里调用 loadWeatherData(),在按钮的setOnClickListener{}里显示 Toast,这些操作都是司空见惯的。但进入 Compose 时代一切不一样了,如果不能及时扭转思维和代码习惯,等待你的将是无尽的BUG!
本篇代码示例环境:
Kotlin 2.2.10 + AGP 9.1.1 + Android Studio Panda 4
二、什么是副作用
先看官方给出的概念:
A side-effect is a change to the state of the app that happens outside the scope of a composable function. Due to composables' lifecycle and properties such as unpredictable recompositions, executing recompositions of composables in different orders, or recompositions that can be discarded, composables should ideally be side-effect free.
副作用是指对应用状态的改变,且这种改变发生在可组合函数的作用域之外。鉴于可组合函数的生命周期及其固有特性------例如重组的不可预测性、重组执行顺序的不确定性,以及重组可能被丢弃等情况------可组合函数在理想情况下应当是无副作用的。
概念简单说就是:在Compose中,副作用(Side-Effect)就是指那些"额外的事"。比如:
- 网络请求
- 数据库读写
- 日志打印
- 注册/注销监听器
- 弹出Toast
- 导航跳转
- ...
由于 Composable 函数随时可能因为状态变化 发生重组(Recomposition) ,这些操作如果我们直接写在了 Composable 函数体里,会面临一个严重问题:无法预测它会被执行多少次 。每当你点一下按钮,count 状态变了,Composable整体重跑,里面的网络请求又被触发一次------这就是副作用的典型灾难。
解决这个问题的办法就是使用副作用 API(Effect API),将副作用锚定到正确的生命周期上。
三、认识生命周期
要理解副作用,必须先理解Compose的生命周期。部分初学者把 Composable 的生命周期等同于 Activity/Fragment 的生命周期,这是灾难的开始。必须建立新的心智模型:Composable 的生命周期是跟随"组合树节点"的,并非页面,其中每个 Composable 组件都对应UI树的一个节点。
一个 Composable 函数只有三个核心阶段:
- 进入组合(Enter Composition) :Composable函数首次被调用,对应的UI节点加入组合树。
- 重组(Recomposition) :Composable读取的
State发生变化时,框架重新执行该函数以刷新UI。 - 离开组合(Leave Composition):Composable从UI树中被移除,通常是条件分支不再渲染或者导航到了别的页面(如列表项滑出屏幕)。

传统 Android View 体系中,
onResume()意味着用户可见,但在 Compose 中,一个Composable 函数处于 "组合中" 并不代表它一定在屏幕上可见。重组可能发生在任何时刻且可能被跳过或取消。
如果把传统 Android View 比作"盖房子"------onCreate盖好,onDestroy拆掉,那么Compose更像"流水线"------同一份蓝图(函数体)可能在任意时刻被重跑。这意味着一行看似无害的 Log.d() 语句,在重组时会被反复打印。
用代码直观感受下:
kotlin
@Composable
fun ProblematicScreen() {
var count by remember { mutableStateOf(0) }
// ❌危险:每次重组都会打印,包括初始进入 + 每次点击按钮
Log.d("Problematic", ">>>>>> 我被执行了,count = $count")
Column {
Text("点击次数:$count")
Button(onClick = { count++ }) {
Text("点我")
}
}
}

点击一次按钮,count++ 触发重组,Log.d 再次执行。如果你在这里放的是网络请求,每点一下就重新请求一次------显然不是我们想要的行为。
理解了这三个阶段,你会发现所有副作用API本质上都是在回答同一个问题:这个副作用应该在哪个生命周期节点执行,以及什么时候应该被取消?
四、副作用核心API
Compose 提供了一系列 Effect API,帮助在受控的生命周期节点执行副作用,下面就来看看如何使用它们。
4.1 LaunchedEffect
LaunchedEffect 是最高频使用的副作用API。它的机制很简洁:进入组合时启动一个协程,当key发生变化或离开组合时,自动取消上一个协程并重新启动。
key的设计是这个API的精髓。看一个搜索防抖的例子:
kotlin
@Composable
fun SearchScreen() {
var query by remember { mutableStateOf("") }
var results by remember { mutableStateOf(emptyList<String>()) }
Log.d(TAG, ">>>>>> query = " + query)
// key = query:每次搜索词变化,取消上一次请求,发起新请求
LaunchedEffect(query) {
if (query.isNotBlank()) {
delay(300) // 防抖
results = simulatedSearch(query)
}
}
Column {
OutlinedTextField(value = query, onValueChange = { query = it })
results.forEach { Text(it) }
}
}
suspend fun simulatedSearch(keyword: String): List<String> {
delay(1000)
return listOf("${keyword}结果1", "${keyword}结果2")
}
每次用户输入新字符,query 改变,LaunchedEffect自动取消上一次搜索协程,起一个新的。防抖300ms和自动取消旧请求一气呵成------这在View时代需要手动管理一堆 Job 和 Handler,而现在只需一个API。
我的建议 :LaunchedEffect 适合"状态驱动"的异步操作------某个状态变了,自动触发副作用。

4.2 rememberCoroutineScope
LaunchedEffect 只能自动执行 ,但很多时候我们需要在用户交互中 启动协程,比如点击按钮后才去请求数据。这时就需要rememberCoroutineScope:
kotlin
@Composable
fun ProfileScreen() {
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) {
Column(modifier = Modifier.padding(it)) {
Button(onClick = {
scope.launch {
val result = simulatedLoadProfile() // 模拟请求
snackbarHostState.showSnackbar(result)
}
}) {
Text("加载用户信息")
}
}
}
}
suspend fun simulatedLoadProfile(): String {
delay(1500) // 模拟网络延迟
return "用户信息加载成功"
}
注意:这个 scope 绑定到当前 Composable 的组合点,离开组合时会自动取消其中所有协程,不会泄漏。
关键区别 :LaunchedEffect = "状态变化,自动触发",rememberCoroutineScope = "用户操作,手动触发"。
4.3 DisposableEffect
当副作用需要"配对管理"时------有注册就必须有注销------DisposableEffect 是最佳选择。它要求在 onDispose 块中写明清理逻辑:
kotlin
@Composable
fun LifecycleAwareScreen(lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current) {
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> Log.d("Lifecycle", ">>>>>> started")
Lifecycle.Event.ON_STOP -> Log.d("Lifecycle", ">>>>>> stopped")
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
Log.d("Lifecycle", ">>>>>> call onDispose")
}
}
}
Compose离开组合时自动调用 onDispose,资源回收一条龙。这和 Android View 体系里的"在 onStart 注册、onStop 注销"是一脉相承的思路,只是用声明式重新表达了一遍。

4.4 SideEffect
SideEffect 是六个API里最"轻量"的一个:每次成功重组后执行 。"成功"二字很关键------如果重组因为某些原因被丢弃,SideEffect 就不会跑。
它主要用于把Compose的内部状态同步给非Compose代码,比如更新 Toolbar 标题、同步分析埋点:
kotlin
@Composable
fun AnalyticsScreen() {
var currentPage by remember { mutableStateOf("首页") }
Column {
Button(onClick = { currentPage = "详情页" }) { Text("跳转") }
Text("当前页: $currentPage")
}
SideEffect {
// 每次重组成功后,将当前页面名同步给埋点SDK
AnalyticsTracker.trackPageView(currentPage)
}
}
object AnalyticsTracker {
fun trackPageView(page: String) {
Log.d("Analytics", ">>>>>> 页面曝光: $page")
}
}
大多数场景下你不需要 SideEffect,但当你需要确保某个操作在每次成功渲染后都执行时,它是最合适的。

4.5 derivedStateOf与snapshotFlow
这两个API解决同一类问题:当某个状态由其他状态推导而来时,如何避免不必要的重组。
derivedStateOf 在组合中使用:
kotlin
@Composable
fun FilteredList(rawList: List<String>, keyword: String) {
// 只有rawList或keyword真正变化时,filtered才重新计算
val filtered by remember {
derivedStateOf {
rawList.filter { it.contains(keyword, ignoreCase = true) }
}
}
LazyColumn {
items(filtered) { Text(it) }
}
}
snapshotFlow 则是把Compose的Snapshot状态转换成Kotlin Flow,适合在ViewModel中使用:
kotlin
class FilterViewModel : ViewModel() {
private val _keyword = MutableStateFlow("")
val filteredResults = snapshotFlow { _keyword.value }
.debounce(300)
.mapLatest { performSearch(it) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
fun onKeywordChange(keyword: String) { _keyword.value = keyword }
}
derivedStateOf 是"在组合里减少计算",snapshotFlow 是"把Compose状态带到ViewModel的Flow世界"。
4.6 一图看清API
六个API各有分工,用一张表快速对比:
| API | 触发时机 | 典型场景 | 自动清理 | 一句话总结 |
|---|---|---|---|---|
| LaunchedEffect | 进入组合 / key变化 | 搜索防抖、页面初始化加载 | 协程自动取消 | 状态变了,自动干活 |
| rememberCoroutineScope | 手动调用 launch |
按钮点击请求、下拉刷新 | 离开组合时取消 | 用户点了,我才干活 |
| DisposableEffect | 进入组合 / key变化 | 注册监听器、绑定Observer | onDispose 回调 |
进来注册,走时注销 |
| SideEffect | 每次成功重组后 | 埋点上报、同步Toolbar标题 | 无需清理 | 重组跑完,顺手通知外部 |
| derivedStateOf | 依赖的状态变化 | 列表过滤、复合状态计算 | 无需清理 | 靠别人算出来的,别白算 |
| snapshotFlow | 状态快照变化时发射 | ViewModel中观察Compose状态 | Flow收集者取消 | 把Compose状态送进Flow世界 |
五、陷阱提醒
以下是刚接触Compose副作用时最容易踩的坑:
陷阱1:在 Composable 函数体顶层写副作用。 前面 ProblematicScreen 的例子已经展示了------每重组一次就执行一次。记住:函数体里只做声明式UI描述,副作用一律交给 Effect API。
陷阱2:LaunchedEffect 的key用常量。 比如 LaunchedEffect(Unit),这在某些场景下是对的(比如进入页面只请求一次),但如果你希望状态变化后重新触发,必须把该状态放入key。用错了轻则逻辑不更新,重则内存泄漏。
陷阱3:DisposableEffect 忘记写 onDispose。 编译器不会报错,但监听器、广播接收器会一直持有引用,导致对象无法被GC。这是Compose 里最容易被忽视的泄漏来源。
陷阱4:在remember块里启动协程。 remember 只在首次组合执行,在重组时返回缓存值。在 remember 里启动协程意味着你无法在重组时重启它,也拿不到正确的生命周期取消。
六、实践准则
经过几个项目的踩坑,我总结了六条准则:
- 凡是"额外的事"(网络、IO、日志、注册监听)一律放进Effect API,绝不裸写在函数体里。
- LaunchedEffect 用于"状态驱动的异步操作",比如关键词变化触发搜索请求、页面初始化数据加载。
- rememberCoroutineScope 用于"用户事件驱动的异步操作",比如点赞、提交表单、下拉刷新。
- DisposableEffect 用于"需要清理的资源绑定",比如注册BroadcastReceiver、添加Observer、绑定Service。
- SideEffect 用于"重组后同步状态到非Compose世界",比如埋点上报、ActionBar标题同步。绝大多数场景你不需要它。
- derivedStateOf 用在"组合内部减少不必要的派生计算",snapshotFlow用在"ViewModel中将Compose状态桥接到Flow体系"。
七、结语
副作用 管理是 Compose 和 Android View 体系最根本的思维差异之一。View 时代我们习惯了"在生命周期回调里做任何事",而 Compose 要求我们把副作用和UI声明做明确分离。刚开始会觉得很别扭,但一旦适应,你会发现代码更清晰:UI长什么样是一段代码,UI该做什么副作用是另一段代码 ,二者通过 Effect API 泾渭分明。
回过头再看,Compose的这些设计不是在限制你,而是在保护你------保护你不会因为重组的不可预测性写出连自己都调不通的代码。
八、参考资料
- https://developer.android.google.cn/develop/ui/compose/side-effects?hl=zh-cn
- https://developer.android.google.cn/develop/ui/compose/lifecycle?hl=zh-cn
- 《Jetpack Compose 从入门到实战》
- https://github.com/android/snippets