Jetpack Compose 附带效应
一、先搞懂:什么是"附带效应"?
在 Jetpack Compose 里,可组合项(Composable) 的核心工作是"描述界面长什么样",理想情况下它应该是"无附带效应"的------也就是说,可组合项里的代码只负责根据状态画 UI,不应该偷偷修改界面之外的东西。
那附带效应(Side Effect) 就是"超出可组合项作用域的状态变化",比如:
- 弹一个提示框(Snackbar/Toast)
- 发送网络请求
- 记录分析数据
- 订阅生命周期事件
为什么不能直接在可组合项里写这些操作?因为 Compose 的重组(Recomposition) 有3个"不可预测"的特性:
- 重组时机不确定(状态变了就可能触发)
- 重组顺序可能乱(可组合项执行顺序不固定)
- 重组可能被舍弃(计算到一半发现不用更了,直接丢了)
如果直接在可组合项里写附带效应,可能导致重复执行(比如弹10次提示)、执行时机错(界面还没画好就发请求)。所以 Compose 提供了专门的 Effect API,让附带效应在"受控环境"里执行,跟可组合项的生命周期绑定。
为什么 重组可能被舍弃 ?
重组被舍弃是因为在计算过程中,可组合项的 "计算目标" 突然失效了,继续算下去只会浪费资源,所以 Compose 会直接中断并丢弃已有的计算结果。
- 新状态让 "旧计算" 过时 : 可组合项的重组由 "状态变化" 触发,但状态变化可能是连续的(比如快速点击按钮,每秒触发多次状态更新)。
- 例子:一个按钮点击后会让 count 从 1→2→3→4,每次变化都会触发重组。
- 过程:当 count=2 触发的重组还在计算时,count=3 的新状态已经到来,此时 count=2 对应的重组结果已经过时 ------ 就算算完,UI 也会立刻被 count=3 的结果覆盖。
- 结论:Compose 会直接舍弃 count=2 的重组计算,专心处理 count=3 的新重组,避免 CPU 做无用功。
- 父组件决定 "不显示" 当前可组合项 : 可组合项的显示 / 隐藏由父组件的状态控制,若父组件在子组件重组过程中,突然决定 "不渲染这个子组件",子组件的重组就失去了意义。
- 例子:父组件有个 isShowChild 状态,控制子组件 Child() 是否显示;当 isShowChild 从 true 变 false 时,子组件 Child() 刚好正在执行重组计算。
- 过程:子组件还在计算 "自己该画成什么样",但父组件已经确定 "不会把它画出来",此时子组件的计算结果毫无用处。
- 结论:Compose 会立刻中断子组件的重组,丢弃计算结果,避免浪费资源在 "永远不会显示" 的 UI 上。
二、逐个吃透:8个核心 Effect API
每个 API 都解决特定场景的问题,先记"用途",再看"怎么用",最后懂"原理"。
1. LaunchedEffect:在可组合项里跑协程(挂起函数)
用途
想在可组合项的生命周期内执行挂起函数 (比如 delay
、网络请求、动画),且协程会跟着可组合项"生死"------可组合项进入组合(显示)时启动协程,退出组合(消失)时取消协程。
关键逻辑
- 当
LaunchedEffect
的键(Key) 变化时,会先取消当前协程,再启动新协程 - 只能在可组合项内部使用(因为它本身是可组合函数)
通俗例子:动画脉冲效果
比如一个控件想每隔3秒"闪一下"(透明度从1→0→1),用 delay
(挂起函数)控制间隔:
kotlin
// 控制闪烁频率(3秒一次,可配置)
var pulseRateMs by remember { mutableLongStateOf(3000L) }
// 控制透明度的动画状态
val alpha = remember { Animatable(1f) }
// 启动协程,键是pulseRateMs(频率变了就重启协程)
LaunchedEffect(pulseRateMs) {
// 只要协程活跃,就循环闪烁
while (isActive) {
delay(pulseRateMs) // 等3秒
alpha.animateTo(0f) // 变透明
alpha.animateTo(1f) // 变不透明
}
}
简单说:这个协程只在控件显示时跑,控件消失就停;如果想让闪烁变快(改 pulseRateMs
),旧协程会停,新协程按新频率跑。
2. rememberCoroutineScope:在可组合项外启动"生命周期绑定"的协程
用途
LaunchedEffect
只能在可组合项里用,但如果想在可组合项之外(比如按钮点击事件、回调里)启动协程,又想让协程跟着可组合项"死"(控件消失就取消协程),就用这个 API。
关键逻辑
- 它是个可组合函数,返回一个 CoroutineScope(协程作用域)
- 这个作用域跟调用它的"组合点"绑定:可组合项退出组合 → 作用域取消 → 里面的协程全停
通俗例子:点击按钮弹 Snackbar
kotlin
@Composable
fun MoviesScreen(snackbarHostState: SnackbarHostState) {
// 获取跟 MoviesScreen 生命周期绑定的协程作用域
val scope = rememberCoroutineScope()
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) {
Button(onClick = {
// 点击事件里启动协程(不在可组合项直接写,在事件里)
scope.launch {
snackbarHostState.showSnackbar("点击成功!")
}
}) {
Text("点我弹提示")
}
}
}
简单说:如果 MoviesScreen 消失了,就算 Snackbar 还没弹完,协程也会被取消,不会出现"界面没了还弹提示"的情况。
3. rememberUpdatedState:效应里引用值,却不想值变就重启
用途
有些效应(比如 LaunchedEffect
)会因为"键变化"重启,但如果效应里有个值(比如回调)可能变,可你不想值一变就重启效应(比如重启一个10秒的延迟),就用 rememberUpdatedState
把这个值"包起来"。
关键逻辑
- 它会创建一个 State 对象,始终持有最新的值
- 效应里引用这个 State 对象,就算原始值变了,效应也不会重启
通俗例子:启动页延迟跳转
比如启动页(LandingScreen)要等3秒后调用 onTimeout
跳转,但如果 onTimeout
回调变了(比如业务逻辑改了跳转目标),3秒的延迟不能重新算:
kotlin
@Composable
fun LandingScreen(onTimeout: () -> Unit) {
// 用 rememberUpdatedState 包着 onTimeout,确保拿到最新值
val currentOnTimeout by rememberUpdatedState(onTimeout)
// 键用 true(常量),让效应只跟 LandingScreen 生命周期绑定(只启动一次)
LaunchedEffect(true) {
delay(3000) // 等3秒,就算 onTimeout 变了,这里也不重新等
currentOnTimeout() // 调用最新的 onTimeout
}
// 启动页的 UI(比如logo)
}
简单说:3秒的延迟只走一次,就算跳转逻辑变了,也不用重新等3秒,直接用最新的跳转逻辑。
4. DisposableEffect:需要"清理"的附带效应
用途
有些附带效应需要"收尾工作"(比如注册了监听,必须取消注册;打开了资源,必须关闭),否则会内存泄漏。DisposableEffect
强制你写清理逻辑,不写会报 IDE 错误。
关键逻辑
- 必须在代码块最后写
onDispose
子句(清理逻辑放这) - 当"键变化"或"可组合项退出组合"时,会先执行
onDispose
,再重启效应(如果键变了)
通俗例子:监听生命周期事件
比如 HomeScreen 要在生命周期走到 ON_START 时发分析事件,ON_STOP 时发另一个,必须在退出组合时移除观察者:
kotlin
@Composable
fun HomeScreen(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
onStart: () -> Unit, // ON_START 时执行
onStop: () -> Unit // ON_STOP 时执行
) {
// 用 rememberUpdatedState 包着回调,避免键变化
val currentOnStart by rememberUpdatedState(onStart)
val currentOnStop by rememberUpdatedState(onStop)
// 键是 lifecycleOwner,它变了就重新注册观察者
DisposableEffect(lifecycleOwner) {
// 创建生命周期观察者
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> currentOnStart()
Lifecycle.Event.ON_STOP -> currentOnStop()
else -> {}
}
}
// 注册观察者
lifecycleOwner.lifecycle.addObserver(observer)
// 清理逻辑:退出组合或键变化时,移除观察者
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
// HomeScreen 的 UI
}
简单说:注册了观察者,就必须在不用的时候删掉,DisposableEffect
强制你做这件事,避免内存泄漏。
5. SideEffect:把 Compose 状态"同步"给非 Compose 代码
用途
如果要把 Compose 管理的状态(比如 State<User>
)传给非 Compose 代码(比如第三方分析库、原生 View 的管理类),且要保证"每次重组成功后才同步",就用 SideEffect
。
关键逻辑
- 每次可组合项成功重组 后,都会执行
SideEffect
里的代码 - 不能直接在可组合项里写同步逻辑(因为重组可能被舍弃,导致同步错)
通俗例子:给 Firebase 分析设置用户属性
kotlin
@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
// 用 remember 保留 FirebaseAnalytics 实例(不重复创建)
val analytics: FirebaseAnalytics = remember { FirebaseAnalytics() }
// 每次重组成功后,把最新的 userType 同步给 analytics
SideEffect {
analytics.setUserProperty("userType", user.userType)
}
return analytics
}
简单说:只要 user 的 userType 变了,重组成功后就会自动更新到分析库,不会因为重组被舍弃导致同步失败。
6. produceState:把"非 Compose 状态"转成"Compose 状态"
用途
如果有非 Compose 管理的状态(比如网络请求结果、Flow/LiveData/RxJava 的数据、原生回调里的状态),想把它转成 Compose 能识别的 State (这样状态变了 Compose 会自动重组),就用 produceState
。
关键逻辑
- 启动一个协程,协程里可以获取非 Compose 状态,然后赋值给
value
(value
就是返回的 State) - 协程跟可组合项生命周期绑定:退出组合 → 协程取消
- 返回的 State 会"去重":赋值相同的值不会触发重组
通俗例子:网络加载图片
kotlin
@Composable
fun loadNetworkImage(
url: String,
imageRepository: ImageRepository = ImageRepository()
): State<Result<Image>> {
// 初始值是 Result.Loading,键是 url 和 imageRepository(任一变了就重启加载)
return produceState<Result<Image>>(initialValue = Result.Loading, url, imageRepository) {
// 协程里调用仓库加载图片(可挂起)
val image = imageRepository.load(url)
// 更新 State 的值:加载失败给 Error,成功给 Success
value = if (image == null) Result.Error else Result.Success(image)
}
}
简单说:调用 loadNetworkImage
会得到一个 State,Compse 能监听它的变化------加载中显示loading,成功显示图片,失败显示错误,不用手动处理生命周期。
7. derivedStateOf:减少"不必要的重组"
用途
有些状态变化频率很高(比如列表滚动位置 firstVisibleItemIndex
,滚动时每秒变几十次),但你只关心"状态是否满足某个条件"(比如 index > 0
),这时候用 derivedStateOf
把高频变化的状态转成"低频触发重组"的 State。
关键逻辑
- 创建一个新的 State,只有当"推导结果变化"时才触发重组(类似 Flow 的
distinctUntilChanged
) - 别乱用:如果推导结果的变化频率和原始状态一致,用它反而增加开销
正确例子:列表滚动时显示"回到顶部"按钮
kotlin
@Composable
fun MessageList(messages: List<Message>) {
Box {
val listState = rememberLazyListState() // 列表状态,滚动时 index 高频变化
LazyColumn(state = listState) { /* 渲染消息列表 */ }
// 用 derivedStateOf:只有 index>0 从 false 变 true,或 true 变 false 时才重组
val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
// 按钮只在 showButton 为 true 时显示,避免滚动时频繁重组
AnimatedVisibility(visible = showButton) {
ScrollToTopButton()
}
}
}
错误例子:组合名字(没必要用)
kotlin
// 错误用法!别这么写!
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }
val fullNameBad by remember { derivedStateOf { "$firstName $lastName" } } // 多余
// 正确用法:直接组合,变化频率一致
val fullNameCorrect = "$firstName $lastName"
简单说:只有"原始状态变10次,推导结果才变1次"的场景,才用 derivedStateOf
。
8. snapshotFlow:把"Compose State"转成"Kotlin Flow"
用途
如果想利用 Flow 的强大功能(比如 map
、filter
、debounce
)处理 Compose State,就用 snapshotFlow
把 State 转成 Flow。
关键逻辑
- 它是冷 Flow:只有被收集时才会执行代码块
- 当 State 变化时,只发出"和上一次不同"的值(类似
distinctUntilChanged
)
通俗例子:滚动过第一个 item 时发分析事件
kotlin
@Composable
fun AnalyticsList() {
val listState = rememberLazyListState()
LazyColumn(state = listState) { /* 渲染列表 */ }
// 用 LaunchedEffect 启动协程收集 Flow
LaunchedEffect(listState) {
// 把 firstVisibleItemIndex 转成 Flow
snapshotFlow { listState.firstVisibleItemIndex }
.map { index -> index > 0 } // 转成"是否过了第一个item"
.distinctUntilChanged() // 只在结果变化时触发
.filter { it == true } // 只关心"从没过到过"的时刻
.collect {
// 发分析事件:用户滚动过第一个item
MyAnalyticsService.sendScrolledPastFirstItemEvent()
}
}
}
简单说:State 转成 Flow 后,可以用 Flow 运算符做过滤、转换,比直接监听 State 更灵活。
三、重要规则:效应的"重启"逻辑
像 LaunchedEffect
、produceState
、DisposableEffect
这些 API,都有"键(Key)"参数------键变了,就会取消当前效应,启动新的效应。
1. 键该怎么选?
- 效应代码块里用到的可变/不可变变量 ,都应该作为键(比如
LaunchedEffect(pulseRateMs)
里的pulseRateMs
) - 如果变量变了但不想重启效应,就用
rememberUpdatedState
包起来(比如前面的currentOnTimeout
) - 如果变量被
remember
(无键)包裹且不会变,不用作为键(比如remember { FirebaseAnalytics() }
)
2. 用常量当键(比如 true)
如果想让效应"只跟可组合项生命周期绑定"(只启动一次,不因为任何变量重启),可以用 true
、Unit
这类常量当键(比如 LaunchedEffect(true)
)。
但一定要谨慎!比如:
- 正确场景:启动页延迟跳转(只等一次,不重启)
- 错误场景:网络请求(如果 url 变了,用常量键会导致不重新请求,拿到旧数据)
总结:怎么选 Effect API?
场景需求 | 对应 API |
---|---|
可组合项内跑挂起函数(如延迟、动画) | LaunchedEffect |
可组合项外启动协程(如点击事件) | rememberCoroutineScope |
效应里引用值,不想值变就重启 | rememberUpdatedState |
附带效应需要清理(如注册/取消监听) | DisposableEffect |
同步 Compose 状态到非 Compose 代码 | SideEffect |
非 Compose 状态转 Compose State(如网络、Flow) | produceState |
高频状态转低频重组(如滚动位置判断) | derivedStateOf |
Compose State 转 Flow(用 Flow 运算符) | snapshotFlow |
参考 : 官方文档