A
第 10 章JAVA40 分钟

自定义 View 入门

掌握 View 绘制三段式、自定义属性、事件分发机制,并手写一个圆形进度条综合示例。

学习目标

  • 理解 View 绘制流程:measure / layout / draw
  • 能重写 onMeasure / onDraw 实现简单自定义控件
  • 会用 attrs.xml + obtainStyledAttributes 暴露自定义属性
  • 理解事件分发 dispatchTouchEvent / onInterceptTouchEvent / onTouchEvent 三者关系
  • 完成一个圆形进度条综合示例

学习目标

  • 能用一句话说清 measure / layout / draw 三阶段分别做什么
  • 会重写 onMeasure 处理 wrap_content,会重写 onDraw 用 Canvas 画图形
  • 能在 attrs.xml 里声明自定义属性,并通过 obtainStyledAttributes 取出
  • 能解释 dispatchTouchEvent / onInterceptTouchEvent / onTouchEvent 三者调用顺序与返回值含义
  • 完成圆形进度条控件:支持自定义颜色、宽度、起始角度,外部调用 setProgress 更新

自定义 View 入门

1. View 的绘制流程:measure / layout / draw

每个 View 在屏幕上显示,都要走完这三步:

阶段 入口方法 作用
测量 measure(widthMeasureSpec, heightMeasureSpec) 算出自己和子 View 应该多大多高
摆放 layout(l, t, r, b) 把自己/子 View 放到屏幕上的具体位置
绘制 draw(canvas) 用 Canvas 把自己画出来

对应我们重写的回调:

public class MyView extends View {
    public MyView(Context context) { super(context); }
    public MyView(Context context, AttributeSet attrs) { super(context, attrs); }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // 计算自己的尺寸,调 setMeasuredDimension
    }

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        // 仅 ViewGroup 需要:决定子 View 摆放位置
    }

    @Override
    protected void onDraw(Canvas canvas) {
        // 用 Canvas 画自己
    }
}

onLayout 只有自定义 ViewGroup 时才需要重写(要决定子 View 位置),单独的 View 直接继承即可。

2. MeasureSpec:测量规则

onMeasure 的入参 widthMeasureSpec 是个打包的 32 位 int:高 2 位是 mode、低 30 位是 size。三种 mode:

Mode 含义 触发场景
EXACTLY 父容器已经确定了精确大小 match_parent、固定 dp
AT_MOST 父容器给了上限,子 View 自己决定 wrap_content
UNSPECIFIED 不限制,想多大就多大 ScrollView 内部子 View,滚动场景

View 默认 onMeasure 的实现:对 AT_MOST 直接取背景可绘制尺寸或 0,所以你写一个 wrap_content 的自定义 View 如果不重写 onMeasure,它会塌成 0×0 或填满父容器——这就是新手最常踩的「自定义 View 看不见」坑。

2.1 一个标准 onMeasure 写法

约定俗成的写法是:默认大小给一个值(比如 200dp),如果是 AT_MOST 取「内容需求 vs 上限」的较小者,EXACTLY 直接用:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int defaultSize = dp(200);
    int width = resolveSize(defaultSize, widthMeasureSpec);
    int height = resolveSize(defaultSize, heightMeasureSpec);
    setMeasuredDimension(width, height);
}

private int dp(int dp) {
    float density = getResources().getDisplayMetrics().density;
    return (int) (dp * density + 0.5f);
}

resolveSize 是系统工具方法:传一个「期望尺寸」和「MeasureSpec」,它会按 mode 决定最终值(EXACTLY 取 size、AT_MOST 取 min(期望, size)、UNSPECIFIED 取期望)。让自定义 View 适配 wrap_content 是几乎每个自定义 View 都要做的事。

3. onDraw 与 Canvas 基础

onDraw(Canvas canvas) 是真正画图的地方。Canvas 提供一系列 drawXxx 方法:drawCircle / drawRect / drawLine / drawPath / drawText。每个 draw 接收一个 Paint(画笔)作为样式参数。

private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    int cx = getWidth() / 2;
    int cy = getHeight() / 2;
    int r = Math.min(getWidth(), getHeight()) / 2 - 8;
    paint.setColor(0xFF1976D2);
    paint.setStyle(Paint.Style.FILL);
    canvas.drawCircle(cx, cy, r, paint);
}

