第18章 自定义View开发:从绘制到交互的完整实践

说实话,自定义View是Android开发里最让我着迷的部分。它就像一张白纸,你想画什么就画什么。我刚开始学的时候,觉得这东西高深莫测,后来才发现——说白了,就是搞清楚系统什么时候叫你画、怎么画、画完怎么响应。

这一章,我会带你走完自定义View的完整流程。从测量到布局,从绘制到触摸,再到自定义属性。嗯,内容不少,但每一步我都会结合我踩过的坑来讲。

核心知识点一览:

  • View绘制三件套:onMeasure、onLayout、onDraw
  • Canvas画布与Paint画笔的配合使用
  • 自定义属性:从attrs.xml到代码获取
  • 触摸反馈:onTouchEvent与手势识别
  • 自定义ViewGroup:布局与测量规则
自定义View开发知识体系 自定义View 绘制流程 onMeasure → onLayout → onDraw Canvas + Paint drawCircle / drawRect / drawText 自定义属性 attrs.xml → TypedArray 触摸反馈 onTouchEvent / GestureDetector 自定义ViewGroup onMeasure + onLayout 重写

18.1 View绘制流程:系统到底在做什么?

你写一个自定义View,系统其实只关心三件事:你有多大?你放在哪?你长什么样?对应的方法就是onMeasure、onLayout、onDraw。我习惯把这叫做「View的三部曲」。

18.1.1 onMeasure:告诉系统你有多大

onMeasure是第一个被调用的。系统会传给你两个参数——widthMeasureSpec和heightMeasureSpec。这两个东西里封装了父布局对你的要求。

@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);
    
    // 处理wrap_content的情况
    if (widthMode == MeasureSpec.AT_MOST) {
        widthSize = 200; // 默认宽度
    }
    if (heightMode == MeasureSpec.AT_MOST) {
        heightSize = 200; // 默认高度
    }
    
    setMeasuredDimension(widthSize, heightSize);
}

我的经验:很多新手自定义View时忘记处理AT_MOST模式。结果在XML里设了wrap_content,View却撑满了全屏。我曾经因为这个被测试小姐姐追着问了一下午……

18.1.2 onLayout:确定你的位置

对于单个View来说,onLayout其实没什么好做的。系统已经帮你算好了。但如果是ViewGroup,这里就是重头戏——你要决定每个子View放在哪。

18.1.3 onDraw:真正开始画了

这是最有趣的部分。系统给你一个Canvas,你拿着Paint往上画就行。

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    
    // 画一个圆
    Paint paint = new Paint();
    paint.setColor(Color.BLUE);
    paint.setStyle(Paint.Style.FILL);
    canvas.drawCircle(getWidth() / 2f, getHeight() / 2f, 100f, paint);
}

18.2 Canvas与Paint:画布和画笔的配合

Canvas是画布,Paint是画笔。你想想看,画画的时候是不是得先选好笔的颜色、粗细,再往画布上画?Android里也是一样的逻辑。

Canvas方法 作用 常用场景
drawCircle() 画圆 圆形头像、按钮
drawRect() 画矩形 进度条、背景
drawLine() 画线 分割线、图表
drawText() 画文字 标签、数值显示
drawPath() 画路径 不规则图形、贝塞尔曲线

Paint的设置也很关键。我习惯把Paint的配置放在构造函数里,避免在onDraw里重复创建对象——那样会触发GC,导致掉帧。

public class MyView extends View {
    private Paint mPaint;
    
    public MyView(Context context, AttributeSet attrs) {
        super(context, attrs);
        mPaint = new Paint();
        mPaint.setColor(Color.RED);
        mPaint.setStrokeWidth(5f);
        mPaint.setStyle(Paint.Style.STROKE);
        mPaint.setAntiAlias(true); // 抗锯齿,必开
    }
}

注意:不要在onDraw里new对象!onDraw每秒可能被调用60次,每次new对象会导致频繁GC。我曾经在一个自定义View里每次画的时候都new了一个Paint,结果列表滑动时卡成PPT……

18.3 自定义属性:让XML也能配置你的View

你写了一个自定义View,总不能每次都在代码里setColor吧?通过attrs.xml,你可以让用户在XML里直接配置属性。

第一步,在res/values/attrs.xml里声明:

<resources>
    <declare-styleable name="MyCustomView">
        <attr name="myColor" format="color" />
        <attr name="mySize" format="dimension" />
        <attr name="myText" format="string" />
    </declare-styleable>
</resources>

第二步,在构造函数里读取:

public MyCustomView(Context context, AttributeSet attrs) {
    super(context, attrs);
    TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.MyCustomView);
    int color = ta.getColor(R.styleable.MyCustomView_myColor, Color.BLACK);
    float size = ta.getDimension(R.styleable.MyCustomView_mySize, 16f);
    String text = ta.getString(R.styleable.MyCustomView_myText);
    ta.recycle(); // 别忘了回收!
}

