Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

UI 体系 - View 与自定义 View ★

你的重点短板. View 体系是应用开发的日常, 绘制流程, 事件分发是中级面试必考的硬核题.

一, View 绘制三大流程

从 ViewRootImpl.performTraversals() 触发, 依次走:

  1. measure (测量): 确定 View 的宽高.
  2. layout (布局): 确定 View 在父容器中的位置.
  3. draw (绘制): 把 View 画到画布上.

measure 与 MeasureSpec

MeasureSpec 是 32 位 int: 高 2 位是模式, 低 30 位是尺寸. 三种模式:

  • EXACTLY: 精确值 (match_parent 或具体 dp).
  • AT_MOST: 最大不超过 (wrap_content).
  • UNSPECIFIED: 不限制 (ScrollView 子 View).

父 View 的 MeasureSpec + 子 View 的 LayoutParams 共同决定子 View 的 MeasureSpec. 自定义 View 必须处理 wrap_content: 否则 AT_MOST 模式下表现得和 match_parent 一样 (因为默认用了父给的最大尺寸), 需在 onMeasure 里给 wrap_content 一个默认尺寸.

推演 getDefaultSize/resolveSize: getDefaultSize(size, spec) 只有 UNSPECIFIED 才回退到传入的 size (建议尺寸), EXACTLY/AT_MOST 一律返回 specSize, 这就是 “不处理 wrap_content 就等同 match_parent” 的来源. resolveSize(size, spec) 与之类似但更精细: EXACTLY 取 specSize, AT_MOST 取 min(size, specSize), UNSPECIFIED 返回 size, 自定义 View 常用来给 wrap_content 一个期望默认值. View.onMeasure 默认实现即 setMeasuredDimension(getDefaultSize(suggestedMinimumWidth, widthSpec), getDefaultSize(suggestedMinimumHeight, heightSpec)), 其中 suggestedMinimum 来自背景与 minWidth/minHeight; 所以覆盖 onMeasure 的常规套路是: 先按子 View 算出内容尺寸, 再用 resolveSize 决定最终测量值.

layout

onLayout 中调用子 View 的 layout(l, t, r, b) 确定位置. View 的 getWidth/getHeight(布局后的实际尺寸) 与 getMeasuredWidth/Height(测量尺寸) 区别: 正常情况相等, 但可被 layout 强行改变.

draw 顺序

  1. 绘制背景 drawBackground
  2. 绘制内容 onDraw
  3. 绘制子 View dispatchDraw
  4. 绘制前景 / 滚动条 onDrawForeground

二, 事件分发机制

三个核心方法, 贯穿 Activity → ViewGroup → View:

  • dispatchTouchEvent: 分发事件, 返回 true 表示消费.
  • onInterceptTouchEvent:仅 ViewGroup 有, 返回 true 拦截, 交给自己的 onTouchEvent.
  • onTouchEvent: 处理事件, 返回 true 表示消费.

传递规律 (U 型): 事件从 Activity 向下分发 (dispatch), ViewGroup 可在 intercept 拦截; 子 View 不消费则向上回传 (onTouchEvent 冒泡).

关键规则:

  • 一旦某 View 在 ACTION_DOWN 返回 true 消费了事件, 后续 MOVE/UP 都直接交给它 (形成事件序列).
  • 若 DOWN 没被消费, 后续事件不再传给它.
  • requestDisallowInterceptTouchEvent(true): 子 View 请求父不要拦截 (滑动冲突解决用).

必考反例链: 子 View 已消费 DOWN 后, 父 ViewGroup 在某个 MOVE 的 onInterceptTouchEvent 返回 true, 父会先向子 View 补发一个 ACTION_CANCEL, 子 View 收到后应复位按下状态, 否则会 “卡在按下态”; 之后的 MOVE/UP 全部交给父, 子 View 收不到. 若 DOWN 一开始就被父拦截, 子 View 从头到尾收不到该序列, 也不会收到 CANCEL. getX/getRawX 坐标系差异: getX 是相对父容器的坐标 (含自身 left 偏移), getRawX 是相对屏幕的原始坐标; 跨容器比较滑动方向或手势位置时应优先用 raw 值, 否则父容器移动后 getX 已不反映真实屏幕位移.

三, 滑动冲突解决

当内外层都能滑动 (如 ViewPager 嵌 ListView, 横滑嵌竖滑), 需解决冲突:

  • 外部拦截法 (推荐):重写父容器 onInterceptTouchEvent, 按需要判断是否拦截. DOWN 不拦截 (否则子 View 收不到), MOVE 时根据方向决定.
  • 内部拦截法: 父容器默认拦截所有, 子 View 通过 requestDisallowInterceptTouchEvent 动态控制父是否拦截, 配合父重写 onInterceptTouchEvent 对 DOWN 不拦截.

