您的位置:  首页 > 技术杂谈 > 正文

高级UI之Canvas深度分析—变换技巧,状态保存

2021-09-16 11:00 https://my.oschina.net/u/5032144/blog/5262397 小新聊Android 次阅读 条评论

前言

在前面我们把Paint关于UI颜色样式的处理进行了学习, 其实真正高级部分就是三个点,渲染,滤镜,图形组合,而我们图形绘制比较重要的另一个对象Canvas也是需要我们去重点掌握的,那么这次咱们来进行Canvas的深层次的学习,主要了解有两个点

  1. Canvas的变换使用技巧
  2. Canvas的状态,Canvas Layer

1.Canvas基本概念

直面意思是画布,其实是分装的一个工具类(绘制会话,用来和底层沟通最终交给底层绘制),一个Canvas类对象有四大基本要素:

  • 一个是用来保存像素的bitmap
  • 一个Canvas在Bitmap上进行绘制操作
  • 绘制的东西
  • 绘制的画笔Paint

2.Canvas变换操作----坐标系概念

在我们进行canvas操作的时候我们会有一个问题产生,在进行图形的平移,旋转操作时,我们没有去更改原始的坐标,只通过了非常简单的几个api就直接进行了移动,那么中间他的具体到底是发生了什么,通过之前在绘制流程当中draw时我们发现在下面我已经缩减了之后的代码上我门发现, 在绘制之初就产生了一个矩形,并且他通过面板进行了一次初始化

 private void draw(boolean fullRedrawNeeded) {
    Surface surface = mSurface;
   ...

    final Rect dirty = mDirty;
    if (mSurfaceHolder != null) {
        // The app owns the surface, we won't draw.
        dirty.setEmpty();
        if (animating && mScroller != null) {
            mScroller.abortAnimation();
        }
        return;
    }

    if (fullRedrawNeeded) {
        mAttachInfo.mIgnoreDirtyState = true;
        dirty.set(0, 0, (int) (mWidth * appScale + 0.5f), (int) (mHeight * appScale + 0.5f));
    }

    int xOffset = -mCanvasOffsetX;
    int yOffset = -mCanvasOffsetY + curScrollY;
    final WindowManager.LayoutParams params = mWindowAttributes;
    final Rect surfaceInsets = params != null ? params.surfaceInsets : null;
    if (surfaceInsets != null) {
        xOffset -= surfaceInsets.left;
        yOffset -= surfaceInsets.top;

        // Offset dirty rect for surface insets.
        dirty.offset(surfaceInsets.left, surfaceInsets.right);
    }
  ......

            if (!drawSoftware(surface, mAttachInfo, xOffset, yOffset, scalingRequired, dirty)) {
                return;
            }
        }
    }

    if (animating) {
        mFullRedrawNeeded = true;
        scheduleTraversals();
    }
}

那么在上面的代码当中我门可以看到在绘制开始之初, 在底层就确定了一个绘制区域,确定了canvas绘制位置的坐标,那么这个就是被称之为我门canvas的坐标系,确定我们canvas绘制图形的位置。那么,当我们进行了

    canvas.translate(50, 50);
    canvas.rotate(45);
    canvas.scale(1.5f, 0.5f);
    canvas.skew(1.73f, 0);

等操作的时候,我们的图形绘制会直接发生改变,那么这个时候我门考虑一个问题,下图中绿色的点移动到红色的点,我们刚才所设置的canvas移动了吗

image.png

其实很多通过会在这里认为我们canvas的坐标进行了移动,其实不然,在Canvas里面牵扯两种坐标系:Canvas自己的坐标系、绘图坐标系

2.1 Canvas的坐标系

它就在View的左上角,做坐标原点往右是X轴正半轴,往下是Y轴的正半轴,有且只有一个,唯一不变这一个其实就是在我们canvas当中在绘制之初由surface所初始化的那个点

2.2 绘图坐标系

它不是唯一不变的,它与Canvas的Matrix有关系,当Matrix发生改变的时候,绘图坐标系对应的进行改变,他有一个特性就是在这个过程中是不可逆的

那么其实实际就是我们在画图的时候,有一块总面板,总面板不动, 而当我在开始进行绘制图形的时候,有一个时时刻刻在动的面板,而这个面板就是具体去绘制我们图形的画板

image.png

