Android Compose中的附带效应

Jetpack Compose 附带效应

一、先搞懂:什么是"附带效应"?

在 Jetpack Compose 里,可组合项(Composable) 的核心工作是"描述界面长什么样",理想情况下它应该是"无附带效应"的------也就是说,可组合项里的代码只负责根据状态画 UI,不应该偷偷修改界面之外的东西。

附带效应(Side Effect) 就是"超出可组合项作用域的状态变化",比如:

  • 弹一个提示框(Snackbar/Toast)
  • 发送网络请求
  • 记录分析数据
  • 订阅生命周期事件

为什么不能直接在可组合项里写这些操作?因为 Compose 的重组(Recomposition) 有3个"不可预测"的特性:

  1. 重组时机不确定(状态变了就可能触发)
  2. 重组顺序可能乱(可组合项执行顺序不固定)
  3. 重组可能被舍弃(计算到一半发现不用更了,直接丢了)

如果直接在可组合项里写附带效应,可能导致重复执行(比如弹10次提示)、执行时机错(界面还没画好就发请求)。所以 Compose 提供了专门的 Effect API,让附带效应在"受控环境"里执行,跟可组合项的生命周期绑定。

为什么 重组可能被舍弃 ?

重组被舍弃是因为在计算过程中,可组合项的 "计算目标" 突然失效了,继续算下去只会浪费资源,所以 Compose 会直接中断并丢弃已有的计算结果。

  • 新状态让 "旧计算" 过时 : 可组合项的重组由 "状态变化" 触发,但状态变化可能是连续的(比如快速点击按钮,每秒触发多次状态更新)。
    • 例子:一个按钮点击后会让 count 从 1→2→3→4,每次变化都会触发重组。
    • 过程:当 count=2 触发的重组还在计算时,count=3 的新状态已经到来,此时 count=2 对应的重组结果已经过时 ------ 就算算完,UI 也会立刻被 count=3 的结果覆盖。
    • 结论:Compose 会直接舍弃 count=2 的重组计算,专心处理 count=3 的新重组,避免 CPU 做无用功。
  • 父组件决定 "不显示" 当前可组合项 : 可组合项的显示 / 隐藏由父组件的状态控制,若父组件在子组件重组过程中,突然决定 "不渲染这个子组件",子组件的重组就失去了意义。
    • 例子:父组件有个 isShowChild 状态,控制子组件 Child() 是否显示;当 isShowChild 从 true 变 false 时,子组件 Child() 刚好正在执行重组计算。
    • 过程:子组件还在计算 "自己该画成什么样",但父组件已经确定 "不会把它画出来",此时子组件的计算结果毫无用处。
    • 结论:Compose 会立刻中断子组件的重组,丢弃计算结果,避免浪费资源在 "永远不会显示" 的 UI 上。

二、逐个吃透:8个核心 Effect API

每个 API 都解决特定场景的问题,先记"用途",再看"怎么用",最后懂"原理"。

1. LaunchedEffect:在可组合项里跑协程(挂起函数)

用途

想在可组合项的生命周期内执行挂起函数 (比如 delay、网络请求、动画),且协程会跟着可组合项"生死"------可组合项进入组合(显示)时启动协程,退出组合(消失)时取消协程。

关键逻辑
  • LaunchedEffect键(Key) 变化时,会先取消当前协程,再启动新协程
  • 只能在可组合项内部使用(因为它本身是可组合函数)
通俗例子:动画脉冲效果

比如一个控件想每隔3秒"闪一下"(透明度从1→0→1),用 delay(挂起函数)控制间隔:

kotlin 复制代码
// 控制闪烁频率(3秒一次,可配置)
var pulseRateMs by remember { mutableLongStateOf(3000L) }
// 控制透明度的动画状态
val alpha = remember { Animatable(1f) }

// 启动协程,键是pulseRateMs(频率变了就重启协程)
LaunchedEffect(pulseRateMs) {
    // 只要协程活跃,就循环闪烁
    while (isActive) {
        delay(pulseRateMs) // 等3秒
        alpha.animateTo(0f) // 变透明
        alpha.animateTo(1f) // 变不透明
    }
}

