当前位置: 首页 > news >正文

Android学习总结之自定义View绘制源码理解

1. View 重绘触发:requestLayout() 如何启动绘制流程?

当调用 View.requestLayout() 时,源码逻辑如下(基于 Android 13 源码):

  • View.java
    public void requestLayout() {if (mMeasureCache != null) mMeasureCache.clear(); // 清除测量缓存mLayoutRequested = true; // 标记布局需要更新// 向上遍历父容器,直到找到 ViewRootImpl(窗口顶级容器)ViewParent parent = getParent();if (parent != null && !parent.isLayoutRequested(this)) {parent.requestLayout(); // 递归父容器的 requestLayout}
    }
    

    最终会调用到 ViewRootImpl.requestLayout(),该方法会通过 checkThread() 确保在主线程(否则抛 CalledFromWrongThreadException),然后调用 scheduleTraversals()
    private void scheduleTraversals() {if (!mTraversalScheduled) {mTraversalScheduled = true;// 通过 Choreographer 安排下一帧的绘制任务(重点!)mChoreographer.postCallback(Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);// 触发 vsync 信号等待(60Hz 时约 16ms 一次)if (!mUnbufferedInputDispatch) {scheduleConsumeBatchedInput(); // 处理输入事件}}
    }
    

    关键点
    • requestLayout() 只会标记布局需要更新,真正的绘制流程由 Choreographer 驱动,结合 vsync 信号(60Hz 对应 16ms 间隔)在下一帧执行。
    • 若在子线程调用 requestLayout(),会因 ViewRootImpl.checkThread() 校验失败而崩溃(除非通过 Looper.getMainLooper() 切换到主线程)。
2. 布局周期为什么是 16ms?深入 Choreographer 与 vsync

Android 的屏幕刷新率通常为 60Hz(即每秒 60 帧),每帧理想耗时 16ms1000ms/60 ≈ 16.6ms)。绘制流程由 Choreographer 协调,其核心逻辑在 Choreographer.java

  • Choreographer 通过 FrameDisplayEventReceiver 监听底层屏幕的 vsync 信号(由硬件定时发送)。
  • 当 vsync 到来时,Choreographer 会触发回调(如 CALLBACK_TRAVERSAL),执行 ViewRootImpl 的 performTraversals() 方法,该方法依次调用:
    performMeasure() → performLayout() → performDraw()
    

    若这三个步骤总耗时超过 16ms,就会导致 掉帧(界面卡顿),严重时触发 ANR(主线程阻塞超 5s)。

面试官常问:“如果自定义 View 的 onMeasure 或 onLayout 耗时过长,会发生什么?”
→ 答:超过 16ms 会导致掉帧,若主线程阻塞超过 5s(如后台耗时操作未切线程),则触发 ANR。

3. MotionEvent 坐标数量与屏幕采样率的关系

触摸事件由 MotionEvent 封装,其坐标数据与 屏幕采样率getDisplay().getRefreshRate())和 触控传感器采样率有关:

  • 单点触控:每次事件(如 ACTION_MOVE)包含 当前坐标getX()/getY()),但多次 ACTION_MOVE 的间隔由采样率决定(如 120Hz 屏幕可能每 8ms 产生一次移动事件)。
  • 多点触控:通过 getPointerCount() 获取触点数量,每个触点有独立坐标(getX(int pointerIndex))。

源码证据
在 MotionEvent.java 中,坐标存储在数组 mX 和 mY 中,支持最多 MAX_POINTERS(通常为 10)个触点。每次触摸事件的坐标数量等于当前活动的触点数,而非 “移动前和移动后” 两个坐标(用户之前回答错误)。

面试官追问:“如何优化滑动卡顿?”
→ 答:减少 onTouchEvent 耗时,避免在事件处理中执行复杂计算;使用 VelocityTracker 计算滑动速度;确保 View 层级简洁,减少 measure/layout/draw 耗时。

