uCrop 的創建過程
在 上篇文章 中,向你介紹了我們最新的 Android 圖片裁剪庫 ,它的裁剪體驗比現有的任何一個方案都要好。也許你已經見過這個庫:發布后不久,uCrop 在 GitHub 上獲得了很多關注。并在 GitHub 的 trending repositories 列表中取得領先的地位。
如果你喜歡,可以 在 Product Hunt 上為 uCrop 投票 。現在讓我們開始深入研究開發 uCrop 的一些技術細節。讀完這篇文章后,希望 Android 上的圖片裁剪在你眼里能變得更容易些。
uCrop 的挑戰
開始這個項目時,我定義了一組相當簡單的特性:
- 裁剪圖片
- 支持任意長寬比
- 使用手勢縮放、移動和旋轉
- 防止裁剪區內的圖片上留下空白的部分
- 創建一個隨時可用的裁剪 Activity,并且它可以使用它內部的裁剪視圖。換句話說,這個庫包含一個 Activity,里面包含了一個裁剪視圖和一些附加組件。
裁剪的視圖
計劃構建這組特性,決定將邏輯視圖分為三層。
-
TransformImageView extends ImageView .
必須可以:
- 從源設置圖片
- 在當前圖片上應用變換(位移、縮放和旋轉)矩陣
-
CropImageView extends TransformImageView .
包括:
- 繪制裁剪框和網格
- 給裁剪區設置圖片(如果用戶放大或是旋轉圖片導致在裁剪框內出現空白區域,圖片將會自動移動或/且縮放回來,來適應至裁剪框沒有空白區域)
- 更具體規則的變換矩陣的擴展方法(如限制最大和最小的縮放等..)
- 添加進出縮放動畫的方法(動畫變換)
- 裁剪圖片
這一層差不多有我們想要去變換和裁剪圖片的所有事情。但它僅僅只是指定方法來做這里所有的事情,我們還要支持手勢呢。
-
GestureImageView extends CropImageView .
這層的功能是:
- 監聽用戶的手勢,調用相應的方法。
TransformImageView
這是最簡單的部分。
首先,拿到一個 Uri 并且解析出合適尺寸的位圖(bitmap)。從拿到這個 FileDescriptor 開始:
ParcelFileDescriptor parcelFileDescriptor = context.getContentResolver().openFileDescriptor(uri, "r");
FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor();
現在,可以使用 BitmapFactory 方法來解析這個 FileDescriptor。
但是解析位圖之前,必須要知道它的尺寸,因為如果它的分辨率太高,取到的將是個縮略圖( subsampled )。
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 設備,找出一個常數來適應所有的屏幕,看上去不是一個好方法。我可以用一個視圖的尺寸,或者根據用戶當前可用內存來計算一個位圖尺寸。然而,我并沒有使用視圖的尺寸,因為用戶不僅僅是看這張圖:他們會縮放圖片,所以我需要一些建議。實現內存和圖片質量之間取得平衡的技術也是非常復雜。
簡短的研究后,我決定使用屏幕的對角線作為位圖的最大寬/高。屏幕對角線是使用的很普遍的值。大屏和硬件配置高的設備有高的顯示密度,低配,便宜或是老的設備屏幕小,顯示密度底并且硬件性能不好。如果一個設備處理自己的屏幕,肯定會把圖片縮小到屏幕的尺寸。
從 Android 2.3.3 的 ldpi 分辨率的垃圾手機 (crap-phones) 一直到 9 寸變態屏幕的品牌全新手機 Nexus 9,我都測試了這個方法,也很滿意內存和圖片質量的平衡效果。如果圖片尺寸有任何問題,可以通過 builder 來改變它的值,也可以直接設置到圖片上。
2. 如何將轉換應用到矩陣上,再將變換后的矩陣作用到圖片上?
我為變換創建了三個方法:(1) 圖片位置,(2) 縮放和 (3) 旋轉角度。例如,讓我們看下圖片縮放的方法:
public void postScale(float deltaScale, float px, float py) {
if (deltaScale != 0) {
mCurrentImageMatrix.postScale(deltaScale, deltaScale, px, py);
setImageMatrix(mCurrentImageMatrix);
}
}
這點沒什么特別的:這個方法簡單的檢查給定的值是否非 0 , 然后將它應用到當前圖片矩陣上。
由于我覆寫了 setImageMatrix() 方法,它用給定的矩陣調用父類的方法,也調用了 updateCurrentImagePoints() 方法去更新 CropImageView 類中幾個需要的變量。
TransformImageView 的邏輯準備好了,我開始實現這個庫中更有趣更有挑戰的部分。
CropImageView
裁剪參考線
我在 TransformImageView 上面添加的第一部分是裁剪參考線。當你想相對圖片的中心和 X/Y 軸來調整位置時,這是相當有用的。
圖片參考線是由一個矩形構成,矩形內部有水平和垂直的線。在畫布上繪制線條很容易,如果你在這方面有問題,可以在網上找到很多相關的信息。你也可以看我們的開源項目是如何實現的。
關于裁剪參考線,另一件我唯一想提的事是我為裁剪區域計算了內邊距。而且,使用了半透明的黑色標注了裁剪區以外的區域,更好的展現哪里會裁剪,哪里不會裁剪。
確保裁剪區內沒有空白區域
我的想法是,用戶必須可以移動,旋轉和縮放圖片(三個動作可以同時執行)。而且,當用戶放開圖片后裁剪框內不能有空白區域。我該怎么做到這點?這里有兩個可行的方案:
- 通過裁剪邊界限制圖片的變換,就是說,如果圖片已經在裁剪區的邊緣,用戶就不能再縮小,旋轉或是移動圖片了。
- 隨意讓用戶移動圖片,但是當圖片被釋放后自動修復它的位置和尺寸。
第一種操作的用戶體驗很糟糕,所以我選擇了第二個。
這樣呢,我必須解決兩個問題: (1) 如何檢測裁剪框是否被圖片填滿; (2) 如何計算所需的變換,讓圖片一定可以返回到邊界內。
檢查圖片是否充滿整個裁剪區
開始,有兩個矩形:圖片框和裁剪框。圖片必需適應裁剪框以至裁剪框完全在圖片框內部。至少,它們的邊必需接觸。如果兩個矩形是坐標軸方向的,那這個任務相當簡單:僅需調用 Rect 類的 contains() 方法就可以了。但在這里,圖片的矩形是能夠自由轉動的。真糟糕!
左邊:圖片框沒有填滿裁剪框。右邊:圖片框填滿了裁剪框
首先,如何檢測一個斜的矩形是否包含了一個坐標軸方向的矩形,讓我很困惑。然后我盡力回憶曾學的很好的三角函數課程,并不斷在紙上做計算。但我突然意識到,如果反過來思考這個問題將變得很容易解決:如何檢測坐標軸方向矩形是否覆蓋這個傾斜矩形?
與坐標軸方向圖片矩形一樣
它現在看起來沒那么難了!只需要知道裁剪框的四個角是不是都在圖片框中。
mCropRect 變量已經定義過了。所以呢,唯一需要的是圖片四個頂點的數組。
前面提到過 setImageMatrix(Matrix matrix) 方法。調用的 updateCurrentImagePoints() 方法,是利用矩陣的 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));
}
核心部分,我分別使用一個臨時矩陣對象來表示未轉動的裁剪框和圖片頂點集,然后通過 RectF 類的 contains(RectF rect) 方法檢查裁剪框的位置是否完全在圖片中。還挺好使。
變換圖片以便它可以覆蓋裁剪框
首先,找到當前圖片中心與裁剪框中心的距離。然后通過一個臨時矩陣和變量轉變圖片至裁剪框中心,判斷它是否充滿整個裁剪框:
float 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;
}
首先,旋轉裁剪框的矩形并將它映射到一個臨時變量中,然后我在 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 任務來使用它們。
跳到 run() 方法,如下:
@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);
}
}
}
這里計算執行的當前時間,使用 CubicEasing 類,給平移(x,y)和縮放設置了插值。設置插值是優化動畫很好的方法。讓我們的眼睛看起來更自然。
最后,這些值會應用于圖片的矩陣上。只要滿足 context 為空,時間結束或圖片已充滿裁剪框任意一個條件,Runnable 就結束。
裁剪圖片
終于來到了需要裁剪圖片這里(吃驚!)。這可是至關重要的功能,不能裁剪圖片,這個庫就沒什么ruan用了。
開始獲取下列計算中需要的當前值:
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 方法,并且回收原來的位圖,將壓縮應用于 currentScale 值上,以便進一步的計算不會受到影響。
現在,是檢查圖片是否旋轉的時候了:
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;
}
同樣在這里:如果 currentAngle 不等于 0,就使用 Bitmap.createBitmap 方法來轉動當前的位圖,然后回收它(沒人喜歡 OutOfMemoryException)。
最后,計算了圖片上必須裁剪區域矩形的坐標:
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);
這里真沒什么復雜的。只考慮了 currentScale 的值,然后調用了 Bitmap.createBitmap 方法。由于上述的方法,生成的位圖必須正確的旋轉和縮放。
GestureImageView
在 TransformImageView 中添加了圖片的移動,旋轉和縮放方法后,緊接著就創建了這一層,因為它對于測試、調試、UX 調整以及盡早獲得反饋至關重要。當然,隨著這個庫開發的腳步,手勢邏輯和支持的手勢也在改變。
讓我們再來看一下我需要支持什么手勢:
1. 縮放手勢
圖片必須響應幾個能夠改變縮放級別的手勢:
- 雙擊放大
- 兩根手指的捏伸
2. 滾動(平面)手勢
用戶可以通過手指拖動來滾動(平面)圖片。
3. 旋轉手勢
用戶可以用兩根手指在圖片上旋轉來轉動圖像。
此外,所有這些手勢必須能夠同時工作,并且必須對于用戶手指之間的焦點應用所有的圖片轉換。這樣給你的感覺就像是真的在設備的屏幕上拖動圖片一樣。
幸運的是,Android SDK 為我們開發者提供了兩個方便的類:GestureDetector 和 ScaleGestureDetector. 兩個類都有很多接口,這里只關注 onScroll,onScale 和 onDoubleTap 的回調。簡而言之,已經有除了旋轉檢測以外所有的解決方案。很不幸,在 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());
}
你也許注意到尚未定義的 mMidPntX 和 mMidPntY 變量和 getDoubleTapTargetScale() 方法。實際上, mMidPntX 和 mMidPntY 是設備屏幕上的兩個手指之間的點的坐標,它幫助圖像矩陣正確的應用圖片變換。 getDoubleTapTargetScale() 方法根據 mDoubleTapScaleSteps 變量計算縮放值。
protected float getDoubleTapTargetScale() {
return getCurrentScale() * (float) Math.pow(getMaxScale() / getMinScale(), 1.0f / mDoubleTapScaleSteps);
}
例如,默認的 mDoubleTapScaleSteps 的值是 5,因此用戶能夠通過 5 次雙擊將圖片從最小縮放到最大。
但是,所有這些手勢的監聽者都是靜默的,直到你觸發一些觸摸事件。這部可以說是錦上添花:
@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 的值。最后,向每個手勢檢測者傳遞了觸摸事件。
就這些了!幾個接口和覆寫 onTouchEvent 方法就是需要添加到自定義視圖上手勢檢測的所有東西了。
UCropActivity
當這個庫差不多完成的時候,我拜托我們的設計師為這個 Activity 做一個 UI 設計。
最后,拿到了這組漂亮的設計圖來實現:
還有一些事,必須重頭開始做:
- 用于水平滾動的自定義控件
- 用于比例切換的選擇器控件
這里就不貼代碼了,這些都是很簡單的自定義視圖,你可以在 GitHub 上拿到這里的代碼。
最終的效果:
除了小控件外,這個 Activity 里所有的數據都是從 UCrop 類拿到的,是使用構建者模式設計的,并分別設置了裁剪視圖。
UCrop Builder
這部分,我不想重造輪子,參考了 SoundCloud 裁剪庫 中 Builder 實現的例子,擴展并修改。
如果你想裁剪一個正方形的用戶頭像,假設圖片最大為 480px,可以這么做:
UCrop.of(sourceUri, destinationUri).withAspectRatio(1, 1).withMaxResultSize(480, 480).start(context);
結束語
開發這個庫最大的挑戰之一是實現穩定的性能和流暢的界面。最初我在三角函數計算上折磨我的大腦,直到突然意識到,只要通過矩陣就可以解決整套問題。
我真的特別喜歡最終整體的效果,但仍然并不完美,也沒有什么是完美的。我們一定會在 Yalantis 的項目中使用 uCrop 庫。就是說,它也一定會有新的版本。我們已經計劃了下一版的幾個更新點了,也許更多。為什么我們不結合幾個庫來選擇、編輯以及應用圖片效果?鬼知道,也許我們會呢?