Compose 学习界面架构 - Side Effects

上一章中了解了可组合项的生命周期和因状态变化重组,而附带效应(Side Effects)正是解决 “重组时如何安全执行界面描述之外的操作” 的核心方案,比如网络请求、日志上报、生命周期监听等。

关键术语:效应(Effect)本身是一种可组合函数,它不输出界面,且只在组合完成后产生附带效应,比如 LaunchedEffectDisposableEffect 都是效应。

本章的示例代码是伪代码只是展示使用场景,部分可运行...

官方文档:Compose 官方文档

一、什么是附带效应?

有一个大前提:可组合项理想状态下应该 “无附带效应”。因为可组合项的核心作用是 “描述界面”,而附带效应是 “发生在可组合函数作用域之外的应用状态变化”,比如:

  • 发送网络请求、保存数据到本地;
  • 显示 Snackbar、Toast 等临时提示;
  • 注册 / 取消生命周期监听、日志上报。

这些操作之所以不能直接写在可组合项里,是因为可组合项的重组有 “不可预测性”:

  1. 重组可能频繁触发(比如状态快速变化时);
  2. 重组顺序可能和首次执行不同;
  3. 部分重组可能被 Compose 舍弃(比如还没执行完就触发新重组)。

如果直接在可组合项里写这些操作,会导致重复请求、监听泄漏、状态混乱等问题。因此,Compose 提供了专门的 “效应 API”,让这些操作能在 “感知可组合项生命周期的受控环境” 中执行 —— 简单说就是 “该执行时执行,该取消时取消”

二、7 个核心效应 API

1. LaunchedEffect:在可组合项内运行挂起函数

适用场景

需要在可组合项的生命周期内执行挂起函数(如 delay、网络请求、动画),且希望函数能随可组合项 “进入而启动、退出而取消”。

核心逻辑

  • 当 LaunchedEffect 进入组合时,启动一个协程执行代码块;
  • 当 LaunchedEffect 退出组合时,自动取消协程;
  • 若传入的 “键(key)” 变化,会先取消当前协程,再启动新协程执行代码块(即 “重启效应”)。

示例代码 - 实现 Alpha 脉冲动画

@Composable
fun PulseAnimation() {
    // 可配置的脉冲间隔(状态变化时会触发效应重启)
    var pulseRateMs by remember { mutableStateOf(3000L) }
    // 动画状态:初始透明度1f(不透明)
    val alphaAni = remember { Animatable(1f) }

    // 键为pulseRateMs:当间隔变化时,重启动画协程
    LaunchedEffect(pulseRateMs) {
        // isActive是协程的状态:效应取消时为false,循环终止
        while (isActive) {
            delay(pulseRateMs) // 等待设定间隔
            alphaAni.animateTo(0f) // 透明度降到0(完全透明)
            alphaAni.animateTo(1f) // 透明度恢复到1(不透明)
        }
    }

    // 使用动画透明度绘制文本
    Text(
        text = "脉冲动画",
        modifier = Modifier.size(200.dp).alpha(alpha = alphaAni.value),
        fontSize = 24.sp,
    )
}

2. rememberCoroutineScope:在可组合项外启动协程

适用场景

  • LaunchedEffect 是可组合函数,只能在其他可组合项内使用;如果需要在 “可组合项外” 启动协程(如按钮点击事件、回调函数),就用 rememberCoroutineScope
  • 需要手动控制协程生命周期(如手动取消动画、中断请求)。

核心逻辑

  • rememberCoroutineScope 是可组合函数,返回一个绑定到 “当前组合点” 的 CoroutineScope
  • 当可组合项退出组合时,该作用域会自动取消,所有在该作用域下启动的协程也会被取消。

示例代码 - 点击按钮显示 Snackbar

@Composable
fun MoviesScreen() {
    // 创建与MoviesScreen生命周期绑定的协程作用域
    val scope = rememberCoroutineScope()
    // Snackbar的状态宿主
    val snackbarHostState = remember { SnackbarHostState() }

    Scaffold(
        snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
    ) { contentPadding ->
        Column(Modifier.padding(contentPadding)) {
            Button(onClick = {
                // 在按钮点击事件(可组合项外)启动协程
                scope.launch {
                    snackbarHostState.showSnackbar(
                        message = "已加载最新电影列表",
                        actionLabel = "知道了"
                    )
                }
            }) {
                Text("加载最新网络电影")
            }
        }
    }
}

3. rememberUpdatedState:效应中引用值但不重启

适用场景

  • 当 LaunchedEffect 等效应依赖某个值,但 “该值变化时不希望效应重启”—— 比如长期运行的任务(如倒计时),中途依赖的回调函数更新了,不想让倒计时重新开始。

核心逻辑

  • rememberUpdatedState 会创建一个 State 对象,始终持有该值的 “最新版本”;
  • 效应中引用这个 State 对象的值,而非直接引用原变量,这样即使原变量变化,效应也不会重启,但能拿到最新值。

