Android图片处理二:PinchImageView源码解析

995 阅读3分钟

PinchImageView 使用 GestureDetector 来处理长按、点击、双击、惯性滑动事件,在 onTouchEvent 里处理双指缩放和单指移动等事件。 里面有两个矩阵,一个是外部变换矩阵(mOuterMatrix),主要记录手势操作的结果,一个是内部变换矩阵(getInnerMatrix(Matrix)),就是根据 fitCenter 等缩放模式进行缩放平移后的初始矩阵。这里区分两个矩阵可能是借鉴了PhotoView的经验,手势操作和原始缩放互不影响,手势操作后最终的缩放只需要两个矩阵相乘就好了。 下面的代码分析不一定会完全贴源码,有的是经过稍微改动的。

1 双击、惯性滑动

长按和点击就是调用回调,我们主要来看双击和惯性滑动。

1.1 双击

PinchImageView 只做了一级的放大缩小 ,就是说只能在最大和初始缩放值之间切换。 基本原理:捕获双击事件,拿到双击点的x、y坐标,对图片进行缩放变换,将双击点位置移动到视图中间。 代码较长,我们一点点拆分。 这里要先介绍下 PinchImageView 的对象池(ObjectsPool)。 ObjectsPool 维护一个对象队列,在容量范围内可以循环复用对象。大致使用流程如下图所示:

  1. 在队列里获取 innerMatrix 对象(take()),队列为空则新建一个对象返回,否则出队一个对象重置后返回。
  2. 在队列里获取 targetMatrix 对象。
  3. 使用完 targetMatrix 归还(given(obj))。
  4. 使用完 innerMatrix 归还。

归还顺序无所谓。

/**
 * 对象池
 *
 * 防止频繁new对象产生内存抖动.
 * 由于对象池最大长度限制,如果吞吐量超过对象池容量,仍然会发生抖动.
 * 此时需要增大对象池容量,但是会占用更多内存.
 *
 * @param <T> 对象池容纳的对象类型
 */
private static abstract class ObjectsPool<T> {
    /**
     * 对象池的最大容量
     */
    private int mSize;
    /**
     * 对象池队列
     */
    private Queue<T> mQueue;
    
    public ObjectsPool(int size) {
        mSize = size;
        mQueue = new LinkedList<T>();
    }
    
    public T take() {
        //如果池内为空就创建一个
        if (mQueue.size() == 0) {
            return newInstance();
        } else {
            //对象池里有就从顶端拿出来一个返回
            return resetInstance(mQueue.poll());
        }
    }
    public void given(T obj) {
        //如果对象池还有空位子就归还对象
        if (obj != null && mQueue.size() < mSize) {
            mQueue.offer(obj);
        }
    }
    
    abstract protected T newInstance();
    
    abstract protected T resetInstance(T obj);
}

继续看双击事件的处理。

private void doubleTap(float x, float y) {
    //获取第一层变换矩阵
    Matrix innerMatrix = MathUtils.matrixTake();
    getInnerMatrix(innerMatrix);
    
    ……
    MathUtils.matrixGiven(innerMatrix);
}

首先是获取内部变换矩阵。MathUtils.matrixTake() 是从 Matrix 对象池(MatrixPool)里获取一个 Matrix 对象。

public static Matrix matrixTake() {
    return mMatrixPool.take();
}
/**
 * 获取某个矩阵的copy
 */
public static Matrix matrixTake(Matrix matrix) {
    Matrix result = mMatrixPool.take();
    if (matrix != null) {
        result.set(matrix);
    }
    return result;
}

然后去获取内部变换矩阵,并存在 innerMatrix 中。

public Matrix getInnerMatrix(Matrix matrix) {
    ……
    
    //原图大小
    RectF tempSrc = MathUtils.rectFTake(0, 0, getDrawable().getIntrinsicWidth(), getDrawable().getIntrinsicHeight());
    //控件大小
    RectF tempDst = MathUtils.rectFTake(0, 0, getWidth(), getHeight());
    //计算fit center矩阵
    matrix.setRectToRect(tempSrc, tempDst, Matrix.ScaleToFit.CENTER);
    
    ……
    
    return matrix;
}

MathUtils.rectFTakematrixTake 方法是一样的,只是取出的是 rectF 。关键在于 matrix.setRectToRect 方法,上面已经介绍过了。 继续往下看:

//当前总的缩放比例
float innerScale = MathUtils.getMatrixScale(innerMatrix)[0];
float outerScale = MathUtils.getMatrixScale(mOuterMatrix)[0];
float currentScale = innerScale * outerScale;

这里把内部矩阵的缩放和外部缩放相乘,得到了最终的缩放,内外不影响的设计确实挺好的。 接下来开始计算和进行缩放。

float nextScale = currentScale < MAX_SCALE ? MAX_SCALE : innerScale;
//如果接下来放大大于最大值或者小于fit center值,则取边界
if (nextScale > maxScale) {
    nextScale = maxScale;
}
if (nextScale < innerScale) {
    nextScale = innerScale;
}
//开始计算缩放动画的结果矩阵
Matrix animEnd = MathUtils.matrixTake(mOuterMatrix);
//计算还需缩放的倍数
animEnd.postScale(nextScale / currentScale, nextScale / currentScale, x, y);
//将放大点移动到控件中心
animEnd.postTranslate(displayWidth / 2f - x, displayHeight / 2f - y);
……
//启动矩阵动画
mScaleAnimator = new ScaleAnimator(mOuterMatrix, animEnd);
mScaleAnimator.start();

这段代码很骚,我们先来梳理下缩放的思路:双击图片,肯定是要以动画的形式来做的,那么动画的开头,自然是当前的变换位置,变换到目标缩放值 nextScale 的倍数是 nextScale / currentScale,遵从手势操作记录在外部矩阵 mOuterMatrix 的原则,动画初始 matrix 拷贝自 mOuterMatrix。 这段代码其实是有问题的。innerScale 是对原图进行 fitCenter 变换后的缩放值,假设原图很大,变换后 innerScale 值为0.2f, maxScale 为2,没有进行过手势操作,outerScale 为1,这时候来看下算的结果:

currentScale=innerScale×outerScale=0.2×1=0.2nextScale=0.2<2 ? 2:0.2=2nextScalecurrentScale=20.2=10currentScale = innerScale \times outerScale = 0.2 \times 1 = 0.2 \\ nextScale = 0.2 < 2\ ?\ 2 : 0.2 = 2 \\ \frac{nextScale}{currentScale} = \frac{2}{0.2} = 10

就是说你双击一下,一下子看到的图片放大了10倍…… 要知道现在很多图宽高都是比手机屏幕大的…… ScaleAnimator 里只做了一件事,不断更新 mOuterMatrix 的值,然后 invalidate ,在 onDraw 里刷新视图。

@Override
public void onAnimationUpdate(ValueAnimator animation) {
    //获取动画进度
    float value = (Float) animation.getAnimatedValue();
    //根据动画进度计算矩阵中间插值
    for (int i = 0; i < 9; i++) {
        mResult[i] = mStart[i] + (mEnd[i] - mStart[i]) * value;
    }
    //设置矩阵并重绘
    mOuterMatrix.setValues(mResult);
    ……
    invalidate();
}
@Override
protected void onDraw(Canvas canvas) {
    ……
    //在绘制前设置变换矩阵
    setImageMatrix(getCurrentImageMatrix(matrix));
    ……
    super.onDraw(canvas);
    ……
}

缩放平移后,图片可能出现边框进入图片控件的情况,此时需要修正位置。用最终缩放后的图片边界和控件边界对比矫正即可。

Matrix testMatrix = MathUtils.matrixTake(innerMatrix);
testMatrix.postConcat(animEnd);
RectF testBound = MathUtils.rectFTake(0, 0, getDrawable().getIntrinsicWidth(), getDrawable().getIntrinsicHeight());
testMatrix.mapRect(testBound);

刚才已经知道, animEnd记录的是当前双击变换操作作用在外部矩阵的结果,把它和内部矩阵(innerMatrix)相乘就得到了最终对原图(testBound)的变换矩阵(testMatrix)。

//修正位置
float postX = 0;
float postY = 0;
if (testBound.right - testBound.left < displayWidth) {
    postX = displayWidth / 2f - (testBound.right + testBound.left) / 2f;
} else if (testBound.left > 0) {
    postX = -testBound.left;
} else if (testBound.right < displayWidth) {
    postX = displayWidth - testBound.right;
}
……
//应用修正位置
animEnd.postTranslate(postX, postY);

这里修正位置很容易看懂,就不说了,纠正源码的两个错误: postX = displayWidth / 2f - (testBound.right + testBound.left) / 2f; 里的 testBound.right + testBound.left 应为 testBound.right - testBound.left。没贴出来的 postY 也要改下。

