Side-effects in Compose
什么是副作用(Side-effects)
在理解副作用之前,我们需要先理解一个核心概念:纯函数(Pure Function)
纯函数有两个严格的特征:
- 输入决定输出:给定相同的参数,永远渲染出相同的 UI。
- 不改变外部状态:函数在执行过程中,不会修改函数作用域之外的任何东西。
副作用故名思意,就是打破了上述第 2 条规则的行为。
在 Compose 中,任何逃离了 Composable 函数作用域、并且改变了应用外部状态的操作,都叫副作用。
例如:
- 发起网络请求或数据库读写 (I/O 操作),修改了外部状态:网络请求会与外部服务器交互,甚至可能会修改本地缓存。它完全跳出了当前 UserDashboard 函数的控制范围。
- 直接修改外部变量或状态 (State Mutation),直接修改外部变量或状态 (State Mutation)
常见的副作用有:
- 发起网络请求(Retrofit)
- 读写数据库或 SharedPreferences
- 弹出一个 Toast 或 Snackbar
- 页面跳转(Navigation)
- 启动一个协程
- 修改一个全局变量或 ViewModel 中的状态
- 注册/注销一个广播接收器(BroadcastReceiver)或传感器监听
为什么在 Compose 中直接写副作用是“灾难”?
在传统View中顺手发个网络请求、弹个 Toast 毫无问题,因为 onCreate 只执行一次。但在 Compose 中,UI 是通过重组(Recomposition)来刷新的。如果你在 Composable 函数体里直接写副作用,可能会遇到失控的情况
直接写Compose 中直接写副作用示例:
// 错误示范:直接在 Composable 中写副作用
@Composable
fun UserProfileScreen(userId: String) {
// 1. 每次 UI 刷新(重组),都会重新发一次网络请求!动画执行时可能每秒发 60 次!
api.getUserInfo(userId)
// 2. 每次重组都会弹一次 Toast,用户屏幕会被 Toast 淹没!
Toast.makeText(context, "加载中...", Toast.LENGTH_SHORT).show()
Text("User ID: $userId")
}
Compose重组有一些一些特点:
- 随时可能发生:只要状态(State)改变,就会触发重组。
- 可能被跳过:如果 Compose 认为参数没变,它会跳过执行你的代码。
- 可能被取消:重组执行到一半,如果状态又变了,Compose 会直接抛弃当前执行,从头再来。
- 可能并发执行:Compose 可能会在后台线程池中并行执行多个 Composable 函数。
Composable 函数会被不可预测地调用。如果你把网络请求或弹窗直接扔在里面,它们就会彻底失控。
如何驯服Compose中的副作用
既然应用不可能没有副作用(没有网络请求和弹窗的应用没有任何价值),Compose 的解法是:提供一套专门的 Effect API,把副作用圈养在受控的生命周期内。
LaunchedEffect:在某个可组合项的作用域内运行挂起函数
核心特性:
- 生命周期绑定:它会在进入组合(Composition)时启动协程,在离开组合时自动取消协程。
- Key 驱动重启:它接收一个或多个 key 参数。只有当 key 发生变化时,它才会取消当前的协程并重新启动一个新的协程;如果重组时 key 没有变化,正在运行的协程不受影响。
示例:
//只在首次进入页面时执行一次(初始化加载)
@Composable
fun UserProfileScreen(viewModel: UserViewModel) {
// 传入 Unit,意味着这个协程只在 UserProfileScreen 首次进入组合时执行一次
// 后续无论 UserProfileScreen 重组多少次,这段代码都不会重复触发
LaunchedEffect(Unit) {
viewModel.fetchUserData()
}
// UI 代码...
}
//根据状态变化触发(MVI / 响应式架构常见)
@Composable
fun LoginScreen(loginState: LoginState) {
val snackbarHostState = remember { SnackbarHostState() }
// 当 loginState.errorMessage 发生变化且不为空时,重新启动协程展示 Snackbar
LaunchedEffect(key1 = loginState.errorMessage) {
loginState.errorMessage?.let { msg ->
snackbarHostState.showSnackbar(message = msg)
}
}
// 假设用户连续输错两次密码,生成了两个不同的 errorMessage。
// LaunchedEffect 会自动取消上一个还没展示完的 Snackbar 协程,立即启动新的展示。
}
rememberCoroutineScope:获取组合感知作用域,以便在可组合项外启动协程
由于 LaunchedEffect 是可组合函数,因此只能在其他可组合函数中使用。为了在可组合项外启动协程,但存在作用域限制,以便协程在退出组合后自动取消.
特性:
1.破纯函数的限制,连接“事件”与“异步”
Composable 函数本身是不能直接调用挂起函数(suspend function)的,因为 Compose 的渲染过程必须是同步且快速的。
rememberCoroutineScope 的核心特性就是提供了一座桥梁。它返回一个 CoroutineScope,让你可以合法地在非 Composable 的作用域(如 onClick、onDrag 等事件回调)中启动协程。
2. 生命周期与调用点(Call Site)强制绑定
这是它最重要的安全特性。rememberCoroutineScope 返回的协程作用域,其生命周期死死地绑定在它被调用的那个 Composable 节点上。
3. 在重组(Recomposition)中保持绝对稳定
之所以叫 remember...,是因为它在内部使用了 remember 机制。无论当前的 Composable 函数发生多少次重组(比如一秒钟内重组 60 次),rememberCoroutineScope() 永远只会返回同一个实例(同一个 Scope)。
示例:
@Composable
fun ScopeDemoScreen() {
// 1. 记录 Snackbar 的状态,用于触发显示
val snackbarHostState = remember { SnackbarHostState() }
// 2. 核心魔法:获取与当前 ScopeDemoScreen 生命周期绑定的协程作用域
val scope = rememberCoroutineScope()
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) }
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.padding(16.dp)
) {
Button(
onClick = {
// 这里是普通的 onClick 回调,不能直接调用挂起函数 (suspend)。
// 比如直接写 snackbarHostState.showSnackbar(...) 会直接在编译期报错。
//使用刚才获取的 scope 手动启动一个协程
scope.launch {
// 这里的 showSnackbar 是挂起函数,它会暂停这个协程,直到弹窗消失
snackbarHostState.showSnackbar("你好!这是一个异步的 UI 弹窗。")
// 验证生命周期绑定:
// 如果在弹窗显示期间,用户按返回键退出了这个页面(节点离开组合),
// scope 会被自动取消,这里的 delay 和后续逻辑将不会执行,绝对安全!
delay(1000)
println("这段代码只有在页面没被销毁时才会执行")
}
}
) {
Text("点击显示 Snackbar")
}
}
}
}
1. val scope = rememberCoroutineScope() 提供了一个协程环境。让你可以在点击按钮时,用 scope.launch { ... } 开辟一条异步通道来执行这些挂起函数。
2. 用户狂点按钮后瞬间退出该页面,随着 ScopeDemoScreen 从屏幕上卸载,底层的协程会被 Compose 引擎瞬间切断,不会有任何内存泄漏或崩溃的风险。
rememberUpdatedState:在效应中引用某个值,该效应在值改变时不应重启
特性:
-
值随时更新,但对象引用绝对稳定
- 时刻保持最新的内核:每次发生重组并传入新的 newValue 时,它都会默默地将内部的 value 替换为最新传入的值。
- 稳定不变的外壳:无论外层组件发生多少次重组,这个 State 对象本身的内存地址(引用)永远不变。
-
核心应用场景:保护“不能被打断的任务”
//你有一个组件,进入页面后需要倒计时 5 秒,然后执行外部传入的 onTimeout 回调。如果你直接写在 LaunchedEffect 里: @Composable fun SplashScreen(onTimeout: () -> Unit) { // 危险:Key 是 Unit,保证了计时绝对不会被打断重启。 // 但是!由于 Kotlin 的闭包特性,协程内部捕获的是【第一次】传进来的 onTimeout。 // 如果父页面在第 2 秒时更新了 onTimeout,这里调用的依然是那个已经过期的旧回调! LaunchedEffect(Unit) { delay(3000) onTimeout() } } //正确写法 使用rememberUpdatedState 打破这个僵局: @Composable fun TimerScreen(onTimeout: () -> Unit) { // 特性发挥作用:用 rememberUpdatedState 包装一下 val currentOnTimeout by rememberUpdatedState(onTimeout) // Key 保持为 Unit,确保 5 秒倒计时绝对不会被打断 LaunchedEffect(Unit) { delay(5000) // 5 秒结束后,去调用它。此时它保证能拿到最新的回调函数! currentOnTimeout() } }
rememberUpdatedState原理
@Composable
public fun <T> rememberUpdatedState(newValue: T): State<T> =
remember {
mutableStateOf(newValue) //只有第一次加载时,创建一个装有初始值的盒子
}.apply { value = newValue }//每次重组(即使不重启副作用),都会把盒子里的值更新为最新的!
- 因为 LaunchedEffect 捕获的是那个通过 remember 创建的盒子(State 对象),所以引用没变,不会引发异常。
- 当协程真正去执行 currentOnTimeout() 时,它是去盒子里取东西。而外面的 .apply { value = newValue } 早就随着重组把最新的回调塞进盒子里了。
DisposableEffect:需要清理的副作用
特性:
- 强制的清理机制 (onDispose):这是它最大的特点。Compose 语法层面强制要求你在 DisposableEffect 的代码块最后必须调用 onDispose { ... }。如果你漏写了,代码直接编译报错。
- 生命周期完美契合:
- 当组件进入组合(首次渲染)时,执行主体代码(比如注册监听)。
- 当组件离开组合(被销毁移除)时,自动执行 onDispose 中的代码(比如注销监听)。
- Key 驱动的重启机制:如果传入的 key 发生了变化,它会先执行旧 Key 的 onDispose 清理资源,然后再执行新 Key 的主体代码重新注册。
- 非协程环境:与 LaunchedEffect 不同,DisposableEffect 不提供协程作用域。它的代码是同步执行的,专门用来处理常规的同步注册操作。
示例:
// 传入 key,这里传入的是 lifecycleOwner
// 如果 lifecycleOwner 变了,它会先注销旧的,再注册新的
DisposableEffect(lifecycleOwner) {
//注册/订阅阶段 (组件进入屏幕时执行)
val observer = LifecycleEventObserver { _, event ->
// 处理生命周期回调...
currentOnEvent(event)
}
// 真正执行注册动作
lifecycleOwner.lifecycle.addObserver(observer)
println("DisposableEffect: 注册了 Lifecycle Observer")
清理/解绑阶段 (组件离开屏幕时执行)
// 语法强制要求:必须以 onDispose 结尾
onDispose {
// 真正执行注销动作,防止内存泄漏!
lifecycleOwner.lifecycle.removeObserver(observer)
println("DisposableEffect: 注销了 Lifecycle Observer")
}
}
与 rememberUpdatedState 搭配
我们把外部传进来的一个回调函数直接当作 DisposableEffect 的 key。那么父组件每次重组(即使回调逻辑没变,只是闭包对象变了),都会导致监听器被频繁注销再重新注册。这不仅浪费性能,还可能丢失中间的事件。
DisposableEffect(固定不变的Key) + rememberUpdatedState(可能变化的参数或回调)
这样就能保证:监听器只注册一次,但触发回调时,永远能拿到最新的数据或执行最新的逻辑!
@Composable
fun DisposableEffectDemoScreen() {
// 获取当前环境的 LifecycleOwner (通常是 Activity 或 Fragment)
val lifecycleOwner = LocalLifecycleOwner.current
// 用于在 UI 上展示当前的生命周期状态
var lifecycleEvent by remember { mutableStateOf("等待事件...") }
// 使用 rememberUpdatedState 保护回调,
// 确保 observer 内部始终能调用到最新的闭包,同时不用频繁重新注册 observer。
val currentOnEvent by rememberUpdatedState { event: Lifecycle.Event ->
lifecycleEvent = event.name
}
// 核心魔法:DisposableEffect
DisposableEffect(lifecycleOwner) {
//【注册/订阅阶段】
// 这段代码会在组件“进入组合”时,或者 lifecycleOwner 发生变化时执行
val observer = LifecycleEventObserver { _, event ->
currentOnEvent(event)
}
lifecycleOwner.lifecycle.addObserver(observer)
println("DisposableEffect: 注册了 Lifecycle Observer")
//【清理/解绑阶段】
// 强制要求的 onDispose 代码块。
// 它会在组件“离开组合”时,或者 key (lifecycleOwner) 发生变化准备重新注册前执行!
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
println("DisposableEffect: 注销了 Lifecycle Observer,成功防止内存泄漏!")
}
}
Scaffold { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.padding(16.dp)
) {
Text("DisposableEffect 演示", style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "当前生命周期: $lifecycleEvent",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(24.dp))
Text("操作建议:\n1. 按 Home 键将应用退到后台 (触发 ON_PAUSE, ON_STOP)\n2. 重新打开应用 (触发 ON_START, ON_RESUME)")
}
}
}
SideEffect:向非 Compose 代码发布 Compose 状态
特性:
- 执行时机:重组成功后(After Recomposition),只有在 Compose 引擎确认这次重组已经完全成功,且新 UI 已经应用生效后,才会被回调执行。
- 每次重组都会触发(没有 Key),只要包含 SideEffect 的 Composable 函数发生了重组,且重组成功,它就会被执行。它不像 LaunchedEffect 那样可以通过 Key 来跳过执行。
- 同步执行。它不是协程,里面的代码是在主线程同步执行的,所以绝对不能放耗时操作。
使用场景:
“把 Compose 内部状态同步给外部非 Compose 世界”,这是什么意思?
假设你正在把一个旧的 Android 项目迁移到 Compose。你的旧代码里有一个非 Compose 的全局单例或者传统的自定义 View,它们需要知道 Compose 内部某个状态的最新值。
如果直接赋值,可能会遇到我们刚才说的“重组被取消”导致的脏数据问题。这时候,SideEffect 就是你的安全通道。
produceState:将非 Compose 状态转换为 Compose 状态
- 自带状态(State):你只需要给它一个初始值,它会在内部自动创建一个 MutableState。
- 自带协程(Coroutine):它像 LaunchedEffect 一样,在进入组合时自动启动一个协程,在离开组合时自动取消协程。
- 桥接异步世界与 UI:你在它提供的协程块中,通过给 value 赋值,就可以将异步结果源源不断地推送到 UI。
- Key 驱动:和 LaunchedEffect 一样,它也接收 key。Key 发生变化时,它会取消当前的协程计算,并以初始状态重新启动。
- awaitDispose:根据需要重写,可以对一些资源进行清理
使用示例:
@Composable
fun NetworkStatusObserver(networkManager: NetworkManager) {
// 将网络连接状态(基于回调)转换为 Compose 的 State
val isConnected by produceState(initialValue = false, key1 = networkManager) {
// 1. 定义回调
val listener = object : NetworkListener {
override fun onStatusChanged(connected: Boolean) {
// 收到回调时更新状态
value = connected
}
}
// 2. 注册监听
networkManager.registerListener(listener)
// 3. 阻塞协程,直到组件离开组合 (Leave Composition) 或 key 发生变化
// 这相当于 DisposableEffect 里的 onDispose
awaitDispose {
// 4. 清理注册,防止内存泄漏
networkManager.unregisterListener(listener)
}
}
Text(if (isConnected) "网络已连接" else "网络已断开")
}
derivedStateOf:将一个或多个状态对象转换为其他状态
在 Compose 中,重组会在 每次观察到的状态对象或可组合输入出现变化时发生。状态对象或输入的变化频率可能高于界面实际需要的更新频率,从而导致不必要的重组。
当可组合项输入的变化频率超过您需要的重组频率时,就应该使用 derivedStateOf 函数。这种情况通常是指,某些内容(例如滚动位置)频繁变化,但可组合项只有在超过某个阈值时才需要对其做出响应。derivedStateOf 会创建一个新的 Compose 状态对象,您可以观察到该对象只会按照您的需要进行更新
假设我们正在开发一个长列表,需求是:当列表向下滑动(超过第一个元素)时,显示一个“回到顶部”的悬浮按钮。
如果你直接这样写,会导致极其严重的性能问题:
示例:
@Composable
fun LazyListScreen() {
val listState = rememberLazyListState()
// 性能灾难:直接在 Composable 中根据高频变化的状态做计算
val showButton = listState.firstVisibleItemIndex > 0
Box {
LazyColumn(state = listState) {
// 列表项...
}
if (showButton) {
FloatingActionButton(...) { Text("回到顶部") }
}
}
}
为什么:
- listState.firstVisibleItemIndex 是一个极高频变化的状态。当用户快速滑动列表时,它的值可能是 0, 1, 2, 3, 4, 5... 飞速改变。
- 只要 firstVisibleItemIndex 变了,读取它的 LazyListScreen 就会触发重组(Recomposition)。
- 荒唐的结果:当索引从 1 变成 2、从 2 变成 3 时,showButton 的结果依然是 true(按钮早就显示了)。但你的 LazyListScreen 却被迫陪伴着列表的滑动,在一秒钟内毫无意义地重组了 60 次
使用derivedStateOf优化
@Composable
fun LazyListScreen() {
val listState = rememberLazyListState()
// ✅ 性能优化:使用 derivedStateOf 将高频状态转换为低频状态
val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
Box {
// ...
if (showButton) { // 现在,只有 showButton 真正发生翻转时,这里才会重组!
FloatingActionButton(...) { Text("回到顶部") }
}
}
}
- derivedStateOf 大括号里的代码,会监听它内部读取的所有 State(在这里就是 firstVisibleItemIndex)。
- 当 firstVisibleItemIndex 从 1 变成 2 时,derivedStateOf 内部确实会重新计算一次:2 > 0,结果为 true。
- derivedStateOf 会拿新结果 (true) 和上一次的旧结果 (true) 做对比。
- 它发现结果根本没变!于是它默默把这个变化拦截下来,绝不通知外层的 LazyListScreen 发生重组。
- 只有当结果从 false 变成 true(或反之)时,它才会向上级报告,触发界面的刷新。
什么时候使用它
理解 derivedStateOf 最难的不是怎么写,而是克制使用的冲动。滥用它反而会增加内存开销。
必须使用的场景:频率差异
当你的输入状态变化极其频繁(如滑动偏移量、动画进度、时间戳),而计算得出的结果变化频率很低(如布尔值、特定的阈值触发)时,必须使用。
坚决不用的场景:同频变化
如果你的衍生状态和源状态的变化频率一模一样,直接计算即可,绝对不要用
snapshotFlow:将 Compose 的 State 转换为 Flow
Compose 的 State 非常适合驱动 UI 重组。但是,当我们面临复杂的异步业务逻辑时,State 就显得力不从心了,因为我们无法对 State 使用各种强大的响应式操作符(如防抖 debounce、限流 throttle、过滤 filter 等)。
特性:
-
自动追踪读取记录(Snapshot 机制)
在大括号 {} 内部,所有被你读取的 Compose State(比如上例中的 listState.firstVisibleItemIndex),都会被 Compose 底层的快照系统(Snapshot System)自动记录并订阅。
-
只有状态改变才触发运算:
一旦它订阅的任何一个 State 发生了改变,snapshotFlow 内部的代码块就会被重新执行一次。
-
自带去重拦截器(distinctUntilChanged):
这是它极其重要的一个特性!当代码块重新执行并产生了一个新结果时,snapshotFlow 会将新结果与上一次 emit(发射)出去的结果进行比对(使用 ==)。
如果结果相同,它会默默拦截,不会向下游发射数据。只有结果真正改变时,下游的 collect 才会收到新数据。(这和 derivedStateOf 的过滤思想如出一辙)。
snapshotFlow {
val index = listState.firstVisibleItemIndex
// 灾难:直接在观察块里发网络请求或打印日志!
// 它可能会被框架疯狂调用几十次,导致网络请求大爆炸。
api.report(index)
index
}
//正确做法
snapshotFlow {
listState.firstVisibleItemIndex // 大括号里只做单纯的 State 读取和计算
}.collect { index ->
// 在下游的 collect 块里执行副作用操作!
api.report(index)
}
评论区