Android Matrix 不再疑惑

前言

在进行坐标变换的时候,都绕不开Matrix类,那到底Matrix原理是什么以及怎么使用,接下来将会详细讲解。
通过这篇文章,你将了解到:

1、Matrix类的基本方法
2、pre/post该怎么理解
3、Matrix底层原理
4、Matrix实际运用

矩阵知识

image.png

如上图,是一个二行二列的矩阵。
矩阵可以相加(相减),条件是两个矩阵的行数和列数需要一致(同型矩阵)。
image.png

矩阵可以相乘,条件是第一个矩阵列数和第二个矩阵的行数一致
image.png

举个简单的乘法的例子:
image.png

将乘数交换位置
image.png

可以看出,交换了位置之后,乘法的结果就不一样了。
实际上:矩阵乘法不满足交换律
单位矩阵:主对角线上都是1,其余位置为0的方阵称为单位矩阵
单位矩阵特点:任何矩阵与单位矩阵相乘结果为其本身
image.png

Matrix原理

上面阐述了矩阵的一些基本知识,接下来分析Matrix的原理。
平时接触到的坐标系大部分是平面坐标(二维),对坐标系的变换包括平移、缩放、旋转、错切等。Android Canvas绘制的时候,默认绘制的起始点是(0,0),如何更改Canvas绘制位置呢?Android提供了Matrix类对Canvas坐标进行变换。
Matrix顾名思义:矩阵。
Android Matrix类操作的是一个3*3(3行/3列)矩阵。
那么Matrix矩阵里的值代表什么呢?

image.png

image.png

从上图可以看出,矩阵里的每个值都有其作用。若要对坐标进行缩放,则通过修改scaleX、scaleY的值,若要对坐标进行平移,则通过修改transX、transY的值,若要对坐标进行错切,则通过修改skewX、skewY值,pers0、pers1、pers2是关于透视的。
也许你已经注意到漏了一项,对坐标进行旋转,该修改哪个值呢?
image.png

如上图红色框内的4个值,当要对坐标进行旋转,则更改这4个值。
通过上述可以知:
Matrix类的作用:对点的坐标进行变换
关于这些值的数学原理,可搜索其它相关文章,此处暂时略过。

Matrix常用方法

既然Matrix类是通过对矩阵操作来达到坐标变换的目的,那么其需要对外暴露操作矩阵的方法,我们在搜索一下该类中的方法:


image.png

方法很多,挑选出translate、rotate、scale、skew方法

translate:平移
setTranslate(float dx, float dy)//dx表示x轴方向移动距离,dy表示y轴方向移动距离
preTranslate(float dx, float dy)
postTranslate(float dx, float dy)

rotate:旋转
setRotate(float degrees)//degrees 旋转角度
setRotate(float degrees, float px, float py)//px py 旋转的支点(轴心点)
preRotate(float degrees)
preRotate(float degrees, float px, float py)
postRotate(float degrees)
postRotate(float degrees, float px, float py)

scale:缩放
setScale(float sx, float sy)//sx、sy缩放倍数
setScale(float sx, float sy, float px, float py)//px py 旋转的支点(轴心点)
preScale(float sx, float sy)
preScale(float sx, float sy, float px, float py)
postScale(float sx, float sy)
postScale(float sx, float sy, float px, float py)

skew:错切
setSkew(float kx, float ky)
setSkew(float kx, float ky, float px, float py)
preSkew(float kx, float ky)
preSkew(float kx, float ky, float px, float py)
postSkew(float kx, float ky)
postSkew(float kx, float ky, float px, float py)

//src 要缩放的源矩形
//dst 缩放后填充的目标矩形
//stf 填充的方式
setRectToRect(RectF src, RectF dst, ScaleToFit stf)

//矩形衔接
setConcat(Matrix a, Matrix b)

这些方法怎么理解以及怎么用呢?来看例子:

    @Override
    protected void onDraw(Canvas canvas) {
        //这里为方便在此new 对象,实际使用过程中不推荐 
        // 原因:1、onDraw()主线程执行,new对象产生耗时。2、onDraw()可能多次调用,导致频繁gc
        rect = new Rect(0,0,200, 200);
        //绘制矩形
        canvas.drawRect(rect, paint);
        //构造matrix
        matrix = new Matrix();
        //水平方向平移300
        matrix.setTranslate(300, 0);
        //matrix.preTranslate(300, 0);
        //matrix.postTranslate(300, 0);
        //设置matrix
        canvas.setMatrix(matrix);
        //设置画笔颜色
        paint.setColor(Color.RED);
        //绘制矩形
        canvas.drawRect(rect, paint);
    }

