UI 绘制 / 事件分发 / RecyclerView — 面试题篇
更新: 5/15/2026 字数: 0 字 时长: 0 分钟
Q1: View 的绘制流程?measure、layout、draw 各做什么?
考察点:View 绘制原理
完整回答:
绘制从 ViewRootImpl.performTraversals() 开始,依次执行三大流程:
measure:确定 View 的大小。ViewGroup 先测量子 View,再根据子 View 大小确定自身大小。核心是 MeasureSpec(高2位模式+低30位大小),三种模式:EXACTLY(精确值)、AT_MOST(最大值,wrap_content)、UNSPECIFIED(无限制)。
layout:确定 View 的位置。ViewGroup 在 onLayout 中调用每个子 View 的 layout(l, t, r, b) 确定其四个顶点坐标。
draw:绘制 View 内容。顺序是:背景 → onDraw(自身内容)→ dispatchDraw(子 View)→ 前景。
追问:自定义 View 中 wrap_content 不处理会怎样?
等同于 match_parent。因为父 EXACTLY + 子 wrap_content 得到 AT_MOST + 父大小,如果 onMeasure 不处理 AT_MOST 直接用 specSize,就是父容器大小。正确做法是在 AT_MOST 模式下计算内容所需大小,取 min(内容大小, specSize)。
追问:requestLayout 和 invalidate 的区别?
requestLayout 强制触发 measure + layout。如果布局发生变化,layout 内部会调用 invalidate 标记脏区域,连带触发 draw。invalidate 只触发 draw,用于内容变化(如颜色、文字)。invalidate 不会重新测量和布局。
加分点:提到 ViewRootImpl 通过 Choreographer 在下一个 VSYNC 信号到来时执行 performTraversals,保证 16ms 一帧。
Q2: 事件分发机制?从手指触摸到 View 响应的完整流程?
考察点:事件分发源码理解
完整回答:
事件从硬件到应用的完整链路:
- 触摸屏产生中断 → InputManagerService → InputDispatcher
- 通过 Socket 发送到应用进程的 InputChannel
- ViewRootImpl 的 WindowInputEventReceiver 接收
- 进入 View 树的分发流程
View 树分发流程:
- Activity.dispatchTouchEvent → PhoneWindow → DecorView → ViewGroup.dispatchTouchEvent
- ViewGroup 先调用 onInterceptTouchEvent 判断是否拦截
- 不拦截则倒序遍历子 View(后添加的先收到),调用 child.dispatchTouchEvent
- 子 View 的 dispatchTouchEvent 中:先调 OnTouchListener.onTouch,返回 false 才调 onTouchEvent
- onTouchEvent 的 ACTION_UP 中触发 OnClickListener.onClick
关键规则:
- DOWN 事件确定事件接收者(mFirstTouchTarget),后续 MOVE/UP 直接发给它
- 子 View 可以调用
requestDisallowInterceptTouchEvent(true)阻止父 ViewGroup 拦截 - 如果所有子 View 都不消费,事件回传给 ViewGroup 自己的 onTouchEvent
追问:onTouchListener、onTouchEvent、onClickListener 的优先级?
onTouchListener.onTouch > onTouchEvent > onClickListener.onClick。onTouch 返回 true 则 onTouchEvent 不调用,onClick 也不会触发。
Q3: 滑动冲突怎么解决?
考察点:事件分发实际应用
完整回答:
两种解决方案:
外部拦截法(推荐):在父 ViewGroup 的 onInterceptTouchEvent 中判断:
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case ACTION_DOWN:
return false; // DOWN 不拦截,否则子 View 收不到事件
case ACTION_MOVE:
if (needIntercept(ev)) return true; // 满足条件拦截
return false;
case ACTION_UP:
return false; // UP 不拦截,否则子 View 的 click 不触发
}
}内部拦截法:子 View 通过 requestDisallowInterceptTouchEvent 控制:
// 子 View
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case ACTION_DOWN:
parent.requestDisallowInterceptTouchEvent(true); // 禁止父拦截
break;
case ACTION_MOVE:
if (parentNeedEvent(ev)) {
parent.requestDisallowInterceptTouchEvent(false); // 允许父拦截
}
break;
}
return super.dispatchTouchEvent(ev);
}常见场景:
- ViewPager + ListView:水平滑动给 ViewPager,垂直滑动给 ListView
- ScrollView 嵌套 RecyclerView:根据滑动方向和子 View 是否到顶/底判断
Q4: RecyclerView 的缓存机制?四级缓存分别是什么?
考察点:RecyclerView 性能原理
完整回答:
RecyclerView 有四级缓存:
mAttachedScrap / mChangedScrap:屏幕内的 ViewHolder 缓存。layout 期间临时存放,layout 结束后复用。不需要重新绑定数据。
mCachedViews:刚滑出屏幕的 ViewHolder,默认容量 2。按 position 精确匹配,命中后不需要 onBindViewHolder。
ViewCacheExtension:用户自定义缓存,很少使用。
RecycledViewPool:按 viewType 分类存储,默认每种类型最多 5 个。命中后需要重新 onBindViewHolder。多个 RecyclerView 可以共享同一个 Pool。
复用流程:
tryGetViewHolderForPositionByDeadline()
→ 1. mAttachedScrap(position 匹配)
→ 2. mCachedViews(position 匹配)
→ 3. ViewCacheExtension
→ 4. RecycledViewPool(viewType 匹配)
→ 5. 都没命中 → onCreateViewHolder 创建新的追问:mCachedViews 和 RecycledViewPool 的区别?
mCachedViews 按 position 匹配,命中后直接复用不需要 bind,性能最好。RecycledViewPool 按 viewType 匹配,命中后需要重新 bind。mCachedViews 满了之后,最老的 ViewHolder 会被移到 RecycledViewPool。
追问:如何优化 RecyclerView 性能?
setHasFixedSize(true):item 大小固定时避免 requestLayoutDiffUtil:精确计算差异,避免 notifyDataSetChangedsetItemViewCacheSize:增大 mCachedViews 容量- 共享 RecycledViewPool:多个 RecyclerView 展示相同类型 item 时
setRecycledViewPool预创建 ViewHolder- 减少 onBindViewHolder 中的耗时操作
- 使用
ConcatAdapter替代多 viewType
Q5: DiffUtil 的原理?和 notifyDataSetChanged 有什么区别?
考察点:列表更新优化
完整回答:
notifyDataSetChanged 会刷新所有可见 item,触发所有 ViewHolder 的 rebind,且没有动画效果。
DiffUtil 使用 Eugene Myers 差分算法,计算新旧列表的最小编辑距离,只更新变化的 item:
- 移动、插入、删除有对应的动画
- 未变化的 item 不会 rebind
使用方式:
class MyDiffCallback(
private val oldList: List<Item>,
private val newList: List<Item>
) : DiffUtil.Callback() {
override fun getOldListSize() = oldList.size
override fun getNewListSize() = newList.size
override fun areItemsTheSame(oldPos: Int, newPos: Int) =
oldList[oldPos].id == newList[newPos].id // 是否同一个 item
override fun areContentsTheSame(oldPos: Int, newPos: Int) =
oldList[oldPos] == newList[newPos] // 内容是否相同
}
val diff = DiffUtil.calculateDiff(callback)
diff.dispatchUpdatesTo(adapter)推荐使用 ListAdapter(内置 AsyncListDiffer),自动在后台线程计算 diff:
class MyAdapter : ListAdapter<Item, ViewHolder>(ItemDiffCallback()) {
// submitList(newList) 自动计算 diff 并更新
}追问:DiffUtil 的时间复杂度?
O(N + D²),N 是新旧列表总长度,D 是编辑距离。列表很大且变化很多时可能耗时,所以 AsyncListDiffer 在后台线程计算。
Q6: 自定义 View 的完整流程?需要注意什么?
考察点:自定义 View 实践能力
完整回答:
- 继承:简单绘制继承 View,需要布局子 View 继承 ViewGroup
- 构造函数:至少实现两个构造函数(代码创建 + XML 解析),处理自定义属性
- onMeasure:处理 wrap_content(AT_MOST 模式),调用 setMeasuredDimension
- onLayout(ViewGroup):确定子 View 位置
- onDraw:使用 Canvas 绑制内容
- 处理 padding:onDraw 中考虑 padding,ViewGroup 的 onLayout 中考虑 padding
- 处理触摸事件:重写 onTouchEvent
注意事项:
- onDraw 中不要创建对象(Paint 等在构造函数中创建)
- 不要在 onDraw 中做耗时操作
- 处理好 wrap_content,否则等同于 match_parent
- 如果有动画,用 invalidate() 触发重绘
- 考虑 View 的状态保存与恢复(onSaveInstanceState/onRestoreInstanceState)
加分点:提到自定义 ViewGroup 时需要处理子 View 的 margin(通过 generateLayoutParams 返回 MarginLayoutParams),以及 clipChildren/clipToPadding 的影响。
Q7: getWidth() 和 getMeasuredWidth() 的区别?
考察点:View 测量与布局
完整回答:
getMeasuredWidth():在 measure 阶段确定,是 View 期望的宽度getWidth():在 layout 阶段确定,是 View 实际的宽度(right - left)
通常两者相等,但在 onLayout 中可以让实际宽度与测量宽度不同:
// 强制让子 View 的实际宽度是测量宽度的一半
child.layout(0, 0, child.getMeasuredWidth() / 2, child.getMeasuredHeight());
// 此时 getWidth() = getMeasuredWidth() / 2追问:在 onCreate 中获取 View 的宽高为什么是 0?怎么解决?
onCreate 时 View 还没有经过 measure 和 layout。解决方案:
// 方案1:View.post(在 View 的消息队列中执行,此时已完成布局)
view.post { val width = view.width }
// 方案2:ViewTreeObserver
view.viewTreeObserver.addOnGlobalLayoutListener(object : OnGlobalLayoutListener {
override fun onGlobalLayout() {
view.viewTreeObserver.removeOnGlobalLayoutListener(this)
val width = view.width
}
})
// 方案3:重写 onWindowFocusChanged(Activity 获得焦点时 View 已布局)
override fun onWindowFocusChanged(hasFocus: Boolean) {
if (hasFocus) { val width = view.width }
}Q8: RecyclerView 和 ListView 的区别?
考察点:列表控件对比
完整回答:
| 维度 | RecyclerView | ListView |
|---|---|---|
| ViewHolder | 强制使用(内置复用机制) | 可选(需手动实现) |
| 布局方式 | LayoutManager(线性/网格/瀑布流) | 只有垂直列表 |
| 动画 | ItemAnimator 内置动画支持 | 无内置动画 |
| 分割线 | ItemDecoration(灵活) | divider 属性(简单) |
| 点击事件 | 无内置,需自己实现 | setOnItemClickListener |
| 局部刷新 | notifyItemChanged/Inserted/Removed | 只有 notifyDataSetChanged |
| 缓存 | 四级缓存 | 两级缓存(ActiveView + ScrapView) |
| 嵌套滚动 | 支持 NestedScrolling | 不支持 |
追问:RecyclerView 的 notifyDataSetChanged 和 notifyItemChanged 的区别?
notifyDataSetChanged:所有 ViewHolder 失效,全部重新绑定,无动画notifyItemChanged(pos):只更新指定位置,有动画- 推荐用 DiffUtil / ListAdapter 自动计算差异
Q9: View.post(Runnable) 的原理?
考察点:View 消息机制
完整回答:
如果 View 已经 attach 到 Window(有 ViewRootImpl),post 直接通过 Handler 发送到主线程消息队列。
如果 View 还没有 attach(如 onCreate 中),Runnable 会被暂存到 View.mRunQueue 中,等到 dispatchAttachedToWindow 时再通过 Handler 发送。
这就是为什么 view.post { view.width } 能获取到正确宽高——Runnable 在 performTraversals 之后执行。
Q10: Choreographer 是什么?VSYNC 信号是什么?
考察点:渲染机制
完整回答:
VSYNC(Vertical Synchronization)是显示器发出的垂直同步信号,60Hz 屏幕每 16.6ms 发一次。
Choreographer(编舞者)是 Android 渲染的调度器,在 VSYNC 信号到来时依次执行:
- Input 事件处理
- 动画计算
- View 的 measure/layout/draw(performTraversals)
VSYNC ──→ Choreographer.doFrame()
├── CALLBACK_INPUT(输入事件)
├── CALLBACK_ANIMATION(动画)
├── CALLBACK_TRAVERSAL(View 绘制)
└── CALLBACK_COMMIT(提交)requestLayout() 和 invalidate() 最终都是向 Choreographer 注册回调,等待下一个 VSYNC 信号触发执行。这保证了所有 UI 更新都在 VSYNC 节奏上,避免画面撕裂。
实习面试补充:UI 与 RecyclerView 高频基础题
实习面试经常从“你项目里的列表怎么写”开始问,再追到事件分发、刷新方式和卡顿原因。
Q11: RecyclerView 写一个列表需要哪些核心角色?
考察点:RecyclerView 基本使用
完整回答:
RecyclerView 的基本角色包括:
RecyclerView:列表容器。Adapter:负责创建和绑定 item。ViewHolder:缓存 item 中的 View 引用,减少重复查找。LayoutManager:决定列表布局方式,比如线性、网格、瀑布流。
最常见流程是:准备数据列表 → 编写 item 布局 → 创建 Adapter/ViewHolder → 设置 LayoutManager 和 Adapter。
追问:为什么要用 ViewHolder?
列表滚动时 item 会频繁复用。ViewHolder 可以缓存 item 内部 View 的引用,避免反复 findViewById,减少开销。
Q12: match_parent、wrap_content 和 dp 有什么区别?
考察点:布局基础
完整回答:
match_parent:尺寸尽量填满父容器允许的空间。wrap_content:尺寸根据自身内容决定。dp:密度无关像素,用于适配不同屏幕密度。
文字大小通常使用 sp,因为 sp 会跟随用户字体大小设置变化;普通布局尺寸一般使用 dp。
加分点:移动端布局要避免写死过多绝对尺寸,可以结合 ConstraintLayout、权重、约束和自适应资源。
Q13: 点击事件不响应可能有哪些原因?
考察点:事件分发排查
完整回答:
常见原因包括:
- View 没有设置点击监听,或
clickable状态不正确。 - 父 View 拦截了事件,比如外层 ScrollView、RecyclerView。
- 有其他 View 覆盖在目标 View 上方。
- View 的宽高为 0,或者实际点击区域不在可见区域内。
- 子 View 消费了事件,父 View 收不到点击。
排查时可以先确认布局层级和点击区域,再通过日志观察 dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent 的返回值。
