Jetpack Compose 之 附带效应(SideEffect, 副作用)和 Kotlin协程

附带效应(SideEffect, 副作用)和 SideEffect()

在 Jetpack Compose 中, "副作用" (Side-Effect)指在组合函数(@Composable)内部执行了与 UI 渲染过程无关的操作,如日志打印、网络请求、写入数据库、导航调用、调用外部框架 API 等。这些操作如果直接放在组合函数里,会因为重组(recomposition)频繁触发而重复执行,导致不可预期的行为或性能问题。Compose 中规定: Compose 的组件函数或者 Composable 函数都是不能有副作用的,他们的作用是用来显示内容的,不能掺杂有其他任何影响。因为副作用可能导致程序的行为不可预期。


1. 什么是"副作用"?

  • 纯函数 vs. 副作用

    • 纯函数:对相同输入总是返回相同输出,不改变任何外部状态,且不依赖外部状态。
    • 副作用 :来源于医学领域的「药物副作⽤」:除了主作⽤之外的其他作⽤,不只是指「负⾯作⽤」,在软件开发领域,「函数的副作⽤」指的是对函数外的程序环境有影响的⾏为。函数的调用能不能被他的返回值替换掉,如果不能被它的返回值直接替换掉,就叫这个函数有副作用。替换所导致的差异就是副作用本身。 函数内部执行了"有状态"或"与 UI 渲染无关"的操作,比如 I/O、日志、网络、导航、权限请求、数据库写入、触发外部回调等。
  • 为什么要避免在 Composable 中直接做副作用

    • Compose 的重组机制会随状态变化多次执行同一 @Composable 函数。
    • 如果在函数体内直接做副作用,会在每次重组时重复发生,可能导致重复网络请求、重复导航、日志刷屏、UI 闪烁、资源泄露等问题。

2. SideEffect() API

Jetpack Compose 提供了一系列 Effect Handler ,其中最基础的就是 SideEffect。它的特点是:

  • 执行时机 :在 每次成功完成重组 后同步执行, 这样只会执行一次。
  • 线程:与 Compose 运行在同一线程(通常是主线程)同步执行,不会启动新协程。
  • 用途 :做轻量级必须同步的操作,比如更新非 Compose 状态、与外部框架同步状态、打印日志、发送埋点。
kotlin 复制代码
@Composable
fun MyScreen(viewModel: MyViewModel) {
    val count by viewModel.count.collectAsState()

    // 不推荐:直接在Composable内打印日志
    Log.d("MyScreen", "Count = $count")  // 每次重组都会打印

    // 推荐:使用 SideEffect
    SideEffect {// 只在重组完成后执行
        // 只在重组完成后执行一次,用于同步外部状态
        updateExternalFramework(count)
        Log.d("MyScreen", "Synchronized count = $count")
    }

    Text(text = "Count: $count")
}
  • 何时使用

    • 需要在每次 Compose 更新后,将最新状态同步给外部系统。
    • 操作足够轻量,可以在主线程中执行。

3. 与其他 Effect Handler 的对比

Effect Handler 执行时机 线程/协程 典型场景
SideEffect 每次重组后立即同步执行 同主线程 更新外部状态、日志、埋点
LaunchedEffect 首次或 key 变化后启动协程块 启动新协程(默认主线程) 网络请求、动画、延时操作
DisposableEffect 首次或 key 变化后执行 onStart;清理时执行 onDispose 同主线程(可启动协程) 注册/注销监听器、生命周期感知
rememberCoroutineScope() 在 Composable 中获取协程作用域 用于手动启动协程 自定义协程管理
  • LaunchedEffect:如果我们的副作用需要挂起函数(delaywithContext、网络请求等),应使用它。
  • DisposableEffect:同时有组件进入(显示出来)与离开界面的监听,如果需要在 Composable 生命周期内注册并在退出时清理资源时使用,比如:监听传感器、广播、观察者等。

4. 小结

  1. 不要@Composable 函数体内直接执行副作用。
  2. 轻量同步的"副作用"可用 SideEffect { ... },保证在"重组后"运行。
  3. 需要异步或挂起操作时,使用 LaunchedEffect;需要清理逻辑时,使用 DisposableEffect
  4. 良好的副作用管理,有助于避免重复执行、资源泄露和不可预期的 UI 行为,让 Compose 组合更可预测、更高效。

DisposableEffect()

DisposableEffect 是 Jetpack Compose 提供的一个 Effect Handler ,增加了离开界面的监听,是一个加强版的 SideEffect。用于在 Composable 的生命周期内注册副作用("附带效应"),并在离开作用域时执行清理逻辑(dispose)。它适合那些需要在进入组合树时做初始化、在退出时做清理的场景,例如注册/注销监听器、启动/停止外部资源等。


1. API 介绍

kotlin 复制代码
@Composable
fun DisposableEffect(
    vararg keys: Any?,
    effect: () -> DisposableEffectResult
)
  • keys :依赖键。当任一 key 变化时,Compose 会先执行上一次 effect 返回的 onDispose,再重新调用新的 effect
  • effect :一个 lambda,同步 执行初始化逻辑,并返回一个 DisposableEffectResult。通常用 onDispose { ... } 来声明清理逻辑。
kotlin 复制代码
DisposableEffect(key1, key2, ...) {
    // 初始化:注册监听、启动协程、设置回调......
    registerListener()

    onDispose {
        // 清理:注销监听、取消协程、移除回调......
        unregisterListener()
    }
}

返回值类型

  • DisposableEffectResult 有两种常用写法:

    • onDispose { /* 清理 */ }
    • DisposableEffectResult.None(如果无需清理)

2. 原理与执行时机

  1. 首次执行

    • 当进入包含该 DisposableEffect 的 Composable 时(且 keys 与先前不相同或首次渲染),Compose 会立即调用 effect lambda,运行"初始化"部分。
  2. 依赖 key 变化

    • 如果传入的任一 key 与上次不等,Compose 会:

      1. 调用上一次 effect 返回的 onDispose(做清理)。
      2. 再次调用新的 effect lambda,执行新的初始化。
  3. 离开组合树

    • 当该 DisposableEffect 所在的 Composable 被移除(或者其 keys 变为 null,使其不再生效)时,Compose 会调用最后一次返回的 onDispose 完成清理。

