3、View的布局(Layout)与绘制(Draw)流程:onLayout与onDraw的调用时机,Canvas与Paint的实战用法
好,咱们接着聊。上一章我们把测量(Measure)流程掰开揉碎讲清楚了,这一章轮到布局(Layout)和绘制(Draw)了。
说实话,很多开发做了两三年,对 onLayout 和 onDraw 的理解还停留在「系统会自动调」这个层面。但你要真去自定义一个复杂的 ViewGroup,或者做一个高性能的自定义 View,光知道「会自动调」是远远不够的。
我个人习惯把 View 的三大流程比作「量尺寸 → 摆位置 → 画出来」。测量是量尺寸,布局就是摆位置,绘制就是画出来。今天我们就重点讲后两步。
onLayout 的调用时机
onLayout 什么时候被调用?
简单说:当父 View 确定好子 View 的位置时。
具体点,是在 ViewGroup 的 layout() 方法里。layout() 方法会先调用 setFrame() 来设置自己的四个边界(left, top, right, bottom),然后调用 onLayout() 来安排子 View 的位置。
我举个例子你就明白了。假设你有一个自定义的 LinearLayout,你想让子 View 按某种特殊规则排列。这时候你重写 onLayout(),在里面遍历子 View,调用每个子 View 的 layout() 方法,传入你计算好的坐标。
@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);
if (child.getVisibility() != GONE) {
int childWidth = child.getMeasuredWidth();
int childHeight = child.getMeasuredHeight();
// 这里我手动给每个子 View 定位
child.layout(l, currentTop, l + childWidth, currentTop + childHeight);
currentTop += childHeight;
}
}
}
注意看,这里我用了 child.getMeasuredWidth() 和 child.getMeasuredHeight()。为什么?因为布局阶段依赖的是测量阶段的结果。测量阶段没走完,你拿不到正确的宽高。
核心要点:onLayout 的职责就是「把子 View 放到正确的位置上」。它不负责计算子 View 的大小,大小已经在 onMeasure 里算好了。
onDraw 的调用时机
onDraw 什么时候被调用?
当 View 需要重新绘制的时候。具体触发条件包括:
- View 首次显示时
- 调用了 invalidate() 或 postInvalidate()
- View 的可见性发生变化(从 GONE 变成 VISIBLE)
- View 的动画或属性变化触发了重绘
这里有个细节很多人会忽略:onDraw 并不是每次 invalidate() 都会调用。如果 View 设置了背景,并且背景没有变化,系统可能只重绘背景而不调用 onDraw。嗯,这里要注意,别在 onDraw 里写太重的逻辑,否则性能会崩。
我曾经在一个项目中,看到同事在 onDraw 里做了大量的 Bitmap 解码操作。结果列表滑动时卡成 PPT。后来我改成在构造方法里预解码,onDraw 只负责画,问题就解决了。
Canvas 与 Paint 的实战用法
Canvas 是画布,Paint 是画笔。这个比喻很形象,但实际用起来有不少坑。
先看一个最简单的例子:画一个圆。
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Paint paint = new Paint();
paint.setColor(Color.RED);
paint.setStyle(Paint.Style.FILL);
canvas.drawCircle(100, 100, 50, paint);
}
这段代码能跑,但性能很差。为什么?因为每次 onDraw 都 new 了一个 Paint 对象。onDraw 每秒可能被调用 60 次,每次 new 对象,GC 会频繁触发,卡顿就来了。
我的建议:把 Paint 对象创建在构造方法或初始化方法里,onDraw 只负责用,不负责创建。
再来说 Canvas 的几个常用操作:
- drawRect:画矩形,常用于背景或边框
- drawCircle:画圆形,进度条、头像常用
- drawText:画文字,注意基线对齐问题
- drawBitmap:画图片,注意不要缩放太频繁
- drawPath:画路径,复杂图形必备
这里我重点说一下 drawText 的坑。文字的绘制不是从左上角开始的,而是基于基线(baseline)。很多新手画文字时发现位置不对,就是因为没算基线。
Paint paint = new Paint();
paint.setTextSize(50);
paint.setColor(Color.BLACK);
// 计算基线
Paint.FontMetrics fm = paint.getFontMetrics();
float baseline = (getHeight() - fm.descent + fm.ascent) / 2 - fm.ascent;
canvas.drawText("Hello World", 0, baseline, paint);
这段代码能让文字垂直居中。你想想看,如果不算基线,文字会偏上或偏下,很难看。
绘制流程的完整链条
从 ViewRootImpl 开始,绘制流程是这样的:
- ViewRootImpl.performTraversals() 触发三大流程
- performLayout() 调用 View.layout()
- View.layout() 调用 onLayout()
- performDraw() 调用 View.draw()
- View.draw() 调用 onDraw()
注意,View.draw() 里其实做了好几件事:绘制背景、绘制自身(onDraw)、绘制子 View(dispatchDraw)、绘制装饰(滚动条等)。
如果你自定义 ViewGroup 并且想改变绘制顺序,可以重写 dispatchDraw()。我做过一个卡片堆叠效果,就是通过重写 dispatchDraw() 来调整子 View 的绘制顺序实现的。
避坑指南:千万不要在 onDraw 里做耗时操作,比如文件读写、网络请求、大对象创建。onDraw 是 UI 线程执行的,一旦阻塞,界面直接卡死。我曾经见过有人在 onDraw 里做 Log 输出,结果日志刷屏,性能暴跌。
SVG 流程图:布局与绘制流程
实战:自定义圆形进度条
光说不练假把式。我们写一个简单的圆形进度条,把今天讲的知识点串起来。
public class CircleProgressView extends View {
private Paint bgPaint;
private Paint progressPaint;
private float progress = 0f;
public CircleProgressView(Context context, AttributeSet attrs) {
super(context, attrs);
// 初始化画笔,只做一次
bgPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
bgPaint.setColor(Color.LTGRAY);
bgPaint.setStyle(Paint.Style.STROKE);
bgPaint.setStrokeWidth(20);
progressPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
progressPaint.setColor(Color.BLUE);
progressPaint.setStyle(Paint.Style.STROKE);
progressPaint.setStrokeWidth(20);
progressPaint.setStrokeCap(Paint.Cap.ROUND);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
int radius = Math.min(width, height) / 2 - 30;
// 画背景圆
canvas.drawCircle(width / 2, height / 2, radius, bgPaint);
// 画进度弧
RectF rect = new RectF(
width / 2 - radius,
height / 2 - radius,
width / 2 + radius,
height / 2 + radius
);
float sweepAngle = progress / 100f * 360f;
canvas.drawArc(rect, -90, sweepAngle, false, progressPaint);
}
public void setProgress(float progress) {
this.progress = progress;
invalidate(); // 触发重绘
}
}
这段代码里,我把 Paint 对象放在了构造方法里初始化,onDraw 只负责画。setProgress 里调用了 invalidate(),系统会重新调用 onDraw。这就是一个完整的自定义 View 绘制流程。
总结一下:onLayout 管位置,onDraw 管绘制。Canvas 是画布,Paint 是画笔。记住:对象复用、避免耗时、理解基线。做到这三点,你的自定义 View 就能跑得又快又稳。
好了,这一章就到这里。下一章我们会聊事件分发,那又是一个让很多人头疼的话题。不过别担心,我会用同样的方式,把底层逻辑讲透。
公众号:蓝海资料掘金营,微信deep3321