我们是如何创建uCrop这个开源的裁剪库的

ps:作者真是好人,将他创建这个库的思路写成了博客,这么好的学习机会,我当然要慢慢的琢磨了,将他翻译出来是一个不错的选择。
这是一个关于图片裁剪的开源框架
原文链接
Github链接
在之前的文章中,我们已经说过uCrop这个开源库在裁剪图片上比市面上现存的解决方案表现的更为出色。
你也许已经关注过这个库了:就在发布后不久,uCrop在Github上已经得到了很多的关注,并且在趋势排行榜上
已经处于领先地位了。
uCrop面临的挑战
在启动项目的时候,我就定义了一些直观的特性:
1.裁剪图片
2.支持任意的裁剪比例
3.通过手势可以缩放,平移,旋转图片
4.在裁剪范围内,不允许有空白区域
5.创建一个可以即刻使用的裁剪Activity,默认使用自己定义的CropView控件。也就是说,这个库会包含一个Activity,同时这个Activity会包含一个CropView和其他的控件。
Crop view
根据我之前打算构建的那些特性,我决定将view的逻辑划分为3层
第一层:
TransformImageView extends ImageView
他的义务:
1.从图片源拿到图片
2.将矩阵进行转换(平移、缩放、旋转),并应用到当前图片上
这一层并不知道裁剪或者手势等行为。
第二层:
CropImageView extends TransformImageView
他要做的事:
1.画出裁剪的边界和网格
2.为裁剪区域设置一张图片(如果用户对图片操作导致裁剪区域内出现了空白,那么图片应该要自动移动到边界填充空白区域)
3.继承父亲的方法,使用更精细的规则来操作矩阵(限制最小和最大的缩放比例)
4.添加方法和缩小的方法(动画变换)
5.裁剪图片
这一层几乎囊括了所有的要对图片进行变换和裁剪的所有操作,但也仅仅是指明了做这些事情的方法,我们还需要支持手势。
第三层:
GestureImageView extends CropImageView
他的功能:
监听用户的手势,调用合适的方法

下面我们仔细的说说每一个View的功能
TransformImageView
这是最简单的部分
首先,我拿到一个Uri,然后解码出一个合适的bitmap,拿到FileDescriptor:

ParcelFileDescriptor parcelFileDescriptor =context.getContentResolver().openFileDescriptor(uri, "r");
FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor();

现在,我们可以使用BitmapFactory方法来解码FileDescriptor.
但是呢,在解码之前,我必须知道图片的大小,因为如果图片的分辨率太高的话,我们还是需要对其进行再次取样的。

final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeFileDescriptor(fileDescriptor, null, options);
options.inSampleSize = calculateInSampleSize(options, requiredWidth, requiredHeight);
options.inJustDecodeBounds = false;

Bitmap decodeSampledBitmap = BitmapFactory.decodeFileDescriptor(fileDescriptor, null, options);
close(parcelFileDescriptor);
ExifInterface exif = getExif(uri);
if (exif != null) {
  int exifOrientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL);
  return rotateBitmap(decodeSampledBitmap, exifToDegrees(exifOrientation));
} else {
  return decodeSampledBitmap;
}

这里,我将要指出两点关于图片尺寸的事情。
1.我是如何设置图片需要的宽高的
calculateInSampleSize(options, requiredWidth, requiredHeight)方法在计算SampleSize上基于这样一个原则,即图片的宽高都不能超过我们给定的大小。
那么,你是如何发现一张图片所需要的宽、高呢?一些开发者使用常量(比如,其中有一个裁剪的开源库使用1000px作为最大的尺寸)。然而,Android设备这么多,仅仅使用一个常量来适配所有的屏幕看起来并不是最好的解决办法。我也可以使用一个view的尺寸,或者将当前有效内存考虑进来计算出一个尺寸。然而,使用view的尺寸在我的案例中无法使用,因为用户不仅仅是观察图片,他们可以对图片进行放大操作,所以,我还需要更多备用的分辨率。当然,在维护内存和图片质量的平衡上面,还有很多复杂的技术。
在做了小小的调查之后,我决定使用屏幕的对角线作为图片最大的宽高尺寸。有的设备分辨率高,屏幕大,硬件处理器强大,也有的设备分辨率低,屏幕小,硬件低效。如果一个设备处理他自己的屏幕,我们只要将图片缩放到屏幕的分辨率即可。
2.我是如何变换矩阵,然后将矩阵更新到view上的
我创建了三个方法用于变换:
a.图片位置 b.比例 c.旋转角度
我们看下关于图片缩放的方法