所有这些调用都在 Compose 的主线程(UI 线程)同步执行,所以适合轻量初始化/清理。


3. 使用场景

  • 注册系统/框架监听

    • 传感器、BroadcastReceiver、LiveData、Flow 收集等,进入时注册,退出时注销。
  • 与外部生命周期同步

    • 在进入时启动动画、跟随页面生命周期;在离开时停止或取消。
  • 平台回调

    • ActivityResultLauncher、权限请求回调等,进来注册,退出时撤销。
  • 一次性初始化

    • 组件首次显示时触发某些操作,后续只在 key 变化时重做。

4. 示例

常规使用:

kotlin 复制代码
Button(onClick = { showText = !showText }) {
    Text("点击")
    if (showText) {
        Text("rengwuxian")
    }
    SideEffect {
        println("@@@ SideEffect()")
    }
    // 与 LaunchEffect(key1 = , block = ) 的key一样,变化了就会触发重新执行。
    // key变化之后,还会让 onDispose 里边的代码先执行,然后再重新执行一遍整个大括号里边的逻辑。
    DisposableEffect(
        /*参数变化,就会重新执行。
         如果换成了一个常量,大括号的代码不会再每次重组的时候触发了,
         此时会先执行onDispose的逻辑,然后执行前边的逻辑。
         这也是与SideEffect的区别,SideEffect 每次重组完后都会调用一次
         */
        key1 = showText
    ) {
        println("@@@ 进入界面啦!") // Button 开始显示的时候会触发这里。
        onDispose {
            println("@@@ 离开界面啦!")// Button 从界面消失的时候会触发这里。
        }
    }
}

打印情况:

4.1 注册/注销 BroadcastReceiver

kotlin 复制代码
@Composable
fun ConnectivityStatus(onStatusChange: (Boolean) -> Unit) {
    val context = LocalContext.current

    DisposableEffect(context) {
        val filter = IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)
        val receiver = object : BroadcastReceiver() {
            override fun onReceive(ctx: Context?, intent: Intent?) {
                val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE)
                        as ConnectivityManager
                val connected = cm.activeNetworkInfo?.isConnected == true
                onStatusChange(connected)
            }
        }
        context.registerReceiver(receiver, filter)

        onDispose {
            context.unregisterReceiver(receiver)
        }
    }
}

4.2 管理自定义监听器

kotlin 复制代码
@Composable
fun SensorListenerDemo(sensorManager: SensorManager) {
    DisposableEffect(sensorManager) {
        val listener = object : SensorEventListener { /* ... */ }
        sensorManager.registerListener(listener, /* 传感器、速率... */)

        onDispose {
            sensorManager.unregisterListener(listener)
        }
    }
    // UI...
}

4.3 结合 LaunchedEffect 处理异步清理

kotlin 复制代码
@Composable
fun TimerDemo() {
    // 当需要挂起操作时,用 LaunchedEffect
    DisposableEffect(Unit) {
        // 同步初始化
        startTimerSync()

        onDispose {
            // 在退出时,启动协程取消定时器
            LaunchedEffect(Unit) {
                stopTimerSuspend()
            }
        }
    }
}

5. 与其它 Effect 的对比

Handler 初始化时机 清理时机 线程/协程 典型场景
DisposableEffect 首次或 key 变化时同步 离开组合树或 key 变化时同步 主线程(同步) 注册/注销监听、资源清理
SideEffect 每次重组后同步 --- 主线程(同步) 同步更新外部状态、日志、埋点
LaunchedEffect 首次或 key 变化时启动 Composable 离开时取消协程 启动新协程(可挂起) 异步请求、延时操作、动画
remember / rememberUpdatedState 首次或 key 变化时记忆 --- --- 缓存对象、保持最新回调

6. 小结

  • DisposableEffect 适合需要成对的 "初始化--清理" 场景。
  • 它保证在 Composable 生命周期边界以及依赖变化时,正确地执行注册与注销逻辑。
  • 对于仅需同步、轻量一次性副作用也可使用 SideEffect;需挂起或更复杂的异步场景请使用 LaunchedEffect

通过合理选用 Effect Handler,可以让 Compose 中的附带效应管理更加清晰、可控,避免资源泄露与重复执行。

协程:LaunchedEffect()

LaunchedEffect 是 Jetpack Compose 中用于在组合函数内启动和管理协程的 Effect Handler ,可以在 Compose 的作用域(Composition)生命周期内执行异步或挂起操作。它会随着指定的 key 变化或 Composable 离开组合树时自动取消协程,简化了异步副作用的管理。在 Composable 组件显示完成之后去启动一个协程,在其所依赖的参数发生改变的时候,重启这个绑定在 Composable 组件里边的协程, 即便重组了,只要参数改变,也不会触发协程的重启。这样避免了资源的消耗。


1. API 介绍

kotlin 复制代码
@Composable
fun LaunchedEffect(
    vararg keys: Any?,
    block: suspend CoroutineScope.() -> Unit
)
  • keys

    • 依赖键。只有当首次进入或 keys 中的任一值发生变化时,Compose 才会(重新)启动 block 中的协程。
  • block

    • 一个挂起函数(suspend lambda),在 Composition 的 CoroutineScope 中执行。可以调用挂起 API(delay、网络请求、Room 查询等)。

