Android Compose 可组合项的生命周期、副作用API

文章目录

在 Android Jetpack Compose 中,可组合项(Composable)的生命周期与传统 Android View(如 Activity 或 Fragment 的 onCreate、onDestroy 等)完全不同。

可组合项生命周期的三个核心阶段

Compose 是声明式的,它的生命周期是由 状态(State) 驱动的,并且只有三个核心阶段:进入组合(Enter the Composition)、重组(Recompose)和退出组合(Leave the Composition)。简单概括为:
进入组合 → 重组(0 次或多次) → 退出组合

进入组合

当第一次调用一个 @Composable 函数时,Compose 运行时(Runtime)会将其添加到"组合"(Composition,即 Compose 构建的 UI 树)中。

  • 发生了什么: Compose 会在这个阶段分配内存,构建 UI 节点。
  • 状态初始化: 如果你在组件内部使用了 remember { ... },传递给 remember 的代码块只会在这个时候执行一次,并将结果保存起来。

重组

当该 Composable 读取的状态(State)发生变化时,Compose 会重新执行这个 @Composable 函数,以更新 UI。这个过程叫作重组。

  • 触发条件: 组件内部读取的 状态(State)发生改变,或者组件接收到的参数发生改变。
  • 智能跳过(Skipping): 如果一个 Composable 的输入参数没有变,Compose 会尽可能跳过它的重组,以提升性能。
  • 重要特性:
    乐观且可取消: 如果在重组完成前状态再次改变,Compose 可能会取消当前的重组并使用新状态重新开始。
    无副作用要求: 重组可能随时发生、多次发生,甚至在后台线程并行发生。因此,绝对不能在 Composable 函数体中直接执行有副作用的代码如网络请求、数据库读写、修改全局变量),这些操作应该交给副作用(Side-Effect)API 处理。

退出组合

当该 Composable 不再被调用(例如被 if 语句排除在外,或者所在的列表项被滑出屏幕并回收)时,它会从 UI 树中被移除。

  • 发生了什么: 相关的 UI 节点被销毁。
  • 资源释放: 之前通过 remember 存储的状态会被遗忘并释放。在这个阶段,绑定到该组件生命周期的清理工作(如取消协程、注销监听器)会被自动触发。

如何在 Compose 生命周期中执行"副作用"

因为 Compose 没有 onStart、onDestroy 这样的回调,你需要使用副作用 API(Side-Effect APIs) 将外部操作绑定到 Compose 的生命周期上。

LaunchedEffect:与生命周期绑定的协程

LaunchedEffect(key1, key2, ...) 会在可组合项进入组合时启动一个协程,在退出组合时自动取消该协程

使用场景: 首次显示页面时加载网络数据、执行一次性动画等。

Key 的作用: 如果传入的 key 发生变化,LaunchedEffect 会取消当前的协程,并用新的 key 重启协程。如果传入 Unit 或 true,则只在进入和退出时执行/取消。

kotlin 复制代码
@Composable
fun UserProfile(userId: String) {
    // 当 userId 改变时,旧的请求会被取消,新的请求会被启动
    LaunchedEffect(userId) {
        viewModel.fetchUserData(userId) 
    }
}

DisposableEffect:需要清理的副作用

DisposableEffect 类似于 LaunchedEffect,但它不是用来启动协程的,而是用来执行需要显式清理的操作。它强制要求你在末尾提供一个 onDispose 代码块。

执行时机: 进入组合时执行,退出组合时(或 Key 改变重新执行前)调用 onDispose。

使用场景: 注册/注销 BroadcastReceiver、绑定/解绑传感器、添加/移除 Lifecycle Observer 等。

kotlin 复制代码
@Composable
fun ScreenWithObserver() {
    val lifecycleOwner = LocalLifecycleOwner.current
    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            // 监听传统的 Android 生命周期事件
        }
        lifecycleOwner.lifecycle.addObserver(observer)

        // 退出组合时必然会执行,防止内存泄漏
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }
}

SideEffect:每次重组成功后执行

SideEffect 会在每次重组成功且应用到界面后执行

使用场景: 将 Compose 内部的状态同步给非 Compose 管理的外部系统(如自定义 View、埋点统计系统)。它不关联协程,也不负责清理。

示例场景:将 Compose 状态同步给外部的数据打点/日志系统

假设我们有一个外部的非 Compose 类 AnalyticsTracker,它需要记录用户当前所在的"步骤"或"状态"。我们不能直接在 Composable 的函数体中调用它,这时候就需要用到 SideEffect。

kotlin 复制代码
// 模拟一个非 Compose 的外部系统(比如埋点、日志记录器或传统的 View)
class AnalyticsTracker {
    fun logUserStep(step: Int) {
        println("日志打点:用户当前成功进入了步骤 $step")
    }
}