避坑指南:TypedArray用完一定要recycle()。我曾经漏掉这一行,结果在低版本Android上出现了内存泄漏。嗯,从那以后我每次写完都检查一遍。

18.4 触摸反馈处理:让View动起来

光能画还不够,用户点上去你得有反应。触摸反馈的核心是onTouchEvent方法。

@Override
public boolean onTouchEvent(MotionEvent event) {
    float x = event.getX();
    float y = event.getY();
    
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            // 手指按下
            mIsPressed = true;
            invalidate(); // 触发重绘
            return true; // 消费事件
        case MotionEvent.ACTION_MOVE:
            // 手指移动
            updatePosition(x, y);
            invalidate();
            break;
        case MotionEvent.ACTION_UP:
            // 手指抬起
            mIsPressed = false;
            performClick(); // 别忘了触发点击事件
            invalidate();
            break;
    }
    return true;
}

你可能会问:为什么ACTION_DOWN要return true?因为系统只把事件分发给消费了ACTION_DOWN的View。如果这里返回false,后续的MOVE和UP都不会收到。

对于复杂的手势(比如滑动、缩放),我建议直接用GestureDetector。手写手势识别太容易出bug了——我试过,真的。

18.5 自定义ViewGroup:布局由你说了算

自定义ViewGroup比自定义View多了一个任务:你要管理子View的位置。核心就是重写onMeasure和onLayout。

18.5.1 onMeasure:测量所有子View

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int childCount = getChildCount();
    for (int i = 0; i < childCount; i++) {
        View child = getChildAt(i);
        // 测量每个子View
        measureChild(child, widthMeasureSpec, heightMeasureSpec);
    }
    
    // 计算自己的大小
    int width = MeasureSpec.getSize(widthMeasureSpec);
    int height = MeasureSpec.getSize(heightMeasureSpec);
    setMeasuredDimension(width, height);
}

18.5.2 onLayout:摆放子View

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    int childCount = getChildCount();
    int currentTop = t;
    
    for (int i = 0; i < childCount; i++) {
        View child = getChildAt(i);
        int childWidth = child.getMeasuredWidth();
        int childHeight = child.getMeasuredHeight();
        
        // 垂直排列,每个子View占一行
        child.layout(l, currentTop, l + childWidth, currentTop + childHeight);
        currentTop += childHeight;
    }
}

关键点:onLayout里用的是getMeasuredWidth(),不是getWidth()。前者是测量后的尺寸,后者是布局后的尺寸。在onLayout执行时,getWidth()可能还没更新。

18.6 实战:做一个带按压效果的圆形按钮

说了这么多,我们来个完整的例子。一个圆形按钮,支持自定义颜色和文字,点击时有缩放效果。

public class CircleButton extends View {
    private Paint mPaint;
    private int mColor;
    private String mText;
    private float mScale = 1.0f;
    
    public CircleButton(Context context, AttributeSet attrs) {
        super(context, attrs);
        TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.CircleButton);
        mColor = ta.getColor(R.styleable.CircleButton_cbColor, Color.BLUE);
        mText = ta.getString(R.styleable.CircleButton_cbText);
        ta.recycle();
        
        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaint.setColor(mColor);
        mPaint.setTextAlign(Paint.Align.CENTER);
    }
    
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int size = Math.min(
            MeasureSpec.getSize(widthMeasureSpec),
            MeasureSpec.getSize(heightMeasureSpec)
        );
        setMeasuredDimension(size, size);
    }
    
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        
        canvas.save();
        canvas.scale(mScale, mScale, getWidth() / 2f, getHeight() / 2f);
        
        // 画圆形背景
        mPaint.setStyle(Paint.Style.FILL);
        canvas.drawCircle(getWidth() / 2f, getHeight() / 2f, 
                          getWidth() / 2f, mPaint);
        
        // 画文字
        mPaint.setColor(Color.WHITE);
        mPaint.setTextSize(getWidth() * 0.3f);
        canvas.drawText(mText, getWidth() / 2f, 
                        getHeight() / 2f + getWidth() * 0.1f, mPaint);
        
        canvas.restore();
    }
    
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mScale = 0.9f;
                invalidate();
                return true;
            case MotionEvent.ACTION_UP:
                mScale = 1.0f;
                performClick();
                invalidate();
                break;
        }
        return true;
    }
}

这个例子虽然简单,但涵盖了自定义View的完整流程。你可以在XML里这样用:

<com.example.CircleButton
    android:layout_width="100dp"
    android:layout_height="100dp"
    app:cbColor="#FF5722"
    app:cbText="Go" />

最后说一句:自定义View没有想象中那么难。你只要记住三个方法、两个对象、一个文件——onMeasure/onLayout/onDraw、Canvas/Paint、attrs.xml。剩下的就是多画多练。我刚开始的时候,光画一个带阴影的圆就折腾了两天。但现在回头看,那些坑都是财富。


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