public void postScale(float deltaScale, float px, float py) {
  if (deltaScale != 0) {
      mCurrentImageMatrix.postScale(deltaScale, deltaScale, px, py);
      setImageMatrix(mCurrentImageMatrix);
  }
}

我重写了setImageMatrix()方法,他调用了父类的方法,并将特定的矩阵参数传过去,然后会调用updateCurrentImagePoints()方法,该方法会更新一些变量(当前图片矩阵的信息),这些变量在CropImageView中会被使用到.
CropImageView
裁剪提示框
在TransformImageView上添加的第一个东西就是裁剪指示框,当你想调整一张处于标准位置的图片时,有了这个指示框是非常方便的
裁剪指示框由矩形组成,里面有横着的线和竖着的线。
关于裁剪提示框,我只有最后一件事情要说了,那就是我计算裁剪边界的时候把padding算进去了。
而且,我将整个要裁剪的矩形局域的颜色变暗了,这样更好的区分了哪些事要裁剪的,哪些是不需要的。
确定在裁剪范围内没有空白区域
我的想法是用户必须能够对图片做出移动、旋转、缩放等操作(可能同时执行这三种操作)。同时,当用户释放图片的时候
在裁剪范围内是不能有空白区域的。我是怎样做到这些的?有两种可供选择的方法:
1.通过裁剪边界来限制图片的平移,所以,当图片已经到达边界时,用户是不能够对其平移、缩放或者旋转的。
2.让用户可以自由的移动图片,但是当用户释放之后会重新校正它的位置和尺寸。
第一种方案的用户体验实在很糟糕,我们选择第二种解决方案。
这样做的话,我们有两个问题需要解决:
1.怎样检测裁剪区域内是否已经填充满了图片
2.怎样计算出所有要做的转换,使得图片可以返回到裁剪区域内
检查图片是否填满了裁剪区域
此时,我们有两块矩形区域:裁剪区域和图片区域。图片区域必须匹配到裁剪区域内部,裁剪区域才能称作完全在图片矩形的内部。
在最小的情况下,他们的边界必须重合。
如果两个矩形在XY轴上都对齐的话,那么仅仅只需要调用contains()方法就可以了。但是,在我们的情况中,图片区域是可以任意旋转的。
这里写图片描述
左边:图片区域并没有填充满裁剪区域 右边:图片区域填充满了裁剪区域
在最开始的时候,让我感到困惑的是,我怎样检测一个旋转过了的矩形是否包含一个XY轴对称的矩形。
然后,我就捡起了曾经的数学知识……
最后,我发现换一种思路会更好。
问题就变成了,我如何在一个XY对称的矩形内检测是否包含一个旋转过的矩形。
这样一来,问题似乎就没这么难了,我们只需要检测裁剪区域的四个顶点的坐标是不是都落在了图片区域内就可以了。
mCropRect这个变量已经定义好了,剩下的就只需要图片顶点的坐标了。
我之前已经提到了ImageView的setImageMatrix(Matrix matrix)方法。在这个方法中,我们会调用到updateCurrentImagePoints()
方法,该方法又会使用Matrix的mapPoints()方法

private void updateCurrentImagePoints() {
  mCurrentImageMatrix.mapPoints(mCurrentImageCorners, mInitialImageCorners);
  mCurrentImageMatrix.mapPoints(mCurrentImageCenter, mInitialImageCenter);
}

每当图片矩阵发生变化时,我都会更新图片的中心和角落坐标.因此,我能够写出方法来检测当前的图片是否包含裁剪区域了。

protected boolean isImageWrapCropBounds() {
   mTempMatrix.reset();
   mTempMatrix.setRotate(-getCurrentAngle());
   float[] unrotatedImageCorners = Arrays.copyOf(mCurrentImageCorners, mCurrentImageCorners.length);
   mTempMatrix.mapPoints(unrotatedImageCorners);
   float[] unrotatedCropBoundsCorners = CropMath.getCornersFromRect(mCropRect);
   mTempMatrix.mapPoints(unrotatedCropBoundsCorners);
   return CropMath.trapToRect(unrotatedImageCorners).contains(CropMath.trapToRect(unrotatedCropBoundsCorners));
}

