自定义控件|从画布裁剪来说过度绘制

自定义View一直是安卓开发中比较困难的技术点,实现一个优秀的自定义View控件不仅涉及到View的定位、测量、绘制等知识体系,还涉及到控件的绘制效率、是否存在过度绘制、是否存在绘制时间超长、是否存在内存泄漏等问题。
过度绘制又是布局优化中很重要的一个环节,有部分过度绘制是因为视图中View层级太多,背景层次太多,还有部分是因为View本身在同一块区域进行了多次绘制导致。关于视图层级,有经验的开发者都会在构造XML文件时进行处理,这点比较好注意到,也比较好优化。而关于View本身的重复绘制,可能不是很好处理,特别是在使用第三方控件时,需要通过修改源码来优化。比较经典的一个例子就是自定义扑克牌控件,下面,我们一步步来看下如何对这种控件进行优化。
准备工作 在查看View的过度绘制状态时,我们一般会打开手机的GPU过度绘制调试开关,位于设备的开发者选项里:
自定义控件|从画布裁剪来说过度绘制
文章图片
他会将屏幕中的View的过度绘制状态以不同的颜色填充,具体为:
自定义控件|从画布裁剪来说过度绘制
文章图片

接下来我们就需要实现扑克牌控件了。
实现控件 我们将几张扑克牌绘制在一个自定义View中,按照从左到右的顺序,右边一张牌盖住左边一张牌的部分。实现效果应该如下图:
自定义控件|从画布裁剪来说过度绘制
文章图片
为了达到比较好的效果,这边准备了54张扑克牌的的素材。
自定义控件|从画布裁剪来说过度绘制
文章图片

【自定义控件|从画布裁剪来说过度绘制】接下来,我们来实现控件,需要注意的几点是:

  1. 计算扑克牌被盖住的部分宽度
  2. 获取扑克牌的Bitmap对象
  3. 获取每张扑克牌的绘制区域
  4. 绘制扑克牌
核心逻辑为:
/** * 扑克相叠视图 */ public class PokerView extends View {/** * 默认一行刚好能排列4张扑克 */ private final static int DEFAULT_COUNT = 4; /** * 扑克资源引用,用来随机发牌 */ private final static List 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 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 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的裁剪方法。主要涉及到clipRectclipOutRect两个方法(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);

效果:
自定义控件|从画布裁剪来说过度绘制
文章图片

从以上测试结果可以看出上述两个方法的作用:
  1. clipRect方法是裁剪出要绘制的画布
  2. clipOutRect方法是裁剪掉不需要绘制的画布
以及一些组合特性
  1. clipRect对两块区域同时裁剪时,最终的绘制区域为公共部分
  2. clipRect只裁剪一块区域时,最终绘制区域为裁剪区域
  3. clipOutRect裁剪时,最终绘制区域不包括裁剪的区域
  4. clipOutRectclipRect同时使用时,最终绘制区域为:clipRect裁剪区域,并且排除掉clipOutRect裁剪的区域
根据上述结论,我们就可以对之前的绘制方案进行优化。
我们看下clipDraw方法的实现
/** * 非过度绘制 */ private void clipDraw(Canvas canvas, int pokerHeight, int defaultWidth) { int pokerWidth = defaultWidth * 3 / (count - 1); Rect lastRect = null; Iterator 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地址

    推荐阅读