2. 原理与行为

  1. 启动时机

    • 首次进入 :当包含 LaunchedEffect 的 Composable 首次被组合时,启动协程执行 block
    • key 变化 :如果传入的任一 key 与上一次不相同,Compose 会取消先前的协程,并启动新的协程执行新的 block
  2. 取消机制

    • 组合树移除:当 Composable 被从 Composition 中移除或被跳过时,正在运行的协程会被自动取消。
    • key 变化:如上所述,旧协程先被取消,再启动新协程。
  3. 作用域

    • 协程运行在 Compose 内部提供的 CoroutineScope(通常与 UI 线程关联),无需手动管理 Job 或 Scope。
    • 协程取消时,会抛出 CancellationException,应注意在 block 中适当处理或让其自然终止。
  4. 源码解析

    • RememberObserver 用于监听Composable函数进入界面和离开界面,然后相应的去启动和结束协程,具体就是发生在 onRemembered 和 onForgotten 的里边。如果 onRemembered 都还没有被调用就离开界面,那么onAbandoned也会被调用。 DisposableEffectLaunchedEffect 的区别就是 LaunchedEffect内部会用一个协程来进行任务的执行,而DisposableEffect则是直接触发代码的执行。LaunchedEffect 面向的是写成,是DisposableEffect的一种特殊场景。
    kotlin 复制代码
    LaunchedEffect(Unit) {
      delay(3000)
      xxxx = false
    }
    
    fun LaunchedEffect(
        key1: Any?,
        block: suspend CoroutineScope.() -> Unit
    ) {
        val applyContext = currentComposer.applyCoroutineContext
        // 在Composable组件显示完成之后去启动一个协程,
        // 在其所依赖的参数发生改变的时候,重启这个绑定在Composable组件里边的协程。
        remember(key1) { LaunchedEffectImpl(applyContext, block) }
    }
    
    internal class LaunchedEffectImpl(
        parentCoroutineContext: CoroutineContext,
        private val task: suspend CoroutineScope.() -> Unit
    ) : RememberObserver { 
        private val scope = CoroutineScope(parentCoroutineContext)
        private var job: Job? = null
    
        override fun onRemembered() { 
            job?.cancel("Old job was still running!")
            job = scope.launch(block = task)
        }
    
        override fun onForgotten() {
            job?.cancel()
            job = null
        }
    
        override fun onAbandoned() {
            job?.cancel()
            job = null
        }
    }
    
    
    private class DisposableEffectImpl(
        private val effect: DisposableEffectScope.() -> DisposableEffectResult
    ) : RememberObserver {
        private var onDispose: DisposableEffectResult? = null
    
        override fun onRemembered() {
            onDispose = InternalDisposableEffectScope.effect()
        }
    
        override fun onForgotten() {
            // 执行离开的回调。
            onDispose?.dispose()
            onDispose = null
        }
    
        override fun onAbandoned() {
            // Nothing to do as [onRemembered] was not called.
        }
    }

3. 使用场景

  • 异步初始化

    • 启动网络请求、加载数据、读取数据库等操作,并在拿到结果后更新状态。
  • 定时任务

    • 周期性地执行 delay + 操作逻辑,例如轮询、心跳、动画。
  • 响应状态变化

    • 当某个状态值(如 userIdqueryText)变化时,自动触发新的异步操作。

4. 示例

4.1 网络请求加载数据

kotlin 复制代码
@Composable
fun UserProfile(userId: String, viewModel: ProfileViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsState()

    LaunchedEffect(userId) {
        // 当 userId 变化时,重新发起加载
        viewModel.loadProfile(userId)
    }

    if (uiState.isLoading) {
        CircularProgressIndicator()
    } else {
        Text("Name: ${uiState.name}")
    }
}

4.2 定时轮询

kotlin 复制代码
@Composable
fun PollingDemo(viewModel: PollingViewModel = viewModel()) {
    LaunchedEffect(Unit) {
        // 持续轮询,直到 Composable 离开或 key 变化
        while (isActive) {
            viewModel.fetchUpdates()
            delay(5000L) // 每 5 秒轮询一次
        }
    }

    // UI 展示轮询结果...
}

4.3 响应输入变化

kotlin 复制代码
@Composable
fun SearchBar() {
    var query by remember { mutableStateOf("") }
    val results by produceState(initialValue = emptyList<String>(), query) {
        // query 变化时自动执行
        value = searchRepository.search(query)
    }

    Column {
        TextField(value = query, onValueChange = { query = it })
        LazyColumn {
            items(results) { Text(it) }
        }
    }
}

Attention : 上例中使用了更简洁的 produceState。但如果我们想在同一个作用域里做更多异步逻辑,也可以直接用 LaunchedEffect(query)


5. 与其它 Effect Handler 的对比

Handler 启动协程/执行时机 取消时机 典型场景
LaunchedEffect 首次或 key 变化时启动协程 Composable 离开或 key 变化时取消 网络请求、定时任务、动画
SideEffect 每次重组后同步执行 --- 同步外部状态、日志
DisposableEffect 首次或 key 变化时同步执行 init;在 dispose 时执行清理 Composable 离开或 key 变化时清理 注册/注销监听、资源释放
rememberCoroutineScope() 获取协程作用域,手动启动协程 需手动管理 Job 取消 复杂协程管理

6. 注意事项

  • 避免重复启动 :合理使用 keys,避免无谓的协程取消与重启。
  • 处理取消block 内部应对 CancellationException 友好,比如在 finally 中释放资源。
  • 使用 remember 缓存 :如果有不随 keys 变化的依赖(如 Repository),用 remember 缓存以免重建。

小结

  • LaunchedEffect 简化了在 Composable 中启动异步任务的流程。
  • 它基于 Composition 的生命周期自动管理协程的启动与取消。
  • 对于网络请求、定时任务、动画等异步场景,推荐使用 LaunchedEffect(key)
  • 与其它 Effect Handler 配合使用,可让 Compose 中的附带效应更清晰可控。

rememberUpdatedState()

rememberUpdatedState 是 Compose 提供的一个辅助 API,用来在保持 State 对象引用不变 的前提下,更新其内部保存的值 。它常用于在 Effect Handler(如 LaunchedEffectDisposableEffect 等)中安全地获取最新的外部参数,避免闭包捕获旧值导致的"stale"问题。

我们知道,在 Composable 组件显示完成之后去启动一个协程,在其所依赖的参数发生改变的时候,重启这个绑定在 Composable 组件里边的协程, 即便重组了,只要参数改变,也不会触发协程的重启。这样避免了资源的消耗。有没有一种可能,我们在参数改变的时候不重启,同时也能达到重启之后的效果的呢? 目前确实实现不了。


