从画布裁剪来说过度绘制

自定义View一直是安卓开发中比较困难的技术点,实现一个优秀的自定义View控件不仅涉及到View的定位、测量、绘制等知识体系,还涉及到控件的绘制效率、是否存在过度绘制、是否存在绘制时间超长、是否存在内存泄漏等问题。

过度绘制又是布局优化中很重要的一个环节,有部分过度绘制是因为视图中View层级太多,背景层次太多,还有部分是因为View本身在同一块区域进行了多次绘制导致。关于视图层级,有经验的开发者都会在构造XML文件时进行处理,这点比较好注意到,也比较好优化。而关于View本身的重复绘制,可能不是很好处理,特别是在使用第三方控件时,需要通过修改源码来优化。比较经典的一个例子就是自定义扑克牌控件,下面,我们一步步来看下如何对这种控件进行优化。

准备工作

在查看View的过度绘制状态时,我们一般会打开手机的GPU过度绘制调试开关,位于设备的开发者选项里:

从画布裁剪来说过度绘制_第1张图片

他会将屏幕中的View的过度绘制状态以不同的颜色填充,具体为:
从画布裁剪来说过度绘制_第2张图片

接下来我们就需要实现扑克牌控件了。

实现控件

我们将几张扑克牌绘制在一个自定义View中,按照从左到右的顺序,右边一张牌盖住左边一张牌的部分。实现效果应该如下图:

从画布裁剪来说过度绘制_第3张图片

为了达到比较好的效果,这边准备了54张扑克牌的的素材。
从画布裁剪来说过度绘制_第4张图片

接下来,我们来实现控件,需要注意的几点是:

  1. 计算扑克牌被盖住的部分宽度
  2. 获取扑克牌的Bitmap对象
  3. 获取每张扑克牌的绘制区域
  4. 绘制扑克牌

核心逻辑为:

/**
 * 扑克相叠视图
 */
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);
      }
    }
  }

我们来看下过度绘制状态:
从画布裁剪来说过度绘制_第5张图片

可以看到梅花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);

效果:
从画布裁剪来说过度绘制_第6张图片

第二组

代码:

    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);

效果:

从画布裁剪来说过度绘制_第7张图片

第三组

代码:

    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);

效果:

从画布裁剪来说过度绘制_第8张图片

第四组

代码:

    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);

效果:
从画布裁剪来说过度绘制_第9张图片

从以上测试结果可以看出上述两个方法的作用:

  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<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();
    }
  }

上述代码是从右边往左边绘制的。我们在绘制时,会先将上一张牌的区域裁减掉,然后在剩下的区域中裁剪出需要绘制的牌的区域。我们可以看下过度绘制状态
从画布裁剪来说过度绘制_第10张图片

很明显,所有的区域都是蓝色的,也就是说,只绘制了一次。明显优于第一种绘制方案。

回顾

可以看到,我们通过简单的裁剪策略就避免了多重的区域绘制,本节主要是介绍了过度绘制检测、画布裁剪、自定义View的一些技术点。旨在为需要的读者提供一种解决问题的思路。

github地址

你可能感兴趣的:(自定义控件,android,canvas,安卓,移动开发,自定义控件)