基本上,我的思路就是在“正”的矩形内判断是否包含旋转的矩形。所以,我会将两个矩形同时进行反转,反转的角度就是图片区域旋转的角度
转换图片让其包裹住裁剪区域
首先,我我拿到当前图片和裁剪区域的中心点的距离。然后,我使用一个临时的矩形和变量,将当前图片平移到裁剪区域并检测图片是否填充满了
裁剪区域(中心点平移)

loat oldX = mCurrentImageCenter[0];
float oldY = mCurrentImageCenter[1];
float deltaX = mCropRect.centerX() - oldX;
float deltaY = mCropRect.centerY() - oldY;
mTempMatrix.reset();
mTempMatrix.setTranslate(deltaX, deltaY);
float[] tempCurrentImageCorners = Arrays.copyOf(mCurrentImageCorners, mCurrentImageCorners.length);
mTempMatrix.mapPoints(tempCurrentImageCorners);
boolean willImageWrapCropBoundsAfterTranslate = isImageWrapCropBounds(tempCurrentImageCorners);

这一点是非常重要的,因为如果图片不能完全填充裁剪区域的话,那么矩阵的平移变换肯定是要伴随尺寸变换的。
因为,我添加了代码来计算出缩放比例:

float currentScale = getCurrentScale();
float deltaScale = 0;
if (!willImageWrapCropBoundsAfterTranslate) {
  RectF tempCropRect = new RectF(mCropRect);
  mTempMatrix.reset();
  mTempMatrix.setRotate(getCurrentAngle());
  mTempMatrix.mapRect(tempCropRect);

  float[] currentImageSides = RectUtils.getRectSidesFromCorners(mCurrentImageCorners);
  deltaScale = Math.max(tempCropRect.width() / currentImageSides[0],
  tempCropRect.height() / currentImageSides[1]);
  deltaScale = deltaScale * currentScale - currentScale;
}

——————————————————————待自己先做一个Matrix的Demo,然后回来
我回来了.
首先,我对临时矩阵进行旋转,然后将裁剪区域矩形映射到一个临时变量中。然后我在RectUtils类中创建了一个方法通过边角
坐标来计算旋转矩形的边长。

public static float[] getRectSidesFromCorners(float[] corners) {
  return new float[]{(float) Math.sqrt(Math.pow(corners[0] - corners[2], 2) + Math.pow(corners[1] - corners[3], 2)),
          (float) Math.sqrt(Math.pow(corners[2] - corners[4], 2) + Math.pow(corners[3] - corners[5], 2))};
}

使用该方法,我拿到了当前图片的宽高,以及需要缩放的比例
现在,我已经有了图片要移动的距离以及他要缩放的比例,所以,我写了一个Runnable方法来动态的展现这一过程。

@Override
public void run() {

  long now = System.currentTimeMillis();
  float currentMs = Math.min(mDurationMs, now - mStartTime);

  float newX = CubicEasing.easeOut(currentMs, 0, mCenterDiffX, mDurationMs);
  float newY = CubicEasing.easeOut(currentMs, 0, mCenterDiffY, mDurationMs);
  float newScale = CubicEasing.easeInOut(currentMs, 0, mDeltaScale, mDurationMs);
  if (currentMs < mDurationMs) {
      cropImageView.postTranslate(newX - (cropImageView.mCurrentImageCenter[0] - mOldX), newY - (cropImageView.mCurrentImageCenter[1] - mOldY));
      if (!mWillBeImageInBoundsAfterTranslate) {
          cropImageView.zoomInImage(mOldScale + newScale, cropImageView.mCropRect.centerX(), cropImageView.mCropRect.centerY());
      }
      if (!cropImageView.isImageWrapCropBounds()) {
          cropImageView.post(this);
      }
  }
}

———————————————————————-这里本人尝试着使用ValueAnimator来实现,作者的算法实在不想看
在这里,我计算出当前流逝的时间,使用CubicEasing这个类,我对平移量和缩放量进行插值操作。
使用插值器替换过的值确实可以改善你的动画,使人们的眼睛看起来更自然。
最终,这些值被应用到图片矩阵,当时间溢出或者图片完全填充了裁剪区域的时候,Runnable任务就会停止。
裁剪图片
终于走到这一步了。我需要裁剪一张图片,如果没有这一功能的话,这个库也就没什么用了.
首先,我需要取得我需要的一些值:

Bitmap viewBitmap = getViewBitmap();
if (viewBitmap == null) {
    return null;
}
cancelAllAnimations();
setImageToWrapCropBounds(false); // without animation
RectF currentImageRect = RectUtils.trapToRect(mCurrentImageCorners);
if (currentImageRect.isEmpty()) {
    return null;
}
float currentScale = getCurrentScale();
float currentAngle = getCurrentAngle();

当前需要被裁剪的图片,当前的矩形代表了屏幕上的一个被变换过的图片,以及当前的缩放比例和旋转角度。
对他们进行一番检测然后接着下一步:

if (mMaxResultImageSizeX > 0 && mMaxResultImageSizeY > 0) {
  float cropWidth = mCropRect.width() / currentScale;
  float cropHeight = mCropRect.height() / currentScale;
  if (cropWidth > mMaxResultImageSizeX || cropHeight > mMaxResultImageSizeY) {
      float scaleX = mMaxResultImageSizeX / cropWidth;
      float scaleY = mMaxResultImageSizeY / cropHeight;
      float resizeScale = Math.min(scaleX, scaleY);
      Bitmap resizedBitmap = Bitmap.createScaledBitmap(viewBitmap,
              (int) (viewBitmap.getWidth() * resizeScale),
              (int) (viewBitmap.getHeight() * resizeScale), false);
      viewBitmap.recycle();
      viewBitmap = resizedBitmap;
      currentScale /= resizeScale;
  }
}

我提供了一个接口,可以指定输出图片的最大宽高。例如,如果你用这个库来裁剪自己的肖像,你也许需要的最大宽高都是500px。
在上面的代码块中,我会检查你有没有指定最大的输出值以及你的裁剪图片的宽高是否大于那个最大值。
如果有必须缩小图片的话,我会使用Bitmap.createScaledBitmap()方法,然后将原始的Bitmap回收掉,将整个计算出的缩小比例
赋予currentScale这个变量,那么后续的计算就不会受到影响了。
检查图片是否被旋转:(通过矩阵来创建需要的Bitmap)

if (currentAngle != 0) {
  mTempMatrix.reset();
  mTempMatrix.setRotate(currentAngle, viewBitmap.getWidth() / 2, viewBitmap.getHeight() / 2);
  Bitmap rotatedBitmap = Bitmap.createBitmap(viewBitmap, 0, 0, viewBitmap.getWidth(), viewBitmap.getHeight(),
          mTempMatrix, true);
  viewBitmap.recycle();
  viewBitmap = rotatedBitmap;
}

同上面一样,我会通过Bitmap.createBitmap()来创建一个图片,然后将原始的图片回收掉,这样就不会导致内存溢出了。

int top = (int) ((mCropRect.top - currentImageRect.top) / currentScale);
int left = (int) ((mCropRect.left - currentImageRect.left) / currentScale);
int width = (int) (mCropRect.width() / currentScale);
int height = (int) (mCropRect.height() / currentScale);
Bitmap croppedBitmap = Bitmap.createBitmap(viewBitmap, left, top, width, height);

最终,我计算出了要剪裁的图片矩形的坐标。
GestureImageView
当我在TransformImageView中添加了对图片的平移、旋转、缩放等方法后。接下来就是创建这一层了。
当然,随着库的更新,手势的逻辑和支持的手势在不断的变化和改进。
建议:对于手势的处理尽量使用开源库,对于细节的处理更加到位。
下面看一看我需要支持的手势:
1.缩放操作
双击放大
两只手指的拉伸
2.滑动操作
通过手指的拖动来拖拽图片
3.旋转操作
用户将两个手指放在图片上,做出旋转操作便可以对图片进行旋转
重要的是,上面的所有操作是可以同时执行的,所有的图形变换必须被应用于用户手指之间的焦点,这样用户就会觉得是他在屏幕上
拖动图片。
我们非常幸运,Android SDK给我们提供了两个很有用的类:GestureDetector和ScaleGestureDetector.
我们只需要重写我们感兴趣的回调方法就可以了,如onScroll,onScale
可惜的是,SDK并没有内置处理旋转的类,然后我根据StackOverFlow上的文章自己创建了一个类。

