先把结论放在前面:@Composable 函数不是"普通函数加了注解",而是被编译器插件改写过的函数——它把"描述 UI 的代码"变成"向运行时描述树(SnapshotTree)注册一份描述",运行时由 Composition 负责组合、状态变化时按需重组。 理解这一点,就理解了 Compose 的各项特性:为什么没有控件引用、为什么重组可以跳过、为什么副作用需要单独声明。
面试官验证 Compose 入门的真实功力有个土办法:聊坑。 默认行为是什么、边界在哪、出错时是什么表现。背资料的人在这里会集体沉默。 所以这篇的重点放在三个必答的坑上。
编译期与运行时的分工
@Composable 函数的处理分两个阶段:
编译期(Compose Compiler Plugin):
- 检查
@Composable调用只能出现在@Composable上下文(这就是"Composable 函数只能从 Composable 函数调用"这条限制的来源) - 生成"重组作用域"标记(
$composer、$changed标记当前函数依赖了哪些状态) - 把状态读取位置编译成"依赖注册"——
val x by state会被转成"当前重组作用域订阅了state的读取" - 静态检查:Composable 里调用普通函数、Composable 里有副作用等都有 lint 提示
运行时(Composition / Recomposer):
- 首次组合:执行 Composable 函数树,生成
LayoutNode树 - 状态变化:只有"订阅了该状态"的重组作用域进入待重组队列
- 重组:重新执行该作用域的 Composable,产生新的 Node 树,与旧的对比(diff)后只更新变化的属性
- 重组不是"重建",而是"重执行 + 差异化更新"——这是理解 Compose 性能的关键
能主动说清"编译器把状态读取变成依赖注册、运行时按依赖重组",说明不是只用过、而是理解了。
三个坑的机制解释
坑一:命令式思维找控件引用。 传统写法是 findViewById(R.id.tv) 拿到控件再 setText。Compose 里根本没有"控件"这个对象——Text("hello") 的含义是"在当前布局节点上描述一个文本节点",它返回 Unit,没有任何可持有的句柄。要改状态就改状态(state.value = "x"),Compose 会重新描述这一段。 规范是"改状态而不是改控件",这是 Compose 的核心心智模型。
坑二:把重计算写在 Composable 里。 因为重组会重新执行 Composable 函数,函数体里任何计算都会重复跑。典型是把 JSON 解析、格式化、列表过滤写在函数体里。规范是 remember { } 缓存纯计算结果,依赖变化时才重算;跨重组且需要跨配置变更保存的用 rememberSaveable。
坑三:在 Composable 里做副作用。 副作用指"影响外部世界"的操作:写文件、发网络请求、打日志、操作 View(Toast、跳转)。如果写在函数体里,每次重组都会执行一次——表现是"每次状态变化都发一次请求"。规范是用 LaunchedEffect、DisposableEffect、SideEffect 三个专用 API 声明副作用的时机**。
最常见的坑是
第一层坑:用命令式思维保存控件引用,Compose 中不存在控件句柄概念。
除了"拿不到句柄",这个思维还带来两个更隐蔽的问题:① 用 textView.text = x 这样的方式在 onClick 里改 UI,在 Compose 里对应的是"改 State",但很多人会试图用 var text by remember { mutableStateOf("") } 在点击回调里改一个局部 state,结果发现改不动——因为这个 state 属于点击回调所在的作用域,回调本身是被 Recomposer 重建的。② 用全局变量(object 里存 TextView)绕过,会造成状态与界面脱节。
规范是状态提升(State Hoisting):状态定义在共同的父级,通过参数传下去,事件通过回调传上来。"这个 Composable 是无状态的、只接收参数与回调"是 Compose 的设计准则——能主动说出这个准则,说明真的理解了 Compose 的架构意图。
第二层坑:把重计算直接写在 Composable 里,每次重组都执行。
// 反模式:JSON 解析在重组时反复执行
@Composable
fun ProfileCard(raw: String) {
val user = remember {
Json.decodeFromString<User>(raw) } // 正确:缓存
val display = formatUser(user) // 错误:每次重组都跑
Text(display)
}
关键区分是"纯计算"与"有副作用的操作":
@Composable
fun ProfileCard(raw: String) {
// 纯计算 → remember 缓存,key 变化时重算
val display = remember(raw) {
formatUser(decodeUser(raw)) }
// 派生状态 → 组合多个状态且有昂贵计算时用 derivedStateOf
val isLong by remember {
derivedStateOf {
display.length > 40 } }
// 副作用 → 单独声明
LaunchedEffect(raw) {
analytics.track("profile_card_shown", mapOf("len" to display.length))
}
Column {
Text(display, maxLines = if (isLong) 2 else 5)
}
}
这段代码里 derivedStateOf 的用途值得单独说:它的作用是"只有当派生值真正变化时才通知使用者"——如果直接用 val isLong = display.length > 40 写在函数体里,display 变化就会重组;而 derivedStateOf 会先比较新旧值,相同就不触发。这是列表里做"是否显示加载更多"这类判断的标准写法。
第三层坑:在重组中执行副作用,结果被反复执行多次。
三种副作用 API 的语义必须分清:
@Composable
fun Screen(viewModel: ScreenVm) {
val state by viewModel.state.collectAsStateWithLifecycle()
// LaunchedEffect:key 变化时取消旧协程并重启新协程
// 适用:网络请求、一次性初始化
LaunchedEffect(state.userId) {
viewModel.loadDetail(state.userId)
}
// DisposableEffect:进入组合时执行,退出时清理
// 适用:注册监听、绑定服务
DisposableEffect(Unit) {
val listener = object : SensorListener {
/* ... */ }
sensorManager.registerListener(listener, sensor)
onDispose {
// 规范:必须清理,否则泄漏
sensorManager.unregisterListener(listener)
}
}
// SideEffect:每次成功重组后执行(无 key,不重启)
// 适用:日志上报、埋点
SideEffect {
analytics.track("screen_rendered")
}
}
这段代码的三个区别是这题的核心:LaunchedEffect 有 key(key 变了重启)、DisposableEffect 有清理回调、SideEffect 无 key 且每次重组都跑。"什么时候用哪个"答不清,这题就过不了。
还有一个更隐蔽的坑:LaunchedEffect 的 key 用错了导致请求不发出或反复发出。 常见错误是 LaunchedEffect(Unit) 里请求依赖了会变的状态(状态变了但 effect 不重启),或者把会变的状态写进 key 里导致每次变化都重启请求(表现为"输入框每敲一个字就发一次请求")。规范是 key 要精确表达"这个 effect 依赖的数据",并在请求层做防抖。
代码里见真章
先看一个体现"无状态 + 状态提升"的完整组件:
// 无状态组件:只接收参数与回调,不持有状态
@Composable
fun CounterCard(
count: Int, // 状态由外部传入
onIncrement: () -> Unit, // 事件回调向上传
modifier: Modifier = Modifier,
title: String = "点击次数"
) {
Column(modifier = modifier.padding(16.dp)) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Text(
text = "$count",
style = MaterialTheme.typography.displayMedium
)
Button(onClick = onIncrement) {
Text("加一") }
}
}
// 有状态包装:把状态与回调收口在这里
@Composable
fun CounterScreen(viewModel: CounterVm = viewModel()) {
val count by viewModel.count.collectAsStateWithLifecycle()
val title by remember {
mutableStateOf("计数器") }
// derivedStateOf:只有 visible 真正变化时才通知使用者
val visible by remember {
derivedStateOf {
count >= MAX_FREE_USE }
}
// 副作用:key 变化才重启
LaunchedEffect(visible) {
if (visible) viewModel.reportQuotaReached(count)
}
Scaffold(topBar = {
TopAppBar(title = {
Text(title) }) }) {
pad ->
Box(Modifier.padding(pad)) {
CounterCard(
count = count,
onIncrement = viewModel::increment,
title = title
)
if (visible) QuotaBanner(Modifier.align(Alignment.TopCenter))
}
}
}
这段代码体现了 Compose 的架构准则:CounterCard 无状态可测(给参数就能测),CounterScreen 有状态并负责收口。 能主动画出这个分界,说明理解了"无状态组件可组合、可测试"的意图。
Modifier 链与重计算规避的完整写法:
@Composable
fun Chart(data: List<Point>, modifier: Modifier = Modifier) {
// 关键:格式化结果缓存,data 变化才重算
val labels = remember(data) {
data.map {
formatLabel(it) } }
val maxValue = remember(data) {
data.maxOf {
it.y } }
Column(
// Modifier 顺序:size 决定测量约束,padding 缩进内容
modifier = modifier
.size(width = 300.dp, height = 180.dp) // 1 尺寸
.padding(12.dp) // 2 内边距
.background(Color(0xFFF5F5F5), RoundedCornerShape(8.dp)) // 3 背景
) {
// 关键:Modifier 复用避免每次重组重复创建
val barModifier = Modifier
.fillMaxHeight(0.6f)
.clip(RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp))
labels.forEach {
label ->
Text(text = label, modifier = Modifier.padding(vertical = 2.dp))
}
// 图条用 drawBehind,避免为每根条创建独立 Composable
Canvas(Modifier.fillMaxWidth().height(120.dp)) {
val barWidth = size.width / (data.size.coerceAtLeast(1))
data.forEachIndexed {
index, point ->
val barHeight = size.height * (point.y.toFloat() / maxValue)
drawRect(
color = Color(0xFF2196F3),
topLeft = Offset(barWidth * index, size.height - barHeight),
size = Size(barWidth - 2f, barHeight)
)
}
}
}
}
这段代码里 remember(data) { ... } 与 Canvas + drawRect 的组合是这题的实用答案:图条不创建独立 Composable(避免 N 个组合节点),直接在 Canvas 的绘制 lambda 里画——这在数据量大时是显著的性能差异。
remember 与 rememberSaveable 的正确区分:
@Composable
fun SearchScreen() {
// remember:只在重组间保留,进程被杀/配置变更后丢失
val tab = remember {
mutableStateOf(0) }
// rememberSaveable:通过 Bundle 保存,配置变更与进程重建后可恢复
val keyword = rememberSaveable {
mutableStateOf("") }
val scrollIndex = rememberSaveable {
mutableIntStateOf(0) }
// 规范:rememberSaveable 只放可序列化的基本类型或 Parcelable
// 大对象(列表、ViewModel)不要放进去
// 需要自定义 Saver 时用 rememberSaveable(saver = ...)
val time = rememberSaveable(
saver = Saver<Date, Long>(
save = {
it.time },
restore = {
Date(it) }
)
) {
mutableStateOf(Date()) }
Column {
SearchField(keyword.value, onChange = {
keyword.value = it })
LazyColumn {
items(count = results.size) {
i ->
ResultRow(results[i], scrollState = {
scrollIndex.value = i })
}
}
}
}
这段代码里 rememberSaveable 的适用边界是这题的判分点——"只放可序列化的基本类型"这条规则能说出来,说明理解它依赖 Bundle 的机制。
derivedStateOf 与 mutableStateOf 的取舍场景:
@Composable
fun LazyColumnDemo(items: List<Item>) {
val listState = rememberLazyListState()
// 错误示范:每帧计算 isAtEnd,但只有布尔值变化才有意义
val isAtEndDirect = listState.layoutInfo.visibleItemsInfo
.lastOrNull()?.index == items.size - 1
// 正确:用 derivedStateOf 做去重,只有布尔值翻转才触发重组
val isAtEnd by remember {
derivedStateOf {
val info = listState.layoutInfo
val last = info.visibleItemsInfo.lastOrNull()?.index ?: 0
last >= items.size - 3
}
}
LaunchedEffect(isAtEnd) {
if (isAtEnd && items.size < TOTAL) loadMore()
}
}
这段代码是 derivedStateOf 最经典的用法——"每帧都变的原始量"经 derivedStateOf 变成"偶尔才变的布尔量",重组次数从每帧降到实际变化时。
面试中的经典考点
问:@Composable 到底被编译成了什么?
答:编译器插件把它改写成"接收 Composer 参数的函数",并把状态读取位置转成"向当前重组作用域注册依赖";运行时 Composition 执行函数树生成 LayoutNode,状态变化时只重执行订阅了该状态的重组作用域,并对结果做 diff 后局部更新。 "重执行 + 差异化更新"而不是"重建",是这套机制的核心。
问:重组的作用域是什么?一个 Composable 里哪些变化会触发重组?
答:作用域就是该 @Composable 函数的执行范围。只有在该函数体内被读取的状态变化才会触发它的重组;读取状态在子函数里,则子函数的重组作用域被触发(子重组不会连带父重组)。这解释了"为什么把状态读放到子 Composable 能缩小重组范围"——这是性能优化的第一手段。
问:remember 的 key 是什么意思?
答:key 变化时 remember 的缓存被丢弃并重新计算。 无 key 表示"整个 Composable 生命周期内只算一次";key 要精确表达"这个计算依赖什么"。规范是依赖哪个状态就把哪个状态(和相关参数)放 key 里,避免"漏了 key 导致不更新"或"key 太宽导致频繁重算"。
问:为什么 Compose 里没有 findViewById?
答:因为 Composable 不产生"控件对象",只向 Composition 描述节点树。 传统模式下控件是"可变的状态载体",Compose 模式下状态是唯一的真相,UI 是状态的函数。所以改 UI 就是改状态,没有句柄可找。能延伸出"状态提升"这个准则,说明理解到了架构层面。
问:LaunchedEffect / DisposableEffect / SideEffect 什么时候用?
答:LaunchedEffect 用于"异步任务随 key 变化重启"(网络请求、订阅流),key 变化时自动取消旧协程;DisposableEffect 用于"注册与成对清理"(监听、传感器),退出组合时执行 onDispose;SideEffect 用于"每次成功重组后执行一次"(埋点、日志),无 key。 三选错就是泄漏或重复执行。
问:Compose 的性能怎么优化?
答:按收益排序五条。① 缩小重组范围——把状态读下沉到子 Composable;② remember 缓存纯计算;③ derivedStateOf 降低触发频率;④ 列表用 key + contentType(LazyColumn);⑤ 避免在 Composable 里创建高成本对象,用 Canvas 批量绘制替代大量子节点。再补一条:用 Trace 或 Layout Inspector 的重组计数来定位,而不是凭感觉优化。
落到项目里怎么做
一条能写进规范的红线:Composable 函数保持无状态——只接收参数与回调,不在内部持有业务状态。 这条准则同时解决了可测性、复用性与性能三个问题,是投入产出比最高的一条 Compose 规范。
配套实践三条:① 用 ComposeContentTestRule + createComposeRule 为每个无状态组件写截图与交互测试,状态外置后测试成本降到最低;② 重计算一律走 remember,跨配置变更需要保留的走 rememberSaveable,在 code review 清单里把"函数体里的重计算"列为必查项;③ 用 Layout Inspector 的 Recomposition 面板在真机上核对重组次数,发现某屏每秒重组上百次就说明有状态读放错了层级。
给正在准备面试的你
这题的答法要落到"编译期改写 + 运行时重组 + 三个副作用 API"这条主线。推荐这条线:先讲"Composable 被编译成带 Composer 的函数、状态读取变成依赖注册"→ 讲"重组是重执行 + diff,不是重建" → 讲三个坑:命令式找控件、重计算没缓存、副作用混在函数体 → 落到"无状态组件 + remember/derivedStateOf/LaunchedEffect 分工"这套规范。能被追问到"重组的作用域粒度"并答出"子重组不连带父重组",说明理解了优化空间;能说清三个副作用 API 的差异与各自适用场景,说明真的在项目里用过而不只是看过文档。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:Paging-分页库:加载更多与占位
下一篇预告:Compose-重组原理:何时执行何时跳过
有任何问题欢迎在评论区留言交流。