Compose 深水区
Compose 深水区考的不是“会写 Text/Button”,而是你是否理解重组、稳定性、Snapshot、Modifier、LazyColumn 性能和测试,能不能把声明式 UI 写得稳定、可测、不卡。
一、Recomposition: 状态读取驱动局部重组
Compose 的核心是 UI = f(State)。当某个 Composable 在组合阶段读取了 State,这个读取关系会被记录;State 改变后,相关范围进入重组。
- 重组是重新执行 Composable,不是重新创建整个 Activity/View 树。
- 重组可能很频繁,Composable 必须幂等、无副作用、执行快。
- 不要在 Composable 体内直接发网络请求、写数据库、改全局变量。
- 把状态读取推迟到最小范围,减少被重组的 UI 面积。
@Composable
fun ProfileScreen(state: ProfileState, onRetry: () -> Unit) {
when {
state.loading -> Loading()
state.error != null -> ErrorView(state.error, onRetry)
else -> ProfileContent(state.user)
}
}
二、Stability 与 Skippable
Compose 编译器会判断参数是否稳定,稳定且参数未变化时,Composable 可以被跳过(skippable)。
| 类型/写法 | 对跳过的影响 | 建议 |
|---|---|---|
| 不可变 data class(val 字段) | 更容易稳定 | UI State 尽量不可变 |
List<T> 接口 | 常被视为不稳定 | 用不可变集合或稳定 wrapper |
含 var 的普通 class | 不稳定 | 改为 State 驱动或不可变模型 |
| lambda 每次新建 | 可能导致参数变化 | 必要时 remember 或上提 |
怎么答:优化 Compose 性能不是“少写 Composable”,而是让状态和参数稳定,让框架能精准重组和跳过。
三、remember / rememberSaveable / derivedStateOf
remember 把值保存在 Composition 中,重组不丢;rememberSaveable 额外通过 Bundle/Saver 支持配置变更和进程恢复;derivedStateOf 用于从一个或多个 State 派生计算结果,只有派生结果变化时才通知。
val listState = rememberLazyListState()
val showTopButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 3 }
}
使用边界:
remember:缓存计算结果、对象实例、滚动状态等组合内状态。rememberSaveable:输入框、tab、筛选条件等需要旋转后恢复的轻量状态。derivedStateOf:高频变化中只关心派生布尔/分组结果,如滚动位置。- 不要滥用
derivedStateOf,普通字符串拼接/低频计算没有必要。
四、Snapshot 系统
Compose 的 mutableStateOf 背后是 Snapshot 状态系统。它记录状态读取和写入,在写入提交后通知受影响的组合范围。
- Snapshot 让 Compose 能知道“谁读了这个状态”。
- 状态写入应发生在主线程或受 Snapshot 管理的上下文中,避免并发写冲突。
snapshotFlow { }可以把 Compose State 读取转换成 Flow,适合观察滚动状态等。- 不要直接修改普通可变集合后期待 UI 更新;要替换 State 值或使用 Snapshot-aware 集合。
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.collect { index -> /* analytics or load trigger */ }
}
五、Modifier 顺序与布局语义
Modifier 是链式包装,顺序会改变测量、绘制、点击区域。
| 写法 | 效果 |
|---|---|
background().padding() | 背景覆盖 padding 外层区域,内容向内缩 |
padding().background() | 只有 padding 后的内部区域有背景 |
clickable().padding() | 点击区域包含 padding 前的范围 |
padding().clickable() | 点击区域通常只覆盖 padding 后内容 |
怎么落地:先想“尺寸/约束”,再想“点击/语义”,最后想“绘制”;可访问性相关 semantics 要放在能覆盖正确节点的位置。
六、LazyColumn 性能
LazyColumn 不是所有问题的自动解,关键是 key、contentType、状态位置和 item 复杂度。
- 给稳定业务 ID 作为
key,避免插入/删除导致 item 状态错位。 - 使用
contentType帮助复用相同类型 item。 - 避免在 item lambda 里做重计算、创建大对象或直接收集 Flow。
- 分页加载用列表滚动状态 +
derivedStateOf/Paging,避免每帧触发请求。 - item 内状态要么跟业务 ID 绑定,要么上提到 ViewModel。
LazyColumn(state = listState) {
items(
items = users,
key = { it.id },
contentType = { "user" },
) { user ->
UserRow(user)
}
}
七、View 互操作与生命周期收集
Compose 和 View 混用在迁移期很常见。
- Compose 中嵌 View:用
AndroidView,在update中同步参数,不要每次重组都重新创建重对象。 - View 中嵌 Compose:用
ComposeView.setContent,并设置合适的ViewCompositionStrategy。 - 收集 Flow:优先用
collectAsStateWithLifecycle(),避免后台生命周期仍持续收集。 - 副作用:进入组合加载用
LaunchedEffect(key),注册监听用DisposableEffect(key)清理。
| 场景 | 推荐 API | 注意点 |
|---|---|---|
| Compose 嵌地图/广告 View | AndroidView | factory 创建,update 更新 |
| Fragment 嵌 Compose | ComposeView | 销毁 View 时释放 Composition |
| Flow 渲染 UI | collectAsStateWithLifecycle | 需要 lifecycle-runtime-compose |
| 注册监听器 | DisposableEffect | onDispose 反注册 |
八、Compose Testing
Compose 测试关注语义树而不是具体 View ID。
- 用
createComposeRule()设置内容。 - 通过
onNodeWithText、onNodeWithTag、onNodeWithContentDescription查找节点。 - 给关键节点设置
Modifier.testTag()。 - 对异步状态变化使用
waitUntil或测试时注入可控 Dispatcher/Repository。
composeTestRule.setContent { LoginScreen(state, onSubmit = {}) }
composeTestRule.onNodeWithTag("login_button").assertIsDisplayed()
怎么答:Compose 测试不是截图测试优先,而是验证语义、状态渲染和用户交互;业务逻辑仍应在 ViewModel/UseCase 里用普通单元测试覆盖。
高频面试题
Q1:什么会触发 Compose 重组? Composable 在组合阶段读取的 State 变化会触发相关范围重组。重组是局部重新执行函数,不是整棵 UI 全量重建。Composable 要幂等、无副作用、快速执行。
Q2:稳定性和 skippable 是什么? 如果 Composable 参数稳定且值没变,编译器/运行时可以跳过它,减少重组成本。不稳定类型如可变 class、普通 List 接口会降低跳过概率,应使用不可变 UI State 和稳定集合。
Q3:remember、rememberSaveable、derivedStateOf 怎么区分? remember 保留组合内状态,重组不丢但配置变更会丢;rememberSaveable 通过 Bundle/Saver 支持恢复;derivedStateOf 用于从高频状态派生低频结果,结果不变时不触发下游重组。
Q4:Snapshot 系统解决什么问题? 它跟踪 Compose State 的读取和写入,让框架知道哪些组合范围依赖某个状态,写入提交后精准通知重组。普通可变集合不受 Snapshot 自动追踪,直接 mutate 可能不刷新 UI。
Q5:LazyColumn 怎么优化? 提供稳定 key 和 contentType,避免 item lambda 重计算和直接收集 Flow,把状态按业务 ID 管理,分页触发用 derivedStateOf/snapshotFlow 控制频率,不要每次滚动都发请求。
易错点 / 追问
- 在 Composable 函数体里直接发请求或写外部状态,会因重组重复执行。
remember不能跨配置变更保存状态,输入框/筛选条件要考虑rememberSaveable或 ViewModel。- Modifier 顺序会改变背景、padding、点击区域和语义范围,不是随便链式调用。
- 普通
mutableList.add()不一定触发 UI 更新,应替换 State 或使用 Snapshot-aware 状态集合。 - LazyColumn 不加稳定 key 时,插入/删除可能导致 item 状态错位和不必要重组。