几个要点:

  • Paint.ANTI_ALIAS_FLAG 开抗锯齿,画曲线必备
  • Paint 复用:不要在 onDraw 里 new Paint()——onDraw 高频调用,对象创建会触发 GC 卡顿。统一在构造函数初始化、字段持有
  • 不要在 onDraw 里 new 对象:包括 Rect、Path,统一在字段预创建、onDraw 里复用
  • Paint.setStyle 区分 FILL(填充)、STROKE(描边)、FILL_AND_STROKE(两者)

4. 自定义属性:attrs.xml

让自定义 View 在 XML 里能配 app:ringColor="#1976D2" 这种属性,需要三步。

4.1 声明属性 res/values/attrs.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="CircularProgressView">
        <attr name="ringColor" format="color" />
        <attr name="ringWidth" format="dimension" />
        <attr name="startAngle" format="integer" />
        <attr name="progress" format="float" />
        <attr name="trackColor" format="color" />
    </declare-styleable>
</resources>

format 取值:color / dimension / integer / float / boolean / string / reference / enum / flag。

4.2 在 View 构造函数读取

public CircularProgressView(Context context, AttributeSet attrs) {
    super(context, attrs);
    init(context, attrs);
}

private void init(Context context, AttributeSet attrs) {
    TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.CircularProgressView);
    ringColor = ta.getColor(R.styleable.CircularProgressView_ringColor, 0xFF1976D2);
    ringWidth = ta.getDimension(R.styleable.CircularProgressView_ringWidth, dp(12));
    startAngle = ta.getInt(R.styleable.CircularProgressView_startAngle, -90);
    progress = ta.getFloat(R.styleable.CircularProgressView_progress, 0f);
    trackColor = ta.getColor(R.styleable.CircularProgressView_trackColor, 0xFFCCCCCC);
    ta.recycle();  // 必须回收,否则会内存泄漏
}

obtainStyledAttributes 返回的 TypedArray 是个对象池,必须 recycle()——否则下次拿不到。

4.3 在布局里使用

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    android:orientation="vertical">

    <com.example.customview.CircularProgressView
        android:id="@+id/progressView"
        android:layout_width="200dp"
        android:layout_height="200dp"
        app:ringColor="@color/brand_primary"
        app:ringWidth="14dp"
        app:startAngle="-90"
        app:progress="0.3"
        app:trackColor="#FFEEEEEE" />

</LinearLayout>

xmlns:app 是声明自定义命名空间,指向 res-auto,系统会自动找项目里的 attrs.xml。

5. 事件分发:dispatchTouchEvent / onInterceptTouchEvent / onTouchEvent

手势相关自定义 View 必须理解事件分发。三个方法都返回 boolean,含义微妙:

方法 谁有 作用 返回值含义
dispatchTouchEvent View / ViewGroup 派发事件入口 true 自己消费,不传子;false 不传子也不消费
onInterceptTouchEvent 仅 ViewGroup 是否拦截、自己处理 true 拦截、自己消费;false 放行给子 View
onTouchEvent View / ViewGroup 真正处理触摸 true 消费,事件不再上抛;false 不消费,回传父级

一次 ACTION_DOWN 调用顺序:

Activity.dispatchTouchEvent
  → ViewGroup.dispatchTouchEvent
    → ViewGroup.onInterceptTouchEvent   (问自己要不要拦)
      → 子 View.dispatchTouchEvent       (没拦,传子)
        → 子 View.onTouchEvent           (问子要不要消费)
      → 回到 ViewGroup.onTouchEvent      (子不消费,父再问自己)
    → Activity.onTouchEvent              (都不消费,回到 Activity)

ViewGroup.onInterceptTouchEvent 默认返回 false,所以普通布局不拦截手势、子 View 正常处理。需要拦截的场景:ScrollView 检测到手指滑动了,就 return true 拦截下来让自己滚动;ViewPager 检测横滑时拦截。

5.1 实战:处理单次点击的写法

下面是处理「按下、抬起算一次点击」的最简写法:

@Override
public boolean onTouchEvent(MotionEvent event) {
    switch (event.getActionMasked()) {
        case MotionEvent.ACTION_DOWN:
            downX = event.getX();
            downY = event.getY();
            return true;  // 必须 return true,否则后续 ACTION_MOVE/UP 收不到

        case MotionEvent.ACTION_UP:
            float dx = Math.abs(event.getX() - downX);
            float dy = Math.abs(event.getY() - downY);
            if (dx < touchSlop && dy < touchSlop) {
                // 视为点击
                performClick();
            }
            return true;
    }
    return super.onTouchEvent(event);
}

touchSlop 是系统定义的「最小可识别滑动距离」(用 ViewConfiguration.get(context).getScaledTouchSlop() 取),小于它就算点击。performClick() 走标准点击回调,让外部 setOnClickListener 生效。

⚠️ Lint 警告:重写 onTouchEvent 处理点击时,IDE 会提示「自定义 View 处理点击未调用 performClick」。务必同时重写 performClick:

@Override
public boolean performClick() {
    super.performClick();
    // 你的点击逻辑
    return true;
}

6. 综合示例:圆形进度条

把上面所有内容综合,写一个可复用的圆形进度条控件:自定义颜色、宽度、起始角度、可动画更新。

6.1 完整 View 代码

package com.example.customview;

import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.RectF;
import android.util.AttributeSet;
import android.view.View;

public class CircularProgressView extends View {

    private final Paint ringPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    private final Paint trackPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    private final RectF arcRect = new RectF();

    private int ringColor;
    private int trackColor;
    private float ringWidth;
    private int startAngle;
    private float progress;  // 0-1

    public CircularProgressView(Context context) {
        this(context, null);
    }

    public CircularProgressView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public CircularProgressView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context, attrs);
    }

    private void init(Context context, AttributeSet attrs) {
        TypedArray ta = context.obtainStyledAttributes(
                attrs, R.styleable.CircularProgressView);
        ringColor = ta.getColor(
                R.styleable.CircularProgressView_ringColor, 0xFF1976D2);
        trackColor = ta.getColor(
                R.styleable.CircularProgressView_trackColor, 0xFFCCCCCC);
        ringWidth = ta.getDimension(
                R.styleable.CircularProgressView_ringWidth, dp(12));
        startAngle = ta.getInt(
                R.styleable.CircularProgressView_startAngle, -90);
        progress = ta.getFloat(
                R.styleable.CircularProgressView_progress, 0f);
        ta.recycle();

        ringPaint.setStyle(Paint.Style.STROKE);
        ringPaint.setStrokeCap(Paint.Cap.ROUND);
        ringPaint.setColor(ringColor);
        ringPaint.setStrokeWidth(ringWidth);

        trackPaint.setStyle(Paint.Style.STROKE);
        trackPaint.setStrokeCap(Paint.Cap.ROUND);
        trackPaint.setColor(trackColor);
        trackPaint.setStrokeWidth(ringWidth);
    }

    private float dp(int dp) {
        float density = getResources().getDisplayMetrics().density;
        return dp * density;
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int defaultSize = (int) dp(200);
        int width = resolveSize(defaultSize, widthMeasureSpec);
        int height = resolveSize(defaultSize, heightMeasureSpec);
        // 让宽高一致,画正圆
        int size = Math.min(width, height);
        setMeasuredDimension(size, size);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        // 计算圆弧外接矩形,向内缩半环宽,避免描边被裁
        float inset = ringWidth / 2f;
        arcRect.set(inset, inset, w - inset, h - inset);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        // 画底环
        canvas.drawArc(arcRect, startAngle, 360f, false, trackPaint);
        // 画进度环
        if (progress > 0f) {
            float sweep = 360f * Math.min(progress, 1f);
            canvas.drawArc(arcRect, startAngle, sweep, false, ringPaint);
        }
    }

    public void setProgress(float progress) {
        this.progress = Math.max(0f, Math.min(1f, progress));
        invalidate(); // 触发重绘
    }

    public void setProgressWithAnimation(float target, long durationMs) {
        ValueAnimator anim = ValueAnimator.ofFloat(this.progress, target);
        anim.setDuration(durationMs);
        anim.addUpdateListener(a -> setProgress((float) a.getAnimatedValue()));
        anim.start();
    }
}

