文章目录
- 可组合项生命周期的三个核心阶段
- [如何在 Compose 生命周期中执行"副作用"](#如何在 Compose 生命周期中执行“副作用”)
-
- LaunchedEffect:与生命周期绑定的协程
- DisposableEffect:需要清理的副作用
- SideEffect:每次重组成功后执行
- rememberCoroutineScope:手动控制的协程
- [remberUpdateState 获取最新的值引用](#remberUpdateState 获取最新的值引用)
- [produceState 将非 Compose 状态转换为 Compose 状态](#produceState 将非 Compose 状态转换为 Compose 状态)
- [derivedStateOf 派生状态](#derivedStateOf 派生状态)
在 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()
}
}
}
执行过程:
- 初始时,
firstVisibleItemScrollOffset = 0,派生状态 showButton 是false,不会调用 ScrollToTopButton()。 - 在列表向上滚动过程中,
firstVisibleItemScrollOffset <= 100时,派生状态 showButton 是false,和之前一样, 不会触发 ScrollableList 组合的重组。 - 继续向上滚动,当首次
firstVisibleItemScrollOffset > 100时,派生状态 showButton 是 true,和之前不一样,触发重组,显示 "回到顶部"按钮。 - 继续向上滚动,派生状态 showButton 是true,和之前一样, 不会触发 ScrollableList 组合的重组。
若需求改为 "当列表滚动导致首个可见项的索引大于0时,才显示 回到顶部 按钮。也是一样的做法,要使用 derivedStateOf ,
kotlin
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
因为若不使用 derivedStateOf,firstVisibleItemIndex 的值每次变化了都会引起重组