07.20 控件-自定义View与绘图

2025/1/14 Android 开发基础

前几节介绍的布局组件(06 布局组件)和系统控件(07.01 ~ 07.19)已经能覆盖大多数界面需求,但总有系统控件"画不出来"的时候:

  • 需要一个水波纹进度球、仪表盘、粒子动画等特殊视觉效果
  • 需要控件拥有系统没有的交互方式(如侧滑删除、拖拽排序)
  • 需要统一封装一组复杂控件的行为(如折线图表、日历选择器)

这时就需要自定义 View。自定义 View 是 Android UI 进阶的分水岭:它要求理解 View 的绘制流程(测量、布局、绘制)、Canvas/Paint 绘图体系,以及上一节的事件分发机制(08 事件处理)。

# 自定义 View 的三种方式

按继承对象的不同,自定义 View 有三种典型方式,选型取决于需求:

方式 继承对象 适用场景 示例
继承 View View 完全自己绘制,不规则图形/特效 进度圆环、水波纹、折线图
继承已有控件 TextView / ImageView 等 在系统控件基础上微调/扩展 带角标的 ImageView
继承 ViewGroup LinearLayout 等 自定义布局规则、组合子元素 流式布局、侧滑菜单

# View 绘制三大流程(原理)

自定义 View 之前必须理解 View 是如何被"画"到屏幕上的。整个绘制过程分为三大步骤,由 ViewRootImpl 发起:

View 绘制三大流程示意图

# 1. onMeasure:测量

决定 View 的大小。系统传入 MeasureSpec(测量规格),View 在其中完成自身测量,最后必须调用 setMeasuredDimension() 存储结果。

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    // 默认实现:根据MeasureSpec计算(EXACTLY时取spec值,AT_MOST/UNSPECIFIED时取默认最小值)
    setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
            getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}

MeasureSpec 是一个 32 位的 int 值,高 2 位是模式,低 30 位是尺寸。三种模式:

模式 含义 对应布局参数 典型场景
EXACTLY 精确大小,子 View 必须是这个尺寸 match_parent / 精确值 100dp 父容器已定死大小
AT_MOST 最大不能超过 specSize wrap_content 内容自适应,但不超过父容器
UNSPECIFIED 不限制,想要多大给多大 少见(ScrollView 对子 View) 系统内部测量

自定义 View 时对 wrap_content 的经典坑:直接继承 View 时,wrap_content 的效果与 match_parent 一样(默认实现把 AT_MOST 当成了 match_parent 处理)。所以自定义 View 通常要重写 onMeasure,对 AT_MOST 模式自行计算内容大小:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int width = measureSize(widthMeasureSpec, mDefaultWidth);
    int height = measureSize(heightMeasureSpec, mDefaultHeight);
    setMeasuredDimension(width, height);
}

// 根据MeasureSpec与期望值计算最终尺寸
private int measureSize(int measureSpec, int expectSize) {
    int mode = MeasureSpec.getMode(measureSpec);
    int size = MeasureSpec.getSize(measureSpec);
    switch (mode) {
        case MeasureSpec.EXACTLY:
            // 精确值或match_parent:直接用系统给的尺寸
            return size;
        case MeasureSpec.AT_MOST:
            // wrap_content:取期望值与父容器限制的较小值
            return Math.min(expectSize, size);
        case MeasureSpec.UNSPECIFIED:
        default:
            return expectSize;
    }
}

# 2. onLayout:定位

决定 View 的位置。View.onLayout 默认是空实现(叶子 View 的位置由父容器调用 child.layout(l, t, r, b) 摆放时确定);自定义 ViewGroup 时必须重写 onLayout,遍历子元素并调用它们的 layout() 方法赋坐标。

// ViewGroup.onLayout:确定每个子元素的位置
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    int childLeft = 0;
    int childTop = 0;
    for (int i = 0; i < getChildCount(); i++) {
        View child = getChildAt(i);
        // 把测量出的宽高摆放到指定坐标(4个参数是相对父容器的左、上、右、下)
        child.layout(childLeft, childTop,
                childLeft + child.getMeasuredWidth(),
                childTop + child.getMeasuredHeight());
        // 下一个子元素排到右边(如横向排列)
        childLeft += child.getMeasuredWidth();
    }
}

# 3. onDraw:绘制

决定 View 长什么样,是自定义 View 的核心舞台。系统传入 Canvas 画布,配合 Paint 画笔完成绘制。