4. 源码级分析:View 绘制的三大核心步骤
  1. measure():确定 View 的宽高(MeasureSpec 控制),调用 onMeasure()
    • 自定义 View 需重写 onMeasure(),并通过 setMeasuredDimension() 设置最终尺寸,否则抛 IllegalStateException
  2. layout():确定 View 的位置(left/top/right/bottom),调用 onLayout()(ViewGroup 实现,View 默认为 0,0 固定大小)。
  3. draw():绘制内容,流程为 background → content → children → decoration,调用 onDraw()(View 实现,ViewGroup 通常不重绘,除非设置 willNotDraw=false)。

关键类

  • ViewRootImpl.performTraversals():驱动三大步骤的核心方法,源码超 2000 行,通过标志位(mLayoutRequestedmDirty)判断是否需要执行某一步骤。
  • ViewGroup.layoutChildren():遍历子 View 调用 layout(),是布局嵌套的核心逻辑。
5. 实战:如何通过源码定位绘制卡顿?
  1. 开启 GPU 过度绘制调试(开发者选项),红色 / 绿色区域表示过度绘制。
  2. Systrace 追踪:使用 adb shell systrace -t 10 -o trace.html gfx input view,分析 performTraversals 耗时是否超过 16ms。
  3. 自定义 View 重写方法耗时:在 onMeasure/onLayout/onDraw 中添加耗时统计,如:
    long start = System.currentTimeMillis();
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    Log.d("ViewDebug", "onMeasure耗时:" + (System.currentTimeMillis() - start) + "ms");
    
  4. Choreographer 帧间隔日志:通过 Choreographer.FrameCallback 监听每帧耗时,超过 16ms 则记录警告。

总结(面试官想听到的源码深度)

  • 绘制触发requestLayout() → ViewRootImpl.scheduleTraversals() → Choreographer 绑定 vsync 信号,16ms 一周期。
  • 核心流程performTraversals() 串联 measure/layout/draw,自定义 View 需正确实现三大方法,避免耗时操作。
  • 触摸事件MotionEvent 支持多点触控,坐标数量由触点数决定,采样率影响事件频率(非 “两个坐标”)。

面试扩展:

一、View 绘制流程(必考题:从源码角度说明三大流程)

面试官问题

“请详细说明 Android View 的绘制流程,涉及哪些关键类和方法?自定义 View 时需要重写哪些方法?”

深度回答

