Jetpack Compose 中,组合是由可组合项构成的树状结构,用于描述应用界面。
它通过初始组合生成,并仅能通过重新组合更新 —— 当应用状态变化时,Compose 会安排重新组合,仅重新执行受状态变化影响的可组合项,而非整个 UI 树。可组合项的生命周期包含三个阶段:进入组合、经历 0 次或多次重新组合、最终离开组合。
组合中可组合项的实例由其调用点(即调用可组合项的源代码位置)唯一标识。编译器会将每个调用点视为不同的位置,从多个调用点调用同一可组合项会在组合中创建多个实例,且每个实例拥有独立的生命周期。在重新组合时,若可组合项的调用点未变且输入参数未发生改变,Compose 会跳过对它的重新执行。
例如,在条件语句中调用的可组合项,即使条件变化导致其他可组合项被添加或移除,只要自身调用点和输入未变,就会被保留且不重新组合。
@Composable
fun GreetingScreen(showWelcome: Boolean) {
Column {
// 固定调用点:每次组合都会执行,实例唯一
Text("App Title")
// 条件调用:showWelcome为true时进入组合
if (showWelcome) {
WelcomeMessage() // 调用点1:仅在条件满足时存在
}
// 固定调用点:无论条件如何,实例始终保留
UserInputField() // 调用点2:始终存在
}
}
@Composable
fun WelcomeMessage() {
Text("Welcome!")
}
@Composable
fun UserInputField() {
var text by remember { mutableStateOf("") }
TextField(value = text, onValueChange = { text = it })
}
当从同一调用点多次调用可组合项时,仅靠调用点可能无法唯一区分实例,此时执行顺序会影响实例识别,但这可能导致列表项排序、添加等操作时出现不必要的重新组合。
为解决这一问题,可使用 key
可组合项,传入一个或多个值作为标识,使 Compose 能基于这些值识别组合中的实例。key
的值只需在当前调用点的可组合项调用中唯一即可,
例如为列表中的电影项传入唯一 ID 作为 key
,这样即使列表项排序或增删,Compose 也能通过 key
识别并重用原有实例,避免副作用(如网络请求)被频繁中断和重启。
未使用Key的问题
@Composable
fun MovieListWithoutKey(movies: List) {
Column {
movies.forEach { movie ->
MovieItem(movie) // 仅靠执行顺序识别,排序/增删会导致大量重组
}
}
}
@Composable
fun MovieItem(movie: Movie) {
LaunchedEffect(Unit) {
// 模拟加载电影封面,若重组会被中断并重启
loadMovieCover(movie.id)
}
Text(text = movie.title)
}
当列表排序或在头部添加新电影时,所有MovieItem
的执行顺序改变,Compose 会视为新实例,导致LaunchedEffect
重新执行(封面加载中断并重启)。
使用Key优化后的代码
@Composable
fun MovieListWithKey(movies: List) {
Column {
movies.forEach { movie ->
key(movie.id) { // 用电影唯一ID作为标识
MovieItem(movie)
}
}
}
}
此时,即使列表排序或增删,Compose 会通过movie.id
识别相同实例,仅移动位置而不重组,LaunchedEffect
也会继续执行未完成的任务。
Jetpack Compose 中,副作用指可组合函数范围外的应用状态变化。由于可组合项存在不可预测的重组、执行顺序变化等特性,直接在可组合项中处理副作用可能导致问题,因此 Compose 提供了一系列副作用 API,用于在受控环境中管理这些操作,确保其可预测性。
核心副作用 API 及用途
LaunchedEffect:在可组合项生命周期内运行挂起函数。进入组合时启动协程,离开组合时取消协程;若传入的键值变化,会取消当前协程并启动新协程。适用于动画、延迟操作等需挂起函数的场景,例如通过延迟实现页面跳转。
@Composable
fun SplashScreen(navController: NavController) {
// 延迟3秒后导航到主页
LaunchedEffect(Unit) { // 键为Unit,仅在进入组合时启动一次
delay(3000)
navController.navigate("home")
}
// 显示启动页内容
Box(modifier = Modifier.fillMaxSize()) {
Text(text = "Loading...", modifier = Modifier.align(Alignment.Center))
}
}
当SplashScreen
进入组合时,LaunchedEffect
启动协程,延迟 3 秒后导航;离开组合时协程自动取消。
rememberCoroutineScope:返回与调用点生命周期绑定的协程作用域,可在可组合项外部(如用户点击事件中)启动协程,离开组合时自动取消。常用于手动控制协程生命周期,例如点击按钮显示 Snackbar。
@Composable
fun ActionScreen() {
val snackbarHostState = rememberSnackbarHostState()
// 创建与当前组合绑定的协程作用域
val scope = rememberCoroutineScope()
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { padding ->
Button(
onClick = {
// 点击时在作用域中启动协程
scope.launch {
snackbarHostState.showSnackbar(
message = "操作成功",
duration = SnackbarDuration.Short
)
}
},
modifier = Modifier.padding(padding)
) {
Text("执行操作")
}
}
}
rememberCoroutineScope
确保离开组合时,未完成的协程(如显示 Snackbar)会被取消。
rememberUpdatedState:在效应中引用一个值,且该值变化时不会导致效应重启。适用于长期运行的效应需使用最新值的场景,例如延迟操作中需保持回调的最新状态,避免效应因值变化频繁重启。
@Composable
fun TimerScreen(onTimeOut: () -> Unit) {
// 用rememberUpdatedState包装回调,确保效应中使用最新值
val currentOnTimeOut by rememberUpdatedState(onTimeOut)
// 效应仅在进入组合时启动一次(键为Unit)
LaunchedEffect(Unit) {
delay(5000) // 延迟5秒
currentOnTimeOut() // 调用最新的回调
}
Text("倒计时中...")
}
即使onTimeOut
参数变化,LaunchedEffect
也不会重启,但currentOnTimeOut
会始终指向最新回调。
SideEffect:每次重组成功后执行,用于将 Compose 状态同步到非 Compose 代码(如原生组件、分析库)。例如将当前用户类型同步到分析工具,确保后续事件携带最新元数据。
@Composable
fun UserProfileScreen(user: User) {
// 初始化分析工具
val analytics = remember { AnalyticsTool() }
// 每次重组成功后,更新分析工具的用户属性
SideEffect {
analytics.setUserProperty("user_level", user.level.toString())
}
Text("用户等级:${user.level}")
}
确保分析工具始终使用最新的用户等级,且仅在重组成功后执行。
produceState:将非 Compose 状态(如 Flow、LiveData)转换为 Compose 状态。进入组合时启动协程推送值到返回的 State 中,离开组合时取消协程,适用于将外部状态引入组合,且值相同不会触发重组。
// 网络请求工具类
class DataRepository {
suspend fun fetchData(): String {
delay(2000) // 模拟网络延迟
return "网络数据"
}
}
@Composable
fun DataDisplayScreen() {
// 将网络请求结果转换为Compose状态
val dataState by produceState(
initialValue = "加载中...", // 初始值
repository = DataRepository() // 键:repository变化时重启
) {
// 启动协程执行网络请求
val data = repository.fetchData()
value = data // 推送结果到State
}
Text(text = dataState)
}
produceState
自动管理协程生命周期,离开组合时取消请求。
snapshotFlow:将 Compose 的 State 转换为 Flow,利用 Flow 运算符(如 distinctUntilChanged
)处理状态变化。例如将滚动位置转换为 Flow,分析用户滚动行为。
场景:监听列表滚动位置,发送分析事件(利用 Flow 的运算符)。
@Composable
fun ScrollTrackingList() {
val listState = rememberLazyListState()
LazyColumn(state = listState) {
items(100) { index ->
Text("第${index + 1}项", modifier = Modifier.padding(8.dp))
}
}
// 监听滚动位置,转换为Flow并处理
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.map { it > 10 } // 仅关注是否滚动过10项
.distinctUntilChanged() // 去重,避免重复事件
.filter { it } // 仅发送“已滚动过10项”的事件
.collect {
AnalyticsTool.sendEvent("scrolled_past_10_items")
}
}
}
snapshotFlow
将滚动位置转换为 Flow,结合 Flow 运算符实现精准的事件跟踪。重点扩展
在 Jetpack Compose 中,derivedStateOf
是一个非常实用的性能优化工具,特别适合处理高频变化状态但只需在特定条件下响应的场景。
1. 为什么需要 derivedStateOf
?
当列表滚动时,listState.firstVisibleItemIndex
会频繁变化(每滚动一帧就可能更新一次)。如果直接使用这个值来控制按钮显示,会导致以下问题:
// 未使用 derivedStateOf 的写法(性能问题)
val showTopButton = listState.firstVisibleItemIndex > 0
if (showTopButton) {
Button(...) // 每次滚动都可能触发重组
}
问题:即使按钮显示状态未变(如从 true
变为 true
),Compose 也会执行重组,因为 firstVisibleItemIndex
的值一直在变化。
2. derivedStateOf
的优化原理
derivedStateOf
会创建一个派生状态,它只在结果值发生变化时才通知 Compose 进行重组。
// 使用 derivedStateOf 的写法(优化后)
val showTopButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
if (showTopButton) {
Button(...) // 仅在 showTopButton 从 true 变 false 或反之触发重组
}
工作流程:
showTopButton = false
(首项可见)。firstVisibleItemIndex
从 0 变为 1、2、3...,但 showTopButton
始终为 true
,不触发重组。firstVisibleItemIndex
从 0 变为 1 时,showTopButton
从 false
变为 true
,触发一次重组(显示按钮)。firstVisibleItemIndex
从 1 变回 0 时,showTopButton
从 true
变为 false
,触发一次重组(隐藏按钮)。3.代码结构解析
val showTopButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
remember
:确保 derivedStateOf
只在首次组合时创建,避免重复创建。
derivedStateOf
:
listState.firstVisibleItemIndex
的变化。index > 0
计算出 showTopButton
的值。equals
比较新旧值,仅当结果变化时通知重组。4.何时使用deriveStateOf?
适合以下场景:高频变化的状态(如滚动位置、动画值)。只需在特定条件下响应(如超过阈值、状态切换)。避免不必要的 UI 更新(如按钮显示 / 隐藏、文本截断)。
阶段总结:derivedStateOf
是 Compose 中用于减少不必要重组的关键工具,通过结果去重机制,将高频变化的状态转换为低频更新的派生状态。在滚动场景中,它能将每秒数十次的重组减少到仅在临界状态变化时触发一次,显著提升性能。使用时需注意:仅在状态变化频率远高于 UI 更新需求时使用。
Compose 将数据转换为 UI 分为三个主要阶段,通常按组合、布局、绘制的顺序执行,形成单向数据流,不过 BoxWithConstraints、LazyColumn 等例外,其子项组合依赖父项布局阶段。
组合阶段通过运行可组合函数生成表示 UI 的布局节点树,确定显示的 UI 内容;布局阶段包含测量和放置步骤,遍历 UI 树为每个节点在 2D 坐标中确定大小和位置,且一次遍历即可完成,性能高效;绘制阶段则从上到下遍历树,将每个节点渲染到 Canvas 上,最终形成屏幕上的 UI。
Compose 会跟踪各阶段的状态读取,当状态值变化时,仅重新执行读取该状态的阶段相关操作,以此优化性能。
状态读取可通过直接访问 value 或使用属性委托实现,不同阶段的状态读取影响不同:组合阶段读取的状态变化会触发重新组合,可能连带引发布局和绘制;布局阶段(测量或放置步骤)读取的状态变化仅触发布局和可能的绘制;绘制阶段读取的状态变化则只触发重绘。
为提升性能,应将状态读取限制在尽可能低的阶段。例如,滚动时的偏移计算若放在布局阶段而非组合阶段,可避免每次滚动都重新组合,减少不必要的工作。同时,需避免 “重新组合循环”—— 即不同帧之间因阶段依赖形成的循环,这会导致 UI 跳动和额外开销,解决方法是使用合适的布局基元或自定义布局,让父级协调元素关系,而非通过动态状态传递尺寸信息。
代码示例:
// 综合示例:整合 Compose 三个阶段的核心用法
@Composable
fun ComposeThreePhasesDemo() {
Column(modifier = Modifier.fillMaxSize()) {
// 1. 组合阶段示例(动态内容 + derivedStateOf 优化)
CompositionPhaseSection()
// 2. 布局阶段示例(动态偏移 + 自定义布局)
LayoutPhaseSection()
// 3. 绘制阶段示例(动态绘制 + 绘制修饰符)
DrawingPhaseSection()
}
}
// 1. 组合阶段:控制显示内容,状态变化触发重组
@Composable
private fun CompositionPhaseSection() {
var clickCount by remember { mutableIntStateOf(0) }
// 优化:使用 derivedStateOf 过滤高频状态变化
val showToast by remember {
derivedStateOf { clickCount % 5 == 0 && clickCount > 0 }
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.border(2.dp, Color.Gray, shape = MaterialTheme.shapes.medium)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "组合阶段示例",
fontSize = 18.sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp)
)
// 组合阶段读取状态:clickCount 变化触发重组
Text(
text = "点击次数: $clickCount",
fontSize = 16.sp
)
if (showToast) { // 仅在 clickCount 是 5 的倍数时显示
Text(
text = "每点击 5 次显示一次",
color = Color.Red,
modifier = Modifier.padding(8.dp)
)
}
Button(
onClick = { clickCount++ },
modifier = Modifier.padding(top = 8.dp)
) {
Icon(Icons.Default.Add, contentDescription = "增加")
Text("点击增加", modifier = Modifier.padding(start = 4.dp))
}
}
}
// 2. 布局阶段:控制位置,状态变化仅触发布局
@Composable
private fun LayoutPhaseSection() {
val listState = rememberLazyListState()
var spacing by remember { mutableStateOf(8.dp) } // 控制自定义布局间距
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.border(2.dp, Color.Blue, shape = MaterialTheme.shapes.medium)
.padding(16.dp)
) {
Text(
text = "布局阶段示例",
fontSize = 18.sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp)
)
// 布局阶段状态读取:滚动偏移仅触发布局(视差效果)
Box(modifier = Modifier.height(150.dp)) {
Image(
painter = painterResource(id = R.drawable.ic_launcher_background), // 替换为实际图片
contentDescription = "视差背景",
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
.offset {
// 布局阶段(放置步骤)读取状态:仅重布局
IntOffset(
x = 0,
y = -listState.firstVisibleItemScrollOffset / 3 // 视差效果
)
}
)
}
// 自定义布局:手动测量和放置子元素
Text("自定义布局(间距可调整)", modifier = Modifier.padding(top = 16.dp))
CustomVerticalLayout(
spacing = spacing,
modifier = Modifier.padding(vertical = 8.dp)
) {
Text("子元素 1", modifier = Modifier.background(Color.LightGray).padding(4.dp))
Text("子元素 2", modifier = Modifier.background(Color.LightGray).padding(4.dp))
Text("子元素 3", modifier = Modifier.background(Color.LightGray).padding(4.dp))
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("间距: ${spacing.value}dp", modifier = Modifier.width(80.dp))
Slider(
value = spacing.value,
onValueChange = { spacing = it.dp },
valueRange = 4f..32f
)
}
// 滚动列表(触发视差效果)
Text("滚动列表触发视差", modifier = Modifier.padding(top = 16.dp))
LazyColumn(
state = listState,
modifier = Modifier
.height(120.dp)
.border(1.dp, Color.Gray),
contentPadding = PaddingValues(8.dp)
) {
items(20) { index ->
Text("列表项 $index", modifier = Modifier.padding(8.dp))
}
}
}
}
// 自定义布局实现(布局阶段核心逻辑)
@Composable
private fun CustomVerticalLayout(
spacing: Dp,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
// 1. 测量所有子元素
val placeables = measurables.map { it.measure(constraints) }
// 2. 计算总高度(子元素高度 + 间距总和)
val totalHeight = placeables.sumOf { it.height } +
(spacing.roundToPx() * (placeables.size - 1)).coerceAtLeast(0)
// 3. 放置子元素(垂直排列)
layout(constraints.maxWidth, totalHeight) {
var yPosition = 0 // 初始 Y 坐标
placeables.forEach { placeable ->
placeable.placeRelative(x = 0, y = yPosition) // 放置子元素
yPosition += placeable.height + spacing.roundToPx() // 更新 Y 坐标
}
}
}
}
// 3. 绘制阶段:控制渲染,状态变化仅触发重绘
@Composable
private fun DrawingPhaseSection() {
var progress by remember { mutableFloatStateOf(0.2f) } // 进度值(0-1)
var rectColor by remember { mutableStateOf(Color.Cyan) } // 矩形颜色
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.border(2.dp, Color.Green, shape = MaterialTheme.shapes.medium)
.padding(16.dp)
) {
Text(
text = "绘制阶段示例",
fontSize = 18.sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp)
)
// 绘制修饰符:在背景绘制进度条
Box(
modifier = Modifier
.fillMaxWidth()
.height(40.dp)
.background(Color.LightGray)
.drawBehind {
// 绘制阶段读取进度:仅触发重绘
val progressWidth = size.width * progress
drawRect(
color = rectColor,
size = Size(progressWidth, size.height)
)
}
)
// 控制进度的滑块
Slider(
value = progress,
onValueChange = { progress = it },
modifier = Modifier.padding(vertical = 16.dp)
)
// 动态绘制示例(Canvas)
Text("自定义 Canvas 绘制", modifier = Modifier.padding(top = 16.dp))
Canvas(
modifier = Modifier
.size(200.dp)
.border(1.dp, Color.Gray)
) {
// 绘制阶段读取状态:颜色和进度变化仅触发重绘
drawCircle(
color = rectColor,
radius = size.minDimension / 4 * (1 + progress), // 半径随进度变化
center = Offset(size.width / 2, size.height / 2)
)
}
// 切换颜色按钮
Button(
onClick = {
rectColor = listOf(Color.Red, Color.Green, Color.Blue, Color.Yellow).random()
},
modifier = Modifier.padding(top = 16.dp)
) {
Text("随机切换颜色")
}
}
}
// 预览
@Preview(showBackground = true, name = "Compose 三阶段综合示例")
@Composable
fun ComposeThreePhasesDemoPreview() {
MaterialTheme {
ComposeThreePhasesDemo()
}
}
阶段总结
Compose 将数据转换为 UI 分为组合、布局、绘制三个主要阶段,通常按此顺序形成单向数据流(BoxWithConstraints、LazyColumn 等例外,其子项组合依赖父项布局阶段):组合阶段通过运行可组合函数生成 UI 布局节点树以确定显示内容,布局阶段经测量和放置步骤为 UI 树节点确定 2D 坐标中的大小和位置(一次遍历高效完成),绘制阶段从上到下遍历树将节点渲染到 Canvas;
Compose 会跟踪各阶段的状态读取(可通过直接访问 value 或属性委托实现),状态变化时仅重新执行对应阶段操作以优化性能 —— 组合阶段的状态变化触发重新组合及可能的后续阶段,布局阶段的仅触发布局和可能的绘制,绘制阶段的只触发重绘;为提升性能,应将状态读取限制在尽可能低的阶段(如滚动偏移计算放在布局阶段),同时需避免不同帧间因阶段依赖形成的 “重新组合循环”(会导致 UI 跳动和额外开销),解决方法是使用合适的布局基元或自定义布局,由父级协调元素关系而非通过动态状态传递尺寸信息