先提前划重点:Compose的核心思想是“状态驱动UI”,而副作用是“脱离UI渲染、影响外部环境的操作”,Compose提供的副作用API,本质上是让这些操作“跟随Composable的生命周期”,避免内存泄漏、重复执行等问题。
一、先搞懂:什么是Compose的副作用?
在讲具体API之前,我们必须先明确“副作用”的定义——不是什么高深的概念,用一句话就能说清楚:
副作用(Side Effect):在Composable函数中,除了“根据状态渲染UI”之外,所有影响外部环境(或被外部环境影响)的操作,都是副作用。
举几个初学者最容易接触到的副作用场景,一看就懂:
- 网络请求(比如调用接口获取数据,影响了服务器,也被服务器返回的数据影响);
- 日志打印(输出内容到控制台,影响了外部的日志系统);
- 操作数据库(增删改查,影响了本地存储);
- 注册/注销监听器(比如监听网络状态、传感器,影响了系统服务);
- 启动协程、延迟操作(比如delay(2000),影响了线程调度);
- 修改全局变量(比如修改一个单例中的属性,影响了函数外部的状态)。
那为什么不能直接在@Composable函数里写这些操作?比如下面这段代码,初学者很容易这么写,但其实是错误的:
@Composable
fun BadExample() {
// 错误:直接在Composable中执行副作用(网络请求)
val data = api.fetchData() // 网络请求,属于副作用
Text(text = data.content)
}
原因很简单:Composable函数的执行是“不可预测”的——它可能会被多次重组(Recompose)、被取消、在后台线程执行,甚至重复执行。如果直接在里面写副作用,会导致:
- 重复执行:比如重组一次,就发起一次网络请求,造成资源浪费;
- 内存泄漏:比如注册了监听器,但没有注销,Composable销毁后监听器还在,导致内存泄漏;
- 状态混乱:比如协程还在执行,Composable已经重组,导致UI状态和协程执行结果不一致。
所以,Compose官方提供了一系列“副作用API”,目的就是:让副作用操作“跟随Composable的生命周期”,确保副作用只在合适的时机执行、在合适的时机清理,避免上述问题。
这里先补充一个基础知识点(很重要):Composable的生命周期只有3个阶段,副作用API都是围绕这3个阶段设计的:
- 进入组合(Enter Composition):Composable首次被调用,加入到UI树中;
- 重组(Recompose):Composable依赖的状态发生变化,重新执行函数、更新UI(可能执行0次或多次);
- 退出组合(Leave Composition):Composable不再被使用(比如页面跳转、if判断为false),从UI树中移除。
所有副作用API,本质上都是“绑定”到这3个阶段,控制副作用的执行和清理时机。接下来,我们逐个解析最常用的5个副作用API,每个都讲透“是什么、怎么用、适用场景、避坑点”。
二、核心副作用API详解(逐个拆解,含完整示例)
我们按照“初学者使用频率”排序,从最常用的LaunchedEffect开始,逐个讲解,每个API都配套可直接复制运行的代码示例,以及详细的注释说明。
1. LaunchedEffect:最常用的“协程副作用”(必学)
1.1 是什么?
LaunchedEffect是Compose中最常用的副作用API,核心作用是:在Composable的作用域内,启动一个协程,并且让协程的生命周期和Composable完全绑定。
简单说:当Composable进入组合时,LaunchedEffect会启动内部的协程;当Composable退出组合时,协程会被自动取消;当LaunchedEffect的“key”发生变化时,旧协程会被取消,新协程会重新启动。
它的核心优势是:无需手动管理协程的生命周期,避免协程泄漏(比如Composable销毁了,协程还在执行)。
1.2 基本用法(语法)
LaunchedEffect(keys = [key1, key2, ...]) {
// 这里可以写挂起函数(suspend),比如网络请求、delay、Flow收集等
// 协程会在Composable进入组合时启动,退出时自动取消
}
关键参数说明(初学者重点关注):
- keys(可变参数):“触发重启”的关键。当keys中的任意一个值发生变化时,LaunchedEffect会取消当前正在运行的协程,重新启动一个新的协程;
- 协程块:里面可以执行任何挂起函数(suspend),这是LaunchedEffect和其他副作用API的核心区别(专门用于协程相关的副作用)。
1.3 3个常用场景(含完整示例)
场景1:页面首次进入时,发起一次网络请求(最常用场景)
@Composable
fun UserProfileScreen(userId: String, viewModel: UserViewModel) {
// 状态:用于存储请求到的用户数据
val userState by viewModel.userData.collectAsState(initial = null)
val loadingState by viewModel.loading.collectAsState(initial = false)
val errorState by viewModel.error.collectAsState(initial = null)
// 副作用:页面进入时,根据userId请求用户数据
// keys = [userId]:当userId变化时,取消旧请求,发起新请求
LaunchedEffect(key1 = userId) {
// 启动协程,执行挂起函数(viewModel.fetchUser是挂起函数)
viewModel.fetchUser(userId)
}
// 根据状态渲染UI(无副作用,纯渲染)
when {
loadingState -> CircularProgressIndicator() // 加载中
errorState != null -> Text(text = "请求失败:${errorState}") // 错误提示
userState != null -> {
// 渲染用户信息
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(text = "用户名:${userState?.name}", fontSize = 18.sp)
Text(text = "手机号:${userState?.phone}")
Text(text = "邮箱:${userState?.email}")
}
}
}
}
// ViewModel中的挂起函数(示例)
class UserViewModel : ViewModel() {
private val _userData = MutableStateFlow<User?>(null)
val userData: StateFlow<User?> = _userData.asStateFlow()
private val _loading = MutableStateFlow(false)
val loading: StateFlow<Boolean> = _loading.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
// 挂起函数:发起网络请求
suspend fun fetchUser(userId: String) {
_loading.value = true
_error.value = null
try {
// 模拟网络请求(delay是挂起函数)
delay(1500)
val user = apiService.getUserById(userId) // 假设apiService是网络请求工具
_userData.value = user
} catch (e: Exception) {
_error.value = e.message
} finally {
_loading.value = false
}
}
}
示例说明:
- 当UserProfileScreen首次进入组合时,LaunchedEffect会启动协程,调用viewModel.fetchUser(userId)发起网络请求;
- 如果userId发生变化(比如从“123”变成“456”),LaunchedEffect会取消之前的协程(如果请求还在进行),重新启动协程,请求新的用户数据;
- 当页面跳转(UserProfileScreen退出组合),协程会被自动取消,避免请求完成后更新已销毁的UI,也避免内存泄漏。
场景2:一次性副作用(只执行一次,不随重组重启)
如果我们希望副作用只在Composable首次进入组合时执行一次,后续重组不重启,只需要将keys设为Unit(Unit是一个常量,永远不会变化)。
@Composable
fun SplashScreen(onNavigateToHome: () -> Unit) {
// 副作用:只执行一次,延迟2秒后跳转到首页
LaunchedEffect(key1 = Unit) {
delay(2000) // 模拟启动页加载(挂起函数)
onNavigateToHome() // 跳转首页
}
// 渲染启动页UI
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = "欢迎使用", fontSize = 24.sp, fontWeight = FontWeight.Bold)
}
}
示例说明:keys = Unit,意味着LaunchedEffect只会在SplashScreen首次进入组合时启动一次协程,后续无论SplashScreen是否重组(比如父组件重组),协程都不会重新启动,完美实现“一次性启动页延迟跳转”。
场景3:监听Flow并更新UI(协程收集Flow)
在Compose中,收集Flow也是常见的副作用,通常用LaunchedEffect来启动协程收集,避免手动管理协程。
@Composable
fun MessageScreen() {
// 状态:存储消息列表
val messages = remember { mutableStateListOf<Message>() }
// 模拟一个Flow(比如从数据库或WebSocket获取消息)
val messageFlow = remember { flow {
repeat(5) {
delay(1000)
emit(Message(content = "消息${it+1}", time = System.currentTimeMillis()))
}
} }
// 副作用:收集Flow,将消息添加到列表中
LaunchedEffect(key1 = messageFlow) {
messageFlow.collect { message ->
messages.add(message)
}
}
// 渲染消息列表
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(messages) { message ->
Text(
text = "${message.content}(${message.time})",
modifier = Modifier.padding(16.dp)
)
}
}
}
// 消息数据类
data class Message(val content: String, val time: Long)
示例说明:LaunchedEffect启动协程,收集messageFlow,每收到一条消息就添加到messages列表中,UI会自动重组更新;当MessageScreen退出组合时,协程会被取消,Flow收集也会停止,避免内存泄漏。
1.4 初学者避坑点
- 坑1:在LaunchedEffect中修改非状态变量 → 导致UI不更新。比如在协程中直接修改普通变量,而不是MutableState或StateFlow,UI不会感知到变化;
- 坑2:keys参数传错 → 比如不传keys(默认是emptyArray),会导致每次重组都重启协程;或者传了一个频繁变化的参数(比如Int类型的count),导致协程频繁取消和重启;
- 坑3:在LaunchedEffect中执行非挂起函数 → 虽然可以,但没必要。LaunchedEffect的核心价值是管理协程和挂起函数,非挂起函数(比如普通日志打印)可以用其他更合适的副作用API。
2. DisposableEffect:需要“手动清理”的副作用(必学)
2.1 是什么?
DisposableEffect和LaunchedEffect类似,也是用于执行副作用,但它的核心场景是:副作用操作需要“手动清理” ——比如注册监听器、绑定系统服务、订阅广播等,这些操作如果不手动清理,会导致内存泄漏。
DisposableEffect的特点:
- 进入组合时,执行副作用操作;
- keys变化时,先执行“清理操作”,再执行新的副作用操作;
- 退出组合时,必须执行“清理操作”(编译器强制要求,不写会报错)。
注意:DisposableEffect不能执行挂起函数(这是和LaunchedEffect的核心区别),如果需要执行挂起函数,需要配合rememberCoroutineScope使用。
2.2 基本用法(语法)
DisposableEffect(keys = [key1, key2, ...]) {
// 1. 执行副作用操作(比如注册监听器、绑定服务)
val listener = MyListener()
systemService.registerListener(listener)
// 2. 必须返回一个清理函数(onDispose),编译器强制要求
onDispose {
// 清理操作:比如注销监听器、解绑服务
systemService.unregisterListener(listener)
}
}
关键说明:
- keys参数:和LaunchedEffect一样,keys变化时,会先执行onDispose清理,再重新执行副作用;
- onDispose函数:必须返回,这是DisposableEffect的核心,用于清理副作用产生的资源,避免内存泄漏;
- 不能写挂起函数:如果需要在DisposableEffect中执行挂起操作,需要先通过rememberCoroutineScope获取协程作用域,再在作用域中启动协程。
2.3 2个常用场景(含完整示例)
场景1:注册/注销位置监听器(最典型场景)
@Composable
fun LocationTrackingScreen(userId: String) {
val context = LocalContext.current
// 状态:存储当前位置
val currentLocation = remember { mutableStateOf<Location?>(null) }
// 获取系统位置服务
val locationManager = remember {
context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
}
// 副作用:注册位置监听器,退出时注销
DisposableEffect(key1 = userId) {
// 1. 创建监听器
val locationListener = object : LocationListener {
override fun onLocationChanged(location: Location) {
// 位置更新,修改状态(触发UI重组)
currentLocation.value = location
// 可选:将位置上传到服务器(非挂起操作)
uploadLocation(userId, location)
}
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}
override fun onProviderEnabled(provider: String) {}
override fun onProviderDisabled(provider: String) {}
}
// 2. 注册监听器(副作用操作)
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER,
5000L, // 5秒更新一次
10f, // 移动10米更新一次
locationListener
)
Log.d("Location", "为用户$userId 注册位置监听器")
}
// 3. 清理操作:注销监听器(必须写)
onDispose {
locationManager.removeUpdates(locationListener)
Log.d("Location", "为用户$userId 注销位置监听器")
}
}
// 渲染UI
Column(modifier = Modifier.padding(16.dp)) {
Text(text = "用户$userId 的位置跟踪", fontSize = 18.sp, fontWeight = FontWeight.Bold)
if (currentLocation.value != null) {
Text(text = "当前经度:${currentLocation.value?.longitude}")
Text(text = "当前纬度:${currentLocation.value?.latitude}")
} else {
Text(text = "正在获取位置...")
}
}
}
// 模拟位置上传(非挂起函数)
fun uploadLocation(userId: String, location: Location) {
// 这里可以写网络请求(非挂起,比如用OkHttp同步请求,或另起协程)
Log.d("Location", "上传用户$userId 位置:${location.latitude}, ${location.longitude}")
}
示例说明:
- 当LocationTrackingScreen进入组合时,DisposableEffect会注册位置监听器,开始获取位置;
- 当userId变化时,会先执行onDispose注销之前的监听器,再为新的userId注册新的监听器;
- 当页面退出组合时,onDispose会自动执行,注销监听器,避免内存泄漏(如果不注销,locationManager会一直持有监听器引用,导致Composable无法被回收)。
场景2:监听Activity生命周期(结合LocalLifecycleOwner)
在Compose中,我们可以通过DisposableEffect监听传统Activity的生命周期(比如onStart、onStop),需要配合LocalLifecycleOwner使用。
@Composable
fun LifecycleListenerScreen() {
// 获取当前的LifecycleOwner(通常是宿主Activity/Fragment)
val lifecycleOwner = LocalLifecycleOwner.current
// 副作用:监听生命周期,退出时移除监听器
DisposableEffect(key1 = lifecycleOwner) {
// 创建生命周期监听器
val lifecycleObserver = object : LifecycleEventObserver {
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
// 根据生命周期事件做操作
when (event) {
Lifecycle.Event.ON_START -> Log.d("Lifecycle", "Activity 进入前台")
Lifecycle.Event.ON_STOP -> Log.d("Lifecycle", "Activity 进入后台")
Lifecycle.Event.ON_DESTROY -> Log.d("Lifecycle", "Activity 销毁")
else -> {}
}
}
}
// 注册监听器
lifecycleOwner.lifecycle.addObserver(lifecycleObserver)
// 清理操作:移除监听器
onDispose {
lifecycleOwner.lifecycle.removeObserver(lifecycleObserver)
Log.d("Lifecycle", "移除生命周期监听器")
}
}
// 渲染UI
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = "监听Activity生命周期", fontSize = 20.sp)
}
}
示例说明:
- LocalLifecycleOwner.current获取当前Composable的宿主生命周期所有者(比如Activity);
- DisposableEffect注册生命周期监听器,监听Activity的ON_START、ON_STOP等事件;
- 退出组合时,onDispose移除监听器,避免内存泄漏。
2.4 初学者避坑点
- 坑1:忘记写onDispose → 编译器会直接报错,这是DisposableEffect的强制要求,即使没有清理操作,也要写空的onDispose {};
- 坑2:在DisposableEffect中执行挂起函数 → 会报错,因为DisposableEffect的代码块是普通函数,不是挂起函数;如果需要执行挂起操作,需要配合rememberCoroutineScope;
- 坑3:keys参数传错 → 比如传了一个频繁变化的参数,导致频繁注册/注销,影响性能(比如传count,每次count变化都要重新注册监听器)。
3. SideEffect:每次重组后执行的副作用(简单但常用)
3.1 是什么?
SideEffect是最“简单”的副作用API,核心作用是:在每次Composable重组成功后,执行一次副作用操作。
它的特点:
- 没有keys参数,每次重组成功(UI更新完成)后都会执行;
- 不需要手动清理(适合不需要清理的简单副作用);
- 不能执行挂起函数;
- 执行时机:重组完成后,UI已经更新到屏幕上。
适用场景:简单的日志打印、埋点统计、将Compose状态同步到非Compose代码(比如传统View、单例类)。
3.2 基本用法(语法)
SideEffect {
// 执行副作用操作(非挂起、无需清理)
// 每次重组成功后都会执行
Log.d("SideEffect", "Composable重组完成")
analytics.trackScreenView("HomeScreen") // 埋点统计
}
3.3 常用场景(含完整示例)
场景:埋点统计(每次重组都更新当前页面的埋点信息)
// 模拟一个非Compose的埋点工具类(传统代码)
object AnalyticsTracker {
// 记录当前页面的步骤(比如注册流程的步骤)
fun trackRegistrationStep(step: Int, isCompleted: Boolean) {
Log.d("Analytics", "注册步骤:$step,是否完成:$isCompleted")
// 实际开发中,这里会调用埋点SDK(比如友盟、百度统计)
}
}
@Composable
fun RegistrationScreen() {
// 状态:当前注册步骤(1-3)
var currentStep by remember { mutableStateOf(1) }
// 状态:当前步骤是否完成
var isStepCompleted by remember { mutableStateOf(false) }
// 副作用:每次重组成功后,上报当前步骤信息(埋点)
SideEffect {
AnalyticsTracker.trackRegistrationStep(currentStep, isStepCompleted)
}
// 渲染注册步骤UI
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "注册步骤 $currentStep", fontSize = 20.sp, fontWeight = FontWeight.Bold)
Button(onClick = {
// 模拟完成当前步骤
isStepCompleted = true
}) {
Text(text = "完成当前步骤")
}
Button(
onClick = {
if (currentStep < 3) {
currentStep++
isStepCompleted = false
}
},
enabled = isStepCompleted
) {
Text(text = "进入下一步")
}
}
}
示例说明:
- 当currentStep或isStepCompleted变化时,Composable会重组;
- 重组成功后,SideEffect会自动执行,调用AnalyticsTracker.trackRegistrationStep上报当前步骤信息;
- 不需要清理操作,因为埋点操作是“一次性”的,不会产生需要回收的资源。
3.4 初学者避坑点
- 坑1:在SideEffect中执行需要清理的操作 → 比如注册监听器,会导致内存泄漏(SideEffect没有onDispose);
- 坑2:在SideEffect中执行耗时操作 → 会阻塞UI线程,因为SideEffect在主线程执行,且每次重组都会执行;
- 坑3:误以为SideEffect只执行一次 → 其实每次重组成功都会执行,比如点击按钮修改状态,导致重组,SideEffect就会再执行一次。
4. rememberCoroutineScope:手动控制的协程作用域(补充API)
4.1 是什么?
rememberCoroutineScope不是“副作用API”,但它和副作用密切相关,核心作用是:获取一个和Composable生命周期绑定的协程作用域(CoroutineScope) ,用于手动启动协程(比如用户点击事件触发的协程)。
它和LaunchedEffect的区别(重点,初学者容易混淆):
| 特性 | LaunchedEffect | rememberCoroutineScope |
|---|---|---|
| 启动时机 | Composable进入组合时,自动启动协程 | 手动调用scope.launch,按需启动协程(比如点击事件) |
| 协程管理 | 自动管理生命周期(进入启动、退出取消、key变化重启) | 手动管理协程(需手动cancel,但Composable退出时会自动取消作用域) |
| 适用场景 | 自动执行的协程(页面进入请求数据、Flow收集) | 用户交互触发的协程(点击按钮发起请求、延迟提示) |
4.2 基本用法(语法)
// 获取协程作用域(和Composable生命周期绑定)
val scope = rememberCoroutineScope()
// 手动启动协程(比如点击事件中)
Button(onClick = {
scope.launch {
// 执行挂起函数
delay(1000)
showToast(context, "点击成功")
}
}) {
Text(text = "点击我")
}
4.3 常用场景(含完整示例)
场景:用户点击按钮,延迟1秒显示Toast(手动触发协程)
@Composable
fun DelayedToastButton() {
val context = LocalContext.current
// 获取协程作用域(和当前Composable生命周期绑定)
val scope = rememberCoroutineScope()
Button(
onClick = {
// 手动启动协程(用户点击时触发)
scope.launch {
// 执行挂起函数(延迟1秒)
delay(1000)
// 显示Toast(非挂起操作)
Toast.makeText(context, "点击成功!", Toast.LENGTH_SHORT).show()
}
},
modifier = Modifier.padding(16.dp)
) {
Text(text = "点击我,1秒后显示Toast")
}
}
示例说明:
- rememberCoroutineScope获取的scope,会和当前Composable的生命周期绑定;
- 用户点击按钮时,手动调用scope.launch启动协程,延迟1秒后显示Toast;
- 如果Composable在协程执行过程中退出组合(比如页面跳转),scope会被自动取消,协程也会停止,避免Toast在页面销毁后还显示。
4.4 初学者避坑点
- 坑1:用GlobalScope替代rememberCoroutineScope → GlobalScope的协程不与任何Composable绑定,Composable退出后协程还会继续执行,容易导致内存泄漏;
- 坑2:忘记手动取消协程 → 虽然scope会在Composable退出时自动取消,但如果需要在某个条件下手动取消(比如用户再次点击),需要保存协程的Job,调用job.cancel();
- 坑3:在rememberCoroutineScope中启动的协程,没有和keys绑定 → 如果需要根据状态变化取消协程,需要手动管理(比如用LaunchedEffect更合适)。
5. rememberUpdatedState:获取最新状态的“保鲜剂”(进阶API)
5.1 是什么?
rememberUpdatedState是一个“辅助API”,核心作用是:在副作用中,获取状态的“最新值”,即使副作用没有被重启。
很多开发者刚开始用的时候会踩坑:比如在一个长期运行的副作用(像循环协程)里直接使用父传参,会发现父组件修改参数后,副作用里拿到的还是初始值,这就是闭包导致的。而rememberUpdatedState的作用,就是让副作用在不重启的前提下,始终能拿到父传参的最新值,不用频繁重启副作用影响性能。
这里要明确一点:组件内部自己定义的状态(比如var a by remember{...}),重组后本身就能拿到最新值,完全不用rememberUpdatedState;它的核心场景,就是处理父组件传递过来的参数,解决闭包带来的状态滞后问题。
5.2 基本用法(语法)
// 父组件:传递可变参数给子组件
@Composable
fun ParentScreen() {
// 父组件可修改的状态,传递给子组件
var parentParam by remember { mutableStateOf("初始值") }
ChildScreen(externalParam = parentParam)
}
// 子组件:使用rememberUpdatedState获取父传参最新值
@Composable
fun ChildScreen(externalParam: String) {
// 用rememberUpdatedState包裹父传参,后续取最新值用. value
val updatedParam = rememberUpdatedState(externalParam)
// 副作用只启动一次(keys=Unit),但要实时拿到父传参最新值
LaunchedEffect(Unit) {
while (true) {
delay(1000)
// 必须用updatedParam.value,才能拿到父组件修改后的最新值
Log.d("UpdatedState", "父传参最新值:${updatedParam.value}")
}
}
}
5.3 常用场景(含完整示例)
最常见的场景就是:子组件接收父组件的参数,启动一个长期运行的协程(比如循环提示、定时刷新),不想让协程随父传参变化而频繁重启,但又要实时响应父传参的修改——这时候rememberUpdatedState就刚好派上用场。
// 父组件:可修改提示文本,传递给子组件
@Composable
fun ParentPromptScreen() {
// 父组件的提示文本,可通过按钮修改
var parentPrompt by remember { mutableStateOf("初始提示:请完成操作") }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
// 两个按钮,用于修改父组件的提示文本
Button(
onClick = { parentPrompt = "更新提示:操作已完成一半" },
modifier = Modifier.padding(bottom = 16.dp)
) {
Text(text = "修改父组件提示文本")
}
Button(onClick = { parentPrompt = "最终提示:操作已完成" }) {
Text(text = "修改为最终提示")
}
// 子组件接收父传参,显示提示
ChildPromptComponent(externalPrompt = parentPrompt)
}
}
// 子组件:接收父传参,启动长期协程循环显示提示
@Composable
fun ChildPromptComponent(externalPrompt: String) {
// 当前显示的提示文本(内部状态)
val currentPrompt = remember { mutableStateOf("") }
// 关键:用rememberUpdatedState包裹父传参,保鲜最新值
val updatedPrompt = rememberUpdatedState(externalPrompt)
// 启动协程,每2秒更新一次提示(只启动一次,不随父传参重启)
LaunchedEffect(Unit) {
while (isActive) {
delay(2000)
// 这里如果直接用externalPrompt,会一直拿到初始值(闭包陷阱)
// 用updatedParam.value,才能拿到父组件修改后的最新值
currentPrompt.value = updatedPrompt.value
Log.d("ChildPrompt", "当前提示:${updatedPrompt.value}")
}
}
// 渲染提示UI
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.background(Color.LightGray, RoundedCornerShape(8.dp))
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = currentPrompt.value,
fontSize = 18.sp,
color = Color.Black
)
}
}
这个场景很贴近实际开发:子组件的协程只启动一次,避免频繁重启导致的提示中断;同时通过rememberUpdatedState,实时获取父组件修改后的提示文本,既保证了性能,又解决了闭包陷阱。
大家可以自己试一下:如果去掉rememberUpdatedState,直接在协程里用externalPrompt,无论父组件怎么点按钮修改,日志里打印的永远是“初始提示:请完成操作”,这就是闭包的问题——副作用捕获的是参数的初始值,后续参数变化不会同步。
5.4 初学者避坑点
- 坑1:滥用rememberUpdatedState——组件内部状态(比如自己用remember定义的变量)不用加,加了纯属多余,反而增加代码冗余;
- 坑2:忘记加.value——rememberUpdatedState返回的是State对象,必须通过. value才能拿到里面的最新值,直接用变量名会报错;
- 坑3:混淆“获取最新值”和“触发副作用重启”——rememberUpdatedState只负责拿最新值,不会让副作用重启;如果需要副作用随父传参变化重启,还是要把父传参放到LaunchedEffect的keys里;
- 坑4:父传参不变还乱用——如果父组件传递的参数是固定值,不会变化,就不用rememberUpdatedState,直接使用参数本身即可
三、总结:5个副作用API对比(初学者快速查阅)
为了方便大家记忆和快速选择合适的API,我整理了一张对比表,涵盖核心特点、适用场景和关键注意点:
| API名称 | 核心特点 | 适用场景 | 关键注意点 |
|---|---|---|---|
| LaunchedEffect | 自动启动协程,绑定Composable生命周期,支持keys重启 | 网络请求、Flow收集、动画、一次性延迟操作 | 可执行挂起函数,keys决定是否重启 |
| DisposableEffect | 需手动清理,支持keys重启,编译器强制要求onDispose | 注册/注销监听器、绑定/解绑系统服务 | 不能执行挂起函数,必须写onDispose |
| SideEffect | 每次重组成功后执行,无需清理,无keys | 埋点统计、日志打印、同步状态到非Compose代码 | 不能执行挂起函数,每次重组都执行 |
| rememberCoroutineScope | 获取协程作用域,手动启动协程,绑定生命周期 | 用户交互触发的协程(点击按钮、手势) | 不要用GlobalScope替代,需手动管理协程 |
| rememberUpdatedState | 保鲜状态最新值,不重启副作用 | 长期运行的副作用,需要获取最新状态但不重启 | 必须通过.value获取值,不滥用 |
四、初学者实战建议(必看)
看完上面的解析,可能有初学者会觉得“知识点太多,记不住”,这里给大家3个实战建议,帮助大家快速上手:
- 先掌握核心3个API:LaunchedEffect、DisposableEffect、SideEffect,这3个覆盖了80%的开发场景,rememberCoroutineScope和rememberUpdatedState可以后续再深入;
- 写代码时,先判断“是否是副作用”:如果是网络请求、协程、监听器、日志,就用对应的副作用API,不要直接写在Composable函数体中;
- 多练多踩坑:把上面的示例代码复制到项目中,运行起来,修改参数(比如keys、状态值),观察副作用的执行和清理时机,比单纯看理论更有效。
最后提醒一句:Compose的副作用API,核心都是“让副作用跟随Composable的生命周期”,只要记住这一点,就能避免大部分坑。比如:需要协程 → LaunchedEffect;需要清理 → DisposableEffect;简单打印 → SideEffect;手动触发 → rememberCoroutineScope。
如果大家在使用过程中遇到具体问题(比如协程泄漏、副作用不执行),可以在评论区留言,我会一一解答。
好了,今天的Jetpack Compose副作用解析就到这里,希望这篇超详细的博客能帮助初学者快速掌握副作用的使用,少走弯路。后续我还会分享更多Compose的实战技巧,记得关注哦!