示例代码 - 倒计时

@Composable
fun LandingScreen(onTimeout: () -> Unit) {
    // rememberUpdatedState可以确保重组LandingScreen时:
    // onTimeout始终包含重组使用的最新值,且不触发效应重启
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    // 键为true:效应只在进入组合时启动一次,不随任何值重启
    LaunchedEffect(true) {
        delay(3000L)       // 倒计时3秒
        currentOnTimeout() // 执行回调
    }

    
    Box(modifier = Modifier.fillMaxSize()) {
        Text(
            text = "rememberUpdatedState 示例",
            modifier = Modifier.align(Alignment.Center),
            fontSize = 28.sp
        )
    }
}


// 在需要的地方使用,3s 后会执行回调内容
LandingScreen() {
    Toast.makeText(context, "这是一条消息", Toast.LENGTH_SHORT).show()
}

不过 LaunchedEffect(true) 官方有个警告,意思大概就是要谨慎使用,因为使用了这个效应就只启动一次,除非可组合项退出再进入才会再次启动,可能会导致状态不一致。

4. DisposableEffect:需要清理的效应

适用场景

  • 当效应需要 “清理操作” 时 —— 比如注册监听后要取消监听、打开资源后要关闭资源,否则会导致内存泄漏。

核心逻辑

  • 行为和 LaunchedEffect 类似:键变化时重启,退出组合时清理;
  • 强制要求在代码块最后添加 onDispose 子句,用于编写清理逻辑(否则 IDE 会报错)。

示例代码 - 监听生命周期事件

@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit, // 页面启动时的分析上报
    onStop: () -> Unit   // 页面停止时的分析上报
) {
    // 用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)
        }
    }

    // 其它的页面内容
    Text(text = "首页内容", fontSize = 24.sp)
}

// 在需要的地方
HomeScreen(
    onStart = {
        Log.d("MainActivity", "onStart 启动")
    },
    onStop = {
        Log.d("MainActivity", "onStop 停止")
    }
)

onDispose 的代码块可以为空,只不过这样用可能会存在某种更适合该使用场景的效应。

5.  SideEffect:将 Compose 状态同步到非 Compose 代码

适用场景

  • 需要将 Compose 管理的状态(如用户信息、主题设置)同步到 “非 Compose 代码”(如第三方 SDK、原生 View、单例类),且确保每次重组成功后都同步。

核心逻辑

  • SideEffect 会在 “每次成功重组后” 执行代码块;
  • 避免直接在可组合项中同步状态(因为重组可能被舍弃,导致同步不及时或重复)。

示例代码 - 将用户类型传递到外部的分析库用于细分用户群体

@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
    // 记住Firebase实例(避免每次重组都创建)
    val analytics = remember { FirebaseAnalytics.getInstance(context) }

    // 每次重组成功后,更新用户属性到Firebase
    SideEffect {
        analytics.setUserProperty("userType", user.userType) // userType是Compose状态
    }

    return analytics
}

6.  produceState:将非 Compose 状态转为 Compose 状态

适用场景

  • 需要将 “非 Compose 管理的状态”(如 Flow、LiveData、RxJava、网络请求结果)转换为 Compose 能识别的 State,以便在界面中使用。

核心逻辑

  • produceState 会启动一个协程,作用域绑定到组合;
  • 初始值由 initialValue 指定, value = 新值 更新状态,相同的值或返回的State有冲突不会触发重组;
  • 键变化时取消当前协程,重启并重新获取状态;
  • 退出组合时取消协程,自动清理资源。

示例代码 - 从网络加载图片(转为 State)

// 从网络加载图片,返回Compose的State<Result<Image>>
@Composable
fun loadNetworkImage(
    url: String,
    imageRepository: ImageRepository = ImageRepository()
): State<Result<Image>> {
    // 初始状态为Loading,键为url和imageRepository(任一变化则重新加载)
    return produceState(
        initialValue = Result.Loading,
        key1 = url,
        key2 = imageRepository
    ) {
        // 协程中执行挂起函数(加载图片)
        val image = imageRepository.loadImage(url)
        // 更新状态:成功则传图片,失败则传Error
        value = if (image != null) {
            Result.Success(image)
        } else {
            Result.Error("图片加载失败")
        }
    }
}

// 结果密封类:表示加载状态
sealed class Result<out T> {
    object Loading : Result<Nothing>()
    data class Success<out T>(val data: T) : Result<T>()
    data class Error(val message: String) : Result<Nothing>()
}

7. derivedStateOf:将一个或多个状态对象转换为其他状态(降低重组频率 - 状态派生优化)

适用场景

  • 当某个状态是由其他状态派生而来,但 “原状态变化频率远高于界面需要的更新频率”—— 比如列表滚动时,firstVisibleItemIndex 每秒变化多次,但界面只需要在 “索引> 0 时更新显示回到顶部按钮即可,不需要频繁观察当前第一项索引是否 > 0了”。

