Compose 与 View 体系的根本差异
传统 View 体系里,界面是一棵可变的对象树:你拿到控件引用,再调用方法改属性。Compose 把这件事反过来了——界面是函数,输入状态、输出 UI 描述,状态变化时框架重新执行函数并计算最小更新。
这个转变带来三个直接好处:不用再写 findViewById 与 XML;状态与界面之间的同步由框架保证,减少了“数据变了但界面没更新”的 bug;组件天然是函数,可以提取、组合、单独预览与测试。
在 Compose 里,你写的是“界面应该是什么样”,而不是“界面要怎么变成这样”。所有技巧都围绕这一点展开。
代价是学习曲线:重组、状态提升、副作用这些概念必须先理解,否则会出现“状态莫名其妙丢了”或者“界面疯狂刷新”的问题。
@Composable 与重组
用 @Composable 标注的函数即一个界面单元。它看起来像普通函数,但有两个特殊约束:不应该有副作用(不能在这里发请求、写数据库),并且必须足够快,因为它会被反复调用。
“重组”(Recomposition)是框架的核心机制:当函数读取的状态发生变化时,Compose 会重新调用受影响的 @Composable 函数,并跳过输入未变化的部分。理解这一点后,优化方向就清楚了——让重组范围尽可能小。
@Composable
fun Counter() {
// remember 让状态在重组之间保持;mutableStateOf 让它可以被观察
var count by remember { mutableStateOf(0) }
Column(Modifier.padding(16.dp)) {
Text("当前计数:$count", fontSize = 20.sp)
Button(onClick = { count++ }) { Text("加一") }
}
}
注意 by 委托与 rememberSaveable 的区别:设备旋转或进程重建时,remember 的值会丢失,需要跨配置变更保留的状态应使用 rememberSaveable。
状态:remember 与状态提升
状态提升(State Hoisting)是 Compose 里最重要的一条设计原则:把状态从组件内部提到调用方,组件只接收值并通过回调上报变化。这样组件变成纯粹无状态的,可复用、可预览、可测试。
- 单一数据源同一份状态只放在一个地方,避免两个副本互相不同步。
- 状态提升无状态组件更易测试;有状态组件只负责持有与转发。
- 可观察类型用 mutableStateOf / StateFlow,普通变量变化不会触发刷新。
- 按需 remember只有需要跨重组保留的值才用 remember,计算结果可用 remember(key)。
实践建议是“状态往下沉,事件往上抛”:数据流向下、事件流向上,整个页面的数据流是单向的。做到这一点,界面逻辑会变得非常容易推理。
布局与修饰符 Modifier
Compose 的布局由基础容器构成:Column(纵向)、Row(横向)、Box(层叠)、以及 LazyColumn / LazyRow(惰性列表)。样式则通过 Modifier 链式设置。
Modifier 有两个容易踩坑的地方。第一,顺序有意义:Modifier.padding(16.dp).background(Color.Blue) 与 Modifier.background(Color.Blue).padding(16.dp) 效果不同,前者背景不含内边距,后者包含。第二,不要复用同一个 Modifier 实例去修改,链式调用本质是创建新对象,把它当不可变值使用。
常用的尺寸与对齐修饰符包括 fillMaxSize、fillMaxWidth、size、width、height、padding、weight(在 Row/Column 中分配剩余空间)。weight 与 View 体系的 layout_weight 类似,是实现自适应的主力工具。
建议把公共样式抽成扩展函数,例如 fun Modifier.cardStyle() = this.clip(RoundedCornerShape(16.dp)).background(Color.White).padding(16.dp),既统一又简洁。
列表:LazyColumn 与键
长列表必须用 LazyColumn,它只组合可见区域的项,滚动时按需创建与回收。与普通 Column 的区别就像 RecyclerView 与 LinearLayout 的区别。
@Composable
fun UserList(users: List<User>, onClick: (User) -> Unit) {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(items = users, key = { it.id }) { user ->
UserRow(user = user, onClick = { onClick(user) })
}
}
}
key 参数非常关键:它让 Compose 在数据顺序变化或插入删除时正确复用组件,并保留各行的内部状态(如滚动位置、展开状态)。不指定 key 时,Compose 按位置匹配,列表头部插入一条就会导致所有行的状态错位——这是最常见的一类“诡异 bug”。
副作用:LaunchedEffect 与 rememberCoroutineScope
@Composable 函数应该保持纯粹,发请求、注册监听、启动协程这类动作属于“副作用”,必须放在专门的 API 里,否则会在每次重组时重复执行。
- LaunchedEffect(key):进入组合时启动协程,key 变化时取消并重启,离开组合时自动取消。用于加载数据。
- rememberCoroutineScope():拿到与组合生命周期绑定的作用域,用于在点击事件里启动协程。
- DisposableEffect(key):需要清理的注册(如监听器、广播),在 onDispose 中注销。
- SideEffect:每次成功重组后执行一次,用于把状态同步给非 Compose 代码。
判断准则:如果一段代码在“重组”时不应该重复执行,它就必须放进副作用 API。没有例外。
实战:一个可测试的搜索列表
把状态提升、副作用与惰性列表组合起来,实现一个带防抖搜索的列表页。
@Composable
fun SearchScreen(vm: SearchViewModel = viewModel()) {
val uiState by vm.state.collectAsStateWithLifecycle()
var keyword by rememberSaveable { mutableStateOf("") }
Column(Modifier.fillMaxSize()) {
OutlinedTextField(
value = keyword,
onValueChange = { keyword = it; vm.onKeywordChange(it) },
label = { Text("搜索用户") },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(16.dp),
)
when (val s = uiState) {
is UiState.Loading -> Box(Modifier.fillMaxSize(), Alignment.Center) { CircularProgressIndicator() }
is UiState.Error -> Text(s.message, Modifier.padding(16.dp), color = MaterialTheme.colorScheme.error)
is UiState.Success -> LazyColumn(contentPadding = PaddingValues(16.dp)) {
items(s.data, key = { it.id }) { user -> UserRow(user = user, onClick = {}) }
}
}
}
}
// ViewModel 中做 300ms 防抖,避免每次输入都发请求
class SearchViewModel(private val repo: UserRepository) : ViewModel() {
private val keyword = MutableStateFlow("")
private val _state = MutableStateFlow<UiState<List<User>>>(UiState.Success(emptyList()))
val state = _state.asStateFlow()
init {
viewModelScope.launch {
keyword.debounce(300).distinctUntilChanged().collectLatest { kw ->
_state.value = UiState.Loading
_state.value = runCatching { repo.search(kw) }
.fold({ UiState.Success(it) }, { UiState.Error(it.message ?: "搜索失败", it) })
}
}
}
fun onKeywordChange(v: String) { keyword.value = v }
}
这段代码有几个值得借鉴的点:界面完全由 UiState 驱动,when 穷尽分支;防抖与请求取消放在 ViewModel,界面层不关心;collectAsStateWithLifecycle 保证界面不可见时停止收集,避免后台耗电。因为 SearchScreen 依赖可注入的 ViewModel,配合 Compose 测试规则就能在不上真机的情况下验证交互。
性能与测试:避免无效重组
Compose 的性能问题几乎都表现为“重组范围过大”或“重组过于频繁”。
- 把频繁变化的状态往下沉:一个每秒更新的计时器不该让整个页面重组。
- 传稳定类型:@Immutable 或 @Stable 标注的数据类能让 Compose 正确跳过未变化的部分;传 Lambda 时用 remember 包裹避免每次新建实例。
- 用派生状态:derivedStateOf 把“多个状态计算出的值”缓存起来,避免每次重组重复计算。
- 列表项保持轻量,复杂计算提前算好或用 remember(key) 缓存。
Compose 的优化不是“少调用函数”,而是“让不必要的重组被跳过”。围绕这一点做设计,性能自然就好了。
测试方面,createComposeRule 能在 JVM 上对界面做断言与交互(onNodeWithText().performClick()),把主要交互路径写成测试,比每次手动点真机靠谱得多。再结合右侧的 Layout Inspector 查看重组计数,问题定位会快很多。