Jetpack Compose 入门系列(八):Compose 中的副作用处理
学完上篇你已经会用 ViewModel 和
UiState管理页面状态了。但真实页面里不只有"根据状态画 UI",还会遇到倒计时、弹 Snackbar、监听生命周期、状态变化后执行一次任务这些场景,本篇解决一个问题:如何在 Compose 中正确处理副作用。
一、什么是副作用
先别被"副作用"这个词吓到,它不是什么玄学。
在 Compose 里,Composable 最理想的状态是:给它什么状态,它就画什么 UI。
比如:
kotlin
@Composable
fun Greeting(
name: String
) {
Text(text = "你好,$name")
}
这段代码很纯粹:传入 name,显示文字,没有偷偷做别的事。
但真实开发里,经常会有这些需求:
- 页面第一次进入时加载数据
- 输入框内容变化后延迟搜索
- 点击按钮弹 Snackbar
- 页面进入前台时刷新数据
- 页面离开时取消监听
- 倒计时结束后执行回调
这些都不是"画 UI"本身,而是在 UI 之外额外做的事情,所以叫副作用。
来做个对比你就明白了:
| 行为 | 是不是副作用 | 原因 |
|---|---|---|
| 显示一段文字 | 不是 | 只是根据状态画 UI |
在 Composable 函数体执行期间直接修改 MutableState |
是,而且通常是错误写法 | 可能导致重复重组或不可预测行为 |
| 在点击、输入等事件回调里修改状态 | 通常不是问题 | 这是正常的状态更新 |
| 启动协程请求数据 | 是 | 发生在 UI 绘制之外 |
| 注册生命周期监听 | 是 | 需要创建和清理外部资源 |
| 弹 Snackbar | 是 | 是一次性事件,不是静态 UI |
在 XML 时代,这些事情通常写在 Activity 或 Fragment 的生命周期方法里:
kotlin
override fun onStart() {
super.onStart()
startLocationUpdates()
}
override fun onStop() {
stopLocationUpdates()
super.onStop()
}
到了 Compose 里,没有 onStart() 这种直接写在 Composable 里的生命周期方法。Compose 提供了一组和副作用、状态派生、重组控制相关的常用 API:
| API | 适合场景 | 类型 |
|---|---|---|
LaunchedEffect |
启动协程、状态变化后执行挂起任务 | 副作用 |
rememberCoroutineScope |
在点击事件中手动启动协程 | 副作用辅助 |
DisposableEffect |
注册监听,并在离开时清理 | 副作用 |
rememberUpdatedState |
在长时间运行的 Effect 中拿到最新回调 | Effect 辅助 |
derivedStateOf |
根据已有状态计算派生状态,减少不必要重组 | 派生状态 |
关键点:Composable 负责描述 UI,副作用相关 API 负责处理那些"不能直接写在 UI 描述里"的事情;派生状态工具负责把能计算出来的状态留在状态系统里。
二、添加依赖
这篇主要用 Compose、Material3 和 Lifecycle。
2.1 libs.versions.toml
toml
[versions]
agp = "9.2.1"
kotlin = "2.2.10"
composeBom = "2026.02.01"
activity = "1.8.0"
lifecycle = "2.11.0"
[libraries]
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
2.2 app/build.gradle.kts
kotlin
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.compose.compiler)
}
android {
namespace = "com.example.sideeffectdemo"
compileSdk = 36
defaultConfig {
applicationId = "com.example.sideeffectdemo"
minSdk = 24
targetSdk = 36
versionCode = 1
versionName = "1.0"
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.lifecycle.runtime.compose)
}
这段配置里发生了什么:
activity-compose用来在Activity中启动 Compose。material3提供Scaffold、SnackbarHost、Button、OutlinedTextField等组件。lifecycle-runtime-compose提供 Compose 和生命周期结合时需要的能力。
本篇的重点不是依赖,而是理解副作用 API 的使用边界:什么时候用、为什么用、怎么避免重复执行。
三、LaunchedEffect:进入组合后启动协程
LaunchedEffect 是最常用的副作用 API。
它适合做这些事:
- 页面第一次显示时执行一次任务
- 某个 key 变化后重新执行任务
- 在 Composable 生命周期内启动协程
- key 变化或离开组合时自动取消旧任务
先看一个简单例子:输入用户名后,延迟 500ms 检查长度。
kotlin
@Composable
private fun UsernameChecker() {
var username by rememberSaveable { mutableStateOf("") }
var message by rememberSaveable { mutableStateOf("请输入用户名") }
LaunchedEffect(username) {
if (username.isBlank()) {
message = "请输入用户名"
return@LaunchedEffect
}
delay(500)
message = if (username.length >= 3) {
"用户名可用"
} else {
"用户名至少需要 3 个字符"
}
}
Column(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedTextField(
value = username,
onValueChange = {
username = it
},
label = {
Text(text = "用户名")
}
)
Text(text = message)
}
}
这段代码里发生了什么:
username是输入框状态。LaunchedEffect(username)表示:当username变化时,执行里面的协程。- 如果用户名为空,直接显示提示。
- 如果不为空,先
delay(500),再检查长度。 - 如果用户在 500ms 内继续输入,旧的协程会被取消,新的协程重新开始。
这正好适合"输入后延迟搜索"这种场景。
LaunchedEffect(key)的 key 很重要:key 不变,任务不会重启;key 变化,旧任务取消,新任务启动。
3.1 常见错误:不要把协程直接写在 Composable 函数体里
❌ 错误:
kotlin
@Composable
fun BadScreen() {
var text by rememberSaveable { mutableStateOf("") }
CoroutineScope(Dispatchers.Main).launch {
delay(1000)
text = "加载完成"
}
Text(text = text)
}
这段代码的问题很大:Composable 可能会重组很多次,每次重组都可能新建协程,最后你会得到一堆失控任务。
✅ 正确:
kotlin
@Composable
fun GoodScreen() {
var text by rememberSaveable { mutableStateOf("") }
LaunchedEffect(Unit) {
delay(1000)
text = "加载完成"
}
Text(text = text)
}
LaunchedEffect(Unit) 表示这个任务在当前 Composable 进入组合后执行一次。
只要你想在 Composable 中启动协程,第一反应应该是:这个任务是否适合放进
LaunchedEffect?
四、rememberCoroutineScope:在事件里启动协程
LaunchedEffect 适合"状态变化后自动执行"。
但有些任务是用户主动触发的,比如点击按钮弹 Snackbar。
这时可以用 rememberCoroutineScope()。
kotlin
@Composable
private fun SnackbarDemo() {
val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()
Scaffold(
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
}
) { innerPadding ->
Button(
modifier = Modifier.padding(innerPadding),
onClick = {
coroutineScope.launch {
snackbarHostState.showSnackbar("保存成功")
}
}
) {
Text(text = "保存")
}
}
}
这段代码里发生了什么:
SnackbarHostState负责管理 Snackbar。rememberCoroutineScope()创建一个和当前组合绑定的协程作用域。- 用户点击按钮时,调用
coroutineScope.launch。 showSnackbar()是挂起函数,所以需要在协程里调用。- 当前 Composable 离开组合时,这个 scope 里的任务也会被取消。
LaunchedEffect 和 rememberCoroutineScope 怎么选?
| 场景 | 推荐 API |
|---|---|
| 页面进入后自动加载 | LaunchedEffect(Unit) |
| 某个状态变化后执行任务 | LaunchedEffect(key) |
| 点击按钮后弹 Snackbar | rememberCoroutineScope() |
| 点击按钮后执行挂起函数 | rememberCoroutineScope() |
简单记:自动发生用
LaunchedEffect,用户事件里手动触发用rememberCoroutineScope。
五、DisposableEffect:注册监听,并负责清理
有些副作用不是执行一次就完了,而是"注册一个东西,然后离开时要清掉"。
比如监听生命周期:
kotlin
@Composable
private fun LifecycleStatusText() {
val lifecycleOwner = LocalLifecycleOwner.current
var lifecycleText by rememberSaveable { mutableStateOf("等待生命周期事件") }
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
lifecycleText = "当前生命周期事件:$event"
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
Text(text = lifecycleText)
}
需要的 import:
kotlin
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
这段代码里发生了什么:
LocalLifecycleOwner.current拿到当前页面的生命周期持有者。DisposableEffect(lifecycleOwner)表示当lifecycleOwner变化时重新注册监听。LifecycleEventObserver监听生命周期事件。addObserver(observer)注册监听。onDispose中调用removeObserver(observer)清理监听。
如果你只注册不清理,就像进房间开了灯,走的时候不关灯。短时间看不出问题,时间长了就容易泄漏。
DisposableEffect的重点不只是"执行副作用",而是必须在onDispose里清理资源。
5.1 常见错误:注册监听但不移除
❌ 错误:
kotlin
@Composable
fun BadLifecycleScreen() {
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(Unit) {
lifecycleOwner.lifecycle.addObserver(
LifecycleEventObserver { _, event ->
println(event)
}
)
}
}
这段代码没有移除监听,页面离开后 observer 可能还在。
✅ 正确:
kotlin
@Composable
fun GoodLifecycleScreen() {
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
println(event)
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}
只要你的副作用需要"成对出现"的注册和注销,就优先考虑
DisposableEffect。
六、rememberUpdatedState:在长任务中拿到最新值
rememberUpdatedState 是初学时最容易忽略,但实际很有用的 API。
先看一个倒计时组件:
kotlin
@Composable
private fun CountdownText(
seconds: Int,
onFinished: () -> Unit
) {
var remainingSeconds by rememberSaveable(seconds) {
mutableIntStateOf(seconds)
}
LaunchedEffect(seconds) {
remainingSeconds = seconds
while (remainingSeconds > 0) {
delay(1000)
remainingSeconds--
}
onFinished()
}
Text(text = "剩余时间:$remainingSeconds 秒")
}
这段代码能跑,但有个隐藏问题:如果倒计时过程中外层传进来的 onFinished 变了,LaunchedEffect 里的协程可能还拿着旧的回调。
更稳妥的写法是:
kotlin
@Composable
private fun CountdownText(
seconds: Int,
onFinished: () -> Unit
) {
val latestOnFinished by rememberUpdatedState(onFinished)
var remainingSeconds by rememberSaveable(seconds) {
mutableIntStateOf(seconds)
}
LaunchedEffect(seconds) {
remainingSeconds = seconds
while (remainingSeconds > 0) {
delay(1000)
remainingSeconds--
}
latestOnFinished()
}
Text(text = "剩余时间:$remainingSeconds 秒")
}
这段代码里发生了什么:
rememberUpdatedState(onFinished)保存最新的onFinished。LaunchedEffect(seconds)只跟seconds绑定。- 倒计时过程中,如果外层回调变化,不会重启倒计时。
- 倒计时结束时,调用的是最新的
onFinished。
为什么不直接把 onFinished 放进 LaunchedEffect 的 key?
kotlin
LaunchedEffect(seconds, onFinished) {
// 倒计时
}
这样只要回调引用变化,倒计时就会重启。很多时候这不是你想要的结果。
rememberUpdatedState适合解决一个问题:Effect 不想重启,但里面又想拿到最新的外部值或回调。
七、derivedStateOf:从已有状态计算派生状态
有些状态不需要单独保存,而是可以从已有状态算出来。
比如注册按钮是否可点:
- 用户名至少 3 个字符
- 密码至少 6 个字符
- 没有正在提交
很多人会这样写:
kotlin
var canSubmit by rememberSaveable { mutableStateOf(false) }
然后在用户名变化、密码变化、提交状态变化时到处改 canSubmit。
这就容易漏。
更推荐用 derivedStateOf:
kotlin
@Composable
private fun RegisterForm() {
var username by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var isSubmitting by rememberSaveable { mutableStateOf(false) }
val canSubmit by remember {
derivedStateOf {
username.length >= 3 && password.length >= 6 && !isSubmitting
}
}
Column(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedTextField(
value = username,
onValueChange = {
username = it
},
label = {
Text(text = "用户名")
}
)
OutlinedTextField(
value = password,
onValueChange = {
password = it
},
label = {
Text(text = "密码")
}
)
Button(
enabled = canSubmit,
onClick = {
isSubmitting = true
}
) {
Text(text = if (isSubmitting) "提交中" else "注册")
}
}
}
这段代码里发生了什么:
username、password、isSubmitting是原始状态。canSubmit是从原始状态计算出来的派生状态。- 任意原始状态变化后,
canSubmit会重新计算。 - 不需要手动维护
canSubmit。
不过要注意:derivedStateOf 不是副作用,它只是派生状态工具。也不是所有计算值都必须用 derivedStateOf。
如果只是非常简单的计算,直接写普通 val 也可以:
kotlin
val canSubmit = username.length >= 3 && password.length >= 6 && !isSubmitting
derivedStateOf 更适合这些场景:原始状态变化很频繁,但派生结果不一定每次都变化,或者计算本身有一定成本。比如列表滚动位置频繁变化,但你只关心"是否已经滚过第一项":
kotlin
val showBackToTop by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
能从已有状态算出来的值,就不要再额外保存一份。多保存一份,就多一个不同步的风险;是否需要
derivedStateOf,则要看它能不能减少不必要的重组或重复计算。
八、综合实战:注册页面副作用 Demo
现在把前面的知识串起来,做一个注册页面:
- 用户名输入后延迟校验
- 密码输入后计算按钮是否可用
- 点击注册后弹 Snackbar
- 注册成功后启动倒计时
- 倒计时结束后更新提示
- 页面监听生命周期事件
这个 Demo 会覆盖:
LaunchedEffectrememberCoroutineScopeDisposableEffectrememberUpdatedStatederivedStateOf
说明:这个 Demo 为了集中展示多个 API,把状态和逻辑都放在同一个页面里。真实项目中,业务状态通常会放到 ViewModel,Composable 主要负责展示状态和分发事件。
完整代码如下:
kotlin
package com.example.sideeffectdemo
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
RegisterSideEffectDemo()
}
}
}
}
@Composable
private fun RegisterSideEffectDemo() {
val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()
var username by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var usernameMessage by rememberSaveable { mutableStateOf("请输入用户名") }
var isSubmitting by rememberSaveable { mutableStateOf(false) }
var showCountdown by rememberSaveable { mutableStateOf(false) }
var resultMessage by rememberSaveable { mutableStateOf("等待注册") }
val canSubmit by remember {
derivedStateOf {
username.length >= 3 && password.length >= 6 && !isSubmitting
}
}
LaunchedEffect(username) {
if (username.isBlank()) {
usernameMessage = "请输入用户名"
return@LaunchedEffect
}
usernameMessage = "正在检查用户名......"
delay(500)
usernameMessage = if (username.length >= 3) {
"用户名格式正确"
} else {
"用户名至少需要 3 个字符"
}
}
Scaffold(
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
}
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "注册页面",
style = MaterialTheme.typography.headlineMedium
)
LifecycleStatusText()
OutlinedTextField(
value = username,
onValueChange = {
username = it
},
modifier = Modifier.fillMaxWidth(),
label = {
Text(text = "用户名")
}
)
Text(text = usernameMessage)
OutlinedTextField(
value = password,
onValueChange = {
password = it
},
modifier = Modifier.fillMaxWidth(),
label = {
Text(text = "密码")
}
)
Button(
enabled = canSubmit,
onClick = {
isSubmitting = true
resultMessage = "注册成功,倒计时结束后进入下一步"
showCountdown = true
coroutineScope.launch {
snackbarHostState.showSnackbar("注册成功")
}
}
) {
Text(text = if (isSubmitting) "已提交" else "注册")
}
Text(text = resultMessage)
if (showCountdown) {
CountdownText(
seconds = 5,
onFinished = {
showCountdown = false
isSubmitting = false
resultMessage = "倒计时结束,可以进入下一步"
}
)
}
}
}
}
@Composable
private fun LifecycleStatusText() {
val lifecycleOwner = LocalLifecycleOwner.current
var lifecycleText by rememberSaveable { mutableStateOf("等待生命周期事件") }
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
lifecycleText = "生命周期事件:$event"
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
Text(text = lifecycleText)
}
@Composable
private fun CountdownText(
seconds: Int,
onFinished: () -> Unit
) {
val latestOnFinished by rememberUpdatedState(onFinished)
var remainingSeconds by rememberSaveable(seconds) {
mutableIntStateOf(seconds)
}
LaunchedEffect(seconds) {
remainingSeconds = seconds
while (remainingSeconds > 0) {
delay(1000)
remainingSeconds--
}
latestOnFinished()
}
Text(text = "剩余时间:$remainingSeconds 秒")
}
这份 Demo 的数据流如下:
实战知识点对应表
| 实战中的效果 | 使用的 API | 对应章节 |
|---|---|---|
| 用户名输入后延迟校验 | LaunchedEffect(username) |
三 |
| 点击注册后弹 Snackbar | rememberCoroutineScope() |
四 |
| 监听生命周期事件 | DisposableEffect(lifecycleOwner) |
五 |
| 倒计时结束调用最新回调 | rememberUpdatedState(onFinished) |
六 |
| 注册按钮是否可点 | derivedStateOf |
七 |
| 状态保存 | rememberSaveable |
三、八 |
这就是副作用处理的核心思路:UI 仍然负责展示,副作用 API 负责把"额外要做的事"放到正确的生命周期里。
九、副作用与相关状态工具速查表
| API | 什么时候用 | 类型 / 关键点 |
|---|---|---|
LaunchedEffect(Unit) |
进入页面执行一次挂起任务 | 副作用;当前 Composable 离开时自动取消 |
LaunchedEffect(key) |
key 变化后重新执行任务 | 副作用;key 变化会取消旧任务 |
rememberCoroutineScope() |
点击事件中手动启动协程 | 副作用辅助;适合 Snackbar、点击后调用挂起函数 |
DisposableEffect(key) |
注册监听、广播、观察者 | 副作用;必须在 onDispose 清理 |
rememberUpdatedState(value) |
Effect 不重启但要拿最新值 | Effect 辅助;常用于最新回调 |
derivedStateOf |
从已有状态计算新状态 | 派生状态;适合减少不必要重组或重复计算 |
十、总结
本篇你学到了 Compose 中副作用处理,以及相关状态工具的核心用法:
- 副作用概念:Composable 应该主要负责描述 UI,启动协程、注册监听、弹 Snackbar 这类事情属于副作用
LaunchedEffect:适合在进入组合后或 key 变化后执行挂起任务rememberCoroutineScope:适合在点击事件中手动启动协程,比如弹 SnackbarDisposableEffect:适合注册需要清理的监听,并在onDispose中释放资源rememberUpdatedState:不是副作用本身,而是适合在不重启 Effect 的情况下拿到最新参数或回调derivedStateOf:不是副作用,而是适合从已有状态计算派生状态,避免额外保存一份容易不同步的数据- 选择原则 :自动触发用
LaunchedEffect,用户事件用rememberCoroutineScope,需要清理用DisposableEffect
核心原则:不要把副作用直接塞进 Composable 函数体里,要把它放进合适的副作用 API 中,让 Compose 帮你处理启动、取消和清理。
下一篇我们将学习 Compose 中的主题与 Material 3------如何统一管理颜色、字体、圆角和深色模式,让 App 从"能用"变成"像一个完整产品"。
如果你在学习过程中有任何疑问,欢迎在评论区留言,我会尽可能回复。
系列文章:
- Jetpack Compose 入门系列(一):从零搭建到基础控件使用
- Jetpack Compose 入门系列(二):布局到 State 的使用
- Jetpack Compose 入门系列(三):MVVM + 协程 实现网络请求列表
- Jetpack Compose 入门系列(四):动画基本使用
- Jetpack Compose 入门系列(五):自定义布局与 ConstraintLayout
- Jetpack Compose 入门系列(六):Navigation 3 页面导航
- Jetpack Compose 入门系列(七):ViewModel 与界面状态管理
- Jetpack Compose 入门系列(八):Compose 中的副作用处理(本文)