那么里层的绘图坐标系他的实际是用一个Matrix矩阵表示的,这个和我门之前的滤镜矩阵表示差不多,只不过,绘图坐标系的矩阵是一个2x2的矩阵传入的值是由我们的canvas进行解析之后将自己想要的数据给底层底层自己计算所得

public void drawRect(@NonNull Rect r, @NonNull Paint paint) {
    throwIfHasHwBitmapInSwMode(paint);
    drawRect(r.left, r.top, r.right, r.bottom, paint);
}

那么在这里我们可以看到在进入底层native方法之前,实现会根据每一种绘制的不同对底层的数据进行传入, 然后会计算出我门的绘制坐标系(此处底层不看,涉及c,我们这里明白这一点就行)

我们通过简单设置translate、rotate、scale、skew来改变我们绘制图形的位置时他的计算时依赖与另外一个矩阵来对绘图坐标系进行改变,这是一个3x3的矩阵,它里面的九个参数

cosX -sinX translateX sinX cosX translateY 0 0 scale

其中,sinX和cosX,代表的是旋转角度的sin和cos值。注意旋转的正方向是顺时针方向。translateX和translateY代表的是平移的X和Y。scale代表的是缩放的大小。

我们可以通过getMatrix()的到这个矩阵,而通过看到底层源码,这里我能清晰的看到我们是直接调用底层的矩阵

@Deprecated
public void getMatrix(@NonNull Matrix ctm) {
    nGetMatrix(mNativeCanvasWrapper, ctm.native_instance);
}

那么这里我做了一组测试

 RectF r = new RectF(0, 0, 400, 500);
    paint.setColor(Color.GREEN);
    canvas.drawRect(r, paint);
    float[] fs = new float[10];
            canvas.getMatrix().getValues(fs);
    for (int i = 0;i < fs.length;i++){
        Log.i("barry","fs:"+fs[i]);
    }
    //平移
    canvas.translate(50, 50);
    float[] fs2 = new float[10];
    canvas.getMatrix().getValues(fs2);
    for (int i = 0;i < fs2.length;i++){
        Log.i("barry","fs2:"+fs2[i]);
    }

    paint.setColor(Color.BLUE);
    canvas.drawRect(r, paint);

image.png

可以很明显看到,矩阵进行平移之后这个矩阵信息的变化。那么注意,绘图矩阵的坐标系移动是一个不可逆转的状态也就是说,一旦矩阵移动完成之后,那么他不能回到之前的位置,具体效果如下

image.png

但是在我们的Canvas当中提供了save和restore方法来保存和还原变化操作,

    RectF r = new RectF(0, 0, 400, 500);
    paint.setColor(Color.GREEN);
    //画完之后,绘图坐标系定位在此处
    canvas.drawRect(r, paint);
    //save保存当前坐标
    canvas.save();

    //平移之后,坐标系发生改变
    canvas.translate(50, 50);
    
    paint.setColor(Color.BLUE);
    canvas.drawRect(r, paint);
    //通过restore进行还原到save保存时的坐标系
    canvas.restore();

image.png

但是想要知道这两个方法是怎么进行操作的才能让我们更加深入的去熟悉Canvas的使用技巧,那么我门必须去了解Canvas的状态栈、Layer栈

3. Canvas的状态保存---状态栈、Layer栈

3.1 状态栈

在前面我们提到坐标系的转换是一个不可逆转的,而我们可以通过save来进行保存restore进行恢复,其实我们在进行save操作时在canvas当中会将我门save下来的坐标系进行保存到一个栈当中,并且可以通过restore或者是restoreToCount进行操作下面通过一段测试代码我门印证下