三大流程的调用链:

ViewRootImpl
    → performMeasure()  → View.measure()    → onMeasure()
    → performLayout()   → View.layout()     → onLayout()
    → performDraw()     → View.draw()       → onDraw()

触发重绘的方法也有讲究:

  • invalidate():重绘(触发 onDraw),必须在主线程,适合自身变化
  • postInvalidate():重绘,可在子线程调用(内部通过 handler 切换)
  • requestLayout():重新走测量 + 布局 + 绘制(大小或位置变化时用,比 invalidate 代价大)

# Paint 与 Canvas:绘图的两把刷子

Paint(画笔)描述"怎么画"——颜色、粗细、样式、字体;Canvas(画布)描述"画什么"——圆、矩形、路径、文字、图片。

# Paint 常用 API

Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);  // 构造时开启抗锯齿

paint.setColor(Color.parseColor("#3F51B5"));     // 画笔颜色
paint.setStyle(Paint.Style.FILL);                // 填充样式(见下表)
paint.setStrokeWidth(10);                        // 画笔宽度(描边时生效)
paint.setAntiAlias(true);                        // 抗锯齿(图形边缘平滑)
paint.setDither(true);                           // 防抖动(颜色渐变更柔和)
paint.setAlpha(128);                             // 透明度 0~255
paint.setTextSize(40);                           // 文字大小(画文字时)
paint.setTextAlign(Paint.Align.CENTER);          // 文字对齐方式

Paint.Style 三种样式:

  • FILL:填充模式(默认)
  • STROKE:描边模式(只画轮廓线)
  • FILL_AND_STROKE:填充加描边

进阶属性(Shader 着色器)可以实现渐变等高级效果:

// 线性渐变:进度条、背景过渡常用
paint.setShader(new LinearGradient(0, 0, width, 0,
        Color.RED, Color.BLUE, Shader.TileMode.CLAMP));

// 圆形渐变(径向):光晕效果
paint.setShader(new RadialGradient(cx, cy, radius,
        Color.WHITE, Color.TRANSPARENT, Shader.TileMode.CLAMP));

// 梳理纹理:沙砾/噪点效果
paint.setShader(new BitmapShader(bitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT));

# Canvas 常用 API

绘制类:

canvas.drawColor(Color.WHITE);                    // 填充画布颜色
canvas.drawCircle(cx, cy, radius, paint);         // 圆
canvas.drawRect(l, t, r, b, paint);               // 矩形
canvas.drawRoundRect(rect, rx, ry, paint);        // 圆角矩形
canvas.drawArc(rect, startAngle, sweepAngle, useCenter, paint);  // 弧/扇形
canvas.drawOval(rect, paint);                     // 椭圆
canvas.drawLine(startX, startY, stopX, stopY, paint);  // 线
canvas.drawPath(path, paint);                     // 路径(任意图形,配合Path)
canvas.drawBitmap(bitmap, x, y, paint);           // 图片
canvas.drawText(text, x, y, paint);               // 文字

Path 路径(绘制不规则图形的利器):

Path path = new Path();
path.moveTo(0, 100);                     // 移动画笔到起点(不画线)
path.lineTo(100, 50);                    // 连线到目标点
path.quadTo(150, 0, 200, 50);            // 二阶贝塞尔曲线(水波纹/圆滑过渡常用)
path.cubicTo(250, 100, 300, 100, 350, 50);  // 三阶贝塞尔曲线
path.close();                            // 闭合路径
canvas.drawPath(path, paint);

变换类(配合 save/restore 使用):

int saveCount = canvas.save();           // 保存当前画布状态
canvas.translate(cx, cy);                // 平移坐标系到圆心
canvas.rotate(angle);                    // 旋转(绕坐标原点,即刚平移的圆心)
canvas.scale(sx, sy);                    // 缩放
canvas.skew(sx, sy);                     // 倾斜
// ...在变换后的坐标系上绘制...
canvas.restoreToCount(saveCount);        // 恢复到保存时的状态

save/restore 的意义:变换(translate/rotate)作用于整个坐标系,绘制完一段图形后要恢复原坐标系,否则后续绘制都会带着变换。典型场景:画仪表盘时,每一根刻度线都是"旋转 → 画线 → 恢复"。

# 实战一:继承 View 实现进度圆环

