自定义View一直是安卓开发中比较困难的技术点,实现一个优秀的自定义View控件不仅涉及到View的定位、测量、绘制等知识体系,还涉及到控件的绘制效率、是否存在过度绘制、是否存在绘制时间超长、是否存在内存泄漏等问题。
过度绘制又是布局优化中很重要的一个环节,有部分过度绘制是因为视图中View层级太多,背景层次太多,还有部分是因为View本身在同一块区域进行了多次绘制导致。关于视图层级,有经验的开发者都会在构造XML文件时进行处理,这点比较好注意到,也比较好优化。而关于View本身的重复绘制,可能不是很好处理,特别是在使用第三方控件时,需要通过修改源码来优化。比较经典的一个例子就是自定义扑克牌控件,下面,我们一步步来看下如何对这种控件进行优化。
在查看View的过度绘制状态时,我们一般会打开手机的GPU过度绘制调试开关,位于设备的开发者选项里:
他会将屏幕中的View的过度绘制状态以不同的颜色填充,具体为:
接下来我们就需要实现扑克牌控件了。
我们将几张扑克牌绘制在一个自定义View中,按照从左到右的顺序,右边一张牌盖住左边一张牌的部分。实现效果应该如下图:
接下来,我们来实现控件,需要注意的几点是:
Bitmap
对象核心逻辑为:
/**
* 扑克相叠视图
*/
public class PokerView extends View {
/**
* 默认一行刚好能排列4张扑克
*/
private final static int DEFAULT_COUNT = 4;
/**
* 扑克资源引用,用来随机发牌
*/
private final static List<Integer> POKER_LIST = new ArrayList<>();
static {
POKER_LIST.add(R.drawable.p1);
POKER_LIST.add(R.drawable.p2);
......省略部分代码
POKER_LIST.add(R.drawable.p53);
POKER_LIST.add(R.drawable.p54);
}
private int count = DEFAULT_COUNT;
private Paint mPaint;
/**
* 当前扑克的Bitmap列表
*/
private Map<Integer, Bitmap> mCurBitmaps = new HashMap<>();
......省略构造方法以及初始化画笔方法init
/**
* 发牌,重绘视图
*/
public void shuffle(int count) {
this.count = count;
randomPoker();
invalidate();
}
private void init() {
mPaint = new Paint();
mPaint.setAntiAlias(true);
mPaint.setStrokeWidth(0);
mPaint.setStyle(Paint.Style.FILL);
}
@Override protected void onAttachedToWindow() {
super.onAttachedToWindow();
randomPoker();
}
/**
* 绘制扑克牌
*/
@Override protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int measuredWidth = getMeasuredWidth();
int defaultPokerWidth = measuredWidth / DEFAULT_COUNT;
int pokerHeight = getMeasuredHeight();
// 一般绘制,存在过度绘制问题
overlayDraw(canvas, pokerHeight, defaultPokerWidth);
// 优化绘制,不存在过度绘制问题
clipDraw(canvas, pokerHeight, defaultPokerWidth);
}
@Override protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
mCurBitmaps.clear();
}
/**
* 获取随机N张牌
*/
private void randomPoker() {
mCurBitmaps.clear();
while (mCurBitmaps.size() < count) {
int random = (int) (Math.random() * 53 + 1);
int rp = POKER_LIST.get(random);
if (!mCurBitmaps.containsKey(rp)) {
mCurBitmaps.put(rp, BitmapFactory.decodeResource(getResources(), rp));
}
}
}
}
可以看出,核心就是重写View的onDraw
方法,上例中的overlayDraw
方法是普通绘制策略,存在过度绘制问题;clipDraw
是优化的绘制策略,不存在过度绘制问题。下面进行详述。
上述overlayDraw
方法的逻辑是确定好每张牌的绘制区域后,进行整个区域的绘制工作,这时,对于被后一张牌盖住的部分,其实也进行了绘制,事实上,这部分不可见的区域完全是不需要绘制的,这也就导致了过度绘制。
/**
* 过度绘制
*/
private void overlayDraw(Canvas canvas, int pokerHeight, int defaultWidth) {
int pokerWidth = defaultWidth * 3 / (count - 1);
Iterator<Bitmap> iterator = mCurBitmaps.values().iterator();
for (int i = 0; i < count; i++) {
Rect rect = new Rect();
rect.left = pokerWidth * i;
rect.bottom = pokerHeight;
rect.top = 0;
rect.right = rect.left + defaultWidth;
if (iterator.hasNext()) {
canvas.drawBitmap(iterator.next(), null, rect, mPaint);
}
}
}
可以看到梅花Q的左边部分过度绘制显示浅红色,也就是三层绘制;梅花Q的右边部分过度绘制显示淡绿色,也就是两层绘制。
整个扑克视图绝大多数部分都存在过度绘制问题。
上一种方案,我们绘制每张扑克的整个区域,事实上,除了最后一张扑克牌显示完全,其他扑克显示都是不完全的,不可见的部分其实就是没必要去绘制的,这样就可以去除过度绘制了。所以,我们需要对绘制区域进行裁剪。具体工具就是canvas
的裁剪方法。主要涉及到clipRect
和clipOutRect
两个方法(api26+)。
我们来看下试图裁剪到底是怎么回事。我们在onDraw
方法中绘制两个有公共部分的正方形,View背景设置浅灰色,左上方正方形背景设置绿色,右下方正方形背景设置红色,我们来测试下效果。
以下测试均在API26以上进行
第一组
代码:
Rect leftRect = new Rect(0,0,300,300);
Rect rightRect = new Rect(150,150,450,450);
mPaint.setColor(Color.GREEN);
canvas.drawRect(leftRect,mPaint);
mPaint.setColor(Color.RED);
canvas.drawRect(rightRect,mPaint);
第二组
代码:
Rect leftRect = new Rect(0,0,300,300);
Rect rightRect = new Rect(150,150,450,450);
canvas.clipRect(leftRect);
mPaint.setColor(Color.GREEN);
canvas.drawRect(leftRect,mPaint);
mPaint.setColor(Color.RED);
canvas.drawRect(rightRect,mPaint);
效果:
第三组
代码:
Rect leftRect = new Rect(0,0,300,300);
Rect rightRect = new Rect(150,150,450,450);
canvas.clipRect(leftRect);
canvas.clipRect(rightRect);
mPaint.setColor(Color.GREEN);
canvas.drawRect(leftRect,mPaint);
mPaint.setColor(Color.RED);
canvas.drawRect(rightRect,mPaint);
效果:
第四组
代码:
Rect leftRect = new Rect(0,0,300,300);
Rect rightRect = new Rect(150,150,450,450);
canvas.clipOutRect(leftRect);
canvas.clipRect(rightRect);
mPaint.setColor(Color.GREEN);
canvas.drawRect(leftRect,mPaint);
mPaint.setColor(Color.RED);
canvas.drawRect(rightRect,mPaint);
从以上测试结果可以看出上述两个方法的作用:
clipRect
方法是裁剪出要绘制的画布clipOutRect
方法是裁剪掉不需要绘制的画布以及一些组合特性
clipRect
对两块区域同时裁剪时,最终的绘制区域为公共部分clipRect
只裁剪一块区域时,最终绘制区域为裁剪区域clipOutRect
裁剪时,最终绘制区域不包括裁剪的区域clipOutRect
和clipRect
同时使用时,最终绘制区域为:clipRect
裁剪区域,并且排除掉clipOutRect
裁剪的区域根据上述结论,我们就可以对之前的绘制方案进行优化。
我们看下clipDraw
方法的实现
/**
* 非过度绘制
*/
private void clipDraw(Canvas canvas, int pokerHeight, int defaultWidth) {
int pokerWidth = defaultWidth * 3 / (count - 1);
Rect lastRect = null;
Iterator<Bitmap> iterator = mCurBitmaps.values().iterator();
for (int i = count - 1; i >= 0; i--) {
canvas.save();
Rect rect = new Rect();
rect.left = pokerWidth * i;
rect.bottom = pokerHeight;
rect.top = 0;
rect.right = rect.left + defaultWidth;
if (lastRect != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
canvas.clipOutRect(lastRect);
}else {
// TODO: api 26 以下适配
}
}
canvas.clipRect(rect);
if (iterator.hasNext()) {
canvas.drawBitmap(iterator.next(), null, rect, mPaint);
}
lastRect = rect;
canvas.restore();
}
}
上述代码是从右边往左边绘制的。我们在绘制时,会先将上一张牌的区域裁减掉,然后在剩下的区域中裁剪出需要绘制的牌的区域。我们可以看下过度绘制状态
很明显,所有的区域都是蓝色的,也就是说,只绘制了一次。明显优于第一种绘制方案。
可以看到,我们通过简单的裁剪策略就避免了多重的区域绘制,本节主要是介绍了过度绘制检测、画布裁剪、自定义View的一些技术点。旨在为需要的读者提供一种解决问题的思路。
github地址