最近在写自定义 View 时,经常会遇到一个问题:明明 Canvas 上的绘制逻辑没有错,控件放进布局以后却大小不对,甚至完全看不见。
后来才慢慢理解,自定义 View 并不是直接从 onDraw() 开始的。系统要先知道它需要多大空间,再确定最终尺寸,最后才轮到我们真正把内容画出来。
三个比较重要的方法
一个简单的自定义 View,最先需要认识下面三个方法:
onMeasure():根据父布局传入的约束,决定自己希望占用的宽高。onSizeChanged():尺寸真正确定或者发生变化后,保存与尺寸有关的计算结果。onDraw():使用已经确定的尺寸绘制内容。
其中最容易忽略的是 onMeasure()。如果 View 直接继承自 View,又希望支持 wrap_content,就不能完全依赖父类的默认处理,否则它可能无法得到我们期望的大小。
MeasureSpec 是什么
父布局不会简单地告诉子 View “你随便多大都可以”,而是通过 MeasureSpec 把限制传下来。它包含尺寸和模式:
EXACTLY:尺寸已经确定,例如设置了固定宽度,或者使用了match_parent。AT_MOST:最大不能超过某个尺寸,常见于wrap_content。UNSPECIFIED:父布局没有给出明确限制,日常布局中相对少见。
例如,一个只需要画圆点的 View,可以在 wrap_content 时提供一个默认尺寸:
public class DotView extends View { private static final int DEFAULT_SIZE_DP = 48; private Paint mPaint; private float mRadius; public DotView(Context context, AttributeSet attrs) { super(context, attrs); init(); } private void init() { mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mPaint.setColor(Color.parseColor("#4CAF50")); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int desiredSize = dpToPx(DEFAULT_SIZE_DP); int width = resolveSize(desiredSize, widthMeasureSpec); int height = resolveSize(desiredSize, heightMeasureSpec); setMeasuredDimension(width, height); } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); mRadius = Math.min(w, h) / 2f; } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); canvas.drawCircle(getWidth() / 2f, getHeight() / 2f, mRadius, mPaint); } private int dpToPx(int dp) { return (int) (dp * getResources().getDisplayMetrics().density + 0.5f); }}
这里使用 resolveSize() 的好处是:当外部明确指定尺寸时尊重布局约束;当外部使用 wrap_content 时,View 仍然有自己的合理默认大小。
不要在 onDraw 里反复创建对象
刚开始写控件时,很容易在 onDraw() 里创建 Paint、RectF 或临时数组。虽然效果能够画出来,但 onDraw() 在动画或滑动时会被频繁调用,反复分配对象会增加不必要的开销。
比较合适的方式是:
- 不随尺寸改变的对象,在初始化时创建,例如
Paint。 - 依赖宽高的对象,在
onSizeChanged()中重新计算。 onDraw()中只负责使用这些结果完成绘制。
这也是为什么圆环、图表或者自定义 loading 控件通常都会把尺寸相关计算和真正绘制拆开。
invalidate 与 requestLayout 的区别
修改 View 状态以后,常常会遇到两个方法:
invalidate():内容变了,尺寸没有变,请求重新绘制。requestLayout():尺寸可能变了,请求重新走测量和布局流程。
例如修改圆点颜色,只需要调用 invalidate();如果修改了控件希望展示的直径,则需要重新测量,调用 requestLayout() 更合理。
小结
自定义 View 的关键不只是会使用 Canvas.drawCircle() 或 Canvas.drawArc(),还要理解尺寸是如何从父布局一路传下来的。
先正确处理测量,再在尺寸确定后准备绘制数据,最后让 onDraw() 保持简单,这样写出来的控件才更容易复用,也更不容易在不同布局中出现奇怪的问题。