public class MyView extends View {
private static final String TAG = "BARRY";

private Paint mPaint = null;
private Bitmap mBitmap = null;

public MyView(Context context) {
    this(context, null);
}

public MyView(Context context, AttributeSet attrs) {
    super(context, attires
    mBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.lsj);
    init();
}

public MyView(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
}

private void init() {
    mPaint = new Paint();
    mPaint.setColor(Color.RED);
    mPaint.setAntiAlias(true);
    mPaint.setStyle(Paint.Style.FILL);
    mPaint.setStrokeWidth(10);
}

@Override
protected void onDraw(Canvas canvas) {
    //第1次保存,并通过canvas.getSaveCount的到当前状态栈容量
    canvas.save();
    Log.i(TAG, "Current SaveCount = " + canvas.getSaveCount());

    canvas.translate(400, 400);
    RectF rectF = new RectF(0,0,600,600);
        canvas.drawBitmap(mBitmap, null, rectF, mPaint);
    //第2次保存,并通过canvas.getSaveCount的到当前状态栈容量
    canvas.save();
    Log.i(TAG, "Current SaveCount = " + canvas.getSaveCount());

    canvas.rotate(45);

    canvas.drawBitmap(mBitmap, null, rectF, mPaint);
    //第3次保存,并通过canvas.getSaveCount的到当前状态栈容量
    canvas.save();
    Log.i(TAG, "Current SaveCount = " + canvas.getSaveCount());

    canvas.rotate(45);

    canvas.drawBitmap(mBitmap, null, rectF, mPaint);
    //第4次保存,并通过canvas.getSaveCount的到当前状态栈容量
    canvas.save();
    Log.i(TAG, "Current SaveCount = " + canvas.getSaveCount());
    //通过canvas.restoreToCount出栈到第三层状态
    canvas.restoreToCount(3);
    Log.i(TAG, "restoreToCount--Current SaveCount = " + canvas.getSaveCount());

    canvas.translate(0, 200);

    //rectF = new RectF(0,0,600,600);
    canvas.drawBitmap(mBitmap, null, rectF, mPaint);
    //通过canvas.restoreToCount出栈到第1层(最原始的那一层)状态
    canvas.restoreToCount(1);
    Log.i(TAG, "restoreToCount--Current SaveCount = " + canvas.getSaveCount());
    canvas.drawBitmap(mBitmap, null, rectF, mPaint);
   }
 }   

image.png

image.png

image.png

那么其实我们这样可以直接明白, 每一次的save其实实际上是用了一个栈保存了我的绘图坐标系,这个栈被我们称之为状态栈起来, 而我们的restore就是一个出栈的过程。save、 restore方法来保存和还原变换操作Matrix以及Clip剪裁

3.2 Layer栈

在我们的canvas当中,提供了一个saveLayer的api主要做用是用来新建一个图层,后续的绘图操作都在新建的layer上面进行,当我们调用restore 或者 restoreToCount 时 更新到对应的图层和画布上

image.png

下面通过这段测试代码的效果我们来验证当前的结论

public class MyView extends View {

Paint mPaint;
float mItemSize = 0;
float mItemHorizontalOffset = 0;
float mItemVerticalOffset = 0;
float mCircleRadius = 0;
float mRectSize = 0;
int mCircleColor = 0xffffcc44;//黄色
int mRectColor = 0xff66aaff;//蓝色
float mTextSize = 25;

private static final Xfermode[] sModes = {
        new PorterDuffXfermode(PorterDuff.Mode.CLEAR),
        new PorterDuffXfermode(PorterDuff.Mode.SRC),
        new PorterDuffXfermode(PorterDuff.Mode.DST),
        new PorterDuffXfermode(PorterDuff.Mode.SRC_OVER),
        new PorterDuffXfermode(PorterDuff.Mode.DST_OVER),
        new PorterDuffXfermode(PorterDuff.Mode.SRC_IN),
        new PorterDuffXfermode(PorterDuff.Mode.DST_IN),
        new PorterDuffXfermode(PorterDuff.Mode.SRC_OUT),
        new PorterDuffXfermode(PorterDuff.Mode.DST_OUT),
        new PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP),
        new PorterDuffXfermode(PorterDuff.Mode.DST_ATOP),
        new PorterDuffXfermode(PorterDuff.Mode.XOR),
        new PorterDuffXfermode(PorterDuff.Mode.DARKEN),
        new PorterDuffXfermode(PorterDuff.Mode.LIGHTEN),
        new PorterDuffXfermode(PorterDuff.Mode.MULTIPLY),
        new PorterDuffXfermode(PorterDuff.Mode.SCREEN)
};

private static final String[] sLabels = {
        "Clear", "Src", "Dst", "SrcOver",
        "DstOver", "SrcIn", "DstIn", "SrcOut",
        "DstOut", "SrcATop", "DstATop", "Xor",
        "Darken", "Lighten", "Multiply", "Screen"
};

public MyView(Context context) {
    super(context);
    init(null, 0);
}