一个完整、典型的自定义 View 案例:带进度动画的圆环进度条。包含构造函数、自定义属性、onMeasure、onDraw、属性动画驱动的完整闭环。

# 1. 自定义属性:values/attrs.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="CircleProgressView">
        <!-- 圆环背景颜色 -->
        <attr name="cpvRingColor" format="color" />
        <!-- 进度颜色 -->
        <attr name="cpvProgressColor" format="color" />
        <!-- 圆环宽度 -->
        <attr name="cpvRingWidth" format="dimension" />
        <!-- 当前进度(0-100) -->
        <attr name="cpvProgress" format="integer" />
        <!-- 中心文字 -->
        <attr name="cpvText" format="string" />
        <!-- 文字颜色 -->
        <attr name="cpvTextColor" format="color" />
        <!-- 文字大小 -->
        <attr name="cpvTextSize" format="dimension" />
    </declare-styleable>
</resources>

# 2. 自定义 View 类:CircleProgressView

/**
* 进度圆环自定义View.
*/
public class CircleProgressView extends View {
    private static final String TAG = "CircleProgressView";
    // 圆环起点角度(-90°即12点钟方向)
    private static final int START_ANGLE = -90;
    // 进度动画时长
    private static final int ANIM_DURATION = 1000;

    private Paint mRingPaint;        // 圆环背景画笔
    private Paint mProgressPaint;    // 进度弧画笔
    private Paint mTextPaint;        // 中心文字画笔

    private int mRingColor;
    private int mProgressColor;
    private int mRingWidth;
    private int mTextColor;
    private float mTextSize;
    private String mText = "";

    // 当前进度(动画过程中的实时值)
    private int mProgress;
    // 进度弧的半径(在onSizeChanged中根据控件大小计算)
    private float mRadius;

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

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

    // 三个构造函数逐级调用,最终在带attrs的构造中读取自定义属性
    public CircleProgressView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context, attrs);
    }

    private void init(Context context, AttributeSet attrs) {
        // 读取xml中配置的自定义属性
        TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.CircleProgressView);
        mRingColor = ta.getColor(R.styleable.CircleProgressView_cpvRingColor, Color.parseColor("#DDE4EB"));
        mProgressColor = ta.getColor(R.styleable.CircleProgressView_cpvProgressColor, Color.parseColor("#3F51B5"));
        mRingWidth = (int) ta.getDimension(R.styleable.CircleProgressView_cpvRingWidth, dp2px(12));
        mTextColor = ta.getColor(R.styleable.CircleProgressView_cpvTextColor, Color.parseColor("#333333"));
        mTextSize = ta.getDimension(R.styleable.CircleProgressView_cpvTextSize, dp2px(28));
        mProgress = ta.getInt(R.styleable.CircleProgressView_cpvProgress, 0);
        mText = ta.getString(R.styleable.CircleProgressView_cpvText);
        ta.recycle();  // 用完必须回收

        // 初始化圆环背景画笔
        mRingPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mRingPaint.setColor(mRingColor);
        mRingPaint.setStyle(Paint.Style.STROKE);
        mRingPaint.setStrokeWidth(mRingWidth);

        // 初始化进度弧画笔(圆头笔帽)
        mProgressPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mProgressPaint.setColor(mProgressColor);
        mProgressPaint.setStyle(Paint.Style.STROKE);
        mProgressPaint.setStrokeWidth(mRingWidth);
        mProgressPaint.setStrokeCap(Paint.Cap.ROUND);

        // 初始化中心文字画笔
        mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mTextPaint.setColor(mTextColor);
        mTextPaint.setTextSize(mTextSize);
        mTextPaint.setTextAlign(Paint.Align.CENTER);
    }

    // 控件大小确定时回调(比onLayout更早,适合计算与大小相关的量)
    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        // 半径 = 控件短边的一半 - 圆环宽度的一半(保证圆环不被裁剪)
        mRadius = Math.min(w, h) / 2f - mRingWidth / 2f;
    }

    // 兼容wrap_content:AT_MOST时使用默认尺寸
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        setMeasuredDimension(measureSize(widthMeasureSpec, dp2px(120)),
                measureSize(heightMeasureSpec, dp2px(120)));
    }

    private int measureSize(int measureSpec, int expectSize) {
        int mode = MeasureSpec.getMode(measureSpec);
        int size = MeasureSpec.getSize(measureSpec);
        if (mode == MeasureSpec.EXACTLY) {
            return size;
        }
        return mode == MeasureSpec.AT_MOST ? Math.min(expectSize, size) : expectSize;
    }

    // 核心绘制逻辑
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        // 圆心即控件中心
        float cx = getWidth() / 2f;
        float cy = getHeight() / 2f;

        // 1. 画圆环背景(整圆)
        canvas.drawCircle(cx, cy, mRadius, mRingPaint);

        // 2. 画进度弧(起角-90°,扫过角度=进度比例*360°)
        RectF rectF = new RectF(cx - mRadius, cy - mRadius, cx + mRadius, cy + mRadius);
        float sweepAngle = mProgress * 360f / 100f;
        // useCenter=false:只画弧线不画扇形
        canvas.drawArc(rectF, START_ANGLE, sweepAngle, false, mProgressPaint);

        // 3. 画中心文字(默认显示进度百分比)
        String text = mText != null ? mText : mProgress + "%";
        // getTextBaseline校正:让文字垂直居中
        float textY = cy - (mTextPaint.descent() + mTextPaint.ascent()) / 2f;
        canvas.drawText(text, cx, textY, mTextPaint);
    }

    // 对外提供进度设置接口,内部用属性动画平滑过渡
    public void setProgress(int progress) {
        if (progress < 0 || progress > 100) {
            throw new IllegalArgumentException("progress must be in [0, 100]");
        }
        ValueAnimator animator = ValueAnimator.ofInt(mProgress, progress);
        animator.setDuration(ANIM_DURATION);
        animator.setInterpolator(new DecelerateInterpolator());
        animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                mProgress = (int) animation.getAnimatedValue();
                // 进度变化只影响绘制内容,调用invalidate触发onDraw即可
                invalidate();
            }
        });
        animator.start();
    }

    private int dp2px(float dp) {
        return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
                dp, getResources().getDisplayMetrics());
    }
}