private class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener
  @Override
  public boolean onScale(ScaleGestureDetector detector) {
      postScale(detector.getScaleFactor(), mMidPntX, mMidPntY);
      return true;
  }
}
private class GestureListener extends GestureDetector.SimpleOnGestureListener {
  @Override
  public boolean onDoubleTap(MotionEvent e) {
      zoomImageToPosition(getDoubleTapTargetScale(), e.getX(), e.getY(), DOUBLE_TAP_ZOOM_DURATION);
      return super.onDoubleTap(e);
  }
  @Override
  public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
      postTranslate(-distanceX, -distanceY);
      return true;
  }
}
private class RotateListener extends RotationGestureDetector.SimpleOnRotationGestureListener {
  @Override
  public boolean onRotation(RotationGestureDetector rotationDetector) {
      postRotate(rotationDetector.getAngle(), mMidPntX, mMidPntY);
      return true;
  }
}

下一步,我创建了一些监听对象,并且指明了上面定义的监听器。

private void setupGestureListeners() {
  mGestureDetector = new GestureDetector(getContext(), new GestureListener(), null, true);
  mScaleDetector = new ScaleGestureDetector(getContext(), new ScaleListener());
  mRotateDetector = new RotationGestureDetector(new RotateListener());
}

现在,你可能已经察觉到mMinPntX,mMinPntY变量和getDoubleTapTargetScale()方法还没有被定义。基本上,mMinPntX,mMinPntY是当前点的
坐标,这个坐标是两个手指之间的点,使用这个点可以更好的处理图片的变换。getDoubleTapTargetScale()方法根据mDoubleTapScaleSteps计算出缩放比例。

protected float getDoubleTapTargetScale() {
  return getCurrentScale() * (float) Math.pow(getMaxScale() / getMinScale(), 1.0f / mDoubleTapScaleSteps);
}

例如,默认的mDoubleTapScaleSteps是5,那么用户从最小比例到最大比例可以通过5次tap完成。
当然,你不给监听器事件,他们是不会发挥作用的。

@Override
public boolean onTouchEvent(MotionEvent event) {
  if ((event.getAction() & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_DOWN) {
      cancelAllAnimations();
  }
  if (event.getPointerCount() > 1) {
      mMidPntX = (event.getX(0) + event.getX(1)) / 2;
      mMidPntY = (event.getY(0) + event.getY(1)) / 2;
  }
  mGestureDetector.onTouchEvent(event);
  mScaleDetector.onTouchEvent(event);
  mRotateDetector.onTouchEvent(event);
  if ((event.getAction() & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_UP) {
      setImageToCropBounds();
  }
  return true;
}

对于每一个触摸事件,我先检查他们是否是ACTION_DOWN或者ACTION_UP事件。
现在假设这样一种场景,一个用户将图片拖出了屏幕然后将手释放了。此时,ACTION_UP事件触发
然后setImageToCropBounds()方法被调用。图片开始执行动画向裁剪边界靠拢,当动画正在执行的时候,用户再次触摸了
图片,ACTION_DOWN事件会被触发,接着动画会被取消,所有的变化都是根据用户的手势来执行的。
在两个或两个以上手指操作屏幕的情况下,我会更新mMidPntX和mMidPntY的值。最终,我会将触摸事件交给每一个手势检测器。

UCropActivity
关于自定义控件的代码在这里省略不写,大家可以在Github上查看源码。
除了控件,Activity会从UCrop类拿到所有的数据,UCrop使用Builder模式设计,会创建不同的CropView。
此时此刻,并没有Activity主题的支持,这也是我们下一个升级版本的目标。
UCrop Builder
对于这个部分,我不想重复造轮子,我这边直接采用了https://github.com/jdamcd/android-crop的代码。
如果你想裁剪一个用户肖像,并需要一个正方形的裁剪比例,最大的裁剪值为480px,你可以这样写:

UCrop.of(sourceUri, destinationUri).withAspectRatio(1, 1).withMaxResultSize(480, 480).start(context);

结束语
我在开发这个库时面临的最大挑战就是如何实现平稳的性能和UI效果。
最后我发现通过Matrix可以游刃有余的解决这些问题。

  • 3
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值