public MyView(Context context, AttributeSet attrs) {
    super(context, attires
    init(attrs, 0);
}

public MyView(Context context, AttributeSet attrs, int defStyle) {
    super(context, attrs, defStyle);
    init(attrs, defStyle);
}

private void init(AttributeSet attrs, int defStyle) {
    if(Build.VERSION.SDK_INT >= 11){
        setLayerType(LAYER_TYPE_SOFTWARE, null);
    }
    mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    mPaint.setTextSize(mTextSize);
    mPaint.setTextAlign(Paint.Align.CENTER);
    mPaint.setStrokeWidth(2);
}

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    //设置背景色
    canvas.drawARGB(255, 139, 197, 186);

    int canvasWidth = canvas.getWidth();
    int canvasHeight = canvas.getHeight();

    for(int row = 0; row < 4; row++){
        for(int column = 0; column < 4; column++){
            canvas.save();
            //此处是建立新的图层
            int layer = canvas.saveLayer(0, 0, canvasWidth, canvasHeight, null, Canvas.ALL_SAVE_FLAG);
            mPaint.setXfermode(null);
            int index = row * 4 + column;
            float translateX = (mItemSize + mItemHorizontalOffset) * column;
            float translateY = (mItemSize + mItemVerticalOffset) * row;
            canvas.translate(translateX, translateY);
            //画文字
            String text = sLabels[index];
            mPaint.setColor(Color.BLACK);
            float textXOffset = mItemSize / 2;
            float textYOffset = mTextSize + (mItemVerticalOffset - mTextSize) / 2;
            canvas.drawText(text, textXOffset, textYOffset, mPaint);
            canvas.translate(0, mItemVerticalOffset);
            //画边框
            mPaint.setStyle(Paint.Style.STROKE);
            mPaint.setColor(0xff000000);
            canvas.drawRect(2, 2, mItemSize - 2, mItemSize - 2, mPaint);
            mPaint.setStyle(Paint.Style.FILL);
            //画圆
            mPaint.setColor(mCircleColor);
            float left = mCircleRadius + 3;
            float top = mCircleRadius + 3;
            canvas.drawCircle(left, top, mCircleRadius, mPaint);
            mPaint.setXfermode(sModes[index]);
            //画矩形
            mPaint.setColor(mRectColor);
            float rectRight = mCircleRadius + mRectSize;
            float rectBottom = mCircleRadius + mRectSize;
            canvas.drawRect(left, top, rectRight, rectBottom, mPaint);
            mPaint.setXfermode(null);
            //canvas.restore();
            canvas.restoreToCount(layer);
        }
    }
}

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, old);)
    mItemSize = w / 4.5f;
    mItemHorizontalOffset = mItemSize / 6;
    mItemVerticalOffset = mItemSize * 0.426f;
    mCircleRadius = mItemSize / 3;
    mRectSize = mItemSize * 0.6f;
}
}

没有加saveLayer的效果

image.png

加了saveLayer的效果

image.png

这段代码我们可以看到,其实实际上就是我们之前的文章当中,xfermode的演示代码,而在这段代码当中我才用了saveLayer进行操作,通过上面两个结果,一个是我加了saveLayer的,一个是没加的, 那么从中我门可以明显看到在没有加的时候,xfermode的像素输出效果直接将外层背景色也给清空了,而加入之后没有,那么其实我们可以很明显的明白如果用了layer那么其实实际上我们是在当前这个canvas图形上面新建了一个图层当我们调用restore 或者 restoreToCount 时 我们的绘制会更新到当前图层

那么这个时候我们来详细分析saveLayer的参数

canvas.saveLayer(0, 0, canvasWidth, canvasHeight, null, Canvas.ALL_SAVE_FLAG);
/**
 * Helper version of saveLayer() that takes 4 values rather than a RectF.
 *
 * @deprecated Use {@link #saveLayer(float, float, float, float, Paint)} instead.
 */
public int saveLayer(float left, float top, float right, float bottom, @Nullable Paint paint,
        @Saveflags int saveFlags) {
    return nSaveLayer(mNativeCanvasWrapper, left, top, right, bottom,
            paint != null ? paint.getNativeInstance() : 0,
            saveFlags);
}