@Composable
fun RegistrationScreen(tracker: AnalyticsTracker) {
    // Compose 内部状态
    var currentStep by remember { mutableStateOf(1) }

    // 【错误做法】:直接在重组域中调用(千万别这么做!)
    // tracker.logUserStep(currentStep) 

    // 【正确做法】:使用 SideEffect
    SideEffect {
        // 这里的代码只会在 RegistrationScreen 成功完成重组后才执行
        tracker.logUserStep(currentStep)
    }

    Column {
        Text("当前进度:第 $currentStep 步")
        
        Button(onClick = { currentStep++ }) {
            Text("下一步")
        }
    }
}

为什么不能直接写在 Composable 函数体里?(为什么需要 SideEffect?)

在上面的【错误做法】中,如果直接写 tracker.logUserStep(currentStep),会面临严重的问题:

1.重组可能被取消: Compose 的重组是乐观的(Optimistic)。如果 Compose 正在计算 RegistrationScreen 的重组,但此时另一个更高优先级的状态改变了,Compose 可能会中断并放弃当前的重组计算。如果直接调用,日志系统就会收到一个实际上并没有真正展示给用户的状态(脏数据)。

2.重组可能极其频繁: 动画或手势滑动可能导致每秒 几十次 的重组,直接在函数体里写会导致外部系统被疯狂调用。
SideEffect 的执行时机

使用 SideEffect {} 包裹后,Compose 引擎会做如下保证:

1.初次组合(Initial Composition): 界面第一次画完,确切地展示了"步骤 1"后,执行 tracker.logUserStep(1)。

2.状态改变触发重组(Recomposition): 当用户点击按钮,currentStep 变为 2。Compose 重新执行 RegistrationScreen。

3.成功提交(Commit): 只有当 Compose 确认这次重组计算完毕,并且把"步骤 2"真正更新到了屏幕上,它才会触发 SideEffect 里的 Lambda,执行 tracker.logUserStep(2)。

rememberCoroutineScope:手动控制的协程

用户事件驱动

rememberCoroutineScope 会返回一个与当前 Composable 生命周期绑定的 CoroutineScope 对象。它主要用于在非 Composable 的作用域(如按钮的点击事件回调)中启动协程。

  • 使用位置: 获取 scope 的操作在 Composable 中进行,但 scope.launch { ... } 必须在非 Composable 的作用域,如回调函数(onClick)中执行。
  • 触发时机: 手动触发。只有在用户执行了某个操作(点击、滑动等)触发回调时,协程才会被启动。
  • 典型场景:
    点击按钮后显示一个 Snackbar(显示 Snackbar 需要挂起函数)。
    用户提交表单后发起网络请求。
kotlin 复制代码
@Composable
fun SubmitButton(snackbarHostState: SnackbarHostState) {
    // 获取与当前 Composable 绑定的协程作用域
    val scope = rememberCoroutineScope()
    
    Button(
        onClick = {
            // 在点击事件(非 Composable 作用域)中手动启动协程
            scope.launch {
            	  // ... 发起网络请求
                snackbarHostState.showSnackbar("提交成功!") 
            }
        }
    ) {
        Text("提交")
    }
}

LaunchedEffect 是状态驱动的,自动触发。rememberCoroutineScope 是手动触发

remberUpdateState 获取最新的值引用

不打断副作用运行,但保持数据最新

在 LaunchedEffect 等长时间运行的代码块中,安全地读取外部传入的可能变化的参数(如回调函数)的最新值,且不打断或重启该代码块。

解决的问题:

在协程等闭包中,捕获的变量如果更新了,闭包内部可能无法感知(这被称为"闭包陷阱"或"捕获陈旧值")。
示例场景:

假设你有一个需要几秒钟后才执行的回调(比如一个延时隐藏的提示框的点击事件)。

kotlin 复制代码
@Composable
fun TimerComponent(onTimeout: () -> Unit) {  
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    LaunchedEffect(true) {
        delay(3000L)
        // 即使 TimerComponent 在这 3 秒内发生了重组,并且传入了新的 onTimeout 实例,
        // currentOnTimeout 也会指向最新的那个,并且 delay 不会被中断。
        currentOnTimeout() 
    }
}
  • 错误做法:LaunchedEffect(onTimeout),如果在 3 秒内 onTimeout 函数引用变了,LaunchedEffect会被取消并重启。会增加性能开销
  • 错误做法:LaunchedEffect(true) { delay(3000L); onTimeout() },如果在 3 秒内 onTimeout 函数引用变了,延时结束后执行的可能是旧的 onTimeout

produceState 将非 Compose 状态转换为 Compose 状态

produceState 会启动一个协程,其生命周期与当前的 Composable 绑定。在这个协程内部,你可以执行挂起函数(如网络请求、流的收集等),并将产生的结果推送到一个返回的 State 对象中。

它可以将那些不属于 Compose 状态管理系统的数据源(如 Flow、LiveData、RxJava 或普通的回调/网络请求)桥接到 Compose 中,让 Compose 能够观察这些数据的变化并触发重组