# 3. xml 布局中使用

<?xml version="1.0" encoding="utf-8"?>
<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.jiangming.widget.CircleProgressView
        android:id="@+id/cpv"
        android:layout_width="160dp"
        android:layout_height="160dp"
        app:cpvProgress="80"
        app:cpvProgressColor="#4CAF50"
        app:cpvRingColor="#E0E7EE"
        app:cpvRingWidth="14dp"
        app:cpvTextColor="#333333"
        app:cpvTextSize="30dp" />

    <Button
        android:id="@+id/btn_refresh"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"
        android:text="随机进度" />

</LinearLayout>

# 4. Activity 使用

public class DemoActivity extends AppCompatActivity {
    private CircleProgressView mCircleProgressView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_demo);
        mCircleProgressView = findViewById(R.id.cpv);
        // 启动时展示一个进度动画
        mCircleProgressView.setProgress(80);
        // 每次点击按钮,随机刷新进度
        findViewById(R.id.btn_refresh).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                mCircleProgressView.setProgress(new Random().nextInt(101));
            }
        });
    }
}

实现要点复盘(对应上面讲过的每个知识点):

  1. 三个构造函数逐级委托,最终在带 AttributeSet 的构造中读取自定义属性,用完 TypedArray 必须 recycle()
  2. onMeasure 处理了 AT_MOST 模式,兼容 wrap_content
  3. onSizeChanged 中计算半径(依赖控件实际大小,放在这里比在构造函数中更可靠)
  4. onDraw 中三步绘制:背景圆 → 进度弧 → 中心文字;文字垂直居中用 baseline 校正
  5. 进度动画用 ValueAnimator 驱动实时值,每次变化只调 invalidate() 重绘(不调 requestLayout,因为大小没变)

# 实战二:继承已有控件(扩展TextView)

在系统控件基础上扩展,适合"大部分行为都一样,只是加一点东西"的场景。例如给 TextView 加一个左上角的"消息角标":

/**
* 带角标的TextView(继承已有控件扩展).
*/
public class BadgeTextView extends AppCompatTextView {
    // 角标数量
    private int mBadgeCount;
    // 角标画笔
    private Paint mBadgePaint;
    // 角标文字画笔
    private Paint mBadgeTextPaint;

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

    private void init() {
        mBadgePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mBadgePaint.setColor(Color.parseColor("#F44336"));
        mBadgePaint.setStyle(Paint.Style.FILL);

        mBadgeTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mBadgeTextPaint.setColor(Color.WHITE);
        mBadgeTextPaint.setTextSize(sp2px(11));
        mBadgeTextPaint.setTextAlign(Paint.Align.CENTER);
    }