判断依据: 水平 / 垂直距离比较, 速度, 业务规则.

四, 自定义 View

三种方式:

  1. 继承现有 View(如 TextView):扩展功能.
  2. 继承 View: 完全自绘, 重写 onMeasure (处理 wrap_content)+ onDraw.
  3. 继承 ViewGroup: 自定义布局, 重写 onMeasure (测量子 View)+ onLayout (摆放子 View).

要点:

  • 自定义属性: attrs.xml 定义 → obtainStyledAttributes 读取.
  • 支持 padding (onDraw 中考虑 paddingLeft 等).
  • 避免在 onDraw 中 new 对象 (每帧调用, 造成内存抖动), Paint 等提前创建.
  • 状态保存: 重写 onSaveInstanceState/onRestoreInstanceState.

五, invalidate vs requestLayout

  • invalidate(): 触发重绘 (只走 draw), 不重新测量布局. UI 内容变了用它. 必须在主线程; 子线程用 postInvalidate().
  • requestLayout(): 触发重新 measure + layout (不一定 draw), 尺寸 / 位置变了用它.
  • 硬件加速: GPU 渲染, 部分 Canvas API 不支持 (老版本), 可按 View 关闭.

六, 动画, 布局与旧列表机制

动画类型: 选错会让视觉状态和交互状态分离

类型作用对象交互性与适用场景错误选型后果
补间动画View 的视觉变换适合简单的 alpha, 缩放, 位移动画通常不改变 View 实际属性和命中区域, 动画后点击位置可能不符合画面
帧动画一组 drawable 帧少量, 短时的逐帧效果大量或高分辨率帧会占用内存并掉帧
属性动画任意对象属性, 可驱动 translationX, alpha 等需要真实属性变化, 可交互或组合动画在每帧做重布局或重 IO 会挤占帧预算

插值器决定时间进度如何从 0 到 1, 例如先快后慢; 估值器根据该进度计算属性值, 例如颜色的 ARGB 插值. 把两者混为一谈会导致无法解释自定义动画的速度曲线和数值计算.

容器与列表的成本边界

容器测量与层级特征何时使用错误选型后果
LinearLayout顺序测量, 使用 layout_weight 可能引入额外测量简单线性排列为表达复杂相对关系嵌套多层, 增加 traversal 成本
RelativeLayout依赖相对规则, 复杂规则可能需要多次测量遗留 View 页面的小型相对布局继续堆叠规则难以维护, 复杂页面测量成本上升
ConstraintLayout在单层表达约束, 仍有约束求解成本需要减少嵌套的复杂页面把所有约束塞入一个巨大布局, 调试和求解都变复杂

ListView 的 convertView 和 ViewHolder 是历史上的行复用优化: 复用旧行并缓存子 View 查找结果. 它受单一布局, 局部更新, 动画和预取能力限制. 现代列表使用 RecyclerView 与 LayoutManager, DiffUtil 和 Recycler; 迁移不等于在 onBindViewHolder 做同步 IO. 更多复用链路见本章的 RecyclerView 复用与预取.

ListView 优化的完整回答

面试中可以按 “复用, 绑定, 数据更新, 滑动状态” 回答:

  • 多 viewType 复用: getViewTypeCount() 返回布局类型总数, getItemViewType(position) 返回稳定且合法的类型下标. convertView 只能在同一 viewType 内复用; 不同类型的行不能强转或混用. 类型数量过多会分散复用池, 但把结构不同的行硬塞成一种类型更容易引入错位和状态残留.
  • ViewHolder: 首次 inflate 时创建 Holder, 用 setTag 保存子 View 引用; 后续从 tag 取引用, 避免每次 findViewById. 每次 bind 都要显式覆盖文本, 可见性, 选中态和监听器等可变状态, 否则复用后会留下上一行的显示结果.
  • bind 热路径: getView() 会在滚动时高频执行, 不要在其中做磁盘 / 网络 IO, 大图解码, 重复资源解析或无必要的对象分配. 预先准备轻量模型, 缓存可复用资源, 把昂贵工作移到后台; 主线程 bind 只做必要的 UI 写入. 优化目标不是 “完全不分配”, 而是避免随滚动频率增长的分配和同步阻塞.
  • 图片生命周期: 图片请求应绑定到行或 ImageView 的当前身份, bind 前先设置占位图并取消或替换旧请求; 回调落地前校验目标仍对应同一 item/key, 避免图片回写到已复用的另一行. 在 adapter/listener 生命周期结束时取消请求或清理观察者, 避免泄漏和无效回调. 具体图片库的缓存与取消 API 以其生命周期约定为准.
  • 分页与增量更新: 不要一次性把无限数据全部放进内存. 按滚动位置和加载状态触发下一页, 防抖并避免重复请求; 加载成功后在主线程追加快照并调用合适的更新通知. 同时处理 loading, empty, error, 重试和末页状态, 不要在 getView() 里直接发起不可控的请求.
  • 滑动监听误区: OnScrollListener 的回调发生在滚动 / 布局过程中, 不能把 “接近末尾” 当成只触发一次的信号. 需要用 loading 标志, 当前数据量或请求 key 去重, 并区分 SCROLL_STATE_IDLE 与正在滚动; 不要在每次 onScroll() 中全量扫描, 刷新 adapter 或触发同步 IO. firstVisibleItem + visibleItemCount >= totalItemCount - threshold 只是触发条件, 不是分页完成条件.