核心逻辑

  • derivedStateOf 创建一个新的 State,只有当 “派生结果变化” 时才触发重组;
  • 类似 Kotlin Flow 的 distinctUntilChanged(),避免不必要的重组(比如索引从 1→2→3,派生结果都是 “显示按钮”,不会触发重组)。

示例代码 - 滚动列表显示回到顶部按钮

@Composable
fun MessageList(names: List<String>) {
    Box {
        // 记住LazyColumn的滚动状态
        val listState = rememberLazyListState()

        // 列表:显示所有信息
        LazyColumn(state = listState) {
            items(names) { name ->
                Text(text = name, modifier = Modifier.padding(8.dp))
            }
        }

        // 派生状态:只有当第一个可见项索引>0时,才显示回到顶部按钮
        val showTopButton by remember {
            derivedStateOf { listState.firstVisibleItemIndex > 0 }
        }

        // 按钮:显示/隐藏由showTopButton控制
        AnimatedVisibility(visible = showTopButton) {
            Button(
                onClick = {
                    // 点击回到顶部
                    CoroutineScope(Dispatchers.Main).launch {
                        listState.animateScrollToItem(0)
                    }
                },
                modifier = Modifier
                    .align(Alignment.BottomEnd)
                    .padding(16.dp)
            ) {
                Text("回到顶部")
            }
        }
    }
}

这里有个警告:不要在 “派生结果变化频率和原状态一致” 时使用 derivedStateOf ,例如,拼接用户名的时候 useName = firstName + SecondName,如果 firstName 变化派生结果(useName )必然变化,这时候使用 derivedStateOf 只会增加额外开销。

// 错误用法:没必要用derivedStateOf
var firstName  by remember { mutableStateOf("") }
var secondName by remember { mutableStateOf("") }
val userName   by remember { derivedStateOf { "$firstName $secondName" } }

// 正确用法:直接拼接即可
val userName = "$firstName $secondName"

三、重启效应

很多效应(如 LaunchedEffect、DisposableEffect、produceState)都支持 “键(key)” 参数,键的变化会触发 “重启效应”—— 即先取消当前效应,再启动新效应。但是用错可能会导致 bug 或性能问题,必须掌握以下规则:

1. 什么时候需要重启效应?

当效应的逻辑 “依赖某个值”,且 “该值变化时必须重新执行效应”—— 比如 Alpha 脉冲动画中脉冲的时间间隔 pulseRateMs 变化时,LaunchedEffect必须重启动画协程才能用新间隔。

当效应依赖的值变化,但不应该导致效应重启时,则应将该值用 rememberUpdatedState 包裹,效应中引用 State 对象的值,而非直接引用原变量(如 LandingScreen 中的 onTimeout 回调)。

2. 键的选择原则

  • 效应代码块中使用的 “可变变量”(如 urllifecycleOwner)必须作为键传入;
  • 效应代码块中使用的 “不可变变量”(如通过 remember 记住且无键的对象)不需要作为键;
  • 可以用常量(如 true)作为键,让效应 “只启动一次,跟随可组合项生命周期”(谨慎使用)。

3. 常见错误案例

如果忘记将依赖的变量作为键,会导致效应不重启,使用旧值。例如,从网络加载图片的 loadNetworkImage 中的键 url:

// 错误:loadNetworkImage的键没有包含url,url变化时不会重新加载图片
produceState(initialValue = Result.Loading, key1 = imageRepository) {
    val image = imageRepository.loadImage(url) // url变化但效应不重启,用旧url
    // ...
}

// 正确:将url作为键,url变化时重启效应,加载新图片
produceState(initialValue = Result.Loading, key1 = url, key2 = imageRepository) {
    // ...
}

四、总结

1. Side Effects - 附带效应

  • 定义:发生在可组合函数作用域之外的应用状态变化,需通过效应 API 执行;
  • 必要性:解决可组合项重组的不可预测性,避免重复执行、资源泄漏;
  • 本质:效应是感知生命周期的可组合函数,不输出界面,只管理副作用。

2. 7 个效应 API 对比

API 适用场景特点
LaunchedEffect可组合项内执行挂起函数协程随组合生命周期,键变化重启
rememberCoroutineScope可组合项外启动协程,手动控制生命周期作用域绑定组合,退出自动取消
rememberUpdatedState效应中引用值但不重启持有最新值,避免效应频繁重启
DisposableEffect需要清理的操作(注册 / 取消监听)强制 onDispose 清理,键变化重启
SideEffectCompose 状态同步到非 Compose 代码每次重组成功后执行,确保同步及时
produceState非 Compose 状态转为 Compose State协程获取状态,自动转换为 State
derivedStateOf降低派生状态的重组频率只在结果变化时触发重组,优化性能

3. 重启效应关键规则

  • 键需包含效应依赖的所有可变变量;
  • 不想重启则用 rememberUpdatedState 包裹变量;
  • 常量键(如 true)使效应跟随组合生命周期,谨慎使用。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值