1. API 介绍

kotlin 复制代码
@Composable
fun <T> rememberUpdatedState(newValue: T): State<T>
  • 参数

    • newValue: T:想要缓存并且在每次重组中更新的新值。
  • 返回

    • 一个 State<T>,其 .value 会在每次重组时被更新为最新的 newValue,但 State 对象自身在初次调用后保持不变。

2. 原理

  • 稳定的 State 对象

    • Compose 在第一次执行 rememberUpdatedState 时,会调用内部的 remember { mutableStateOf(newValue) },创建并记忆一个 MutableState<T> 对象。
  • 同步更新 value

    • 在后续的重组中,Compose 不会 重新创建新的 MutableState,而是在原有对象上执行 state.value = newValue,从而保证所有引用该 State 的消费者都能读取到最新的值。
  • 解决闭包捕获问题

    • 如果直接在 LaunchedEffect 等协程块里捕获外部参数,那么参数在 Effect 启动后即被固定;通过 rememberUpdatedState,Effect 内部只要读取 state.value,就能拿到最新数据。

3. 常见使用场景

当 LaunchedEffect() 或者 DisposableEffect() 中有「稍后使⽤」的外部依赖,并且依赖是由函数参数提供⽽导致失去了跨越多次重组的能⼒的时候,需要在函数内⽤ remember()结合赋值⾏为来重新建⽴变量的缓存能⼒(跨越多次重组的能⼒)。rememberUpdatedState() 把这种⼯作整合进了⼀个函数⾥。所以,更重要的不是 rememberUpdatedState() 的原理,⽽是从对它所⾯向的问题的理解,来了解它适⽤的场景。

kotlin 复制代码
class MainActivity : ComponentActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
      CourseComposeRememberUpdatedStateTheme {
        CustomDisposableEffet(user)
        var welcome by remember { mutableStateOf("欢迎光临!") }
        CustomLaunchedEffect(welcome)
        Button(onClick = { welcome = "不欢迎" }) {
          Text(welcome)
        }
      }
    }
  }
}

@Composable
private fun CustomLaunchedEffect(welcome: String) {
  // 利用remember处理不能跨越多次重组的问题, 比如这里就是让 welcome 变得再次可以跨越。
  //  var rememberedWelcome by remember { mutableStateOf(welcome) }
  //  rememberedWelcome = welcome
  val rememberedWelcome by rememberUpdatedState(welcome)
  //  @Composable
  //  fun <T> rememberUpdatedState(newValue: T): State<T> = remember {
  //    mutableStateOf(newValue)
  //  }.apply { value = newValue }
  LaunchedEffect(Unit) {
    delay(3000)
    println("@@= $rememberedWelcome")
  }
}
  1. 问题:Effect 的闭包会"捕获"参数

    当我们在 LaunchedEffectDisposableEffect 内部直接使用函数参数(如 welcome),这个参数会在 Effect 启动时被"捕获"------后续哪怕外部参数变化,Effect 里的闭包里仍然用的是启动时的那一份旧值。如果我们把这个参数作为 Effect 的 key,加到 LaunchedEffect(welcome) 里,Effect 会在每次 welcome 变化时重新启动,这并不一定是我们想要的。

  2. 手动的 "remember + 赋值" 解决方案

    我们可以自己写:

    kotlin 复制代码
    var rememberedWelcome by remember { mutableStateOf(welcome) }
    rememberedWelcome = welcome
    LaunchedEffect(Unit) {
      delay(3000)
      println("$$rememberedWelcome")
    }
    • 这里 rememberrememberedWelcome 这个状态对象跨越重组被保留不重建;
    • 每次重组,我们再把最新的 welcome 赋给它;
    • 因为 Effect 的 key 是 Unit,它只启动一次,但在内部读到的就是最新的 rememberedWelcome
  3. rememberUpdatedState 就是把上面模式封装好了

    kotlin 复制代码
    val rememberedWelcome by rememberUpdatedState(welcome)
    LaunchedEffect(Unit) {
      delay(3000)
      println("$$rememberedWelcome")  // 永远是最新的 welcome
    }
    • 它内部本质就是 remember { mutableStateOf(newValue) }.apply { value = newValue }
    • 既保证了状态对象不变(Effect 不重启),又同步更新了 .value

适用场景

  • 想让 Effect 只在首次(或特定 key 变化时)运行一次 ,但又需要在 Effect 里拿到参数的最新值
  • 如果我们反而希望 Effect 在参数变化时重新执行 ,那就应把参数当 key,像 LaunchedEffect(welcome)
  • rememberUpdatedState 的价值就在于:保持 Effect 生命周期不被参数变化打断 ,同时内部拿到最新的参数值

同样的方式,也适用于CustomDisposableEffet

kotlin 复制代码
@Composable
private fun CustomDisposableEffet(user: User) {
  val updatedUser by rememberUpdatedState(user)
  DisposableEffect(Unit) {
    suscriber.subscribe(updatedUser)
    onDispose {
      xxxx.unsubscribe()
    }
  }
}

3.1 在异步 Effect 中安全引用最新参数

kotlin 复制代码
@Composable
fun TimerButton(onTimeout: () -> Unit) {
    // 用来在协程中获取最新的 onTimeout
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    Button(onClick = {
        // 启动一个 5 秒倒计时
        LaunchedEffect(Unit) {
            delay(5000)
            // 调用 .value,总是最新的 lambda
            currentOnTimeout()
        }
    }) {
        Text("Start Timer")
    }
}

如果直接在 LaunchedEffect 中捕获 onTimeout,那么在重组后即使 onTimeout 更新,旧的协程仍会调用旧的回调;使用 rememberUpdatedState 则可保证调用的是最新版本。

3.2 与 DisposableEffect 配合

kotlin 复制代码
@Composable
fun LocationTracker(onLocationUpdate: (Location) -> Unit) {
    val latestCallback by rememberUpdatedState(onLocationUpdate)

    DisposableEffect(Unit) {
        val listener = object : LocationListener {
            override fun onLocationChanged(loc: Location) {
                // 始终调用最新的回调
                latestCallback(loc)
            }
        }
        locationManager.requestLocationUpdates(listener)
        onDispose {
            locationManager.removeUpdates(listener)
        }
    }
}

