Jetpack Compose 让 Android 开发变得极其快速和直观。但这种速度背后隐藏着代价:一不小心就极容易写出触发大量不必要 UI 渲染周期的代码。
1. 在错误的阶段读取状态(滚动陷阱)
场景:你正在构建一个精美的个人资料页面,其中有一个"回到顶部"按钮,当用户向下滚动一段距离后会出现。你在 composable 中直接读取滚动状态。你在自己的高端手机上测试,一切看起来正常。然后 QA 在一台中端设备上测试,应用卡得像幻灯片一样。 原因分析:Compose 的运行分为三个不同的阶段:组合(显示什么)、布局(放在哪里)和绘制(如何渲染)。如果在组合阶段读取一个快速变化的状态(如滚动位置),就会迫使整个组件在每滚动一个像素时都重新组合。
c
@Composable
fun ScrollToTopButton(scrollState: ScrollState) {
// 糟了!在这里读取.value会导致每滚动一像素就触发重组
val showButton = scrollState.value > 1000
if (showButton) {
Button(onClick = { /* ... */ }) {
Text("Scroll to Top")
}
}
}
正确做法:将条件包裹在 derivedStateOf 中。这会告诉 Compose 仅在布尔结果真正发生变化时(即跨越 1000px 阈值时)才触发重组,而不是每次原始整数值更新时都触发。
c
@Composable
fun ScrollToTopButton(scrollState: ScrollState) {
// 仅布尔值状态翻转时才触发重组
val showButton by remember {
derivedStateOf { scrollState.value > 1000 }
}
if (showButton) {
Button(onClick = { /* ... */ }) {
Text("Scroll to Top")
}
}
}
2. 传递整个对象(重组税)
场景:你有一个庞大的 UserUiState 数据类,其中包含用户的姓名、简介、账单历史记录和最后活跃时间戳。你需要构建一个小小的 UserAvatar 组件。因为这比逐个传入参数更省事,你就把整个 UserUiState 对象传进了头像 composable 函数中。 为什么会发生这种情况:如果后台同步更新了用户的 lastActiveTime , UserUiState 对象就会发生变化。由于你的 UserAvatar 将整个对象作为参数,Compose 被迫重新绘制头像,即使头像图片 URL 根本没有改变。这是企业级应用中最常见的问题。
c
@Composable
fun UserAvatar(userState: UserUiState) {
AsyncImage(model = userState.profilePicUrl, contentDescription = "Avatar")
}
整洁的做法:只传递组件绘制像素时明确需要的基本类型值。这样可以保持组件"无脑"、高度可复用,且不受无关状态变化的影响。
c
@Composable
fun UserAvatar(profilePicUrl: String) {
AsyncImage(model = profilePicUrl, contentDescription = "Avatar")
}
3. LazyColumn 中缺少 key (重排序噩梦)
场景:你构建了一个动态购物车或待办事项列表。用户滑动删除列表中的第二项。突然,第三项闪烁了一下,或者动画在错误的行上播放。
为什么会发生这种情况:我们都知道应该使用 key,但我们跳过了这一步,因为 Compose 默认情况下"处理得很好"。确实如此------直到列表改变顺序或某个项被删除。如果没有唯一的 key,Compose 会使用项在列表中的位置作为其标识。如果第 2 项被删除,Compose 会认为第 2 项只是数据变成了第 3 项的样子,从而强制列表剩余的全部项进行重组。
c
LazyColumn {
items(cartItems) { item ->
CartRow(item)
}
}
简洁的方法:始终定义一个唯一且不可变的键。
c
LazyColumn {
items(
items = cartItems,
key = { item -> item.productId } // Compose 现在可精准追踪对应条目
) { item ->
CartRow(item)
}
}
4. 不稳定的 Lambda(匿名函数陷阱)
场景:你有一个干净、分离的 UI 组件。你向一个按钮传递了一个简单的 lambda: onClick = { viewModel.saveData() } 。看起来很无害,对吧?但不知为何,Layout Inspector 显示每次父屏幕重组时,这个按钮都会重组。
为什么会发生这种情况:如果该 lambda 捕获了一个不稳定的变量(比如注入的 ViewModel,Compose 通常会将其视为不稳定的),Compose 可能会在父组件每次重组时都将该 lambda 视为一个全新的实例。由于 lambda "发生了变化",子按钮也会被强制重组。
c
@Composable
fun ProfileScreen(viewModel: ProfileViewModel) {
// 捕获视图模型,频繁创建新的 Lambda 实例
SaveButton(onClick = { viewModel.saveProfile() })
}
简洁的做法:尽可能使用方法引用。在 Kotlin 中,方法引用默认是稳定的。或者,确保被捕获的类显式标记为 @Stable 。
c
@Composable
fun ProfileScreen(viewModel: ProfileViewModel) {
SaveButton(onClick = viewModel::saveProfile)
}
5. 忘记在 remember 中传递key(过期数据 Bug)
场景:你有一个繁重的日期格式化工具,它将时间戳转换为友好的"2 小时前"字符串。你聪明地将它包裹在 remember 块中,这样它就不会在每一帧都重新计算。但当底层时间戳实际更新时,UI 却顽固地拒绝变化。
为什么会这样:你记得使用 remember ,但忘记了 key。如果底层数据发生变化, remember 代码块不会重新运行,除非你明确告诉它监视那个特定的数据。
c
@Composable
fun DateDisplay(timestamp: Long) {
// 这会将时间戳格式化一次,如果时间戳发生变化将永远不会更新!
val formattedDate = remember { DateFormatter.format(timestamp) }
Text(text = formattedDate)
}
简洁的做法:将依赖变量作为 key 传入。当 key 变化时,代码块会重新执行。
c
@Composable
fun DateDisplay(timestamp: Long) {
// 仅时间戳发生变化时才重新计算
val formattedDate = remember(timestamp) { DateFormatter.format(timestamp) }
Text(text = formattedDate)
}