    public void setBadgeCount(int count) {
        mBadgeCount = count;
        invalidate();  // 角标变化,触发重绘
    }

    // 重写onDraw:先让父类画完原内容,再叠加角标
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);  // 关键:先执行父类的绘制(文字内容)
        if (mBadgeCount <= 0) {
            return;
        }
        // 角标位置:右上角
        float cx = getWidth() - dp2px(10);
        float cy = dp2px(10);
        float radius = dp2px(8);
        // 画红色圆点
        canvas.drawCircle(cx, cy, radius, mBadgePaint);
        // 画角标数字(未读数超过99显示99+)
        String text = mBadgeCount > 99 ? "99+" : String.valueOf(mBadgeCount);
        float textY = cy - (mBadgeTextPaint.descent() + mBadgeTextPaint.ascent()) / 2f;
        canvas.drawText(text, cx, textY, mBadgeTextPaint);
    }

    private int dp2px(float dp) { /* 同上,省略 */ return 0; }
    private int sp2px(float sp) { /* 同上,省略 */ return 0; }
}

继承已有控件的核心套路:重写 onDraw 时先调用 super.onDraw() 保留原有绘制,再用 Canvas/Paint 叠加自己的内容。另一种思路是不调 super.onDraw(),完全自绘(如让 TextView 只画下划线动画)。

# 实战三:自定义 ViewGroup 实现流式布局

流式布局(自动换行排列标签)是自定义 ViewGroup 的经典案例,完整展示 onMeasure + onLayout 的协作:

/**
* 流式布局:子View按行排列,一行放不下自动换行.
*/
public class FlowLayout extends ViewGroup {
    // 行间距、列间距
    private static final int ROW_SPACING = 20;
    private static final int COLUMN_SPACING = 20;
    // 记录每一行子View的集合(onLayout时使用)
    private final List<List<View>> mAllLines = new ArrayList<>();
    // 记录每行的高度
    private final List<Integer> mLineHeights = new ArrayList<>();

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

    // ---------- 测量:确定自己的大小,并测量每个子View ----------
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // 清空上一轮记录(测量可能被多次触发)
        mAllLines.clear();
        mLineHeights.clear();

        // 解析自己(FlowLayout)的测量规格
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        // 自己实际需要的宽高(wrap_content时使用)
        int contentWidth = 0;
        int contentHeight = 0;
        // 当前行的行宽、行高
        int lineWidth = 0;
        int lineHeight = 0;
        // 当前行子View集合
        List<View> lineViews = new ArrayList<>();

        // 遍历子View,测量并按"一行放不下就换行"的原则分组
        int childCount = getChildCount();
        for (int i = 0; i < childCount; i++) {
            View child = getChildAt(i);
            // 测量子View(把父容器的MeasureSpec和LayoutParams传下去)
            measureChild(child, widthMeasureSpec, heightMeasureSpec);
            MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
            // 子View实际占用的宽度 = 测量宽 + 左右margin
            int childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
            int childHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;

            if (lineWidth + childWidth > widthSize) {
                // 当前行放不下:换行
                // 1. 记录上一行
                mAllLines.add(lineViews);
                mLineHeights.add(lineHeight);
                // 2. 更新总宽高:宽度取最宽行,高度累加行高
                contentWidth = Math.max(contentWidth, lineWidth);
                contentHeight += lineHeight + ROW_SPACING;
                // 3. 新行从当前子View开始
                lineViews = new ArrayList<>();
                lineViews.add(child);
                lineWidth = childWidth;
                lineHeight = childHeight;
            } else {
                // 当前行放得下:加入本行
                lineViews.add(child);
                lineWidth += childWidth + COLUMN_SPACING;
                lineHeight = Math.max(lineHeight, childHeight);
            }
        }
        // 最后一行也要记录
        mAllLines.add(lineViews);
        mLineHeights.add(lineHeight);
        contentWidth = Math.max(contentWidth, lineWidth);
        contentHeight += lineHeight;