迁移到 RecyclerView 的边界: 新功能通常优先 RecyclerView, 当需要多类型, 局部更新 / 动画, 横向或网格布局, 预取, 稳定 ID 与 DiffUtil 时迁移收益明显. 仅为修复单个旧页面的小问题不必机械迁移; 迁移会带来 ViewHolder, LayoutManager, item 装饰 / 动画, 无障碍和状态恢复等适配工作. RecyclerView 也不能自动解决 bind 热路径 IO, 图片错位或分页重复请求, 这些约束仍然成立.

自定义 LayoutManager 至少负责: 计算可见范围并摆放 child, 响应滚动距离, 回收离屏 View 并向 Recycler 取 View, 保存和恢复滚动位置. 下面只展示职责骨架, 不是可直接用于生产的完整实现:

// 示意代码: 未处理测量,预测布局,动画,边界与状态恢复细节.
class VerticalLayoutManager : RecyclerView.LayoutManager() {
    override fun generateDefaultLayoutParams() = RecyclerView.LayoutParams(
        ViewGroup.LayoutParams.MATCH_PARENT,
        ViewGroup.LayoutParams.WRAP_CONTENT,
    )

    override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
        detachAndScrapAttachedViews(recycler)
        // 依次 obtain/bind/layout 可见 item, 并记录首个 position 与 offset.
    }
}

scrollVerticallyBy(dy, recycler, state) 不是必须实现, 但不实现列表无法手动滚动 (只能显示首屏): 实现里算出实际滚动量, 用 offsetChildrenVertical(-dy) 移动 child, 回收移出屏幕的 View, 返回实际消费的距离. canScrollVertically() 同理返回能否继续滚动, 影响嵌套滚动和事件消费判断.

RemoteViews: 受限的跨进程 View 描述

通知和 App Widget 不能把任意 View 实例跨进程传给宿主. RemoteViews 传递的是受支持布局和操作的描述, 由宿主进程 inflate 并应用, 因而只支持有限 View 类型和方法. 它适合通知与小部件, 不适合自定义 View, 任意事件逻辑或频繁复杂 UI 更新.

七, 布局时机, 文本与渲染表面

View.post 与布局时机

获取 View 宽高可以用下面的矩阵回答, 关键是区分 “本轮是否已经 layout” 和 “是否要等待一次布局回调”:

时机 / 方法能读到什么适用边界
layout 完成之后读取 width/heightView 的实际布局边界尺寸, 分别约等于 right - left 和 bottom - top适合依赖最终摆放结果; layout() 可被父容器以不同于 measured 值的边界调用
onMeasure 完成之后读取 measuredWidth/measuredHeight测量结果, 尚不代表父容器最终摆放的尺寸适合自定义 View 的测量逻辑; 不能代替 layout 后的 width/height
doOnLayout { ... }下一次 layout 完成后的回调一次性读取尺寸的常用方式; 需要 AndroidX Core KTX, 回调执行后会移除自身
addOnLayoutChangeListener每次边界变化后的回调, 含新旧 left/top/right/bottom需要持续观察尺寸变化时使用, 不需要时及时移除, 防止重复回调和生命周期泄漏
ViewTreeObserver.OnGlobalLayoutListenerView 树全局布局状态变化通知监听范围较宽且可能频繁触发; 只关心一个 View 时优先局部监听, 并注意 observer 存活状态和移除
onWindowFocusChanged(true)窗口获得焦点时的一个时机, 此时通常已经完成首轮布局不是尺寸就绪回调, 焦点可能反复变化且会重复调用; 不应依赖它只执行一次
手动 measure()主动得到 measured 尺寸仅在能根据父约束构造正确 MeasureSpec 时使用; 不能随意用屏幕宽高或 UNSPECIFIED 模拟真实布局, 否则结果与实际层级不一致