自定义view绘制简单矩形,运行后:


image.png

蓝色矩形是先于setTranslate()方法绘制,红色是后于setTranslate()方法绘制。
相当于蓝色矩形里的每个点都在水平方向移动了300px。

matrix = new Matrix() ->构造矩阵,并且是3*3单位矩阵
setTranslate(300, 0)->修改新构造的矩阵TransX值,其它值为单位矩阵的默认值

用矩阵表示:


image.png

换做preTranslate方法呢?

matrix = new Matrix()->构造3*3单位矩阵,我们称为I
preTranslate()->称为“左乘”,将平移矩阵称为T,“左乘”的意思是将当前存在的矩阵I“乘”待变换的平移矩阵T,新矩阵的结果为:M=I * T

用矩阵表示:


image.png

postTranslate方法与preTranslate方法相反,称为“右乘”,“右乘”的意思是将待变换的平移矩阵T“乘”当前存在的矩阵I,新矩阵的结果为:M=T * I

也许你发现了,在上述的例子里,分别使用setTranslate、preTranslate、postTranslate,最终的效果是一致的,是否说明了这三个方法功能一样的。实际上并不是如此,还记得之前我们说的矩阵知识,“任何矩阵乘单位矩阵结果为其自身”。原本的矩阵是单位矩阵I,因此此种情况下,preTranslate和postTranslate效果一致。
接下来看另一个例子:

    @Override
    protected void onDraw(Canvas canvas) {
        //这里为方便在此new 对象,实际使用过程中不推荐
        // 原因:1、onDraw()主线程执行,new对象产生耗时。2、onDraw()可能多次调用,导致频繁gc
        rect = new Rect(0,0,200, 200);
        //绘制矩形
        canvas.drawRect(rect, paint);
        //构造matrix
        matrix = new Matrix();
        //水平方向平移300
        matrix.setTranslate(300, 0);//1
        matrix.preTranslate(100, 0);//2
        matrix.postTranslate(50, 0);//3
        matrix.preTranslate(-50, 0);//4
        matrix.preTranslate(10, 0);//5
        matrix.postTranslate(20, 0);//6
        //设置matrix
        canvas.setMatrix(matrix);
        //设置画笔颜色
        paint.setColor(Color.RED);
        //绘制矩形
        canvas.drawRect(rect, paint);
    }

看到一些文章说pre先执行,post后执行,照此说法,上面的执行顺序是:
124563
。实际上这种说法是错误的,同一个线程内,除开编译优化等调整的代码顺序外,代码的执行按照书写的顺序执行的,也就是123456。

可以这么理解:

0、初始的矩阵为单位矩阵I
1、setTranslate(300, 0)-> 矩阵变为:T1
2、matrix.preTranslate(100, 0)->矩阵变为:T2=T1 * Ta(待变换)
3、matrix.postTranslate(50, 0)->矩阵变为:T3=Tb(待变换) * T2
4、matrix.preTranslate(-50, 0)->矩阵变为:T4=T3 * Tc(待变换)
5、matrix.preTranslate(10, 0)->矩阵变为:T5=T4 * Td(待变换)
6、matrix.postTranslate(20, 0)->矩阵变为:T6=Te(待变换) * T5
最终展开:T6=(Te) * (Tb) * (T1) * (Ta) * (Tc) * (Td)

经过一些列变化,Matrix矩阵最终变为T6,Canvas将使用T6进行坐标变换。
由上可知,矩阵乘法不满足交换律,pre/post方法的不同结合会影响最终结果。

Matrix复合变换

前面讲了Translate变换,比较简单。那么加上其它变换一起,如rotate、scale、skew等一起变换称为复合变换。

    @Override
    protected void onDraw(Canvas canvas) {
        Matrix matrix = new Matrix();
        canvas.drawBitmap(bitmap, matrix, paint);
        matrix.setRotate(45);
        canvas.drawBitmap(bitmap, matrix, paint);
    }

先绘制一张图,再将这张图旋转45度,看看效果:


image.png

可以看出,matrix.setRotate(45) 默认是以左上角(0,0)作为旋转支点的(想象一下钉一颗钉子在左上角,然后图片绕着钉子旋转),现在我们想让图片围绕其中心点旋转,该怎么运用matrix呢?