        // 根据测量模式确定最终大小(EXACTLY用系统给的,否则用内容大小)
        setMeasuredDimension(
                widthMode == MeasureSpec.EXACTLY ? widthSize : contentWidth,
                heightMode == MeasureSpec.EXACTLY ? heightSize : contentHeight);
    }

    // ---------- 定位:把每个子View摆到正确的坐标 ----------
    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        int currentTop = 0;
        for (int line = 0; line < mAllLines.size(); line++) {
            List<View> lineViews = mAllLines.get(line);
            int lineHeight = mLineHeights.get(line);
            int currentLeft = 0;
            // 摆放这一行的每个子View
            for (View child : lineViews) {
                MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
                int childLeft = currentLeft + lp.leftMargin;
                int childTop = currentTop + lp.topMargin;
                child.layout(childLeft, childTop,
                        childLeft + child.getMeasuredWidth(),
                        childTop + child.getMeasuredHeight());
                // 下一个子View排在右边
                currentLeft += child.getMeasuredWidth() + lp.leftMargin
                        + lp.rightMargin + COLUMN_SPACING;
            }
            // 下一行的起始Y = 当前行Y + 行高 + 行间距
            currentTop += lineHeight + ROW_SPACING;
        }
    }

    // 生成子View的默认LayoutParams(使用Margin时必须重写)
    @Override
    public LayoutParams generateLayoutParams(AttributeSet attrs) {
        return new MarginLayoutParams(getContext(), attrs);
    }
}

自定义 ViewGroup 的核心套路:

  1. onMeasure 中"两件事":测量每个子 View(measureChild);根据子 View 的测量结果 + 自己的布局规则,决定自己的大小
  2. onLayout 中"一件事":按布局规则给每个子 View 调用 child.layout(l, t, r, b) 赋坐标
  3. 重写 generateLayoutParams 系列方法,让子 View 支持 layout_margin
  4. onMeasure 可能被多次触发(父容器不同约束下反复测量),临时集合要先清空

# 视图刷新:invalidate 与 requestLayout

自定义 View 中最常混淆的两个刷新方法:

方法 触发范围 使用场景
invalidate() 只触发 onDraw(绘制) 内容变化(颜色、进度、角度)
requestLayout() 触发 measure → layout → draw 完整流程 大小/位置变化(内容导致尺寸变化)
postInvalidate() 只触发 onDraw,可在子线程 子线程中刷新

判断口诀:只是"画的东西变了"用 invalidate();"控件要变大变小/挪位置"用 requestLayout()。只调 invalidate() 是不会重新测量和布局的——如果依赖控件大小绘制,注意先改变尺寸再刷新。

# 注意事项与最佳实践

  1. 不要在 onDraw 中创建对象:onDraw 可能被高频调用(动画中每帧一次),创建 Paint/RectF 等对象会造成内存抖动甚至触发频繁 GC 引起卡顿。画笔等对象在构造函数或 onSizeChanged 中初始化。
  2. 动画刷新用 invalidate(),避免 requestLayout:进度条每帧变化用 invalidate() 足矣。
  3. 开启硬件加速:API 14+ 默认开启,大部分 Canvas API 都支持;个别 API(如 canvas.drawBitmapMesh 部分 path 效果)不支持硬件加速,需针对性确认。
  4. 处理 wrap_content:继承 View 时务必重写 onMeasure 处理 AT_MOST,否则 wrap_content 等同于 match_parent。
  5. 处理 padding:系统不会自动帮你处理 padding,onDraw/onMeasure 中要自行考虑 getPaddingLeft() 等值。
  6. 结合事件分发实现交互:需要跟手拖动、手势识别的自定义 View,结合 08 事件处理中讲的 onTouchEvent、GestureDetector、滑动冲突处理来实现。
  7. 动画驱动选择:简单属性变化用 ValueAnimator 驱动 + invalidate();帧序列绘制用 postInvalidateDelayed() 或 Choreographer;复杂动效考虑属性动画体系。

# 结束

本节系统介绍了自定义 View 与绘图体系:自定义 View 的三种继承方式、View 绘制三大流程(onMeasure / onLayout / onDraw)与 MeasureSpec 测量模式、Paint 与 Canvas 绘图 API(含 Path 贝塞尔曲线、Shader 着色器、save/restore 坐标变换),并通过三个实战案例(进度圆环、角标 TextView、流式布局 ViewGroup)覆盖了三种自定义方式的完整套路,最后梳理了 invalidate/requestLayout 的区别与最佳实践。自定义 View 的进阶还需要结合事件分发机制与属性动画体系,更多内容请参考官方文档:自定义视图组件 (opens new window) 与 Canvas and Drawables (opens new window)。