Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 一定不能跳过.

当前更可靠的判断方式:

  1. 先确认函数是否 restartable/skippable, 以及项目是否使用匹配版本的 Compose Compiler Gradle plugin.
  2. 用 compiler reports/metrics 查看稳定性推断, 不凭肉眼猜测.
  3. 用重组计数, Layout Inspector, Perfetto 或 Macrobenchmark 证明是否存在实际性能问题.
  4. 优先修正状态读取范围, 对象频繁重建和列表 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 嵌地图 / 广告 ViewAndroidViewfactory 创建, update 更新
Fragment 嵌 ComposeComposeView销毁 View 时释放 Composition
Flow 渲染 UIcollectAsStateWithLifecycle需要 lifecycle-runtime-compose
注册监听器DisposableEffectonDispose 反注册

八, 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 状态错位和不必要重组.