注意几个工程细节:

  • 三个构造函数链式调用:自定义 View 标准模板,少写一个会让某些场景(XML 默认 defStyleAttr)崩。this(...) 的链让最终的实现都收敛在第三个构造函数里
  • onSizeChanged 而非 onDraw 里算 RectF:尺寸变化才需要重算,onDraw 高频调用不应做这种工作。避免在 onDraw 里 new 对象,arcRect 是字段复用
  • setProgress 调 invalidate() 触发重绘。如果是从非主线程调,必须用 postInvalidate()
  • drawArc 的 useCenter=false:不画到圆心,画完整圆环而不是扇形

6.2 Activity 使用 + 动画

package com.example.customview;

import android.animation.ValueAnimator;
import android.os.Bundle;
import android.widget.Button;
import androidx.appcompat.app.AppCompatActivity;

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        CircularProgressView progressView = findViewById(R.id.progressView);
        Button btnAnim = findViewById(R.id.btnAnim);

        btnAnim.setOnClickListener(v -> {
            // 0.3 -> 0.8 用 1 秒动画
            progressView.setProgressWithAnimation(0.8f, 1000);
        });
    }
}

需要 import android.animation.ValueAnimator;。ValueAnimator 是属性动画核心,会在后续体验优化章节展开。这里直接用最简形式。

6.3 扩展点

这个示例能继续扩展的方向:

  • 在中间画文字百分比(再开一个 TextPaint + canvas.drawText)
  • 加渐变色(SweepGradient)
  • 处理触摸调整进度(重写 onTouchEvent,根据 event.getX/Y 算角度反推 progress)
  • 自定义动画插值器(BounceInterpolator 让 100% 时弹一下)

这些留作练习,建议每做一个都对应一种新知识点。

常见坑与最佳实践

  1. wrap_content 自定义 View 看不见:默认 onMeasure 在 AT_MOST 模式下尺寸退化为背景或 0。重写 onMeasure 用 resolveSize(default, spec) 是万能写法。
  2. onDraw 里 new 对象:Paint、Path、Rect 都该在字段创建。onDraw 一秒可能调用几十次,GC 风暴就是从这里来的。
  3. 忘了 TypedArray.recycle():对象池上限,不回收会拿到 null,且 Android Studio 会报警告。所有 obtainStyledAttributes 后立刻 try-finally recycle。
  4. onInterceptTouchEvent 拦了但没消费:父 ViewGroup 返回 true 拦截后,自己 onTouchEvent 又返回 false 不消费,事件会回到父的父继续问,最终回 Activity——子 View 完全收不到 MOVE。所以拦截后必须保证自己能消费。
  5. ACTION_DOWN 返回 false:返回 false 表示不消费 DOWN,后续 MOVE/UP 都不会再发给你。新手调试「为什么 move 收不到」多半是这个。
  6. 重写 onTouchEvent 没同时重写 performClick:Lint 警告,且 OnClickListener 不会触发。两者必须配对。
  7. postInvalidate vs invalidate:invalidate() 只能在主线程调,子线程调会崩。后台任务里要重绘用 postInvalidate(),让系统切到主线程后再 invalidate。

章节小结

  • View 绘制三段式:measure(量)→ layout(摆)→ draw(画),重写 onMeasure / onLayout / onDraw
  • MeasureSpec 三 mode:EXACTLY / AT_MOST / UNSPECIFIED;wrap_content 必须重写 onMeasure
  • onDraw 里用 Canvas.drawXxx + Paint 画图,Paint 在字段复用、不在 onDraw new 对象
  • 自定义属性三步:attrs.xml 声明 → 构造函数 obtainStyledAttributes 读取 → recycle() 释放
  • 事件分发:dispatchTouchEvent → onInterceptTouchEvent(ViewGroup)→ onTouchEvent;ACTION_DOWN 返回 false 后续都收不到
  • 圆形进度条综合示例串起 measure / draw / attrs / 动画,是自定义 View 入门里程碑
  • 重写 onTouchEvent 处理点击时,必须同时重写 performClick,否则 OnClickListener 不工作

下一章预告

UI 基础到这里告一段落,你已经能独立写出任何静态界面和复杂交互。下一部分「核心组件」将走出 UI,进入 Android 应用的四大组件——Activity 生命周期与启动模式、Service 后台任务、BroadcastReceiver 消息广播、ContentProvider 数据共享,让你的 App 真正「活」起来。