🚀 第一梯队:高频核心考点(必问 / 实战难点)
Q1:自定义 View 的绘制流程(测量、布局、绘制)是怎样的?核心方法有哪些?如何正确处理 wrap_content 和 padding?
核心答案
自定义 View 的绘制流程由 ViewRootImpl.performTraversals() 触发,依次执行三大步骤:
- 测量 (Measure) :
measure()→onMeasure(),根据父容器约束和自身尺寸要求确定宽高。wrap_content对应AT_MOST:必须主动计算内容所需尺寸,并通过resolveSizeAndState()取不超过父容器的值。padding处理:内容尺寸需加上getPaddingLeft/Right/Top/Bottom()。
- 布局 (Layout) :
layout()→onLayout()(ViewGroup 需实现),确定自身及子 View 在父容器中的位置。 - 绘制 (Draw) :
draw()→onDraw(),使用 Canvas 绘制内容,绘制坐标需偏移 padding。
流程图

精简源码(支持 wrap_content 和 padding 的圆形 View)
java
public class CircleView extends View {
private int radius = 100;
private Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int contentWidth = radius * 2 + getPaddingLeft() + getPaddingRight();
int contentHeight = radius * 2 + getPaddingTop() + getPaddingBottom();
int width = resolveSizeAndState(contentWidth, widthMeasureSpec, 0);
int height = resolveSizeAndState(contentHeight, heightMeasureSpec, 0);
setMeasuredDimension(width, height);
}
@Override
protected void onDraw(Canvas canvas) {
int cx = getPaddingLeft() + radius;
int cy = getPaddingTop() + radius;
canvas.drawCircle(cx, cy, radius, paint);
}
}
Q2:详述 ViewGroup 的事件分发机制,三个核心方法的作用?如何解决滑动冲突?
核心答案
- 三大方法 :
dispatchTouchEvent:分发事件,最先到达。决定交给onInterceptTouchEvent还是子 View。onInterceptTouchEvent:仅 ViewGroup 有,返回true则拦截,交由自己的onTouchEvent;返回false则传递给子 View。onTouchEvent:消费事件,返回true表示消费,流程结束;返回false则回传给父 View。
- 滑动冲突解决 :
- 外部拦截法(推荐) :父容器在
onInterceptTouchEvent中根据滑动方向判断。横向滑动不拦截(给子 View),纵向滑动拦截(父容器处理)。 - 内部拦截法 :子 View 调用
parent.requestDisallowInterceptTouchEvent(true)禁止父容器拦截。
- 外部拦截法(推荐) :父容器在
流程图
精简源码(外部拦截法示例)
java
// 父容器
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
return false; // 必须 false
}
float dx = ev.getX() - lastX;
float dy = ev.getY() - lastY;
if (Math.abs(dx) > Math.abs(dy)) {
return false; // 横向,不拦截
} else {
return true; // 纵向,拦截
}
}
Q3:如何优化自定义 View 的性能?(避免过度绘制与卡顿)
核心答案
- 绘制优化 :
- 严禁在
onDraw中new对象(Paint,Path,Rect),应在构造函数中初始化复用,避免 GC 卡顿。 - 使用
canvas.clipRect()裁剪不可见区域,canvas.quickReject()快速拒绝。 - 复杂图形使用
Bitmap缓存或Path复用。 - 开启硬件加速:
setLayerType(LAYER_TYPE_HARDWARE, null)。
- 严禁在
- 刷新优化 :使用
invalidate(Rect)局部刷新,代替全屏刷新。 - 布局优化 :减少布局层级(
<merge>标签),使用ConstraintLayout扁平化,按需加载(ViewStub)。 - 测量优化 :避免在
onMeasure中执行耗时计算,使用缓存结果。
流程图
精简源码(绘制优化示例)
java
@Override
protected void onDraw(Canvas canvas) {
if (canvas.quickReject(mPath.getBounds(), Canvas.EdgeType.ANTI_ALIAS)) return;
canvas.save();
canvas.clipRect(getLeft(), getTop(), getRight(), getBottom());
canvas.drawPath(mPath, mPaint); // mPaint 在构造函数中初始化
canvas.restore();
}
Q4:属性动画 ObjectAnimator 的原理是什么?与 View 动画有何区别?
核心答案
- 原理 :
- 基于
ValueAnimator,通过Choreographer监听 VSYNC 信号驱动。 - 计算动画分数(0~1)→ 插值器调整速率 → 估值器计算属性值 → 反射调用 Setter 方法 →
invalidate()重绘。
- 基于
- 与 View 动画区别 :
- View 动画(补间)只改变视觉效果(矩阵变换),不改变 View 的实际属性(点击事件位置不变)。
- 属性动画真正改变对象属性,交互正常,支持任意对象。
流程图
精简源码(自定义抛物线估值器)
java
ObjectAnimator anim = ObjectAnimator.ofFloat(view, "translationY", 0f);
anim.setDuration(1000);
anim.setInterpolator(new AccelerateDecelerateInterpolator());
anim.setEvaluator(new ParabolaEvaluator(500f));
anim.start();
Q5:invalidate() 与 requestLayout() 的区别是什么?为什么 invalidate() 有时候不会回调 onDraw()?
核心答案
- 区别:
| 方法 | 触发流程 | 影响范围 | 适用场景 |
|---|---|---|---|
invalidate() |
标记脏 → 向上找根 View → performDraw() |
仅重绘 | 内容改变,位置大小不变 |
postInvalidate() |
同上,通过 Handler 切到主线程 | 仅重绘 | 子线程中调用重绘 |
requestLayout() |
标记 View 及父容器 → measure + layout + draw |
重新测量+布局+重绘 | 位置或大小改变(开销大) |
invalidate()不回调onDraw()的原因 :- 当前 View 不可见(
getVisibility()为 GONE 或动画未开始)。 - 请求重绘的区域被其他 View 完全覆盖且父容器未标记
willNotDraw(false)。 - 多次连续调用被系统优化合并,仅重绘一次。
- View 尚未 attached 到窗口(例如
onCreate中调用invalidate)。
- 当前 View 不可见(
流程图
精简源码
java
// postInvalidate 实现原理(简化)
public void postInvalidate() {
getRunQueue().postDelayed(new Runnable() {
@Override
public void run() {
invalidate();
}
}, 0);
}
🏗️ 第二梯队:底层原理与系统启动(架构视角)
Q6:Android 视图从 setContentView 到最终显示在屏幕上的完整底层链路?
核心答案
Activity.attach():创建PhoneWindow。setContentView():PhoneWindow创建DecorView,通过LayoutInflater将布局添加到android.R.id.content。ActivityThread.handleResumeActivity():执行onResume,调用WindowManagerImpl.addView(DecorView)。ViewRootImpl.setView():绘制起点。调用requestLayout()→scheduleTraversals()。Choreographer& VSYNC:收到 VSYNC 信号 →doTraversal()→performTraversals()。- 三大流程:
measure→layout→draw。 - 上屏:通过
Surface.lockCanvas()和unlockCanvasAndPost()将图形数据交给SurfaceFlinger,合成后显示。
流程图
精简源码(模拟绘制核心)
java
// ViewRootImpl 绘制部分
private void performDraw() {
Canvas canvas = surface.lockCanvas(null);
try {
draw(canvas);
} finally {
surface.unlockCanvasAndPost(canvas);
}
}
Q7:为什么子线程不能更新 UI?ViewRootImpl 起了什么作用?如何正确更新?
核心答案
- 直接原因 :
ViewRootImpl.checkThread()对比当前线程与创建ViewRootImpl时的线程(主线程),不一致则抛出CalledFromWrongThreadException。 - 深层原因:Android UI 控件非线程安全,多线程并发修改会导致状态混乱;加锁会严重降低渲染效率。
- 特例 :在
ViewRootImpl创建之前(onResume前),子线程更新 UI 不会报错。 - 正确更新方式 :
Activity.runOnUiThread、View.post、Handler绑定主线程 Looper。
流程图
精简源码
java
// 错误写法
new Thread(() -> textView.setText("error")).start();
// 正确写法
new Thread(() -> {
final String data = loadData();
textView.post(() -> textView.setText(data));
}).start();
Q8:Activity、Window、View 三者之间的联系和区别?
核心答案
- Activity :UI 的管理者,负责生命周期、事件分发、与系统交互,内部持有一个
Window。 - Window :抽象类(实现类
PhoneWindow),代表一个"窗口",负责管理View的添加、移除以及接收系统事件。每个 Activity 对应一个 Window。 - View :UI 的基本单元,处理自身绘制和事件。Window 通过
ViewRootImpl将 DecorView 附着到屏幕上。 - 联系 :Activity 创建 Window → Window 创建 DecorView → DecorView 加载
setContentView的布局。
流程图
精简源码(伪代码)
java
// Activity 内部
public void setContentView(int layoutResID) {
getWindow().setContentView(layoutResID);
}
// PhoneWindow 内部
public void setContentView(int layoutResID) {
if (mDecor == null) installDecor();
mLayoutInflater.inflate(layoutResID, mContentParent);
}
📏 第三梯队:测量与布局核心(MeasureSpec / 宽高)
Q9:MeasureSpec 的工作原理是什么?三种模式分别对应什么场景?为什么 wrap_content 会失效?如何解决?
核心答案
MeasureSpec:32 位 int,高 2 位为模式(SpecMode),低 30 位为大小(SpecSize),由父容器的MeasureSpec和子 View 的LayoutParams组合而成。- 三种模式 :
EXACTLY(精确):match_parent或固定数值。父容器已确定大小,子 View 必须遵守。AT_MOST(最大):wrap_content。父容器给出最大值,子 View 不能超过此值。UNSPECIFIED(未指定):系统内部使用(如ScrollView),子 View 想要多大就多大。
wrap_content失效原因 :自定义 View 在onMeasure()中没有针对AT_MOST模式做特殊处理,直接使用父容器给的尺寸(即match_parent的效果)。- 解决方案 :在
onMeasure()中判断模式,当为AT_MOST时,根据内容计算出期望尺寸,然后取min(期望尺寸, 父容器尺寸)。
流程图
精简源码
java
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int desiredWidth = getDesiredWidth(); // 根据内容计算期望宽
int desiredHeight = getDesiredHeight();
int finalWidth = 0, finalHeight = 0;
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
if (widthMode == MeasureSpec.EXACTLY) {
finalWidth = widthSize;
} else if (widthMode == MeasureSpec.AT_MOST) {
finalWidth = Math.min(desiredWidth, widthSize);
} else {
finalWidth = desiredWidth;
}
// 高度同理...
setMeasuredDimension(finalWidth, finalHeight);
}
Q10:getMeasuredWidth() 与 getWidth() 有什么区别?在 onResume 中能获取到宽高吗?
核心答案
- 区别:
| 方法 | 数据来源 | 赋值时机 | 含义 |
|---|---|---|---|
getMeasuredWidth() |
mMeasuredWidth |
onMeasure 结束 (setMeasuredDimension) |
测量出来的理论宽高 |
getWidth() |
mRight - mLeft |
onLayout 阶段 (setFrame) |
实际在屏幕上占据的宽高 |
- 在
onResume中获取宽高的结果 :首次启动不能,因为测量布局在onResume之后才执行;再次进入时可以。
流程图
精简源码(正确获取方式)
java
view.post(() -> {
int w = view.getWidth();
});
Q11:ViewGroup 的 onDraw 方法默认为什么不执行?如何让它执行?
核心答案
- 原因 :为了优化性能,
ViewGroup默认设置了PFLAG_SKIP_DRAW标志位,即setWillNotDraw(true)。 - 触发执行 :调用
setWillNotDraw(false),或设置背景 (setBackground)、前景等属性。
流程图
🛠️ 第四梯队:进阶实战与细节(资深必备)
Q12:自定义 ViewGroup 时如何正确实现 onMeasure 和 onLayout?需要处理哪些边界情况?自定义 View 与 ViewGroup 的核心区别是什么?
核心答案
- 自定义 ViewGroup 核心步骤 :
onMeasure:遍历子 View,调用measureChildWithMargins(),累加宽高,加上 padding,setMeasuredDimension()。onLayout:遍历子 View,计算位置,调用child.layout()。
- 边界情况 :处理
GONE、margin、gravity、MATCH_PARENT/WRAP_CONTENT。 - 区别 :View 只需
onMeasure+onDraw,ViewGroup 还需onLayout管理子 View。
流程图
精简源码(简易垂直 LinearLayout 核心)
java
@Override
protected void onMeasure(int widthSpec, int heightSpec) {
int totalHeight = 0, maxWidth = 0;
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
if (child.getVisibility() == GONE) continue;
measureChildWithMargins(child, widthSpec, 0, heightSpec, 0);
MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
totalHeight += child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
maxWidth = Math.max(maxWidth, child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin);
}
totalHeight += getPaddingTop() + getPaddingBottom();
maxWidth += getPaddingLeft() + getPaddingRight();
setMeasuredDimension(resolveSize(maxWidth, widthSpec),
resolveSize(totalHeight, heightSpec));
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int currentTop = getPaddingTop();
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
if (child.getVisibility() == GONE) continue;
MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
int left = getPaddingLeft() + lp.leftMargin;
int right = left + child.getMeasuredWidth();
int bottom = currentTop + lp.topMargin + child.getMeasuredHeight();
child.layout(left, currentTop + lp.topMargin, right, bottom);
currentTop = bottom + lp.bottomMargin;
}
}
Q13:自定义 View 中如何处理触摸事件(单点拖拽、双指缩放)?如何与 ViewGroup 的事件分发协作?
核心答案
- 单点拖拽:记录坐标,MOVE 中更新位置。
- 双指缩放 :使用
ScaleGestureDetector。 - 事件协作 :调用
getParent().requestDisallowInterceptTouchEvent(true)阻止父容器拦截。
流程图
精简源码(拖拽+缩放)
java
public class DragScaleView extends View {
private float lastX, lastY;
private ScaleGestureDetector scaleDetector;
// ...
@Override
public boolean onTouchEvent(MotionEvent event) {
scaleDetector.onTouchEvent(event);
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
lastX = event.getRawX();
lastY = event.getRawY();
getParent().requestDisallowInterceptTouchEvent(true);
return true;
case MotionEvent.ACTION_MOVE:
float dx = event.getRawX() - lastX;
float dy = event.getRawY() - lastY;
setX(getX() + dx);
setY(getY() + dy);
lastX = event.getRawX();
lastY = event.getRawY();
return true;
}
return super.onTouchEvent(event);
}
}
Q14:如何在自定义 View 中高效实现沿贝塞尔曲线移动的动画?需要注意哪些性能问题?
核心答案
- 实现 :
Path定义曲线 →PathMeasure获取长度和坐标 →ValueAnimator驱动 →getPosTan获取坐标 →setTranslationX/Y。 - 性能注意:对象复用、硬件加速、局部刷新。
流程图
精简源码
java
public class PathMoveView extends View {
private Path path;
private PathMeasure pathMeasure;
private float[] pos = new float[2];
// ...
public PathMoveView(Context context, AttributeSet attrs) {
super(context, attrs);
path = new Path();
path.moveTo(100, 500);
path.cubicTo(300, 100, 600, 900, 800, 500);
pathMeasure = new PathMeasure(path, false);
ValueAnimator anim = ValueAnimator.ofFloat(0, pathMeasure.getLength());
anim.setDuration(3000);
anim.addUpdateListener(animation -> {
float distance = (float) animation.getAnimatedValue();
pathMeasure.getPosTan(distance, pos, null);
setTranslationX(pos[0] - getWidth()/2f);
setTranslationY(pos[1] - getHeight()/2f);
invalidate();
});
anim.start();
}
}
Q15:如何定义和使用自定义属性?在自定义 View 中如何获取这些属性?
核心答案
- 在
attrs.xml中定义<declare-styleable>。 - 布局中使用
app:赋值。 - 构造函数中
obtainStyledAttributes获取,最后recycle()。
流程图
精简源码
java
// attrs.xml
<declare-styleable name="CircleView">
<attr name="circleColor" format="color" />
</declare-styleable>
// CircleView.java
public CircleView(Context context, AttributeSet attrs) {
super(context, attrs);
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.CircleView);
int color = ta.getColor(R.styleable.CircleView_circleColor, Color.RED);
ta.recycle();
}
Q16:自定义 View 的内存优化有哪些注意事项?
核心答案
- 避免内存泄漏:静态内部类+弱引用。
- 资源释放:
onDetachedFromWindow中停止动画、回收 Bitmap。 - 大图采样压缩。
流程图
Q17:onMeasure 方法通常会执行几次?为什么?
核心答案
最少 2 次,最多 4 次。默认预测量+正式测量=2次;弹窗场景预测量可能2-3次,加上正式测量达4次。
流程图
Q18:View 的 post、postDelayed 与 Handler 的关系?如何利用它们实现延迟任务?
核心答案
View 的 post 内部通过 ViewRootImpl.getHandler() 获取主线程 Handler 执行。可用于延迟获取宽高、延迟动画等。
流程图
精简源码
java
view.postDelayed(() -> {
view.animate().translationX(100f).setDuration(300).start();
}, 1000);
Q19:如何实现一个自定义 View 的"限行数"效果(例如给 TextView 限定最多显示 3 行)?应该在哪个步骤处理?
核心答案
在 onMeasure 阶段使用 StaticLayout 计算行数,超过限制则调整高度,onDraw 中只绘制前 maxLines 行。
流程图
精简源码(核心片段)
java
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int width = MeasureSpec.getSize(widthMeasureSpec);
int availableWidth = width - getPaddingLeft() - getPaddingRight();
StaticLayout layout = new StaticLayout(text, paint, availableWidth, ...);
int lineCount = layout.getLineCount();
int desiredHeight;
if (lineCount > maxLines) {
desiredHeight = layout.getLineBottom(maxLines - 1) + getPaddingTop() + getPaddingBottom();
} else {
desiredHeight = layout.getHeight() + getPaddingTop() + getPaddingBottom();
}
setMeasuredDimension(width, resolveSize(desiredHeight, heightMeasureSpec));
}
Q20:什么是多点触控?如何在自定义 View 中处理?
核心答案
多点触控指屏幕同时支持多个手指操作。通过 event.getPointerCount()、event.getX(int index)、event.getActionIndex() 处理。
流程图
Q21:简述 Android 中 View 的绘制性能分析工具及常见问题(过度绘制、丢帧)的排查方法。
核心答案
工具:GPU 过度绘制、Profile GPU Rendering、Systrace、Layout Inspector。
常见问题:过度绘制、丢帧。解决:减少层级、clipRect、硬件加速、局部刷新。
流程图
Q22:View 的硬件加速是什么?如何开启?有什么注意事项?
核心答案
硬件加速利用 GPU 进行绘制,提升性能。开启方式:应用级、Activity 级、View 级。注意:某些 Canvas 操作不支持,过度使用会占用 GPU 内存。
流程图
Q23:你做过哪些高级的自定义 View 和动画结合的项目?请具体说明。
核心答案 (示例)
项目一:可拖拽环形菜单 + 弹性动画;项目二:波浪进度条 + 颜色渐变动画。
流程图
精简源码(波浪动画核心)
java
ValueAnimator animator = ValueAnimator.ofFloat(0f, 2f * (float) Math.PI);
animator.setRepeatCount(ValueAnimator.INFINITE);
animator.addUpdateListener(animation -> {
phase = (float) animation.getAnimatedValue();
postInvalidate();
});
animator.start();
Q24:自定义 View 的 onDraw 中如何避免频繁 GC?ClipRect 和 QuickReject 如何使用?
核心答案
- 避免 GC:成员变量复用,不在
onDraw中new对象。 clipRect:裁剪绘制区域。quickReject:快速判断是否在裁剪区域外,若是则跳过绘制。
流程图
精简源码
java
@Override
protected void onDraw(Canvas canvas) {
if (canvas.quickReject(mPath.getBounds(), Canvas.EdgeType.AA)) return;
canvas.save();
canvas.clipRect(visibleRect);
canvas.drawPath(mPath, mPaint);
canvas.restore();
}
Q25:onWindowFocusChanged、onAttachedToWindow、onDetachedFromWindow 在自定义 View 中的使用场景?
核心答案
onWindowFocusChanged:窗口焦点变化,可获取宽高、启停动画。onAttachedToWindow:View 添加到窗口,注册监听、启动动画。onDetachedFromWindow:View 移除窗口,取消注册、停止动画、回收资源。
流程图
精简源码
java
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
getViewTreeObserver().addOnGlobalLayoutListener(mListener);
mAnimator.start();
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
getViewTreeObserver().removeOnGlobalLayoutListener(mListener);
mAnimator.cancel();
if (mBitmap != null) mBitmap.recycle();
}