先把结论放在前面:组件与 UI 这条线的能力差异,多数集中在"默认值"上——不是 API 会不会用,而是知不知道每个组件的默认行为是什么、什么时候必须改、改了会带来什么副作用。 面试里最常见的区分题就是:"你说这个方案可以,那它默认的间距、缓存策略、生命周期回调、重组边界是什么?答不上来,说明只是照着 Demo 写过。**
这题是收口题,所以答法要给地图 + 给判定标准:地图是"能力顺序",判定标准是"每个知识点上你需要能回答什么问题"。能把这两样讲清,比逐个罗列 API 有价值得多。
五段地图与每段的判定标准
| 段 | 核心能力 | 判定标准(能答出即通关) |
|---|---|---|
| 生命周期 | Activity/Fragment/ViewModel 三者关系 | 旋转屏幕时哪些对象重建、哪些保留、为什么 |
| View 体系 | 测量布局绘制、事件分发、滑动 | 一个自定义 View 的 onMeasure 里 MeasureSpec 三个模式分别何时出现 |
| 列表 | 复用、缓存、多类型、动画 | 为什么 notifyDataSetChanged 会让所有 item 重绑 |
| Jetpack | Room/Paging/Lifecycle/Compose | Room 的编译期校验拦住了什么错 |
| Compose | 重组、状态、布局、副作用 | 重组的边界由什么决定,为什么 lambda Modifier 少重组 |
这张表的用法:每段只问自己一个问题。 例如 View 体系段只问"MeasureSpec 三个模式何时出现";答不出就回去补,答得出就过。这比"把每个 API 都记一遍"效率高得多,也不容易忘。
生命周期段:三个对象的重建边界
这是整条线里最常被问的"重建边界",标准答案的结构:
| 场景 | Activity | Fragment | ViewModel |
|---|---|---|---|
| 旋转屏幕 | 重建 | 重建(若跟随宿主) | 保留 |
| 配置变更(深色/语言) | 重建 | 重建 | 保留 |
| 进程被杀后重建 | 重建 | 重建 | 丢失(数据靠 SavedState) |
| 导航到下一屏 | 视设计 | 通常保留在返回栈 | 各自独立 |
// 判定这三件事的标准动作
class MyViewModel(private val saved: SavedStateHandle) : ViewModel() {
// 1) 屏幕旋转:VM 实例被保留,init 不再执行
// 2) 进程被杀:VM 新建,靠 SavedStateHandle 恢复 key-value
// 3) 真正结束:onCleared 触发
val drafts = saved.getStateFlow("drafts", "")
}
// Activity 侧取 VM
private val vm: MyViewModel by viewModels()
这道题的加分点是能主动说清"Activity 的 onRetainNonConfigurationInstance 与 ViewModelStore 的关系"——两者都是在配置变更时保留对象,但前者走的是宿主回调,后者走的是 ViewModelStore 里的 NonConfigurationInstances 键。能区分这两条路径,说明不是死记硬背。
View 体系段:MeasureSpec 的三个模式
这是 View 体系最有效的单点判定题,因为答案唯一且可验证:
- EXACTLY:
MeasureSpec.makeMeasureSpec(exactSize, mode=EXACTLY),父容器给了确定尺寸(match_parent或固定 dp)。 - AT_MOST:
specSize = 剩余空间,子 View 不得超过,wrap_content走这个。 - UNSPECIFIED:
specSize = 0,父容器不约束,常见于ScrollView测量其子 View 纵向、横向HorizontalScrollView纵向。
// 答不出这个,说明自定义 View 还没真写过
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val wMode = MeasureSpec.getMode(widthMeasureSpec)
val hMode = MeasureSpec.getMode(heightMeasureSpec)
val wSize = MeasureSpec.getSize(widthMeasureSpec)
val hSize = MeasureSpec.getSize(heightMeasureSpec)
// 必须在 setMeasuredDimension 前 resolveSize,否则自定义属性(如 maxWidth)不生效
val desiredW = calculateWidth()
val desiredH = calculateHeight()
setMeasuredDimension(
resolveSize(desiredW, widthMeasureSpec),
resolveSize(desiredH, heightMeasureSpec)
)
}
再补一个容易被追问的点:resolveSize 与 resolveSizeAndState 的区别——后者额外处理 MEASURED_STATE_TOO_SMALL 标记,供父容器判断子 View 是否被裁剪。这个细节能答出来,基本可以认为真读过 View 测量流程。
列表段:复用与缓存的默认值
这一段的判定标准全在默认值上:
RecyclerView的setHasFixedSize(true)(尺寸不变时开,开启后能跳过重新布局)——不知道这个默认值就等于浪费了一半优化空间。LinearLayoutManager的initialPrefetchItemCount(手动设置会改变预取行为)。ViewHolder的itemView复用时机。ListAdapter的AsyncDiffer默认在后台线程计算 diff。
// 默认值意识:三个"不设就吃亏"的参数
recyclerView.setHasFixedSize(true) // 尺寸不变,跳过 onLayout 重排
recyclerView.setItemViewCacheSize(20) // 默认 2,浅层缓存太小导致频繁创建
recyclerView.itemAnimator = null // 调试期关掉动画,排除干扰
val differ = AsyncDiffer(DIFF, ::onDiff) // ListAdapter 默认后台线程
能主动说出"默认值不合适时怎么调、调了会换来什么代价",就是这一段通关的标志。 例如 setItemViewCacheSize 调大能减少创建 View 的次数,但会增加内存占用,需要按机型定档。
Jetpack 与 Compose 段:机制层面的判定
Room 的判定标准是"编译期校验拦住了什么":主键缺失、类型不匹配、NOT NULL 字段缺默认值导致迁移失败、@Query 里的 SQL 语法错——这些都在编译期报错。答不出这个,说明只是照抄 @Entity 用过。
Compose 的判定标准是"重组边界由什么决定":由读取状态的 Composable 决定,不是由写了状态的 Composable 决定。这个区分是整段的核心:
// 父组件写状态、子组件读状态:只有子组件重组
@Composable
fun Parent() {
var count by remember {
mutableIntStateOf(0) }
Column {
Text("${count} 次点击") // 读 count,随父重组
CounterDisplay(count) // 参数传入,父重组就会重组它
}
}
再补 lambda 版 Modifier 的作用(offset { } 在布局阶段读、只重跑布局不重组),这是 Compose 性能的判定点。能答出"读状态决定重组边界 + lambda Modifier 避开重组",这段就通了。
最常见的坑是
第一层坑:只写 Compose 不懂重组模型,性能问题无从定位。
表现是"代码读起来很干净,但滑动时掉帧"。根因是把 state 用 by 读在 Composable 函数体里,或者在高频变化的值上用了 graphicsLayer 之外的方式。规范是动画进度、滚动偏移这类高频值一律走 lambda 版 Modifier 或 graphicsLayer,不在函数体 by 读取。
第二层坑:忽略系统提供的默认值,白白丢掉优化。
例如忘了 setHasFixedSize(true)、忘了 AsyncDiffer 的后台计算、忘了 Compose 的编译器强优化在稳定版默认开启。规范是每个知识点上手前先查默认值——这是这题标题"差距藏在默认值里"的由来。
第三层坑:跨段的知识不连边,遇到组合场景就露怯。
典型场景是"列表 item 里的 Compose 状态在配置变更后丢了"——需要同时懂 View 体系的 ViewHolder 复用、Compose 的 remember(不跨配置变更)与 rememberSaveable(跨配置变更但不能跨进程)、以及 Fragment 的 viewLifecycleOwner。这类跨段问题才是资深候选人的分水岭。
这题在面试里怎么问、怎么答
问:给你一个 UI 需求,你怎么判断该用 View 体系还是 Compose?
答:给三条判据。① 团队与存量:新模块用 Compose、存量模块不强行迁移,避免两套体系长期并存带来维护成本。② 需求形态:静态布局 Compose 更简洁;需要精细控制绘制、复用 View、接入复杂平台控件时 View 体系仍有优势。③ 互操作边界成本:每一条边界都要登记与处理状态同步,越多越贵。能补一句"决策的落点是维护成本而不是技术新旧",说明判断成熟。
问:Compose 的重组边界到底是什么决定的?
答:由在 Composable 函数体里读取了哪些状态决定。写了状态但没读的组件不会因为那个状态变化而重组;读了状态的组件会在该状态变化时重组。配套的是稳定性判定——编译器通过比较参数判断能否跳过重组,lambda 与不稳定类型是破坏跳过的常见来源。再补 lambda Modifier 的优化原理:在布局或绘制阶段读值,不触发重组。
问:View 体系的 measure 流程是怎样的?MeasureSpec 三个模式分别什么时候出现?
答:measure 阶段父容器给子 View 下发 MeasureSpec,三种模式分别是 EXACTLY(确定尺寸)、AT_MOST(不超过剩余空间,wrap_content 走这个)、UNSPECIFIED(不约束,ScrollView 测量其子 View 时纵向会给)。子 View 计算期望尺寸后必须用 resolveSize 收敛再 setMeasuredDimension。再补 resolveSizeAndState 处理裁剪标记的细节,这题就完整了。
问:RecyclerView 的缓存有几层?为什么会有四层?
答:四层——AttachedScrap(刚 detach 未复用)、CacheViews(布局内缓存,默认 2)、ViewCacheExtension(按 LayoutManager 扩展,如 Grid 的按面积计算)、RecycledViewPool(按类型跨列表复用)。设计成多层是因为不同生命周期的 View 复用成本不同:越靠近 attach 的 View 复用越快、越不需要重新绑定。能答出"为什么分层"而不是"分几层",说明理解了设计意图。
落到项目里怎么做
一条能写进规范的红线:新写的 UI 代码在提交前必须确认三件事——默认值是否已按需调整、高频值是否走了正确的作用域更新路径、跨体系边界是否已登记。 这条覆盖了本篇三类最常见的坑。
配套实践三条:① 建一份"组件默认值速查"内部文档,把生命周期回调顺序、RecyclerView 缓存参数、Compose 重组规则列成表格,团队共用;② 在 code review 模板里加一条"默认值检查",reviewer 逐项确认;③ 把性能基线做成可回归的数字(某页面的首屏时间、滑动帧率、内存峰值),优化前后用数据说明,而不是靠"感觉快了"。
给正在准备面试的你
这题是收口题,答法要落到"能力差异体现在默认值上,所以要给判定标准而不是 API 清单"这条主线。推荐这条线:先给五段地图与每段的判定标准表 → 挑两段展开(生命周期重建边界、View 体系 MeasureSpec 三模式)→ 讲默认值意识(setHasFixedSize、AsyncDiffer、编译器强优化)→ 讲跨段组合场景才是分水岭 → 补三个坑 → 收在"提交前三件事"。能被追问"View 体系的 MeasureSpec 三个模式何时出现"并答出 ScrollView 纵向给 UNSPECIFIED 这条,说明真写过自定义 View;能主动把"生命周期 + Compose 状态 + ViewHolder 复用"三条线串起来回答一个丢失问题,说明具备资深候选人的知识结构。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:无障碍与国际化:可达性是质量的一部分
下一篇预告:Fragment-与-ViewModel-作用域:谁该持有谁
有任何问题欢迎在评论区留言交流。