4. 与其他 remember API 对比

API 对象重用 值更新方式 典型用途
remember { mutableStateOf(v) } State 对象不变 需在重组时手动 setValue 在 Composable 内创建可变状态
remember(v) { ... } 依赖 v 变化时重建 --- 记忆复杂对象;依赖变化时重建
rememberUpdatedState(v) State 对象不变 自动将 .value 设为 v Effect 中安全地引用最新外部参数

5. 小结

  1. 目的:在 Effect Handler 或长生命周期闭包中,安全地引用最新值而不重启 Effect。
  2. 实现 :记忆同一个 State 对象,重组时只更新其 .value
  3. 用法 :用 val current by rememberUpdatedState(param),在协程或回调中读取 current

合理使用 rememberUpdatedState,能让我们的异步副作用在参数变化时依旧保持最新行为,而无需重启整个 Effect。

协程:rememberCoroutineScope()

rememberCoroutineScope() 是 Compose 提供的一个辅助函数,用来在组合函数中创建并记忆 一个 CoroutineScope,从而在 UI 事件(例如按钮点击)或回调中手动 启动协程,并保证这个协程作用域会随着 Composable 的生命周期自动取消。 与 Composable 组件的生命周期绑定。

kotlin 复制代码
CourseComposeRememberCoroutineScopeTheme {
  // 不要这么写,这会让 Compose 组件和页面的生命周期绑定了。
  lifecycleScope.launch {  }
  val scope = rememberCoroutineScope()
  Box(Modifier.clickable {
    scope.launch {  }
  })
  //    // 1. 只有第一次 Composition 时才会执行一次 launch
  //    val coroutine = remember {
  //      scope.launch {
  //        // ... 只在首次组合时启动
  //      }
  //    }
  //
  //    // 2. 以 value 作为 key,每次 value 改变时都会重新执行 block
  //    val coroutine1 = remember(value) {
  //      scope.launch {
  //        // ... 每当 value 发生变化,就会再启动一个新的协程
  //      }
  //    }
  LaunchedEffect(key1 = , block = )
}

一点点补充: 小心"协程泄露"问题

  • remember(value) 会返回新的 Jobscope.launch {}),而之前的 Job 并不会自动被取消------如果我们不手动 job.cancel(),可能会造成多余的后台协程在跑,直到 Composable 卸载时才统一取消。

  • 如果我们想"value 变了就取消旧协程再启动新协程",可以这样写:

    kotlin 复制代码
    var currentJob by remember(value) { mutableStateOf<Job?>(null) }
    LaunchedEffect(value) {
      currentJob?.cancel()          // 先取消旧的
      currentJob = scope.launch {   // 启动新的
        // ...
      }
    }

或者直接用 LaunchedEffect(value),让 Compose 帮我们管理 cancellation:

kotlin 复制代码
LaunchedEffect(value) {
  // 每当 value 变化,Compose 会自动 cancel 上一次 block, 然后重启这个 block
  // ... 我们的挂起逻辑
}

总结

  • remember { ... } ------ block 只执行一次,协程只会启动一次。
  • remember(value) { ... } ------ block 会在 value 改变时重新执行,协程也会随之重新启动。
  • 记得根据需求,选择合适的 key,并处理好旧协程的取消,以免资源泄露。

1. API 介绍

kotlin 复制代码
@Composable
fun rememberCoroutineScope(): CoroutineScope
  • 返回值 :一个与当前 Composition 绑定的 CoroutineScope
  • 生命周期 :当包含它的 Composable 退出组合树 (composition)时,这个 CoroutineScope 会自动取消,所有在此作用域中启动的协程都会被取消。

2. 原理

  • Compose 在第一次调用时,内部通过 remember { CoroutineScope(...) } 创建了一个新的 CoroutineScope,并记忆(remember)它。
  • 在后续重组中,不会重复创建新的 scope;
  • 当 Composable 从 Composition 中移除或发生重组导致作用域失效时,Compose 会自动调用 scope.cancel(),确保没有"野生"协程泄露。

3. 典型使用场景

  1. UI 事件响应

    例如在 ButtononClick 中发起一次异步请求:

    kotlin 复制代码
    @Composable
    fun FetchButton(viewModel: MyViewModel) {
      val scope = rememberCoroutineScope()
      Button(onClick = {
        scope.launch {
          val data = viewModel.fetchData()  // 挂起函数
          // 更新状态...
        }
      }) {
        Text("Fetch")
      }
    }
  2. 组合内部启动一次性任务

    如果不想使用 LaunchedEffect(因为它会在 key 变化时自动重启),而在某个回调或副作用里手动控制:

    kotlin 复制代码
    @Composable
    fun UploadDemo(file: File) {
      val scope = rememberCoroutineScope()
      LaunchedEffect(Unit) {
        // 先做一次预初始化
        preUploadSetup()
      }
      Button("Upload") {
        scope.launch {
          uploadFile(file)
        }
      }
    }

4. 与其他 Effect Handler 的对比

API 启动时机 取消时机 典型用途
LaunchedEffect(keys...) 首次组合或 key 变化时自动启动 key 变化或 Composable 离开时取消 异步副作用,自动随 key 管理 lifecycle
DisposableEffect(keys...) 首次组合或 key 变化时同步执行 init;离开时执行 dispose 同上 注册/注销资源、监听器
rememberCoroutineScope() 手动获取 scope,在任意回调中启动协程 Composable 离开时自动取消 UI 事件或手动触发的异步任务
  • 自动 vs 手动

    • LaunchedEffect:由 Compose 自动调度启动与取消,适合一进入就要跑的协程;
    • rememberCoroutineScope:手动调用 scope.launch { ... },更灵活,适合响应用户交互等场景。