手动 measure 更适合脱离 View 树测量可独立构造的内容, 例如已知父约束的自定义布局预计算. 普通页面不要把它当作 “提前拿到最终宽高” 的捷径; 最终 width 仍由后续 layout 决定.

View.post 历史上 “经常” 能取到宽高, 是因为 View attach 后 Runnable 会投递到所属主线程消息队列, 通常排在当前 traversal 的 measure/layout 之后执行. 这只是队列顺序带来的常见结果, 不是 Android 对布局完成的保证: 首次 attach, requestLayout(), 异步数据更新或队列中其他任务都可能改变先后关系, 也可能读到旧尺寸或 0. 需要等待一次布局时推荐 doOnLayout; 需要连续观察变化时使用并正确移除 OnLayoutChangeListener.

资源尺寸 API 的差别也在取整时显现: getDimension() 返回按 density 换算的浮点像素值; getDimensionPixelSize() 四舍五入为整数且非零尺寸会至少为 1px; getDimensionPixelOffset() 向 0 截断. 绘制可保留浮点值, 像素边界或 LayoutParams 通常需要选择明确的整数取整策略.

StaticLayout 用于在 Canvas 上测量并绘制多行文本, 包含换行, 宽度, 对齐和行距处理; 不能只用 Paint.measureText() 计算整段文本宽度. 长文本应缓存可复用布局, 文本或可用宽度变化后才重建, 否则在 onDraw() 重建会造成抖动.

LayoutInflater 解析 XML, 根据标签创建 View 并应用属性. <merge> 省去多余根节点, 但要求提供且 attach 到实际 root; attachToRoot = true 会立即加入 root, false 则只创建供调用方稍后添加. View.inflate() 是常用便利入口, 仍应理解它委托给 Inflater. 错误传入 root 或 attach 标志常导致 LayoutParams 丢失, 重复添加或 ClassCastException.

SurfaceView 与 TextureView

视图渲染与合成边界适合场景限制
SurfaceView独立 Surface, 可由独立线程生产缓冲并交给 SurfaceFlinger 合成视频, 相机预览等高吞吐内容与普通 View 的层级, 裁剪, 动画和过渡效果协调受限
TextureView内容进入 View 树纹理, 可参与普通 View 的 alpha, 旋转, 缩放需与 View 动画和变换融合的预览纹理合成通常有额外开销, 也受硬件加速和生命周期限制

两者都跨越普通 Canvas 绘制边界. 按变换需求和实测帧性能选择, 不能仅因 “TextureView 可变换” 就用于所有视频场景.

八, 位移, 拖动与屏幕适配

layout() 改变 View 的实际边界和命中区域, 常用于父容器摆放子 View; translationX/Y 保留 layout 边界但改变渲染与通常的命中位置, 适合属性动画; scrollTo/scrollBy 改变容器内容的绘制偏移, 不改变子 View 的 layout 坐标; 动画是随时间改变上述属性的过程. 错误地用 layout 做每帧动画会触发更多布局, 用 translation 表达永久结构位置又会使读取坐标和状态恢复混乱.

两个嵌套 ViewPager 的冲突可按手势协商: DOWN 先让子页面收到事件, MOVE 比较 abs(dx) 与 abs(dy); 垂直手势交给可纵向滚动的父容器, 水平手势由子 pager 处理, 到子 pager 的首尾页且继续向外滑时允许父 pager 拦截. 阈值应基于 touch slop, 并处理多指, CANCEL 与 RTL 方向; 单凭第一次 MOVE 的固定方向会造成抖动.

ViewDragHelper 将复杂拖动拆为 tryCaptureView() 决定可捕获对象, clampViewPositionHorizontal/Vertical() 限制边界, onViewReleased() 决定释放后的停靠或回弹, 再在容器 computeScroll() 中持续推进. 它不是自动解决嵌套手势和无障碍的完整组件, 仍需协调父拦截和状态恢复.

适配清单

  • 使用 dp 表示几何尺寸, sp 表示文字并尊重用户字体缩放; 不把固定 px 或关闭 font scale 当适配方案.
  • 用限定符资源和可伸缩约束处理宽度, 高度, 语言和方向差异, 避免以单一屏幕尺寸硬编码 margin.
  • 通过 WindowInsets 处理 system bars, 屏幕缺口, 手势区和 IME, 让输入框与列表内容避让而非猜测状态栏高度.
  • 在折叠屏和大屏上根据窗口尺寸类别, 折叠状态和多窗口重新组织布局, 不假定横屏只是手机旋转.
  • 在实际字体倍率, RTL, 分屏, 键盘展开和横竖屏下检查文本截断, 触摸目标与焦点顺序.

完整自定义 View: 可选评分条