Android View 绘制分为 measure(测量)layout(布局)draw(绘制) 三大流程,由 ViewRootImpl 统一协调

  1. measure 阶段(确定宽高)

    • 核心方法:View.measure(int, int) → View.onMeasure(int, int)
    • MeasureSpec 控制测量规则(EXACTLY/AT_MOST/UNSPECIFIED),自定义 View 需重写 onMeasure 并调用 setMeasuredDimension(width, height),否则抛 IllegalStateException
    • 源码示例(LinearLayout 测量子 View):
      // ViewGroup.java  
      protected void measureChildren(int widthMeasureSpec, int heightMeasureSpec) {  final int size = mChildrenCount;  final View[] children = mChildren;  for (int i = 0; i < size; ++i) {  final View child = children[i];  if (child.getVisibility() != GONE) {  measureChild(child, widthMeasureSpec, heightMeasureSpec); // 递归测量子View  }  }  
      }  
      
  2. layout 阶段(确定位置)

    • 核心方法:View.layout(int, int, int, int) → View.onLayout(boolean, int, int, int, int)(仅 ViewGroup 需重写,View 默认位置为 (0,0))。
    • 父 View 通过 setChildFrame(child, left, top, right, bottom) 确定子 View 坐标,如 RecyclerView 的 LayoutManager 在此阶段计算子 View 布局。
  3. draw 阶段(像素渲染)

    • 核心方法:View.draw(Canvas),分 6 步(背景→内容→子 View→修饰),自定义 View 需重写 onDraw(Canvas) 绘制内容(如绘制文本、路径)。
    • 源码关键逻辑
      // View.java  
      public void draw(Canvas canvas) {  int saveCount;  // 1. 绘制背景  drawBackground(canvas);  // 2. 绘制主体内容(自定义View重写此步)  onDraw(canvas);  // 3. 绘制子View(仅ViewGroup执行)  dispatchDraw(canvas);  // 4. 绘制前景(边框、滚动条等)  onDrawForeground(canvas);  
      }  
      
加分项
  • 提及 ViewRootImpl.performTraversals() 是三大流程的总入口,通过 mLayoutRequested/mDirty 标志位判断是否执行对应流程。
  • 强调自定义 View 时:
    • 仅绘制内容 → 重写 onDraw
    • 自定义尺寸 → 重写 onMeasure 并正确处理 MeasureSpec
    • 自定义容器 → 重写 onLayout 并遍历子 View 调用 layout()

二、requestLayout () 与重绘触发(高频陷阱题)

面试官问题

requestLayout()invalidate()postInvalidate() 有什么区别?为什么不能在子线程调用前两者?”

深度回答
方法触发流程线程限制使用场景
requestLayout()触发 measure + layout必须主线程View 尺寸 / 布局参数变化(如setLayoutParams
invalidate()触发 draw(可能先触发requestLayout必须主线程View 内容变化(如文字 / 颜色更新)
postInvalidate()内部通过Handler切主线程子线程可用子线程中触发重绘(等价invalidate()的线程安全版)
源码级差异
  1. requestLayout() 线程校验

    // ViewRootImpl.java  
    public void requestLayout() {  checkThread(); // 校验是否为主线程,否则抛异常  mLayoutRequested = true;  scheduleTraversals();  
    }  
    void checkThread() {  if (mThread != Thread.currentThread()) {  throw new CalledFromWrongThreadException(  "Only the original thread that created a view hierarchy can touch its views.");  }  
    }  
    
     

    子线程调用会因 checkThread() 失败崩溃,需通过 view.post(() -> requestLayout()) 间接调用。

  2. invalidate() 触发条件

    • 若 View 尺寸变化(如LayoutParams修改),invalidate() 会先调用 requestLayout() 触发测量布局,再执行绘制。
    • 若仅内容变化(如setText()),直接标记 mDirty 区域,跳过测量布局,仅重绘。

三、布局周期 16ms 与性能优化(核心原理题)

面试官问题

“为什么说 Android 的理想布局周期是 16ms?超过会怎样?如何通过源码定位掉帧?”

深度回答
  1. 16ms 的由来(VSYNC 机制)

    • 主流屏幕刷新率 60Hz(每秒 60 帧),每帧理想耗时 1000ms/60 ≈ 16.6ms,由 Choreographer(源码Choreographer.java)监听硬件 VSYNC 信号(垂直同步),确保绘制与屏幕刷新同步。
    • Choreographer 通过 FrameDisplayEventReceiver 接收 VSYNC 事件,触发 ViewRootImpl 的 performTraversals() 执行绘制(约 16ms 一次)。
  2. 掉帧与卡顿

    • 若 measure+layout+draw 总耗时超过 16ms,会导致 掉帧(该帧被丢弃),连续掉帧即感知为卡顿;
    • 主线程阻塞超 5s 触发 ANR,日志中可见 Input dispatching timed out
  3. 源码级性能分析

    • 使用 Systrace 追踪:adb shell systrace -t 10 -o trace.html gfx input view,查看 performTraversals 各阶段耗时(绿色为 measure,蓝色为 layout,红色为 draw)。
    • Choreographer 帧回调
      Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {  @Override  public void doFrame(long frameTimeNanos) {  long frameDuration = frameTimeNanos - mLastFrameTimeNanos;  if (frameDuration > 16_666_667) { // 超过16.6ms即掉帧  Log.w("ViewRender", "Frame dropped: " + frameDuration);  }  mLastFrameTimeNanos = frameTimeNanos;  Choreographer.getInstance().postFrameCallback(this);  }  
      });  
      

四、触摸事件与坐标(细节辨析题)

面试官问题

MotionEvent 中的坐标如何获取?多点触控时如何区分不同触点?移动事件的坐标数量由什么决定?”

深度回答
  1. 坐标类型

    • 绝对坐标getX()/getY()(相对于屏幕左上角);
    • 相对坐标getRawX()/getRawY()(相对于设备左上角,含系统栏高度)。
  2. 多点触控支持

    • 通过 getPointerCount() 获取触点数量,每个触点有唯一 pointerId(通过 getPointerId(int index) 获取);
    • 单个触点坐标:getX(int pointerIndex)/getY(int pointerIndex),如双指缩放时需同时获取两个触点的坐标。
  3. 采样率影响

    • 坐标数量与 屏幕采样率Display.getRefreshRate())和 触控传感器采样率 相关,高刷新率屏幕(如 120Hz)会更频繁触发 ACTION_MOVE(约 8ms 一次),每次事件携带当前触点坐标(非 “移动前 / 后两个坐标”)。
源码证据

MotionEvent 内部通过数组存储多触点坐标(mX[]mY[]),支持最多 MAX_POINTERS(通常 10 个),示例:

// 处理多点触控  
for (int i = 0; i < event.getPointerCount(); i++) {  float x = event.getX(i);  float y = event.getY(i);  int pointerId = event.getPointerId(i);  // 根据pointerId区分不同手指  
}  

五、实战:自定义 View 常见坑与优化(经验题)

面试官问题

“自定义 View 时容易出现哪些性能问题?如何避免过度绘制和布局嵌套?”

深度回答
  1. 测量尺寸未正确处理

    • 错误:未在 onMeasure 中调用 setMeasuredDimension(),导致运行时崩溃;
    • 正确:
      @Override  
      protected void onMeasure(int widthSpec, int heightSpec) {  int width = measureDimension(DEFAULT_WIDTH, widthSpec);  int height = measureDimension(DEFAULT_HEIGHT, heightSpec);  setMeasuredDimension(width, height);  
      }  
      private int measureDimension(int defaultSize, int measureSpec) {  int specMode = MeasureSpec.getMode(measureSpec);  int specSize = MeasureSpec.getSize(measureSpec);  return specMode == MeasureSpec.EXACTLY ? specSize : defaultSize;  
      }  
      
  2. 过度绘制优化

    • 开启 GPU 过度绘制调试(开发者选项→调试 GPU 过度绘制),红色表示过度绘制 4 次以上;
    • 避免多层透明背景,使用 android:background="@null" 或 setWillNotDraw(true)(ViewGroup 默认不绘制,减少不必要的draw调用)。
  3. 布局嵌套优化

    • 用 ConstraintLayout 替代多层 LinearLayout/RelativeLayout,减少 measure/layout 次数;
    • 使用 ViewStub 延迟加载非必要视图,避免启动时全量渲染。

相关文章:

  • springboot入门-controller层
  • 多系统安装经验,移动硬盘,ubuntu grub修改/etc/fstab 移动硬盘需要改成nfts格式才能放steam游戏
  • YOLOv8改进新路径:Damo-YOLO与Dyhead融合的创新检测策略
  • 第三方测试机构如何保障软件质量并节省企业成本?
  • Xilinx FPGA支持的FLASH型号汇总
  • git 工具
  • 架构进阶:105页PPT学习数字化转型企业架构设计手册【附全文阅读】
  • ARM架构的微控制器总线矩阵仲裁策略
  • 【Android】四大组件之Activity
  • Java 中 ConcurrentHashMap 1.7 和 1.8 之间有哪些区别?
  • 【补题】Codeforces Global Round 20 F1. Array Shuffling
  • Unity-Shader详解-其一
  • LabVIEW 工业产线开发要点说明
  • 深入理解TransmittableThreadLocal:原理、使用与避坑指南
  • 职业教育新形态数字教材的建设与应用:重构教育生态的数字化革命
  • html初识
  • 【JavaScript】自增和自减、逻辑运算符
  • LeetCode热题100——70. 爬楼梯
  • SQL盲注问题深度解析与防范策略
  • Python 第 11 节课 - string 与 random 的方法
  • 湖州通告13批次不合格食品,盒马1批次多宝鱼甲硝唑超标
  • 人民日报:光荣属于每一个挺膺担当的奋斗者
  • 湖南省郴州市统战部部长黄峥嵘主动交代问题,接受审查调查
  • 牧原股份一季度归母净利润44.91亿元,同比扭亏为盈
  • 何立峰出席跨境贸易便利化专项行动部署会并讲话
  • 大家聊中国式现代化|彭羽:为国家试制度探新路,推进高水平对外开放