自定义View:从绘制到触摸,一次讲透

说实话,自定义View是Android开发里绕不过去的一道坎。我刚开始带团队时,发现很多新人一碰到自定义View就发怵。其实没那么可怕,你把它拆开来看,无非就是三件事:画什么、怎么画、怎么响应。今天我就把这套东西掰开了揉碎了讲给你听。

View绘制流程:onMeasure、onLayout、onDraw

Android的View绘制流程,说白了就是三个步骤:量一量、摆一摆、画一画。系统会从根View开始,递归调用这三个方法。

核心流程:

  • onMeasure:测量自己有多大
  • onLayout:确定子View的位置
  • onDraw:把内容画出来

我画了一张流程图,帮你理清这个关系:

performTraversals ① onMeasure → 测量宽高 ② onLayout → 确定位置 ③ onDraw → 绘制内容

onMeasure:量体裁衣

onMeasure是第一步,也是最容易踩坑的一步。系统会给你两个参数:widthMeasureSpecheightMeasureSpec。这两个东西里藏着三种模式:

模式 含义 典型场景
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配置颜色、大小、样式。这就用到自定义属性了。

步骤很简单,三步走:

  1. res/values/attrs.xml里声明属性
  2. 在View的构造方法里解析
  3. 在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:处理事件

流程是这样的:

Activity ViewGroup.dispatchTouchEvent onInterceptTouchEvent? true false 子View.onTouchEvent 子View.dispatchTouchEvent onTouchEvent

你想想看,如果ViewGroup拦截了事件,子View就收不到了。反过来,如果子View处理了事件,ViewGroup的onTouchEvent就不会被调用。这就是所谓的事件消费链

核心原则:

  • 事件从Activity → ViewGroup → 子View,层层下发
  • 任何一个环节返回true,事件就被消费了
  • 如果没人消费,最终回到Activity的onTouchEvent

自定义ViewGroup实战:做一个流式布局

光说不练假把式。我们来做一个流式布局(FlowLayout),就是那种子View自动换行的布局。微信的标签、淘宝的搜索历史都用到了它。

核心逻辑就三点:

  1. onMeasure:测量所有子View,计算总宽高
  2. onLayout:按行排列,放不下就换行
  3. 支持自定义属性:比如行间距、列间距
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才能过试用期。你想想看,一个流式布局写下来,整个绘制流程就全通了。

公众号:蓝海资料掘金营,微信deep3321