可运行示例 (工程内): 在 Android App 中新增 res/values/attrs.xml, 以下 Kotlin 类和布局引用. 它涵盖 wrap_content 测量, 绘制, 自定义属性, 触摸和状态保存. 点击评分条的不同位置, 预期分数在 1–5 之间变化; 旋转后分数保持.

<!-- res/values/attrs.xml -->
<resources><declare-styleable name="RatingBarView">
    <attr name="starCount" format="integer" /><attr name="ratingColor" format="color" />
</declare-styleable></resources>
<!-- res/layout/fragment_rating.xml;父层须参与 View hierarchy state 保存 -->
<com.example.RatingBarView
    android:id="@+id/product_rating"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:starCount="5"
    app:ratingColor="@color/yellow" />
class RatingBarView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : View(context, attrs) {
    private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
    private var starCount = 5; private var rating = 0; private var color = Color.YELLOW
    init { context.obtainStyledAttributes(attrs, R.styleable.RatingBarView).use {
        starCount = it.getInt(R.styleable.RatingBarView_starCount, 5).coerceAtLeast(1)
        color = it.getColor(R.styleable.RatingBarView_ratingColor, Color.YELLOW)
    }; isSaveEnabled = true }
    override fun onMeasure(widthSpec: Int, heightSpec: Int) {
        setMeasuredDimension(resolveSize(paddingLeft + paddingRight + starCount * 48.dp, widthSpec), resolveSize(paddingTop + paddingBottom + 48.dp, heightSpec))
    }
    override fun onDraw(canvas: Canvas) { val cell = (width - paddingLeft - paddingRight).toFloat() / starCount; if (cell <= 0f) return; paint.color = color
        repeat(rating) { canvas.drawCircle(paddingLeft + cell * (it + .5f), height / 2f, cell * .3f, paint) } }
    override fun onTouchEvent(event: MotionEvent): Boolean {
        if (!isEnabled) return false
        val usableWidth = width - paddingLeft - paddingRight
        if (usableWidth <= 0) return false
        return when (event.actionMasked) {
            MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE, MotionEvent.ACTION_CANCEL -> true
            MotionEvent.ACTION_UP -> { rating = (((event.x - paddingLeft) / usableWidth) * starCount).toInt().plus(1).coerceIn(1, starCount); updateStateDescription(); performClick(); invalidate(); true }
            else -> false
        }
    }
    override fun performClick(): Boolean = super.performClick()
    override fun onSaveInstanceState(): Parcelable = SavedState(super.onSaveInstanceState()).also { it.rating = rating }
    override fun onRestoreInstanceState(state: Parcelable?) { val saved = state as? SavedState; super.onRestoreInstanceState(saved?.superState ?: state); rating = saved?.rating ?: 0; updateStateDescription(); invalidate() }
    private class SavedState : BaseSavedState {
        var rating = 0
        constructor(superState: Parcelable?) : super(superState)
        constructor(source: Parcel) : super(source) { rating = source.readInt() }
        override fun writeToParcel(out: Parcel, flags: Int) { super.writeToParcel(out, flags); out.writeInt(rating) }
        companion object CREATOR : Parcelable.Creator<SavedState> {
            override fun createFromParcel(source: Parcel) = SavedState(source)
            override fun newArray(size: Int) = arrayOfNulls<SavedState>(size)
        }
    }
    private fun updateStateDescription() { ViewCompat.setStateDescription(this, "$rating / $starCount") }
    private val Int.dp get() = (this * resources.displayMetrics.density).toInt()
}

需导入 android.os.Parcel, android.os.Parcelable, android.view.View.BaseSavedState 和 androidx.core.view.ViewCompat. ViewCompat.setStateDescription 负责 API 兼容; performClick() 仅委托 super 发送标准无障碍点击语义, 不重复手动发事件. 稳定 android:id 与父层 View hierarchy state 保存是恢复前提. 数字推演: 父容器给 AT_MOST 300px, density 为 1,5 星 期望宽 5 x 48 = 240px, resolveSize 得 240px; 给 EXACTLY 180px 则得 180px. 事件 trace: DOWN 获取序列, MOVE 保持消费, UP 更新分数并触发标准点击语义, CANCEL 不改分数. 该示例未在本仓库构建验证, 预期旋转后恢复最后评分.

九, Window / DecorView / ViewRootImpl

  • Window: 抽象窗口, PhoneWindow 是唯一实现.
  • DecorView: Window 的顶层 View (含 status bar, content).
  • ViewRootImpl: 连接 WindowManager 和 DecorView, 是绘制流程的发起者, 事件分发的入口.
  • 关系: Activity → PhoneWindow → DecorView → ViewRootImpl 驱动绘制.
  • 触发时机: View attach 到窗口后, requestLayout/invalidate 等请求会经 ViewRootImpl 调度下一帧 traversal; 面试说到这里即可, 不要把具体内部调度函数当稳定 API 背诵.

