Skip to content

UI 绘制 / 事件分发 / RecyclerView — 面试题篇

更新: 5/15/2026 字数: 0 字 时长: 0 分钟


Q1: View 的绘制流程?measure、layout、draw 各做什么?

考察点:View 绘制原理

完整回答

绘制从 ViewRootImpl.performTraversals() 开始,依次执行三大流程:

  1. measure:确定 View 的大小。ViewGroup 先测量子 View,再根据子 View 大小确定自身大小。核心是 MeasureSpec(高2位模式+低30位大小),三种模式:EXACTLY(精确值)、AT_MOST(最大值,wrap_content)、UNSPECIFIED(无限制)。

  2. layout:确定 View 的位置。ViewGroup 在 onLayout 中调用每个子 View 的 layout(l, t, r, b) 确定其四个顶点坐标。

  3. 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 响应的完整流程?

考察点:事件分发源码理解

完整回答

事件从硬件到应用的完整链路:

  1. 触摸屏产生中断 → InputManagerService → InputDispatcher
  2. 通过 Socket 发送到应用进程的 InputChannel
  3. ViewRootImpl 的 WindowInputEventReceiver 接收
  4. 进入 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 中判断:

java
@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 控制:

java
// 子 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 有四级缓存:

  1. mAttachedScrap / mChangedScrap:屏幕内的 ViewHolder 缓存。layout 期间临时存放,layout 结束后复用。不需要重新绑定数据。

  2. mCachedViews:刚滑出屏幕的 ViewHolder,默认容量 2。按 position 精确匹配,命中后不需要 onBindViewHolder。

  3. ViewCacheExtension:用户自定义缓存,很少使用。

  4. 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 大小固定时避免 requestLayout
  • DiffUtil:精确计算差异,避免 notifyDataSetChanged
  • setItemViewCacheSize:增大 mCachedViews 容量
  • 共享 RecycledViewPool:多个 RecyclerView 展示相同类型 item 时
  • setRecycledViewPool 预创建 ViewHolder
  • 减少 onBindViewHolder 中的耗时操作
  • 使用 ConcatAdapter 替代多 viewType

Q5: DiffUtil 的原理?和 notifyDataSetChanged 有什么区别?

考察点:列表更新优化

完整回答

notifyDataSetChanged 会刷新所有可见 item,触发所有 ViewHolder 的 rebind,且没有动画效果。

DiffUtil 使用 Eugene Myers 差分算法,计算新旧列表的最小编辑距离,只更新变化的 item:

  • 移动、插入、删除有对应的动画
  • 未变化的 item 不会 rebind

使用方式:

kotlin
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:

kotlin
class MyAdapter : ListAdapter<Item, ViewHolder>(ItemDiffCallback()) {
    // submitList(newList) 自动计算 diff 并更新
}

追问:DiffUtil 的时间复杂度?

O(N + D²),N 是新旧列表总长度,D 是编辑距离。列表很大且变化很多时可能耗时,所以 AsyncListDiffer 在后台线程计算。


Q6: 自定义 View 的完整流程?需要注意什么?

考察点:自定义 View 实践能力

完整回答

  1. 继承:简单绘制继承 View,需要布局子 View 继承 ViewGroup
  2. 构造函数:至少实现两个构造函数(代码创建 + XML 解析),处理自定义属性
  3. onMeasure:处理 wrap_content(AT_MOST 模式),调用 setMeasuredDimension
  4. onLayout(ViewGroup):确定子 View 位置
  5. onDraw:使用 Canvas 绑制内容
  6. 处理 padding:onDraw 中考虑 padding,ViewGroup 的 onLayout 中考虑 padding
  7. 处理触摸事件:重写 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 中可以让实际宽度与测量宽度不同:

java
// 强制让子 View 的实际宽度是测量宽度的一半
child.layout(0, 0, child.getMeasuredWidth() / 2, child.getMeasuredHeight());
// 此时 getWidth() = getMeasuredWidth() / 2

追问:在 onCreate 中获取 View 的宽高为什么是 0?怎么解决?

onCreate 时 View 还没有经过 measure 和 layout。解决方案:

kotlin
// 方案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 的区别?

考察点:列表控件对比

完整回答

维度RecyclerViewListView
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 信号到来时依次执行:

  1. Input 事件处理
  2. 动画计算
  3. 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_parentwrap_contentdp 有什么区别?

考察点:布局基础

完整回答

  • match_parent:尺寸尽量填满父容器允许的空间。
  • wrap_content:尺寸根据自身内容决定。
  • dp:密度无关像素,用于适配不同屏幕密度。

文字大小通常使用 sp,因为 sp 会跟随用户字体大小设置变化;普通布局尺寸一般使用 dp

加分点:移动端布局要避免写死过多绝对尺寸,可以结合 ConstraintLayout、权重、约束和自适应资源。


Q13: 点击事件不响应可能有哪些原因?

考察点:事件分发排查

完整回答

常见原因包括:

  • View 没有设置点击监听,或 clickable 状态不正确。
  • 父 View 拦截了事件,比如外层 ScrollView、RecyclerView。
  • 有其他 View 覆盖在目标 View 上方。
  • View 的宽高为 0,或者实际点击区域不在可见区域内。
  • 子 View 消费了事件,父 View 收不到点击。

排查时可以先确认布局层级和点击区域,再通过日志观察 dispatchTouchEventonInterceptTouchEventonTouchEvent 的返回值。