1.2 惯性滑动(Fling)

PinchImageView 的惯性滑动是自己处理衰减的…… 每次衰减的程度还一样,不支持插值器,比起PhotoView 使用 OverScroller 来处理滑动,就显得有点简陋了。 GestureDetectoronFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) 包含x、y轴的加速度,加速度单位是像素/秒,每秒60帧,转换成像素/帧即 velocityX/60velocityY/60PinchImageView 使用 FlingAnimator来做动画,动画更新初始滑动距离 velocityX/60,然后乘以衰减值(FLING_DAMPING_FACTOR,0.9),待下次更新使用。

//移动图像并给出结果
boolean result = scrollBy(mVector[0], mVector[1], null);
mVector[0] *= FLING_DAMPING_FACTOR;
mVector[1] *= FLING_DAMPING_FACTOR;
//速度太小或者不能移动了就结束
if (!result || MathUtils.getDistance(0, 0, mVector[0], mVector[1]) < 1f) {
    animation.cancel();
}

scrollBy(float xDiff, float yDiff, MotionEvent motionEvent) 方法处理滚动,主要考虑图片边界和控件边界的处理,跟上面缩放时的修正位置是一样的原理,图片边界的获取也跟缩放时是一样的。

//获取内部变换矩阵
matrix = getInnerMatrix(matrix);
//乘上外部变换矩阵
matrix.postConcat(mOuterMatrix);
rectF.set(0, 0, getDrawable().getIntrinsicWidth(), getDrawable().getIntrinsicHeight());
matrix.mapRect(rectF);

最后对 mOuterMatrix 进行平移变换(postTranslate),invalidate 触发 onDraw 给图片设置新矩阵。

3.2 双指缩放、单指移动

双指缩放、单指移动是在 onTouch 里做的。

3.2.1 双指缩放

原理:记录双指在屏幕上距离,外部矩阵的缩放值与此距离相除的商为单位距离的缩放值,以这个缩放值去乘以双指滑动后的距离得到一个新的缩放值,用这个缩放值给外部矩阵做缩放变换得到最终的外部矩阵。

mScaleBase=mOuterMatrix.scaleinitialDistancenextScale=mScaleBase×newDistancey(nextScale)=k(mScaleBase)x0(newDistance)mScaleBase = \frac{mOuterMatrix.scale}{initialDistance}\\ nextScale= mScaleBase \times newDistance\\ y(nextScale) = k(mScaleBase)x_0(newDistance)

很明显,mScaleBase 这个单位距离的缩放值是斜率,决定了双指缩放的速度。那么决定双指缩放速度的因素有:当前外部矩阵的缩放大小、双指间初始距离。外部矩阵缩放越大,双指间初始距离越小,双指滑动缩放越快。 还有一个要注意的是图片的缩放中心点,在 PinchImageView 中,双指缩放变换是在单位矩阵中进行的。所以当双指按下的时候需要记录外部矩阵变换之前的中心点,源码里用 mScaleCenter 成员变量来记录这个点(PS:建议肉眼屏蔽源码里在所有用到这个变量地方的注释,你会晕的)。 快速看下相关的代码:

private PointF mScaleCenter = new PointF();
private float mScaleBase = 0;
……
public boolean onTouchEvent(MotionEvent event) {
    ……
    int action = event.getAction() & MotionEvent.ACTION_MASK;
    if (action == MotionEvent.ACTION_POINTER_DOWN) {
        //切换到缩放模式
        mPinchMode = PINCH_MODE_SCALE;
        //保存缩放的两个手指
        saveScaleContext(event.getX(0), event.getY(0), event.getX(1), event.getY(1));
    }else if (action == MotionEvent.ACTION_MOVE) {
        ……
        //两个缩放点间的距离
        float distance = MathUtils.getDistance(event.getX(0), event.getY(0), event.getX(1), event.getY(1));
        //保存缩放点中点
        float[] lineCenter = MathUtils.getCenterPoint(event.getX(0), event.getY(0), event.getX(1), event.getY(1));
        mLastMovePoint.set(lineCenter[0], lineCenter[1]);
        //处理缩放
        scale(mScaleCenter, mScaleBase, distance, mLastMovePoint);
        ……
    }
}