简单说:这个协程只在控件显示时跑,控件消失就停;如果想让闪烁变快(改 pulseRateMs),旧协程会停,新协程按新频率跑。

2. rememberCoroutineScope:在可组合项外启动"生命周期绑定"的协程

用途

LaunchedEffect 只能在可组合项里用,但如果想在可组合项之外(比如按钮点击事件、回调里)启动协程,又想让协程跟着可组合项"死"(控件消失就取消协程),就用这个 API。

关键逻辑
  • 它是个可组合函数,返回一个 CoroutineScope(协程作用域)
  • 这个作用域跟调用它的"组合点"绑定:可组合项退出组合 → 作用域取消 → 里面的协程全停
通俗例子:点击按钮弹 Snackbar
kotlin 复制代码
@Composable
fun MoviesScreen(snackbarHostState: SnackbarHostState) {
    // 获取跟 MoviesScreen 生命周期绑定的协程作用域
    val scope = rememberCoroutineScope()

    Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) {
        Button(onClick = {
            // 点击事件里启动协程(不在可组合项直接写,在事件里)
            scope.launch {
                snackbarHostState.showSnackbar("点击成功!")
            }
        }) {
            Text("点我弹提示")
        }
    }
}

简单说:如果 MoviesScreen 消失了,就算 Snackbar 还没弹完,协程也会被取消,不会出现"界面没了还弹提示"的情况。

3. rememberUpdatedState:效应里引用值,却不想值变就重启

用途

有些效应(比如 LaunchedEffect)会因为"键变化"重启,但如果效应里有个值(比如回调)可能变,可你不想值一变就重启效应(比如重启一个10秒的延迟),就用 rememberUpdatedState 把这个值"包起来"。

关键逻辑
  • 它会创建一个 State 对象,始终持有最新的值
  • 效应里引用这个 State 对象,就算原始值变了,效应也不会重启
通俗例子:启动页延迟跳转

比如启动页(LandingScreen)要等3秒后调用 onTimeout 跳转,但如果 onTimeout 回调变了(比如业务逻辑改了跳转目标),3秒的延迟不能重新算:

kotlin 复制代码
@Composable
fun LandingScreen(onTimeout: () -> Unit) {
    // 用 rememberUpdatedState 包着 onTimeout,确保拿到最新值
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    // 键用 true(常量),让效应只跟 LandingScreen 生命周期绑定(只启动一次)
    LaunchedEffect(true) {
        delay(3000) // 等3秒,就算 onTimeout 变了,这里也不重新等
        currentOnTimeout() // 调用最新的 onTimeout
    }

    // 启动页的 UI(比如logo)
}

简单说:3秒的延迟只走一次,就算跳转逻辑变了,也不用重新等3秒,直接用最新的跳转逻辑。

4. DisposableEffect:需要"清理"的附带效应

用途

有些附带效应需要"收尾工作"(比如注册了监听,必须取消注册;打开了资源,必须关闭),否则会内存泄漏。DisposableEffect 强制你写清理逻辑,不写会报 IDE 错误。

关键逻辑
  • 必须在代码块最后写 onDispose 子句(清理逻辑放这)
  • 当"键变化"或"可组合项退出组合"时,会先执行 onDispose,再重启效应(如果键变了)
通俗例子:监听生命周期事件

比如 HomeScreen 要在生命周期走到 ON_START 时发分析事件,ON_STOP 时发另一个,必须在退出组合时移除观察者:

kotlin 复制代码
@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit, // ON_START 时执行
    onStop: () -> Unit   // ON_STOP 时执行
) {
    // 用 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)
        }
    }

    // HomeScreen 的 UI
}

简单说:注册了观察者,就必须在不用的时候删掉,DisposableEffect 强制你做这件事,避免内存泄漏。

5. SideEffect:把 Compose 状态"同步"给非 Compose 代码

用途

如果要把 Compose 管理的状态(比如 State<User>)传给非 Compose 代码(比如第三方分析库、原生 View 的管理类),且要保证"每次重组成功后才同步",就用 SideEffect