5. 注意事项

  • 不要 在 Composable 顶层 直接调用挂起函数;必须通过 scope.launch 或 Effect Handler。
  • Scope 取消 :当 Composable 被移除,所有 rememberCoroutineScope() 启动的协程都会被取消,无需额外管理。
  • 避免内存泄露 :相比自己全局创建 CoroutineScope,使用 rememberCoroutineScope() 能确保协程与 UI 同步销毁。

6. 小结

  • 目的 :在 Compose 中便捷地获取一个生命周期感知的 CoroutineScope,用于手动启动异步任务。
  • 特性:仅创建一次,跨越重组;在 Composable 生命周期结束时自动取消。
  • 场景:响应按钮点击、事件回调或需要按需启动的异步操作。

合理结合 LaunchedEffect(自动启动)和 rememberCoroutineScope(手动启动),能让异步逻辑既干净又安全。

从produceState() 说起:协程(和其他)状态向Compose 状态的转换

在 Jetpack Compose 中,UI 的更新完全由 "状态(State)" 驱动 ------ 当某个 State<T> 的值发生变化时,Compose 会重新执行(recompose)读取该状态的可组合函数,从而更新界面。而在实际应用里,很多状态并不直接来自用户点击或简单的变量,而是依赖于诸如协程、Flow、回调、LiveData 等异步或外部数据源。要让这些外部状态也能触发 Compose 的重组,就需要把它们"转换"成 Compose 可识别的 State<T>produceState() 就是这样一个桥梁:它在组合阶段启动一个协程,监听外部源,然后把每次产生的新值写入到内部的 MutableState<T> 中。如何实现把外界的协程或者非协程状态转化为Compose的状态?下面我们一步步来拆解


1. 为什么要将协程(或其他异步源)状态转换为 Compose State?

  • 统一的响应式模型

    Compose 只关心 State<T>,它不会察看我们代码里开了多少协程、收集了多少回调。只要我们把数据写入 State<T>,就能触发重组。

  • 正确的生命周期管理

    传统在 View 层启动协程需要手动在 onDestroyonStop 时取消,否则容易内存泄漏。而 produceState 将协程的生命周期和组合(Composition)绑定:组件离开组合时自动取消。

  • 简化代码

    不必在外层维护单独的 MutableState<T>、手动管理 LaunchedEffect { ... }、取消和错误处理,一行 produceState 就搞定。

把协程转化为 Compose 的思想,也包括把非协程状态状态转化为Compose 状态的思想。下边的两种写法是等价的:

kotlin 复制代码
// 1
var position by remember { mutableStateOf(Point(0, 0)) }
LaunchedEffect(Unit) {
  positionState.collect { newPos ->
    position = newPos
  }
}
// 2
val producedState = produceState(Point(0, 0)) {
  positionState.collect { newPos ->
    value = newPos
  }
}

2. produceState():在 Compose 内启动协程生产 State

kotlin 复制代码
@Composable
fun <T> produceState(
    initialValue: T,
    vararg keys: Any?,
    producer: suspend ProduceStateScope<T>.() -> Unit
): State<T> {
    val result = remember { mutableStateOf(initialValue) }
    @Suppress("CHANGING_ARGUMENTS_EXECUTION_ORDER_FOR_NAMED_VARARGS")
    LaunchedEffect(keys = keys) {
        ProduceStateScopeImpl(result, coroutineContext).producer()
    }
    return result
}
  • 原理

    • remember { mutableStateOf(initialValue) } 确保了状态跨重组保留。

    • LaunchedEffect(keys) 在初次进入 Composition 或任一依赖 keys 变化时重启协程;协程结束或 Composable 离开时自动取消。

    • ProduceScope<T> 里定义了 value 属性(背后就是 MutableState<T>),以及诸如 awaitDispose { ... }send()yield() 等辅助方法。

    • producer() 块里可以任意调用挂起函数;通过 value = newValue 更新返回的 State<T>

  • 示例:请求网络数据

    kotlin 复制代码
    @Composable
    fun UserProfile(userId: String): State<User?> {
      return produceState<User?>(initialValue = null, userId) {
        // 当 userId 变化时,上一协程会被 cancel,启动新协程
        val user = repository.fetchUser(userId) // suspend 函数
        value = user
      }
    }
    
    // 在 UI 中使用
    @Composable
    fun ProfileScreen(userId: String) {
      val userState = UserProfile(userId)
      when (val user = userState.value) {
        null -> CircularProgressIndicator()
        else -> Text("Name: ${user.name}")
      }
    }

3. FlowcollectAsState()

如果我们有一个 Flow<T>,可以直接在 Compose 中收集:

kotlin 复制代码
@Composable
fun TimerText() {
  val tickerFlow = remember { tickerFlow(1000L) }  // Flow<Long>
  val seconds by tickerFlow.collectAsState(initial = 0L)
  Text("Seconds: $seconds")
}
  • 原理collectAsState() 底层也是用 produceState(initial, this) { collect { value = it } }
  • StateFlow / LiveData :对于 StateFlow,也可以用 collectAsState();对于 LiveData,用 observeAsState()

4. 在 ViewModel 侧用 stateIn

有时我们先在 VM 里把 Flow<T> 转成 StateFlow<T>,再在 UI 端直接收集:

kotlin 复制代码
class MyViewModel : ViewModel() {
  private val _uiEvents = MutableSharedFlow<UiEvent>()
  val uiEvents: StateFlow<UiEvent> =
    _uiEvents.stateIn(
      scope = viewModelScope,
      started = SharingStarted.WhileSubscribed(5000),
      initialValue = UiEvent.Idle
    )
}

// UI
@Composable
fun EventScreen(vm: MyViewModel = viewModel()) {
  val event by vm.uiEvents.collectAsState()
  // ...
}
  • 好处 :可复用、测试友好、脱离 Compose 也能使用 StateFlow

5. 手动管理:remember { mutableStateOf() } + LaunchedEffect

当我们想对状态赋予更细粒度的控制,或需要在多个地方更新它时,可以手动:

kotlin 复制代码
@Composable
fun WeatherScreen(city: String) {
  var weather by remember { mutableStateOf<Weather?>(null) }

  // 启动一次请求
  LaunchedEffect(city) {
    weather = repository.fetchWeather(city)
  }

  // UI...
}
  • produceState 对比

    • produceState 更简洁、一行搞定;
    • 手动写时,可以在不同事件里(点击、刷新)复用同一个 weather 变量。

6. 回调/监听器:DisposableEffect + rememberUpdatedState

对于依赖回调的场景,比如传感器、广播接收器:

kotlin 复制代码
@Composable
fun BatteryLevel(onLevel: (Int) -> Unit) {
  val callback by rememberUpdatedState(onLevel)

  DisposableEffect(Unit) {
    val receiver = object : BroadcastReceiver() {
      override fun onReceive(ctx: Context?, intent: Intent?) {
        val level = intent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
        callback(level)
      }
    }
    context.registerReceiver(receiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
    onDispose { context.unregisterReceiver(receiver) }
  }
}
  • 转换路径 :回调 → rememberUpdatedState → 在回调里调用最新 lambda → 驱动外部 mutableStateOf

7. derivedStateOf:在多个 State 之上派生新 State

kotlin 复制代码
@Composable
fun CartSummary(cartItems: List<Item>) {
  val itemCount by remember { derivedStateOf { cartItems.size } }
  Text("Items in cart: $itemCount")
}
  • 用途 :当一个或多个基础 State 换了值,derivedStateOf 会重算,输出新的 State,触发依赖它的重组。

8. 何时用哪种方式

需求场景 推荐方式
单次挂起函数输出 produceState
Flow<T> 动态流 collectAsState
StateFlow / LiveData in VM collectAsState
想在多个事件里复用状态 remember + mutableStateOf + Effect
监听回调 / 注册-注销 DisposableEffect
响应参数变化"仅更新内部值" rememberUpdatedState
从多个 State 计算新 State derivedStateOf

9 其他异步源的转换:FlowLiveData

Compose 官方还提供了更高层次的封装:

  • Flow<T>.collectAsState(initial)
    底层就是调用 produceState(initial, this) { collect { value = it } }
  • LiveData<T>.observeAsState(initial)
    则把 LiveData 的观察注册在组合中,并将每次回调写入 State。

有了这些转换,我们可以在 UI 层直接写:

kotlin 复制代码
@Composable
fun MessageList(viewModel: MyViewModel) {
    val messages by viewModel.messagesFlow.collectAsState(emptyList())
    LazyColumn { items(messages) { MsgItem(it) } }
}

Compose 无缝地把 Flow 推送的列表,转化为可触发重组的 State,简洁又安全。

10. 小结

如何实现把外界的协程或者非协程状态转化为Compose的状态?对于非协程的用 DisposableEffect 进行转化,对于协程的,则使用 LaunchedEffect 进行订阅和更新值,produceStateLaunchedEffect 的简化版的替代品,写起来更容易。结合协程与非协程的情况下,awaitDispose 可能有用武之地。手动去取消订阅

kotlin 复制代码
// (1)对于非协程的用 DisposableEffect 进行转化,进行订阅和取消订阅,并且在回调里边去更新状态值。
DisposableEffect(Unit) {
  val observer = Observer<Point> { newPos ->
    position = newPos
  }
  positionData.observe(this@MainActivity, observer)
  onDispose {
    positionData.removeObserver(observer)
  }
}
val positionStateFromLiveData = positionData.observeAsState()
// (2)对于协程的,则使用 LaunchedEffect 进行订阅和更新值,并且不需要手动取消订阅,因为协程会自动取消。
LaunchedEffect(Unit) {
  positionState.collect { newPos ->
    position = newPos
  }
}
// (2-1)produceState 是 LaunchedEffect 的简化版的替代品,写起来更容易。
val producedState = produceState(Point(0, 0)) {
  // collect就会保持协程一直被挂起。
  positionState.collect { newPos ->
    value = newPos
  }
  // 从调用的此时此刻,把协程无限期的调起,直到协程被取消,取消时会触发大括号两边的逻辑。
  awaitDispose {

  }
}

val positionStateFromFlow = positionState.collectAsState()

// (3)结合协程与非协程的情况下,awaitDispose 可能有用武之地。手动去取消订阅
val producedState2 = produceState<Point>(initialValue = Point(0, 0)) {
  // 创建一个 LiveData 的 Observer,每次有新值就更新 produceState 的 value
  val observer = Observer<Point> { newPos ->
    value = newPos
  }

  // 开始观察
  positionData.observe(this@MainActivity, observer)

  // 当 produceState 容器被 Dispose 时,移除 Observer
  awaitDispose {
    positionData.removeObserver(observer)
  }
}
  • 核心 :Compose 只识别 State<T>;我们要把协程、流、监听器、回调等都桥接State<T>
  • 快捷produceStatecollectAsState 系列;
  • 灵活 :手动 remember + Effect;
  • 系统化 :在 ViewModel 侧用 stateIn,在 UI 侧 collectAsState
  • 派生derivedStateOf 让我们基于多个 State 轻松构建新 State。并且只在其依赖的 state 真正发生变化时才重新计算和触发重组

snapshotFlow():把 Compose的 State 转换成协程Flow

snapshotFlowCompose 提供的一个桥梁,创建一个对 Compose State 有感知的协程的 Flow。它能把"基于快照(Snapshot)"的 Compose State<T>(或其它 SnapshotState)转换成一个标准的冷 Flow<T>,从而让我们在协程/Flow 的世界里以响应式的方式处理 UI 状态变化。需要注意的是,SnapshotState 支持将多个 Compose State 转化成一个 Flow。当我们的 Flow 需要使用到 ComposeState 时,就可以使用 snapshotFlow 来做监听和保存。

snapshotFlow 与"协程以及其他状态往 Compose 转化"并不是两个互补的转换关系,因为后者是由 ComposeActivity 提供的一种给 Compose 使用的工具。而把 ComposeState 转换成协程的 Flow 则是 ComposeActivity 提供的一种给协程使用的工具,因此只有在编写并发逻辑时才会考虑使用 snapshotFlow------前者侧重于界面展示,后者侧重于并发处理,本质上就不同。

  • snapshotFlow :将一个或多个 Compose State 的读取(snapshot)包装成一个冷 Flow,状态变了就发射新值,用于在协程/Flow 逻辑中响应 UI 状态更新。

  • produceState / collectAsState :将外部的异步数据(FlowLiveData 等)转换成 Compose State,用于在 UI 里直接绑定和重组渲染。

kotlin 复制代码
var name by remember { mutableStateOf("rengwuxian") }
var age by remember { mutableStateOf(18) }
val flow = snapshotFlow { "$name $age" }
LaunchedEffect(Unit) {
  flow.collect { info ->
    println(info)
  }
}

1. 为什么要用 snapshotFlow

  • 跨域副作用
    有时我们需要在协程作用域中对 Compose State 做副作用(比如发网络请求、写日志、存数据库),而这些操作更适合写在 flow + collect 里。
  • 去抖动(debounce)/节流(throttle)
    如果状态变化非常频繁(比如用户极速滑动 Slider),可以借助 Flow 操作符(debouncedistinctUntilChangedsample 等)来减少上游处理压力。
  • 与外部 API 交互
    当我们需要把 Compose State 的变化"推送"给 ViewModel 或其它非 Compose 模块时,用 Flow 集成更自然。

2. API 签名

kotlin 复制代码
fun <T> snapshotFlow(snapshot: () -> T): Flow<T>
  • 输入 :一个无参数 lambda snapshot,在其内部我们可以读取 任何 SnapshotState(比如 MutableState<T>MutableStateList<T>SnapshotStateMap<K,V> 等)。
  • 输出 :一个冷 Flow<T>,它会在每次 snapshot() 返回值不等于上一次时发射最新值。

3. snapshotFlow 的原理简述

  1. 快照读取
    snapshotFlow 启动时,会立即在当前快照中执行一次 snapshot(),并且记录下它依赖的所有 SnapshotState。
  2. 变更监听
    当任一被记录的状态发生变化,系统会在下一个快照应用时再次调用 snapshot()
  3. 值比较与发射
    如果新返回值与上一次不相等(使用 == 判断),就把它通过 emit() 发送到下游的 FlowCollector
  4. 协程取消
    当收集者(collector)取消协程时,Flow 停止监听状态变化,自动解绑。

4. 典型用法示例

示例 1:对文本输入做去抖处理

kotlin 复制代码
@Composable
fun SearchScreen(viewModel: SearchViewModel) {
    var query by remember { mutableStateOf("") }

    // 把 query 变化封装成 Flow,并在 500ms 内抖动
    LaunchedEffect(Unit) {
        snapshotFlow { query }
            .debounce(500)                    // 等待用户停止输入 500ms
            .filter { it.isNotBlank() }       // 过滤空串
            .distinctUntilChanged()           // 只在内容变更时才继续
            .collectLatest { text ->
                viewModel.search(text)       // 发起搜索网络请求
            }
    }

    TextField(
        value = query, 
        onValueChange = { query = it },
        label = { Text("搜索") }
    )
}
  • snapshotFlow { query }:只要 query 变了,就发射新值。
  • .debounce(500):用户停止输入 500ms 后才下发真正的搜索。
  • .collectLatest:自动取消上一次搜索请求,保证只有最新一次生效。

示例 2:滑动组件的连续监听

kotlin 复制代码
@Composable
fun VolumeControl(onVolumeChanged: (Float) -> Unit) {
    var sliderPos by remember { mutableStateOf(0f) }

    LaunchedEffect(Unit) {
        snapshotFlow { sliderPos }
            .distinctUntilChanged()
            .collect { level ->
                onVolumeChanged(level)
            }
    }

    Slider(
        value = sliderPos,
        onValueChange = { sliderPos = it },
        valueRange = 0f..1f
    )
}
  • distinctUntilChanged() 可以避免重复发同样的值给回调。

5. 使用注意

  1. 慎重捕获复杂对象
    snapshotFlow { someList } 会把整个 List 作为一个对象比较,若想对元素变化敏感,请考虑对 .size.toList()、或者"映射后再发射"做细粒度处理。
  2. 在合适的作用域内启动
    通常用在 LaunchedEffectrememberCoroutineScope().launch 等组合安全的协程作用域。
  3. Flow 操作符配合
    极速抖动、批量发送、错误重试等都可以直接用 Flow 丰富的中间操作符来实现。

小结

  • snapshotFlow 就是把 Compose Snapshot State 的变化包装成一个 的"监听器",但暴露为 Flow
  • 它让我们可以在协程管道中使用各种 Flow 操作符,对 UI 状态变化做去抖、节流、过滤、错误处理等。
  • 底层基于 Compose 的 Snapshot 观察机制,只在依赖的状态真正变化时才发射,性能开销小。
相关推荐
kyriewen10 分钟前
豆包和千问同时关了智能体,我用它们搭的 3 个自动化全废了——迁移方案整理
前端·javascript·ai编程
前端一小卒24 分钟前
我用 TypeScript 从零手写了一个 Claude Code,然后发现它的核心只有 30 行
前端·agent
大圣编程2 小时前
Python中continue语句的用法是什么?
开发语言·前端·python
yuhaiqiang2 小时前
随手 vibecoding 的浏览器插件已经 6000 多次下载,聊聊他的产品设计
前端·后端·面试
之歆3 小时前
Vue商品详情与放大镜组件
前端·javascript·vue.js
再吃一根胡萝卜3 小时前
如何把小米 MiMo 接入 CodeBuddy,打造私有 Agent
前端
负责的蛋挞4 小时前
异步HttpModule的实现方式
java·服务器·前端
丹宇码农7 小时前
把 HLS 字幕玩出花:zwPlayer 如何让 M3U8 视频支持全文搜索、翻译与码率自适应
前端·javascript·音视频·hls·视频播放器
2501_943782357 小时前
【共创季稿事节】猜数字游戏:二分法思维与交互式反馈
前端·游戏·microsoft·harmonyos·鸿蒙·鸿蒙系统