1、rotate默认支点是左上角,因此先将支点移动到图片中心,使用translate,记为矩阵T
2、再将图片旋转45度,使用rotate,记为矩阵R
3、旋转完成后,需要把支点移动到默认点,使用translate,记为矩阵-T
最终的矩阵M=I * T * R * (-T) = T * R * (-T);

使用Matrix提供的方法完成上述三个步骤

    @Override
    protected void onDraw(Canvas canvas) {
        Matrix matrix = new Matrix();
        canvas.drawBitmap(bitmap, matrix, paint);
        matrix.preTranslate(bitmap.getWidth() / 2, bitmap.getHeight() / 2);
        matrix.preRotate(45);
        matrix.preTranslate(-bitmap.getWidth() / 2, -bitmap.getHeight() / 2);
        canvas.drawBitmap(bitmap, matrix, paint);
    }
image.png

如图,图片已经围绕其中心点旋转了。
那是否还有其它方法呢?
由之前推导的公式:M = T * R * (-T)。不管我们中途如何运算,最终的矩阵为M即可。
之前全部用preXX方法,现在全部用postXX方法:

    @Override
    protected void onDraw(Canvas canvas) {
        Matrix matrix = new Matrix();
        canvas.drawBitmap(bitmap, matrix, paint);
        matrix.postTranslate(-bitmap.getWidth() / 2, -bitmap.getHeight() / 2);
        matrix.postRotate(45);
        matrix.postTranslate(bitmap.getWidth() / 2, bitmap.getHeight() / 2);
        canvas.drawBitmap(bitmap, matrix, paint);
    }

preXX和postXX结合

    @Override
    protected void onDraw(Canvas canvas) {
        Matrix matrix = new Matrix();
        canvas.drawBitmap(bitmap, matrix, paint);
        matrix.postRotate(45);
        matrix.postTranslate(bitmap.getWidth() / 2, bitmap.getHeight() / 2);
        matrix.preTranslate(-bitmap.getWidth() / 2, -bitmap.getHeight() / 2);
        canvas.drawBitmap(bitmap, matrix, paint);
    }

那到底什么时候使用preXX,什么时候使用postXX呢?

1、从上面可以看出,先将我们想要的效果列出来,并简单推导矩阵乘法得出最终的矩阵。
2、接下来考虑使用preXX、postXX单独使用或者结合使用,只要推导的最终结果与第1步一样即可。preXX、postXX只是用来交换变换的次序。

当然Matrix提供了围绕某个支点旋转的方法:setRotate(float degrees, float px, float py)。
以上以Translate、Rotate为例,阐述了Matrix方法使用的一些理解。Scale、Skew方法与前两者很相似,很容易类比,此处不再展开说。

Matrix代码实现矩阵运算

(涉及到C++层代码,不感兴趣的可忽略,不影响对Matrix的使用)
Matrix类提供了几种变换的方法,很方便就可以设置想要的变换,那么这些方法后面是怎么实现矩阵的加法、乘法的呢?接下来看看Matrix底层原理。

数据结构

先看看java 层的Matrix.java

    // ------------------ Critical JNI ------------------------

    @CriticalNative
    private static native boolean nIsIdentity(long nObject);
    @CriticalNative
    private static native boolean nIsAffine(long nObject);
    @CriticalNative
    private static native boolean nRectStaysRect(long nObject);
    @CriticalNative
    private static native void nReset(long nObject);
    @CriticalNative
    private static native void nSet(long nObject, long nOther);
    @CriticalNative
    private static native void nSetTranslate(long nObject, float dx, float dy);
    @CriticalNative
    private static native void nSetScale(long nObject, float sx, float sy, float px, float py);
//省略

发现调用的是jni层,通过jni调用.cpp代码。

Matrix.java最终调用的是SkMatrix.cpp。

    SkScalar         fMat[9];
    mutable uint32_t fTypeMask;

    constexpr SkMatrix(SkScalar sx, SkScalar kx, SkScalar tx,
                       SkScalar ky, SkScalar sy, SkScalar ty,
                       SkScalar p0, SkScalar p1, SkScalar p2, uint32_t typeMask)
        : fMat{sx, kx, tx,
               ky, sy, ty,
               p0, p1, p2}
        , fTypeMask(typeMask) {}