关键逻辑
  • 每次可组合项成功重组 后,都会执行 SideEffect 里的代码
  • 不能直接在可组合项里写同步逻辑(因为重组可能被舍弃,导致同步错)
通俗例子:给 Firebase 分析设置用户属性
kotlin 复制代码
@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
    // 用 remember 保留 FirebaseAnalytics 实例(不重复创建)
    val analytics: FirebaseAnalytics = remember { FirebaseAnalytics() }

    // 每次重组成功后,把最新的 userType 同步给 analytics
    SideEffect {
        analytics.setUserProperty("userType", user.userType)
    }

    return analytics
}

简单说:只要 user 的 userType 变了,重组成功后就会自动更新到分析库,不会因为重组被舍弃导致同步失败。

6. produceState:把"非 Compose 状态"转成"Compose 状态"

用途

如果有非 Compose 管理的状态(比如网络请求结果、Flow/LiveData/RxJava 的数据、原生回调里的状态),想把它转成 Compose 能识别的 State (这样状态变了 Compose 会自动重组),就用 produceState

关键逻辑
  • 启动一个协程,协程里可以获取非 Compose 状态,然后赋值给 valuevalue 就是返回的 State)
  • 协程跟可组合项生命周期绑定:退出组合 → 协程取消
  • 返回的 State 会"去重":赋值相同的值不会触发重组
通俗例子:网络加载图片
kotlin 复制代码
@Composable
fun loadNetworkImage(
    url: String,
    imageRepository: ImageRepository = ImageRepository()
): State<Result<Image>> {
    // 初始值是 Result.Loading,键是 url 和 imageRepository(任一变了就重启加载)
    return produceState<Result<Image>>(initialValue = Result.Loading, url, imageRepository) {
        // 协程里调用仓库加载图片(可挂起)
        val image = imageRepository.load(url)

        // 更新 State 的值:加载失败给 Error,成功给 Success
        value = if (image == null) Result.Error else Result.Success(image)
    }
}

简单说:调用 loadNetworkImage 会得到一个 State,Compse 能监听它的变化------加载中显示loading,成功显示图片,失败显示错误,不用手动处理生命周期。

7. derivedStateOf:减少"不必要的重组"

用途

有些状态变化频率很高(比如列表滚动位置 firstVisibleItemIndex,滚动时每秒变几十次),但你只关心"状态是否满足某个条件"(比如 index > 0),这时候用 derivedStateOf 把高频变化的状态转成"低频触发重组"的 State。

关键逻辑
  • 创建一个新的 State,只有当"推导结果变化"时才触发重组(类似 Flow 的 distinctUntilChanged
  • 别乱用:如果推导结果的变化频率和原始状态一致,用它反而增加开销
正确例子:列表滚动时显示"回到顶部"按钮
kotlin 复制代码
@Composable
fun MessageList(messages: List<Message>) {
    Box {
        val listState = rememberLazyListState() // 列表状态,滚动时 index 高频变化

        LazyColumn(state = listState) { /* 渲染消息列表 */ }

        // 用 derivedStateOf:只有 index>0 从 false 变 true,或 true 变 false 时才重组
        val showButton by remember {
            derivedStateOf { listState.firstVisibleItemIndex > 0 }
        }

        // 按钮只在 showButton 为 true 时显示,避免滚动时频繁重组
        AnimatedVisibility(visible = showButton) {
            ScrollToTopButton()
        }
    }
}
错误例子:组合名字(没必要用)
kotlin 复制代码
// 错误用法!别这么写!
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }
val fullNameBad by remember { derivedStateOf { "$firstName $lastName" } } // 多余

// 正确用法:直接组合,变化频率一致
val fullNameCorrect = "$firstName $lastName"

简单说:只有"原始状态变10次,推导结果才变1次"的场景,才用 derivedStateOf

8. snapshotFlow:把"Compose State"转成"Kotlin Flow"

用途

