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

参考 : 官方文档

相关推荐
00后程序员张8 小时前
iOS 上架费用全解析 开发者账号、App 审核、工具使用与开心上架(Appuploader)免 Mac 成本优化指南
android·macos·ios·小程序·uni-app·cocoa·iphone
来来走走8 小时前
Android开发(Kotlin) 扩展函数和运算符重载
android·开发语言·kotlin
wuwu_q8 小时前
用通俗易懂 + Android 开发实战的方式,详细讲解 Kotlin Flow 中的 retryWhen 操作符
android·开发语言·kotlin
天选之女wow8 小时前
【代码随想录算法训练营——Day60】图论——94.城市间货物运输I、95.城市间货物运输II、96.城市间货物运输III
android·算法·图论
沐怡旸9 小时前
【底层机制】Android对Linux线程调度的移动设备优化深度解析
android·面试
正经教主10 小时前
【咨询】Android Studio 第三方手机模拟器对比【202511】
android·ide·android studio
Jomurphys11 小时前
网络 - 缓存
android
似霰12 小时前
安卓14移植以太网&&framework-connectivity-t 编译问题
android·framework·安卓·ethernet
Android-Flutter12 小时前
kotlin - 显示HDR图(heic格式),使用GainMap算法,速度从5秒提升到0.6秒
android·kotlin