UCrop是目前比较火的开源android图片剪裁框架,效果如下:
地址:
Git源码地址
本文重点解释核心功能代码,梳理项目流程,建议阅读时结合源码一起看~。
业务流程:
选择图片(从系统图片库选择图片)→ 放置图片(把图片放置到操作台)→ 操作图片(包括旋转,缩放,位移等操作来得到需要的图片)→ 剪裁图片(根据原始比例剪裁框剪裁目标图片或根据给定的比例剪裁)→ 获得目标图片(返回bitmap并保存到本地)
代码结构解析:
项目使用Bulider设计模式,结构功能分工明确,下面就来看看作者是怎么实现的,注意看核心代码的注释
代码结构大致分为三个部分:
-
第一部分: UCropAcitivity(图片的操作窗口)
它主要作为项目的入口和一些初始化工作以及加载自定义View;
使用时直接传入一个目标图片的URI和目标存储文件的URI即可开始剪裁。
这里作者封装了一个UCropActivity的helper类UCrop,把startActivityForResult和Bundle封装在了里面,Options内部类用作用户初始参数配置包括(setToolbarCropDrawable设置toolbar图片,setToolbarTitle标题等等)。UCropActivity中包含自定义UCropView,UCropView中包含 GestureCropImageView和OverlayView两个自定义View。
OverlayView负责绘制剪裁框
GestureCropImageView负责操作选择图片 -
第二部分:OverlayView-负责绘制剪裁框
View在初始化的时候做了判断,当系统大于3.0小于4.3时启动硬件加速,为什么选择这个区间呢,因为在android系统3.0之前android还不支持硬件加速,而在4.3之后Android 4.4,也就是KitKat版本,这个版本对内存方法做了很大的优化:一方面是通过优化内存使用,另一方面是可选地支持使用ART运行时替换Dalvik虚拟机,来提高应用程序的运行效率,所以选择4.4之后不开启硬件加速。
剪裁框的绘制主要分为两个部分:
1、drawlines()绘制剪裁框内分割线段,
2、drawRect()绘制矩形,从而形成了一个带横纵线平均分割的矩形剪裁框核心方法:
drawCropGrid()该方法主要功能为绘制剪裁框,根据设定的剪裁框分割线数量使用canvas.drawlines()方法绘制分割线和限制矩形,具体代码如下:
绘制剪裁框:
protected void drawCropGrid(@NonNull Canvas canvas) {
//判断是否显示剪裁框
if (mShowCropGrid) {
//判断矩形数据是否为空,mGridPoints如果等于空的话进入填充数据
if (mGridPoints == null && !mCropViewRect.isEmpty()) {
//该数组为canvas.drawLines的第一个参数,该参数要求其元素个数为4的倍数
mGridPoints = new float[(mCropGridRowCount) * 4 + (mCropGridColumnCount) * 4];
int index = 0;
//组装数据,数据为每一组线段的坐标点
for (int i = 0; i < mCropGridRowCount; i++) {
mGridPoints[index++] = mCropViewRect.left;
mGridPoints[index++] = (mCropViewRect.height() * (((float) i + 1.0f) / (float) (mCropGridRowCount + 1))) + mCropViewRect.top;
mGridPoints[index++] = mCropViewRect.right;
mGridPoints[index++] = (mCropViewRect.height() * (((float) i + 1.0f) / (float) (mCropGridRowCount + 1))) + mCropViewRect.top;
}for (int i = 0; i < mCropGridColumnCount; i++) { mGridPoints[index++] = (mCropViewRect.width() * (((float) i + 1.0f) / (float) (mCropGridColumnCount + 1))) + mCropViewRect.left; mGridPoints[index++] = mCropViewRect.top; mGridPoints[index++] = (mCropViewRect.width() * (((float) i + 1.0f) / (float) (mCropGridColumnCount + 1))) + mCropViewRect.left; mGridPoints[index++] = mCropViewRect.bottom; } } //绘制线段 if (mGridPoints != null) { canvas.drawLines(mGridPoints, mCropGridPaint); } } //绘制矩形包裹线段 if (mShowCropFrame) { canvas.drawRect(mCropViewRect, mCropFramePaint); } //绘制边角包裹,mFreestyleCropMode此参数如果等于1的话 剪裁框为可移动状态,一般不用 if (mFreestyleCropMode != FREESTYLE_CROP_MODE_DISABLE) { canvas.save(); mTempRect.set(mCropViewRect); mTempRect.inset(mCropRectCornerTouchAreaLineLength, -mCropRectCornerTouchAreaLineLength); canvas.clipRect(mTempRect, Region.Op.DIFFERENCE); mTempRect.set(mCropViewRect); mTempRect.inset(-mCropRectCornerTouchAreaLineLength, mCropRectCornerTouchAreaLineLength); canvas.clipRect(mTempRect, Region.Op.DIFFERENCE); canvas.drawRect(mCropViewRect, mCropFrameCornersPaint); canvas.restore(); }
}
这部分主要负责第一层的剪裁框绘制,保存剪裁框矩阵用于后期剪裁用
第三部分:GestureCropImageView-负责操作选择图片
这一部分应该是项目最核心的部分,实现逻辑作者在他的说明文章中也说的比较清楚。
这一部分的逻辑解耦做的非常好,把View的功能逻辑划分为3层,每一层负责各自的功能:
-
第一层:
TransformImageView extends ImageView
他的工作:
1.从图片源拿到图片
2.将矩阵进行转换(平移、缩放、旋转),并应用到当前图片上
这一层并不知道裁剪或者手势等行为,但提供了手势行为操作供子类调用。
这里作者选择使用ImageView的Matrix做旋转缩放而没有使用重写onDraw方法,因为onDraw方法重绘有可能会有闪屏的情况而且在性能比较差的机器上可能体验会很差,使用Matrix则会好许多。
Matrix在android中其实一个3*3的矩阵如下:
MSCALE_X MSKEW_X MTRANS_X
MSKEY_Y MSCALE_Y MTRANS_Y
MPERSP_0 MPERSP_1 MPERSP_2
Matrix对图像的处理分为四类基本变换:
Translate 平移变换
Rotate 旋转变换
Scale 缩放变换
Skew 错切变换
在项目中只用到了前三种,它们对应的api如下:
//平移变换,在X轴上平移x多个距离,在Y轴上平移y多个距离
postTranslate(float x,floaty);//旋转变换,degrees旋转度数,px,py旋转原点
postRotate(float degrees,float px,float py);//缩放变换 x变换距离,y变换距离,px,py缩放原点
postScale(float sx,float sx,float px,float py);作者在这一层写了postTranslate,postScale,postRotate三个方法暴露给子类调用,封装Matrix属性后在调用setImageMatrix()实现变换效果,mCurrentImageMatrix.mapPoints这个方法为更新当前图像的角点和存储的中心点,这些变量在CropImageView中会被使用到.
-
第二层:
CropImageView extends TransformImageView
他要做的事:
1.画出裁剪的边界和网格
2.为裁剪区域设置一张图片(如果用户对图片操作导致裁剪区域内出现了空白,那么图片应该要自动移动到边界填充空白区域)
3.继承父亲的方法,使用更精细的规则来操作矩阵(限制最小和最大的缩放比例)
4.添加方法和缩小的方法(动画变换)
5.裁剪图片
这一层几乎囊括了所有的要对图片进行变换和裁剪的所有操作,但也仅仅是指明了做这些事情的方法,我们还需要支持手势。在这一层中作者重写了onImageLaidOut方法,该方法为上层View TransformImageView的图片加载完毕回调方法,在此方法中作者设置了初始要剪裁的矩形,并且把图片移动到屏幕居中的位置操作(因为ImageView设置的ScaleType为Matrix所以图像一开始是默认在屏幕上方的)
这一层中的操作大概可以分为三步操作:图片偏移剪裁框偏移计算、图片归位动画处理,剪裁图片
第一步(也是最复杂的一种):
当手指离开屏幕时要保证图片处于剪裁区域中如果不在剪裁区域中通过位移变换来移动到剪裁区域,看代码:public void setImageToWrapCropBounds(boolean animate) { //如果图片加载完毕并且图片不处于剪裁区域 if (mBitmapLaidOut && !isImageWrapCropBounds()) { //获取中心点X,Y坐标 float currentX = mCurrentImageCenter[0]; float currentY = mCurrentImageCenter[1]; //获取缩放比例 float currentScale = getCurrentScale(); //获取偏移距离 float deltaX = mCropRect.centerX() - currentX; float deltaY = mCropRect.centerY() - currentY; float deltaScale = 0; mTempMatrix.reset(); mTempMatrix.setTranslate(deltaX, deltaY); final float[] tempCurrentImageCorners = Arrays.copyOf(mCurrentImageCorners, mCurrentImageCorners.length); mTempMatrix.mapPoints(tempCurrentImageCorners); //判断图片是否包含在剪裁区域 boolean willImageWrapCropBoundsAfterTranslate = isImageWrapCropBounds(tempCurrentImageCorners); //如果包含在剪裁区域 if (willImageWrapCropBoundsAfterTranslate) { //获取偏移的距离 final float[] imageIndents = calculateImageIndents(); //偏移的距离,横坐标加横坐标 纵坐标加纵坐标 deltaX = -(imageIndents[0] + imageIndents[2]); deltaY = -(imageIndents[1] + imageIndents[3]); } else { //如果不包含在剪裁区域,创建临时矩形 RectF tempCropRect = new RectF(mCropRect); mTempMatrix.reset(); //设置偏移角度 mTempMatrix.setRotate(getCurrentAngle()); mTempMatrix.mapRect(tempCropRect); //获得矩形的边长坐标 final float[] currentImageSides = RectUtils.getRectSidesFromCorners(mCurrentImageCorners); //获取放大比例 deltaScale = Math.max(tempCropRect.width() / currentImageSides[0], tempCropRect.height() / currentImageSides[1]); deltaScale = deltaScale * currentScale - currentScale; } //如果需要动画 if (animate) { post(mWrapCropBoundsRunnable = new WrapCropBoundsRunnable( CropImageView.this, mImageToWrapCropBoundsAnimDuration, currentX, currentY, deltaX, deltaY, currentScale, deltaScale, willImageWrapCropBoundsAfterTranslate)); } else { //不需要动画,直接移动到目标位置 postTranslate(deltaX, deltaY); if (!willImageWrapCropBoundsAfterTranslate) { zoomInImage(currentScale + deltaScale, mCropRect.centerX(), mCropRect.centerY()); } } }
}
第二步:作者在这里使用了一个Runable线程来操作,使用时间差值的计算来移动动画,使动画看起来更真实
此方法主要处理偏移回归的动画 写在一个Runable子线程中
/**
* This Runnable is used to animate an image so it fills the crop bounds entirely.
* Given values are interpolated during the animation time.
* Runnable can be terminated either vie {@link #cancelAllAnimations()} method
* or when certain conditions inside {@link WrapCropBoundsRunnable#run()} method are triggered.
* 在这里,我计算出当前流逝的时间,使用CubicEasing这个类,我对平移量和缩放量进行插值操作。
* 使用插值器替换过的值确实可以改善你的动画,使人们的眼睛看起来更自然。
* 最终,这些值被应用到图片矩阵,当时间溢出或者图片完全填充了裁剪区域的时候,Runnable任务就会停止。
*/
private static class WrapCropBoundsRunnable implements Runnable {
private final WeakReference<CropImageView> mCropImageView;
private final long mDurationMs, mStartTime;
private final float mOldX, mOldY;
private final float mCenterDiffX, mCenterDiffY;
private final float mOldScale;
private final float mDeltaScale;
private final boolean mWillBeImageInBoundsAfterTranslate;
public WrapCropBoundsRunnable(CropImageView cropImageView,
long durationMs,
float oldX, float oldY,
float centerDiffX, float centerDiffY,
float oldScale, float deltaScale,
boolean willBeImageInBoundsAfterTranslate) {
mCropImageView = new WeakReference<>(cropImageView);
mDurationMs = durationMs;
mStartTime = System.currentTimeMillis();
mOldX = oldX;
mOldY = oldY;
mCenterDiffX = centerDiffX;
mCenterDiffY = centerDiffY;
mOldScale = oldScale;
mDeltaScale = deltaScale;
mWillBeImageInBoundsAfterTranslate = willBeImageInBoundsAfterTranslate;
}
@Override
public void run() {
CropImageView cropImageView = mCropImageView.get();
if (cropImageView == null) {
return;
}
long now = System.currentTimeMillis();
//花费的时间,最多500ms,
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);
}
}
}
}
另一个Runable方法类,用于双击放大时使用
同样使用了时间差值计算偏移大小动画
MaxScale为图片最大的放大值,大小为最小尺寸的10倍
minScale为图片缩小的最小值,大小为初始矩形的宽和高分别除以剪裁框的宽高取最小值。
/**
* This Runnable is used to animate an image zoom.
* Given values are interpolated during the animation time.
* Runnable can be terminated either vie {@link #cancelAllAnimations()} method
* or when certain conditions inside {@link ZoomImageToPosition#run()} method are triggered.
*/
private static class ZoomImageToPosition implements Runnable {
private final WeakReference<CropImageView> mCropImageView;
private final long mDurationMs, mStartTime;
private final float mOldScale;
private final float mDeltaScale;
private final float mDestX;
private final float mDestY;
public ZoomImageToPosition(CropImageView cropImageView,
long durationMs,
float oldScale, float deltaScale,
float destX, float destY) {
mCropImageView = new WeakReference<>(cropImageView);
mStartTime = System.currentTimeMillis();
mDurationMs = durationMs;
mOldScale = oldScale;
mDeltaScale = deltaScale;
mDestX = destX;
mDestY = destY;
}
@Override
public void run() {
CropImageView cropImageView = mCropImageView.get();
if (cropImageView == null) {
return;
}
long now = System.currentTimeMillis();
float currentMs = Math.min(mDurationMs, now - mStartTime);
float newScale = CubicEasing.easeInOut(currentMs, 0, mDeltaScale, mDurationMs);
if (currentMs < mDurationMs) {
cropImageView.zoomInImage(mOldScale + newScale, mDestX, mDestY);
cropImageView.post(this);
} else {
cropImageView.setImageToWrapCropBounds();
}
}
}
- 第三步:最后一步,剪裁图片
/**
* Cancels all current animations and sets image to fill crop area (without animation).
* Then creates and executes {@link BitmapCropTask} with proper parameters.
*/
public void cropAndSaveImage(@NonNull Bitmap.CompressFormat compressFormat, int compressQuality,
@Nullable BitmapCropCallback cropCallback) {
//结束子线程
cancelAllAnimations();
//设置要剪裁的图片,不需要位移动画
setImageToWrapCropBounds(false);
//存储图片信息,四个参数分别为:mCropRect要剪裁的图片矩阵,当前图片要剪裁的矩阵,当前放大的值,当前旋转的角度
final ImageState imageState = new ImageState(
mCropRect, RectUtils.trapToRect(mCurrentImageCorners),
getCurrentScale(), getCurrentAngle());
//剪裁参数,mMaxResultImageSizeX,mMaxResultImageSizeY:剪裁图片的最大宽度、高度。
final CropParameters cropParameters = new CropParameters(
mMaxResultImageSizeX, mMaxResultImageSizeY,
compressFormat, compressQuality,
getImageInputPath(), getImageOutputPath(), getExifInfo());
//剪裁操作放到AsyncTask中执行
new BitmapCropTask(getViewBitmap(), imageState, cropParameters, cropCallback).execute();
}
剪裁部分的核心代码: float resizeScale = resize(); crop(resizeScale);
//调整剪裁大小,如果有设置最大剪裁大小也会在这里做调整到设置范围
private float resize() {
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeFile(mImageInputPath, options);
boolean swapSides = mExifInfo.getExifDegrees() == 90 || mExifInfo.getExifDegrees() == 270;
float scaleX = (swapSides ? options.outHeight : options.outWidth) / (float) mViewBitmap.getWidth();
float scaleY = (swapSides ? options.outWidth : options.outHeight) / (float) mViewBitmap.getHeight();
float resizeScale = Math.min(scaleX, scaleY);
mCurrentScale /= resizeScale;
resizeScale = 1;
if (mMaxResultImageSizeX > 0 && mMaxResultImageSizeY > 0) {
float cropWidth = mCropRect.width() / mCurrentScale;
float cropHeight = mCropRect.height() / mCurrentScale;
if (cropWidth > mMaxResultImageSizeX || cropHeight > mMaxResultImageSizeY) {
scaleX = mMaxResultImageSizeX / cropWidth;
scaleY = mMaxResultImageSizeY / cropHeight;
resizeScale = Math.min(scaleX, scaleY);
mCurrentScale /= resizeScale;
}
}
return resizeScale;
}
/**
* 剪裁图片
*/
private boolean crop(float resizeScale) throws IOException {
ExifInterface originalExif = new ExifInterface(mImageInputPath);
//四舍五入取整
int top = Math.round((mCropRect.top - mCurrentImageRect.top) / mCurrentScale);
int left = Math.round((mCropRect.left - mCurrentImageRect.left) / mCurrentScale);
mCroppedImageWidth = Math.round(mCropRect.width() / mCurrentScale);
mCroppedImageHeight = Math.round(mCropRect.height() / mCurrentScale);
//计算出图片是否需要被剪裁
boolean shouldCrop = shouldCrop(mCroppedImageWidth, mCroppedImageHeight);
Log.i(TAG, "Should crop: " + shouldCrop);
if (shouldCrop) {
//调用C++方法剪裁
boolean cropped = cropCImg(mImageInputPath, mImageOutputPath,
left, top, mCroppedImageWidth, mCroppedImageHeight, mCurrentAngle, resizeScale,
mCompressFormat.ordinal(), mCompressQuality,
mExifInfo.getExifDegrees(), mExifInfo.getExifTranslation());
//剪裁成功复制图片EXIF信息
if (cropped && mCompressFormat.equals(Bitmap.CompressFormat.JPEG)) {
ImageHeaderParser.copyExif(originalExif, mCroppedImageWidth, mCroppedImageHeight, mImageOutputPath);
}
return cropped;
} else {
//直接复制图片到目标文件夹
FileUtils.copyFile(mImageInputPath, mImageOutputPath);
return false;
}
}
第三层:
GestureImageView extends CropImageView
他的功能:
监听用户的手势,调用合适的方法
由于系统对手势操作已经有了监听方法,所以作者在这里使用了系统的监听方法:
ScaleGestureDetector:用来检测两个手指在屏幕上做缩放的手势。
GestureListener:这个类我们可以识别很多的手势,作者在这里重写了双击onDoubleTap,拖动onScroll,两种手势处理。
RotationGestureDetector:两只以上的手指触摸屏幕才会产生旋转事件用这个接口回调。
/**
* If it's ACTION_DOWN event - user touches the screen and all current animation must be canceled.
* If it's ACTION_UP event - user removed all fingers from the screen and current image position must be corrected.
* If there are more than 2 fingers - update focal point coordinates.
* Pass the event to the gesture detectors if those are enabled.
*/
@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);
//两指缩放监听
if (mIsScaleEnabled) {
mScaleDetector.onTouchEvent(event);
}
//旋转监听
if (mIsRotateEnabled) {
mRotateDetector.onTouchEvent(event);
}
if ((event.getAction() & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_UP) {
//最后一指抬起时判断图片是否填充剪裁框
setImageToWrapCropBounds();
}
return true;
}
大致的核心逻辑基本就这些
项目中的异步操作使用AsyncTask,一共两个主要的AsyncTask:BitmapLoadTask用于初次进入load图片,BitmapCropTask图片剪裁异步操作。
项目涉及到的技术点:
自定义View,手势操作监听,Matrix实现图片变换缩放,Canvas绘制View,exif存储图片信息,文件存储操作,以及大量的计算。
*有疑问的可以在评论区留言一起讨论~~