进阶补充: 嵌套滑动, 帧管线与 RecyclerView

NestedScrolling

嵌套滑动解决父子 View 都想消费滑动的问题. child 先询问 parent 是否参与, 滚动前后分发消耗量.

接口方法概览: child 调 startNestedScroll 发起协商, parent 的 onStartNestedScroll 决定是否参与; 滚动前 dispatchNestedPreScroll 让 parent 先消费, child 处理剩余后经 dispatchNestedScroll 分发, 回调 parent 的 onNestedScroll 落账; fling 同理走 dispatchNestedPreFling/dispatchNestedFling. requestDisallowInterceptTouchEvent(true) 只禁止父拦截 touch 事件, 与 NestedScrolling 的 scroll 协商是两套独立机制; CoordinatorLayout + AppBarLayout 正是靠这套协商协调 header 折叠与子列表滚动.

典型场景: CoordinatorLayout + AppBarLayout + RecyclerView.

Choreographer 与 VSync

Choreographer 接收 VSync 信号, 在主线程依次调度 input, animation, traversal 和 commit 等回调. traversal 完成 measure/layout 并记录绘制命令, 硬件加速路径再由 RenderThread 和 GPU 处理, 最终通过 Surface 提交缓冲, 由 SurfaceFlinger 合成显示:

VSync
  -> Choreographer
     -> UI Thread: input / animation / traversal
        -> RenderThread
           -> GPU
              -> Surface / BufferQueue
                 -> SurfaceFlinger
                    -> Display
刷新率单帧理论预算
60Hz约 16.7ms
90Hz约 11.1ms
120Hz约 8.3ms

理论预算不等于业务代码可以全部占满的时间, 系统调度和流水线阶段也需要预算. 掉帧可能来自主线程布局和绑定, RenderThread, 纹理上传, GPU 或 SurfaceFlinger 合成, 用 Perfetto 和 Frame Timeline 确认实际瓶颈, 不能只盯 onDraw().

RecyclerView 复用与预取

RecyclerView 的性能来自布局, 复用, 数据差分和预取共同协作, 不是只有一个 ViewHolder 缓存:

角色主要职责高频追问
LayoutManager测量和摆放可见 Item, 决定滚动方向, 回收与预取位置它决定布局, 但不持有业务数据
Recycler按 position/viewType 查找 scrap, cache 和 pool, 必要时创建并绑定 ViewHolder命中缓存不代表一定跳过 bind
RecycledViewPool跨 RecyclerView 按 viewType 共享已回收 ViewHolder嵌套同构列表可共享, 不同语义的 viewType 不要误复用
Adapter创建 ViewHolder, 绑定数据并报告数据变化bind 路径不能做重 IO 或重复解码
GapWorker根据滚动信息协调多个 RecyclerView, 利用帧间剩余预算做预取预取过多会挤占 CPU 和内存预算

一次滚动可以这样回答: LayoutManager 计算即将进入屏幕的 position, Recycler 优先从 scrap/cache/pool 找可用 ViewHolder, Adapter 在需要时创建或绑定, 离屏 ViewHolder 被回收, GapWorker 根据预取位置提前准备后续 Item.

数据更新与局部绑定

  • DiffUtil 比较旧列表和新列表, 计算插入, 删除, 移动和内容变化. 大列表应在后台计算, ListAdapter 和 AsyncListDiffer 已封装异步差分.
  • areItemsTheSame 判断是否为同一业务实体, areContentsTheSame 判断展示内容是否变化. 两者不能都用 position.
  • stable IDs 用稳定业务 ID 表达 Item 身份, 可帮助状态和动画保持, 但它不能替代正确的 DiffUtil 实现.
  • payload 表达局部字段变化, 例如只更新点赞数, 避免重新绑定图片和复杂子树. Adapter 仍要保留完整 bind 作为兜底.
  • 提交给 DiffUtil 的列表和 Item 应按快照理解. 原地修改旧对象可能让差分看不到变化.

Adapter 通知 API 的取舍

