自定义View:从绘制到触摸,一次讲透
说实话,自定义View是Android开发里绕不过去的一道坎。我刚开始带团队时,发现很多新人一碰到自定义View就发怵。其实没那么可怕,你把它拆开来看,无非就是三件事:画什么、怎么画、怎么响应。今天我就把这套东西掰开了揉碎了讲给你听。
View绘制流程:onMeasure、onLayout、onDraw
Android的View绘制流程,说白了就是三个步骤:量一量、摆一摆、画一画。系统会从根View开始,递归调用这三个方法。
核心流程:
- onMeasure:测量自己有多大
- onLayout:确定子View的位置
- onDraw:把内容画出来
我画了一张流程图,帮你理清这个关系:
onMeasure:量体裁衣
onMeasure是第一步,也是最容易踩坑的一步。系统会给你两个参数:widthMeasureSpec和heightMeasureSpec。这两个东西里藏着三种模式:
| 模式 | 含义 | 典型场景 |
|---|---|---|
| EXACTLY | 精确值,比如100dp或match_parent | 父View已经算好了 |
| AT_MOST | 最大不能超过某个值,比如wrap_content | 需要自己算大小 |
| UNSPECIFIED | 随便多大都行 | ScrollView里常见 |
我个人的习惯是,先解析MeasureSpec,再决定怎么处理:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
int width, height;
if (widthMode == MeasureSpec.EXACTLY) {
width = widthSize;
} else {
// 自己算一个合适的宽度
width = calculateDesiredWidth();
if (widthMode == MeasureSpec.AT_MOST) {
width = Math.min(width, widthSize);
}
}
// 高度同理...
setMeasuredDimension(width, height);
}
小技巧:如果你只是简单继承View,不想处理复杂的测量逻辑,可以直接调用super.onMeasure(widthMeasureSpec, heightMeasureSpec)。但如果你继承的是ViewGroup,那就必须自己处理了。
onLayout:排兵布阵
onLayout是ViewGroup特有的方法。它的任务很简单:告诉每个子View,你该站在哪。
我记得有一次做自定义流式布局,子View的排列逻辑特别复杂。当时我写了个循环,逐个计算每个子View的left、top、right、bottom:
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int childCount = getChildCount();
int currentLeft = getPaddingLeft();
int currentTop = getPaddingTop();
int lineHeight = 0;
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
if (child.getVisibility() == GONE) continue;
int childWidth = child.getMeasuredWidth();
int childHeight = child.getMeasuredHeight();
// 如果放不下了,换行
if (currentLeft + childWidth > r - getPaddingRight()) {
currentLeft = getPaddingLeft();
currentTop += lineHeight;
lineHeight = 0;
}
child.layout(currentLeft, currentTop,
currentLeft + childWidth,
currentTop + childHeight);
currentLeft += childWidth + child.getLayoutParams().rightMargin;
lineHeight = Math.max(lineHeight, childHeight);
}
}
注意:onLayout里不要调用child.measure()!测量应该在onMeasure阶段完成。我曾经犯过这个错,结果导致无限循环测量,界面直接卡死。
onDraw:挥毫泼墨
onDraw是真正画东西的地方。你拿到Canvas,想画什么就画什么。但有几个原则要记住:
- 不要创建对象:onDraw每秒可能调用60次,在里面new对象会导致频繁GC
- 用Paint控制样式:颜色、粗细、阴影都在Paint里设置
- Canvas负责形状:画圆、画线、画文字都用Canvas的方法
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 画背景圆
canvas.drawCircle(getWidth() / 2f, getHeight() / 2f,
radius, bgPaint);
// 画文字
canvas.drawText("Hello", getWidth() / 2f, getHeight() / 2f,
textPaint);
}
自定义属性:让View更灵活
写死数值的View没什么用。你得让使用者能通过XML配置颜色、大小、样式。这就用到自定义属性了。
步骤很简单,三步走:
- 在
res/values/attrs.xml里声明属性 - 在View的构造方法里解析
- 在XML里使用
<!-- attrs.xml -->
<resources>
<declare-styleable name="CustomView">
<attr name="customColor" format="color"/>
<attr name="customSize" format="dimension"/>
<attr name="customText" format="string"/>
</declare-styleable>
</resources>
// 在构造方法中解析
public CustomView(Context context, AttributeSet attrs) {
super(context, attrs);
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CustomView);
int color = a.getColor(R.styleable.CustomView_customColor, Color.BLUE);
float size = a.getDimension(R.styleable.CustomView_customSize, 16f);
String text = a.getString(R.styleable.CustomView_customText);
a.recycle(); // 别忘了回收!
}
避坑指南:TypedArray用完后一定要recycle(),否则会内存泄漏。我曾经因为这个被线上用户反馈说App越用越卡,查了半天才发现是这里的问题。
触摸事件分发机制
触摸事件分发,说白了就是三个方法之间的博弈:
- dispatchTouchEvent:分发事件,决定给谁
- onInterceptTouchEvent:拦截事件,ViewGroup专属
- onTouchEvent:处理事件
流程是这样的:
你想想看,如果ViewGroup拦截了事件,子View就收不到了。反过来,如果子View处理了事件,ViewGroup的onTouchEvent就不会被调用。这就是所谓的事件消费链。
核心原则:
- 事件从Activity → ViewGroup → 子View,层层下发
- 任何一个环节返回true,事件就被消费了
- 如果没人消费,最终回到Activity的onTouchEvent
自定义ViewGroup实战:做一个流式布局
光说不练假把式。我们来做一个流式布局(FlowLayout),就是那种子View自动换行的布局。微信的标签、淘宝的搜索历史都用到了它。
核心逻辑就三点:
- onMeasure:测量所有子View,计算总宽高
- onLayout:按行排列,放不下就换行
- 支持自定义属性:比如行间距、列间距
public class FlowLayout extends ViewGroup {
private int horizontalSpacing = 16; // 列间距
private int verticalSpacing = 12; // 行间距
public FlowLayout(Context context, AttributeSet attrs) {
super(context, attrs);
// 解析自定义属性
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.FlowLayout);
horizontalSpacing = a.getDimensionPixelSize(
R.styleable.FlowLayout_horizontalSpacing, 16);
verticalSpacing = a.getDimensionPixelSize(
R.styleable.FlowLayout_verticalSpacing, 12);
a.recycle();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int totalWidth = getPaddingLeft() + getPaddingRight();
int totalHeight = getPaddingTop() + getPaddingBottom();
int lineWidth = 0;
int lineHeight = 0;
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
measureChild(child, widthMeasureSpec, heightMeasureSpec);
int childWidth = child.getMeasuredWidth();
int childHeight = child.getMeasuredHeight();
// 换行判断
if (lineWidth + childWidth > widthSize - totalWidth) {
totalHeight += lineHeight + verticalSpacing;
lineWidth = 0;
lineHeight = 0;
}
lineWidth += childWidth + horizontalSpacing;
lineHeight = Math.max(lineHeight, childHeight);
}
totalHeight += lineHeight;
setMeasuredDimension(
widthMode == MeasureSpec.EXACTLY ? widthSize : totalWidth,
totalHeight
);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int currentLeft = getPaddingLeft();
int currentTop = getPaddingTop();
int lineHeight = 0;
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
if (child.getVisibility() == GONE) continue;
int childWidth = child.getMeasuredWidth();
int childHeight = child.getMeasuredHeight();
if (currentLeft + childWidth > r - getPaddingRight()) {
currentLeft = getPaddingLeft();
currentTop += lineHeight + verticalSpacing;
lineHeight = 0;
}
child.layout(currentLeft, currentTop,
currentLeft + childWidth,
currentTop + childHeight);
currentLeft += childWidth + horizontalSpacing;
lineHeight = Math.max(lineHeight, childHeight);
}
}
}
使用方式:
<com.example.FlowLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:horizontalSpacing="12dp"
app:verticalSpacing="8dp">
<TextView ... />
<TextView ... />
<!-- 想加多少加多少 -->
</com.example.FlowLayout>
嗯,到这里自定义View的核心内容就讲完了。其实说白了,就是测量、布局、绘制、响应这四个环节。你只要把每个环节的职责搞清楚,遇到什么复杂的UI需求都不怕。
我刚开始做自定义View时,也经常搞混onMeasure和onLayout的职责。后来带团队时,我要求新人必须手写一个FlowLayout才能过试用期。你想想看,一个流式布局写下来,整个绘制流程就全通了。