在多指按下的时候记录当前的是双指缩放模式,saveScaleContext()记录上面提到的 mScaleBasemScaleCenter 。在 MotionEvent.ACTION_MOVE 里处理缩放逻辑。看下 saveScaleContext 的处理。

private void saveScaleContext(float x1, float y1, float x2, float y2) {
    mScaleBase = MathUtils.getMatrixScale(mOuterMatrix)[0] / MathUtils.getDistance(x1, y1, x2, y2);
    float[] center = MathUtils.inverseMatrixPoint(MathUtils.getCenterPoint(x1, y1, x2, y2), mOuterMatrix);
    mScaleCenter.set(center[0], center[1]);
}

mScaleBase 上面已经讲过了,这里主要提下 inverseMatrixPoint,看下方法定义:

public static float[] inverseMatrixPoint(float[] point, Matrix matrix) {
    if (point != null && matrix != null) {
        float[] dst = new float[2];
        //计算matrix的逆矩阵
        Matrix inverse = matrixTake();
        matrix.invert(inverse);
        //用逆矩阵变换point到dst,dst就是结果
        inverse.mapPoints(dst, point);
        //清除临时变量
        matrixGiven(inverse);
        return dst;
    } else {
        return new float[2];
    }
}

srcMatrix.invert(targetMatrix)srcMatrix 矩阵的逆矩阵存到 targetMatrix 中,martrix.mapPoints(targetPoint, srcPoint);srcPoint 应用矩阵变换并存放到 targetPoint 中。很明显这个方法的作用的是得到经过矩阵变换之前的点。 mScaleCenter 存的正是外部矩阵变换之前的点的位置。 接下来看下缩放的处理。

private void scale(PointF scaleCenter, float scaleBase, float distance, PointF lineCenter) {
    ……
    //计算图片从fit center状态到目标状态的缩放比例
    float scale = scaleBase * distance;
    Matrix matrix = MathUtils.matrixTake();
    //按照图片缩放中心缩放,并且让缩放中心在缩放点中点上
    matrix.postScale(scale, scale, scaleCenter.x, scaleCenter.y);
    //让图片的缩放中点跟随手指缩放中点
    matrix.postTranslate(lineCenter.x - scaleCenter.x, lineCenter.y - scaleCenter.y);
    mOuterMatrix.set(matrix);
    ……
}

很容易看懂,上面都讲过了。这里吐槽一下,如果 mOuterMatrix 发生过错切、旋转、透视变换,那不就废了吗? 还有一个多个手指抬起一个手指的情况。注释已经修改过了,很容易看懂。

if (action == MotionEvent.ACTION_POINTER_UP) {
    if (mPinchMode == PINCH_MODE_SCALE) {
        //event.getPointerCount()表示抬起手指时点的数量,包含抬起的那个点
        if (event.getPointerCount() > 2) {
            //event.getAction() >> 8得到的是当前抬起的点的索引。第一个点抬起了,那么让第二个点和第三个点作为缩放控制点
            if (event.getAction() >> 8 == 0) {
                saveScaleContext(event.getX(1), event.getY(1), event.getX(2), event.getY(2));
                //第二个点抬起了,那么让第一个点和第三个点作为缩放控制点
            } else if (event.getAction() >> 8 == 1) {
                saveScaleContext(event.getX(0), event.getY(0), event.getX(2), event.getY(2));
            }
        }
        //如果抬起的点等于2,那么此时只剩下一个点,也不允许进入单指模式,因为此时可能图片没有在正确的位置上
    }
}

最后需要在松手的时候修正下边界。进入 scaleEnd 方法。大多数代码其实刚才都分析过了,这里只讲一个变量,scalePost

private void scaleEnd() {
    ……
    getCurrentImageMatrix(currentMatrix);
    float currentScale = MathUtils.getMatrixScale(currentMatrix)[0];
    float outerScale = MathUtils.getMatrixScale(mOuterMatrix)[0];
    //比例修正
    float scalePost = 1f;
    //如果整体缩放比例大于最大比例,进行缩放修正
    if (currentScale > maxScale) {
        scalePost = maxScale / currentScale;
    }
    //如果缩放修正后整体导致外部矩阵缩放小于1(外部矩阵的初始值就是1,如果操作导致比初始值还小,就还原回去),重新修正缩放
    if (outerScale * scalePost < 1f) {
        scalePost = 1f / outerScale;
    }
}

注释是我改过的。

3.2.1 单指移动

单指移动主要是调用 scrollBy,之前已经分析过了。

到这里基本分析完了。