API绑定范围与动画位置一致性与成本
notifyDataSetChanged()不描述具体变化, 可见项通常需要重新 bind; 默认无法得到可靠的插入 / 删除动画, 可能造成闪烁调用方必须已更新完整数据源且保持 position 语义一致; 最粗粒度, 成本和无效绑定最多
notifyItemChanged(position)只通知一个位置重新 bind, 通常可产生该 Item 的 change 动画只适用于该位置确实仍代表同一 Item; position 由 adapter 当前快照解释, 不要把旧 position 跨异步回调长期保存
notifyItemChanged(position, payload)仍只影响一个位置, 非空 payload 可走 partial bind (见「数据更新与局部绑定」)payload 是优化提示而非完整数据; 需完整 bind 兜底, 多个 payload 可能合并
notifyItemInserted/Removed(position)插入 / 删除位置及其后的布局位置更新, RecyclerView 可播放结构变化动画先改数据快照再通知, position 必须对应修改前后的契约; 单项通知成本低于全量通知, 但大量逐项通知可能不如批量 range 或差分
notifyItemRangeChanged/Inserted/Removed(start, count)对连续范围做同类更新, 范围内 Item 可能重新 bind 或移动并参与相应动画范围必须连续且数量准确; 误报范围会造成错绑或额外工作, 不连续变化不要用一个范围硬覆盖
DiffUtil / ListAdapter.submitList()按 Item 身份与内容差分计算移动与变化 (见「数据更新与局部绑定」); 通常保留局部动画差分有 CPU 和临时内存成本, 适合快照式列表; ListAdapter 异步计算并按提交顺序处理结果, Item 不应在计算期间原地修改

DiffUtil / stable IDs / payload 的机制, 比较原则与配合边界统一见「数据更新与局部绑定」.

位置通知的共同原则是 “先更新数据, 再发与变更完全匹配的通知”. 不要在异步回调里依赖过期 adapter position; 需要当前绑定位置时读取 bindingAdapterPosition, 并处理 NO_POSITION. 如果业务变化难以可靠地手工描述, 用不可变的新列表交给 DiffUtil/ListAdapter; 如果只是一个已知字段变化且身份和位置都未变, 单项 payload 通知更便宜. 通知越粗, 无效 bind 越多; 通知越细, 调用方维护位置和快照的正确性成本越高.

嵌套横向列表可让多个子 RecyclerView 共享 RecycledViewPool, 并根据首屏需求设置预取数量. 前提是相同 viewType 的 ViewHolder 结构和绑定契约一致; 还要避免每次父 Item bind 都创建新 Adapter, Pool 或 LayoutManager.

GestureDetector 与 VelocityTracker

复杂手势不要全靠手写坐标判断. 点击, 长按, fling 可用 GestureDetector; 速度计算可用 VelocityTracker.

追问: 为什么 RecyclerView 滑动卡顿? 常见是 bind 太重, 图片加载尺寸失控或复用回调错位, 布局层级深, 频繁全量刷新, 预取配置不合理以及共享 Pool 使用错误. 先用 Perfetto, Frame Timeline 和 Layout Inspector 判断是主线程, 渲染线程还是 GPU 瓶颈.

进阶补充: WindowInsets, 无障碍与 RecyclerView 细节

Edge-to-edge 与 WindowInsets

新版本 Android 对 edge-to-edge 的要求逐步收紧. View 体系里不要靠写死 status bar/nav bar 高度, 而是通过 WindowInsets 把系统栏, 屏幕缺口, IME 输入法区域当作布局输入.

常见处理方式:

ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
    val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
    view.updatePadding(top = bars.top, bottom = bars.bottom)
    insets
}

面试重点: Insets 应该在基础容器统一处理, 否则每个页面各写一套 padding, 很容易出现输入框被键盘遮挡, 列表最后一项被导航栏遮挡, 沉浸式页面返回后状态不一致.

无障碍 Accessibility

中级面试常追问 “你做 UI 有没有考虑可访问性”.View 体系至少要说清:

  • 图片按钮要有 contentDescription, 纯装饰图可标记为不重要.
  • 触摸目标建议不小于 48dp.
  • 自定义 View 要补充语义, 状态和点击行为, 不要只画图不暴露给 TalkBack.
  • 表单错误要能被读屏感知, 不能只靠红色边框.
  • 列表/弹窗/底部 Sheet 要考虑焦点顺序和返回键.

RecyclerView 现代实践

问题推荐做法易错点
全量刷新卡顿DiffUtil 差分 (见「数据更新与局部绑定」)notifyDataSetChanged() 一把梭
局部字段更新payload partial bind (见「数据更新与局部绑定」)每次都重绑图片 / 复杂布局
Item 身份稳定stable IDs 或业务唯一 key (见「数据更新与局部绑定」)用 position 当 ID
动画闪烁评估 ItemAnimator / payload默认动画导致图片闪烁
嵌套列表共享 RecycledViewPool, 控制预取每个子列表重复创建大量 ViewHolder

ConstraintLayout / MotionLayout