我们之前说的Matrix 3*3的单位矩阵总共有9个值,这9个值用一位数组存储,对应上面的SkScalar fMat[9];

SkScalar fMat[9] -> float fMat[9]

在Matrix.java 里setXX/preXX/postXX最终改变的就是fMat[9]里相应的值。
9个值数组下标用如下值代表

    static constexpr int kMScaleX = 0; //!< horizontal scale factor
    static constexpr int kMSkewX  = 1; //!< horizontal skew factor
    static constexpr int kMTransX = 2; //!< horizontal translation
    static constexpr int kMSkewY  = 3; //!< vertical skew factor
    static constexpr int kMScaleY = 4; //!< vertical scale factor
    static constexpr int kMTransY = 5; //!< vertical translation
    static constexpr int kMPersp0 = 6; //!< input x perspective factor
    static constexpr int kMPersp1 = 7; //!< input y perspective factor
    static constexpr int kMPersp2 = 8; //!< perspective bias

从这几个值可以看出对应位置代表含义。

SkMatrix.cpp 函数举例

Matrix.java : setTranslate(float dx, float dy)
对应的
SkMatrix.cpp:

SkMatrix& SkMatrix::setTranslate(SkScalar dx, SkScalar dy) {
    *this = SkMatrix(1, 0, dx,
                     0, 1, dy,
                     0, 0, 1,
                     (dx != 0 || dy != 0) ? kTranslate_Mask | kRectStaysRect_Mask
                                          : kIdentity_Mask  | kRectStaysRect_Mask);
    return *this;
}

可以看出,除了kMTransX、kMTransY 分别赋值dx、dy,其余的值都重置了。
同样的setScale、setRotate、setSkew也是一样的。因此,当使用Matrix.java setXX方法时,其它变换会被重置。

Matrix.java : setScale(float sx, float sy, float px, float py)
对应的
SkMatrix.cpp:

SkMatrix& SkMatrix::setScale(SkScalar sx, SkScalar sy, SkScalar px, SkScalar py) {
    if (1 == sx && 1 == sy) {
        this->reset();
    } else {
        this->setScaleTranslate(sx, sy, px - sx * px, py - sy * py);
    }
    return *this;
}

    void setScaleTranslate(SkScalar sx, SkScalar sy, SkScalar tx, SkScalar ty) {
        fMat[kMScaleX] = sx;
        fMat[kMSkewX]  = 0;
        fMat[kMTransX] = tx;

        fMat[kMSkewY]  = 0;
        fMat[kMScaleY] = sy;
        fMat[kMTransY] = ty;

        fMat[kMPersp0] = 0;
        fMat[kMPersp1] = 0;
        fMat[kMPersp2] = 1;

        unsigned mask = 0;
        if (sx != 1 || sy != 1) {
            mask |= kScale_Mask;
        }
        if (tx || ty) {
            mask |= kTranslate_Mask;
        }
        this->setTypeMask(mask | kRectStaysRect_Mask);
    }

该函数是以(px,py)为支点,进行放大缩小。

setScaleTranslate(SkScalar sx, SkScalar sy, SkScalar tx, SkScalar ty)
这函数是直接设置缩放比例与平移距离

this->setScaleTranslate(sx, sy, px - sx * px, py - sy * py)
这里面的参数是怎么确定的?
我们回顾一下上面的矩阵乘法。实际上setScale(float sx, float sy, float px, float py)可以分为三步:

1、scale默认缩放的支点是原点(0,0),因此需要先将支点移动到(px,py)
2、再在x方向缩放sx,在y轴方向缩放sy
3、最后将支点从(px,py)移动到(0,0)

用矩阵表示,T代表平移矩阵、S代表缩放矩阵,-T代表平移回来的矩阵,M代表最终的矩阵。
M = T * S * (-T)


image.png

看到最后的结果是不是很眼熟,没错就是:this->setScaleTranslate(sx, sy, px - sx * px, py - sy * py)
实际上SkMatrix.cpp通过此种方式,用代码实现了矩阵的乘法。再此佐证了我们的说法:只要我们最终的矩阵运算结果一致,那么体现出来的变换效果也一致。

其它函数实现大同小异,限于篇幅,不再展开说。
Matrix在Android源码里运用之一请移步:ImageView scaleType 各种不同效果解析
ps:若有疑惑,请评论留言,会尽快回复

推荐阅读更多精彩内容