如果想利用 Flow 的强大功能(比如 mapfilterdebounce)处理 Compose State,就用 snapshotFlow 把 State 转成 Flow。

关键逻辑
  • 它是冷 Flow:只有被收集时才会执行代码块
  • 当 State 变化时,只发出"和上一次不同"的值(类似 distinctUntilChanged
通俗例子:滚动过第一个 item 时发分析事件
kotlin 复制代码
@Composable
fun AnalyticsList() {
    val listState = rememberLazyListState()

    LazyColumn(state = listState) { /* 渲染列表 */ }

    // 用 LaunchedEffect 启动协程收集 Flow
    LaunchedEffect(listState) {
        // 把 firstVisibleItemIndex 转成 Flow
        snapshotFlow { listState.firstVisibleItemIndex }
            .map { index -> index > 0 } // 转成"是否过了第一个item"
            .distinctUntilChanged() // 只在结果变化时触发
            .filter { it == true } // 只关心"从没过到过"的时刻
            .collect {
                // 发分析事件:用户滚动过第一个item
                MyAnalyticsService.sendScrolledPastFirstItemEvent()
            }
    }
}

简单说:State 转成 Flow 后,可以用 Flow 运算符做过滤、转换,比直接监听 State 更灵活。

三、重要规则:效应的"重启"逻辑

LaunchedEffectproduceStateDisposableEffect 这些 API,都有"键(Key)"参数------键变了,就会取消当前效应,启动新的效应

1. 键该怎么选?

  • 效应代码块里用到的可变/不可变变量 ,都应该作为键(比如 LaunchedEffect(pulseRateMs) 里的 pulseRateMs
  • 如果变量变了但不想重启效应,就用 rememberUpdatedState 包起来(比如前面的 currentOnTimeout
  • 如果变量被 remember(无键)包裹且不会变,不用作为键(比如 remember { FirebaseAnalytics() }

2. 用常量当键(比如 true)

如果想让效应"只跟可组合项生命周期绑定"(只启动一次,不因为任何变量重启),可以用 trueUnit 这类常量当键(比如 LaunchedEffect(true))。

但一定要谨慎!比如:

  • 正确场景:启动页延迟跳转(只等一次,不重启)
  • 错误场景:网络请求(如果 url 变了,用常量键会导致不重新请求,拿到旧数据)

总结:怎么选 Effect API?

场景需求 对应 API
可组合项内跑挂起函数(如延迟、动画) LaunchedEffect
可组合项外启动协程(如点击事件) rememberCoroutineScope
效应里引用值,不想值变就重启 rememberUpdatedState
附带效应需要清理(如注册/取消监听) DisposableEffect
同步 Compose 状态到非 Compose 代码 SideEffect
非 Compose 状态转 Compose State(如网络、Flow) produceState
高频状态转低频重组(如滚动位置判断) derivedStateOf
Compose State 转 Flow(用 Flow 运算符) snapshotFlow

参考 : 官方文档

相关推荐
雨白4 小时前
Kotlin 协程的灵魂:结构化并发详解
android·kotlin
我命由我123454 小时前
Android 开发问题:getLeft、getRight、getTop、getBottom 方法返回的值都为 0
android·java·java-ee·android studio·android jetpack·android-studio·android runtime
Modu_MrLiu4 小时前
Android实战进阶 - 用户闲置超时自动退出登录功能详解
android·超时保护·实战进阶·长时间未操作超时保护·闲置超时
Jeled4 小时前
Android 网络层最佳实践:Retrofit + OkHttp 封装与实战
android·okhttp·kotlin·android studio·retrofit
信田君95274 小时前
瑞莎星瑞(Radxa Orion O6) 基于 Android OS 使用 NPU的图片模糊查找APP 开发
android·人工智能·深度学习·神经网络
tangweiguo030519875 小时前
Kotlin 实现 Android 网络状态检测工具类
android·网络·kotlin
nvvas6 小时前
Android Studio JAVA开发按钮跳转功能
android·java·android studio
怪兽20146 小时前
Android多进程通信机制
android·面试
叶羽西7 小时前
Android CarService调试操作
android