ConstraintLayout 适合减少层级, 表达复杂相对约束; MotionLayout 适合声明式描述状态间动画. 面试回答时不要只说 “性能好”, 要补一句: 复杂约束也会增加求解成本, 真正优化要用 Layout Inspector/Perfetto 看 measure/layout 时间.


刷新率, Insets 与 Compose 互操作

16.7 ms 只对应 60 Hz 的单帧周期示例, 不是所有设备固定 deadline; 90/120 Hz 与动态刷新率下预算更短且帧调度受系统影响. 自定义 View 要处理 window insets, 手势导航, 键盘, RTL, 字体缩放和无障碍. View/Compose 混用时明确状态 owner, ComposeView composition disposal, View 生命周期与重组边界. 卡顿证据统一见 ANR 与卡顿排查.

高频面试题

Q1: View 的绘制流程? 从哪里开始? 从 ViewRootImpl.performTraversals 开始, 依次 performMeasure (measure)→ performLayout (layout)→ performDraw (draw). measure 确定大小, layout 确定位置, draw 绘制内容.

Q2: MeasureSpec 是什么? 三种模式? 32 位 int, 高 2 位模式 + 低 30 位尺寸. EXACTLY (精确, match_parent / 具体值), AT_MOST (最大, wrap_content), UNSPECIFIED (不限, 如 ScrollView 子 View).由父 MeasureSpec + 子 LayoutParams 共同决定.

Q3: 自定义 View 直接继承 View, wrap_content 不生效怎么办? 在 onMeasure 中判断模式为 AT_MOST 时, 给一个默认尺寸 (不能直接用父给的最大值, 否则等同 match_parent).

Q4: 事件分发三个方法? 返回值含义? dispatchTouchEvent (分发), onInterceptTouchEvent (ViewGroup 拦截), onTouchEvent (处理).返回 true 表示消费, 事件序列后续都给它; 返回 false 向上回传.

Q5: 滑动冲突怎么解决? 外部拦截法 (父重写 onInterceptTouchEvent 按需拦截, DOWN 不拦截) 或内部拦截法 (子用 requestDisallowInterceptTouchEvent 控制).判断依据是滑动方向 / 距离.

Q6: invalidate 和 requestLayout 区别? 可以在子线程调用吗? invalidate 重绘 (走 draw), requestLayout 重新测量布局. invalidate 必须主线程, 子线程用 postInvalidate.

Q7: getWidth 和 getMeasuredWidth 区别? getMeasuredWidth 是 measure 后的测量值, getWidth 是 layout 后的实际值 (= right - left).通常相等, 但 layout 可强行改变实际尺寸使其不等.

Q8: onDraw 里能不能 new 对象? 不是绝对禁止, 而是避免: onDraw 每帧调用, 频繁创建对象导致内存抖动, 频繁 GC, 卡顿. Paint/Path 等应在构造时创建并复用; 偶发的低频分配并非违规, 重点是别在热路径每帧 new.

Q9: Edge-to-edge 页面为什么容易被系统栏或键盘遮挡? 因为内容区域延伸到了系统栏 / IME 后面, 但页面没有正确消费 WindowInsets. 正确做法是统一监听 systemBars/ime insets, 给根容器, 列表或输入框加动态 padding/margin, 不要写死状态栏高度.

Q10: View 体系里怎么做无障碍适配? 给可点击图片 / 图标补 contentDescription, 保证触摸目标大小, 自定义 View 暴露语义和状态, 错误提示可被 TalkBack 读到, 并检查焦点顺序和返回行为.

Q11: RecyclerView 的 Recycler, RecycledViewPool, LayoutManager 和 GapWorker 如何协作? LayoutManager 决定可见范围, 摆放和预取位置; Recycler 按 position 和 viewType 从 scrap, cache, pool 中找 ViewHolder; RecycledViewPool 保存可跨 RecyclerView 复用的回收 Holder; GapWorker 汇总预取位置并提前准备. Adapter 只负责创建和绑定, 不能把所有复用能力都归因于 Adapter.

Q12: DiffUtil, stable IDs 和 payload 分别解决什么问题? 三者分别解决列表差分, Item 身份稳定与局部字段更新, 可配合使用; 比较原则与边界 (stable IDs 不能替代 DiffUtil, payload 需完整 bind 兜底) 见「数据更新与局部绑定」.

Q13: 一帧从 VSync 到显示经历什么? Choreographer 接收 VSync 并在主线程调度输入, 动画和 traversal; 主线程记录绘制命令后, RenderThread/GPU 完成渲染并向 Surface 提交缓冲; SurfaceFlinger 合成各图层后交给显示系统. 60Hz, 90Hz 和 120Hz 的理论预算分别约为 16.7ms, 11.1ms 和 8.3ms.