通过上诉方法的注释,以及代码我们明显知道,前面四个参数,为上下左右四个点构成一个图层区,Paint画笔也可以继承过来,而最后一个参数表示的是我们当前的保存形式,总共下面6种,这六个模式其实实际上讲的就是告诉canvas当前保存那些信息

  • MATRIX_SAVE_FLAG:只保存图层的matrix矩阵 save,saveLayer
  • CLIP_SAVE_FLAG:只保存大小信息 save,saveLayer
  • HAS_ALPHA_LAYER_SAVE_FLAG:表明该图层有透明度,和下面的标识冲突,都设置时以下面的标志为准
  • FULL_COLOR_LAYER_SAVE_FLAG:完全保留该图层颜色(和上一图层合并时,清空上一图层的重叠区域,保留该图层的颜色)
  • CLIP_TO_LAYER_SAVE_:创建图层时,会把canvas(所有图层)裁剪到参数指定的范围,如果省略这个flag将导致图层开销巨大(实际上图层没有裁剪,与原图层一样大)
  • ALL_SAVE_FLAG:保存所有信息 save,saveLayer

从源码当中我发现其他几种模式在高版本当中已经剔除,只保留了一种。就是我门的all_save_flag

  /** @hide */
@IntDef(flag = true,
        value = {
            ALL_SAVE_FLAG
        })
@Retention(RetentionPolicy.SOURCE)
public @interface Saveflags {}

那么这个时候我们来测试一下

 public class MyView3 extends View {

public MyView3(Context context) {
    super(context);
}

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);

    RectF rectF = new RectF(0,0,400,500);
    Paint paint = new Paint();
    paint.setStyle(Paint.Style.STROKE);
    paint.setStrokeWidth(10);
    paint.setColor(Color.GREEN);

    canvas.drawRect(rectF, paint);
    canvas.translate(50,50);

    canvas.saveLayer(0,0,canvas.getWidth(),canvas.getHeight(),null,Canvas.ALL_SAVE_FLAG);
    //canvas.save();
    canvas.drawColor(Color.BLUE);// 通过drawColor可以发现saveLayer是新建了一个图层,
                                // 同时结合Lsn5的16种Xfermode叠加形式Demo可以验证是新建的透明图层
    paint.setColor(Color.YELLOW);
    canvas.drawRect(rectF,paint);
    //canvas.restore();
    canvas.restore();

    RectF rectF1 = new RectF(10,10,300,400);
    paint.setColor(Color.RED);
    canvas.drawRect(rectF1,paint);

}

加了saveLayer

image.png

没加saveLayer

image.png

那么这段代码也验证了我们上诉的理论,在加了saveLayer之后,背景色被绘制到了另外一个图层导致前面有一节空白的,同时也得出了一个有趣的结论,貌似,平移操作也被继承了,其实这里我们的出一个结论saveLayer会将之前的一些Canvas状态操作延续过来。这里是通过之前的最后一个参数设置成ALL_SAVE_FLAG完成。他在新建图层的时候完成了保留当前所有信息状态的操作.

总结

Canvas里面牵扯两种坐标系: Canvas自己的坐标系、绘图坐标系

  • Canvas的坐标系:它就在View的左上角,做坐标原点往右是X轴正半轴,往下是Y轴的正半轴,有且只有一个,唯一不变
  • 绘图坐标系:它不是唯一不变的,它与Canvas的Matrix有关系,当Matrix发生改变的时候,绘图坐标系对应的进行改变,同时这个过程是不可逆的(save和restore方法来保存和还原变化操作),Matrix又是通过我们设置translate、rotate、scale、skew来进行改变的

Canvas的状态保存---状态栈、Layer栈

  • 状态栈-- save、 restore方法来保存和还原变换操作Matrix以及Clip剪裁,也可以通过restoretoCount直接还原到对应栈的保存状态
  • Layer栈--- saveLayer的时候都会新建一个透明的图层(离屏Bitmap-离屏缓冲),并且会将saveLayer之前的一些Canvas操作延续过来。后续的绘图操作都在新建的layer上面进行,当我们调用restore 或者 restoreToCount 时 更新到对应的图层和画布上

看完三件事❤️

如果你觉得这篇内容对你还蛮有帮助,我想邀请你帮我三个小忙:

  1. 点赞,转发,有你们的 『点赞和评论』,才是我创造的动力。

  2. 关注公众号 『 小新聊Android 』,不定期分享原创知识。

  3. 同时可以期待后续文章ing🚀

  4. 公众号回复【666】扫码还可拿到Android进阶学习资料包

- END -

本文分享自微信公众号 - 小新聊Android(gh_44b363ab5571)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

  • 0
    感动
  • 0
    路过
  • 0
    高兴
  • 0
    难过
  • 0
    搞笑
  • 0
    无聊
  • 0
    愤怒
  • 0
    同情
热度排行
友情链接