Compose 深水区
本篇只讨论 Snapshot, 重组范围, 跳过与性能诊断; Column, Scaffold, Material 3, Preview 和状态提升的入门页面见 Compose 入门.
一, 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)
}
}
Phase 细分 (与 09「三大阶段」断链补齐): 一次 UI 更新依次走 Composition (组合, 执行 Composable 生成节点), Layout (布局, 测量与摆放), Drawing (绘制, 画到 Canvas). State 在哪个阶段被读取决定后续触发范围: 组合阶段读取 → 该 State 变化触发重组; 布局阶段读取 (如自定义 Layout 测量中读) → 变化只触发重新测量布局, 不重组; 绘制阶段读取 (如 Canvas 绘制中读) → 只触发重绘. 所以应把状态读取尽量放在组合阶段交给 Compose 追踪; 布局 / 绘制阶段直接读可变值会绕过快照追踪, 改值不一定刷新.
二, Stability, Strong Skipping 与 Skippable
Compose 的跳过规则依赖 Kotlin 与 Compose Compiler 版本及项目配置. 对采用 Kotlin 2.0.20+ 且未显式关闭 strong skipping 的项目, 它通常默认启用; 许多参数类型不稳定但函数可重启的 Composable 也可以被标记为 skippable. 因此, 下面这种旧口诀不再准确:
参数不稳定 → 整个 Composable 一定不能跳过.
当前更可靠的判断方式:
- 先确认函数是否 restartable/skippable, 以及项目是否使用匹配版本的 Compose Compiler Gradle plugin.
- 用 compiler reports/metrics 查看稳定性推断, 不凭肉眼猜测.
- 用重组计数, Layout Inspector, Perfetto 或 Macrobenchmark 证明是否存在实际性能问题.
- 优先修正状态读取范围, 对象频繁重建和列表 key/contentType; 不要为了 “稳定” 盲目添加注解.
@Immutable
// 只有字段及其可达状态确实不可变时才成立
data class UserUiModel(
val id: String,
val name: String,
)
@Stable/@Immutable 是正确性契约, 不是性能开关. 若对象内部变化无法被 Compose 观察, 错误标注可能让界面漏更新. 对于第三方或集合类型, 可在边界转换成不可变 UI model, 或通过 stability configuration 谨慎声明, 并用报告验证结果.
strong skipping 具体例子: 参数稳定且 lambda 未变时可整段跳过, 例如 ProfileRow(user, onClick = onClickRef) 中 user 与 onClickRef 都未变 (strong skipping 下捕获稳定值的 lambda 会被自动 remember 为同一实例), 编译器跳过 ProfileRow. 反例: var count by remember { mutableStateOf(0) } 后传 ProfileRow(user, onClick = { log(count) }): lambda 捕获了会变的 count, count 一变 lambda 便重建, 参数引用不再相等, 即使 user 相同也无法跳过. strong skipping 只能忽略 “真的没变” 的参数, 不能阻止 “捕获了变化状态” 的 lambda 重建.
三, 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 */ }
}
提交推演: 写入先落在当前 Snapshot, 由 global snapshot 推进提交后统一使读取方失效并调度重组, 一次事务内多次写会合并, 不是每次写都立即重组. snapshotFlow 采用合并 (conflate) 语义: 两次 collect 之间状态变化多次, 只发射最新一次结果, 高频滚动位置经它收集天然去抖, 下游不会收到中间值.
五, 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)
}
}
movableContentOf { ... } 把一段组合与其组合状态绑定: 在组合树中移动位置 (列表重排) 时状态随之迁移而非重建, 适合需要保留 item 内部 remember 状态的场景. LazyColumn 可用 beyondViewportItemCount 扩大组合 / 预取窗口, 提前准备即将滑入的 item, 减少滚动期卡顿但增加前期组合成本.
七, 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 里用普通单元测试覆盖.
九, Compiler reports 与 Macrobenchmark: 先测量再优化
上下文片段: 这是 Gradle Kotlin DSL 的诊断配置示意, 键名与产物位置随 Kotlin/Compose 插件版本变化, 必须以项目当前插件文档为准; 它不声称已经在本仓库执行. 目标是产出 report 后检查 composable 的 restartable/skippable 推断, 再结合真实交互验证.
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
metricsDestination = layout.buildDirectory.dir("compose_metrics")
}
可运行示例 (独立 benchmark module): 在 AndroidX Macrobenchmark 模块配置被测 app package, 并准备可冷启动的 MainActivity. 运行后预期报告含多次 timeToInitialDisplayMs; 不能把一次数字或未运行示例写成收益结论.
@RunWith(AndroidJUnit4::class)
class StartupBenchmark {
@get:Rule val benchmarkRule = MacrobenchmarkRule()
@Test fun startup() = benchmarkRule.measureRepeated(
packageName = "com.example.app", metrics = listOf(StartupTimingMetric()),
iterations = 10, startupMode = StartupMode.COLD,
) { pressHome(); startActivityAndWait() }
}
排障路径: 症状是滚动或点击掉帧; 证据先取 Macrobenchmark 分布和 Perfetto trace; 定位主线程 composition/layout, RenderThread/GPU 或 I/O 的超预算阶段; 修复方向可能是缩小 state 读取, 避免对象重建, 补 key/contentType 或降低 item 工作量; 最后在同一设备, 构建类型和场景重复测量.60Hz 的 16.7ms 只是一帧周期示例, 90/120Hz 预算更短, 不能作为固定业务阈值.
高频面试题
Q1: 什么会触发 Compose 重组? Composable 在组合阶段读取的 State 变化会触发相关范围重组. 重组是局部重新执行函数, 不是整棵 UI 全量重建. Composable 要幂等, 无副作用, 快速执行.
Q2: 稳定性和 skippable 是什么? 稳定性是编译器用于推断参数变化的属性, skippable 表示可在本次重组跳过该函数的能力. 稳定参数且值未变通常有利于跳过; 但在 Kotlin/Compose Compiler 版本和项目配置满足 strong skipping 条件时, 部分不稳定参数的 restartable Composable 也可能跳过. 不要凭口诀判断, 应查看当前 compiler report 并测量实际重组/性能.
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 状态错位和不必要重组.