示例场景:

在组件中直接发起一个简单的网络请求并显示结果(虽然通常建议在 ViewModel 中做,但对于简单的 UI 组件,这很方便)。

kotlin 复制代码
@Composable
fun NetworkImage(url: String) {
    // 传入初始值和 key (url)
    // 当 url 改变时,produceState 的协程会取消并用新 url 重新启动
    val imageState by produceState<ImageBitmap?>(initialValue = null, key1 = url) {
        // 在这个挂起块中,'value' 属性代表了暴露给 Compose 的当前状态
        value = loadNetworkImage(url) // 假设这是一个挂起函数
    }

    if (imageState == null) {
        // 未获取到图片
    } else {
        Image(bitmap = imageState!!, contentDescription = null)
    }
}

注意:虽然 produceState 内部可以包含非挂起的数据源,但它更常用于基于协程的数据源。

对于 Flow 和 LiveData,Compose 提供了专门的 collectAsState()、collectAsStateWithLifecycle() 和 observeAsState()。

总结: 用于在 Composable 内部启动协程,并将挂起函数的执行结果转化为 Compose 可观察的 State。

derivedStateOf 派生状态

在 Compose 中,每次观察到的状态对象或可组合输入出现变化时都会发生重组。状态对象或输入的变化频率可能高于界面实际需要的更新频率,从而导致不必要的重组。
derivedStateOf 用于从一个或多个现有的 State 对象中计算出一个派生(derived)状态。它能起到缓存和过滤的作用:只有当派生出来的值真正发生变化时,才会触发依赖这个派生状态的 Composable 发生重组。

示例场景:当纵向列表向上滚动超过 100 像素时,显示一个"回到顶部"的按钮。
错误实现:

kotlin 复制代码
@Composable
fun ScrollableList() {
    val listState = rememberLazyListState()
    // 只要 offset 发生改变,读取它的作用域(这里是 ScrollableList)就会发生重组。
    val showButton = listState.firstVisibleItemScrollOffset > 100 

    Box {
        LazyColumn(state = listState) { /* ... */ }
        if (showButton) {
            ScrollToTopButton()
        }
    }
}

正确做法:使用 derivedStateOf(高频转低频,拦截无效重组)

kotlin 复制代码
@Composable
fun ScrollableList() {
    val listState = rememberLazyListState()

    // 使用 derivedStateOf 作为"缓冲器"
    val showButton by remember {
        derivedStateOf { 
            listState.firstVisibleItemScrollOffset > 100 
        }
    }

    Box {
        LazyColumn(state = listState) { /* ... */ }
        if (showButton) {
            ScrollToTopButton()
        }
    }
}

执行过程:

  1. 初始时,firstVisibleItemScrollOffset = 0,派生状态 showButton 是false,不会调用 ScrollToTopButton()。
  2. 在列表向上滚动过程中, firstVisibleItemScrollOffset <= 100 时,派生状态 showButton 是false,和之前一样, 不会触发 ScrollableList 组合的重组。
  3. 继续向上滚动,当首次 firstVisibleItemScrollOffset > 100 时,派生状态 showButton 是 true,和之前不一样,触发重组,显示 "回到顶部"按钮。
  4. 继续向上滚动,派生状态 showButton 是true,和之前一样, 不会触发 ScrollableList 组合的重组。

若需求改为 "当列表滚动导致首个可见项的索引大于0时,才显示 回到顶部 按钮。也是一样的做法,要使用 derivedStateOf

kotlin 复制代码
derivedStateOf {
	listState.firstVisibleItemIndex > 0
}

因为若不使用 derivedStateOf,firstVisibleItemIndex 的值每次变化了都会引起重组

相关推荐
hnlgzb3 小时前
目前编写安卓app的话有哪几种设计模式?
android·设计模式·kotlin·android jetpack·compose
studyForMokey4 小时前
【Android面试】Fragment生命周期专题
android·microsoft·面试
Android系统攻城狮5 小时前
Android tinyalsa深度解析之pcm_plugin_open调用流程与实战(一百七十四)
android·pcm·tinyalsa·音频进阶手册
用户622386252175 小时前
Android 列表控件实战:从 ListView 到 RecyclerView,仿今日头条 HeadLine 项目全解析
android
呦呼4575 小时前
Android 仿今日头条项目分析
android
Android系统攻城狮5 小时前
Android tinyalsa深度解析之pcm_params_set_max调用流程与实战(一百七十)
android·pcm·tinyalsa·android音频进阶
怀化纱厂球迷6 小时前
android车载应用动画-仿窗帘式下拉显示!Android 实现跟手裁剪动画 + RecyclerView 列表展示
android·java
木易 士心7 小时前
Android应用启动流程源码级解析
android
八宝粥大朋友8 小时前
Android sqlite3 编译及安装
android·java·sqlite