uCrop 的創建過程

Stu30J 7年前發布 | 5K 次閱讀 安卓開發 Android開發 移動開發

在 上篇文章 中,向你介紹了我們最新的 Android 圖片裁剪庫 ,它的裁剪體驗比現有的任何一個方案都要好。也許你已經見過這個庫:發布后不久,uCrop 在 GitHub 上獲得了很多關注。并在 GitHub 的 trending repositories 列表中取得領先的地位。

如果你喜歡,可以 在 Product Hunt 上為 uCrop 投票 。現在讓我們開始深入研究開發 uCrop 的一些技術細節。讀完這篇文章后,希望 Android 上的圖片裁剪在你眼里能變得更容易些。

uCrop 的挑戰

開始這個項目時,我定義了一組相當簡單的特性:

  • 裁剪圖片
  • 支持任意長寬比
  • 使用手勢縮放、移動和旋轉
  • 防止裁剪區內的圖片上留下空白的部分
  • 創建一個隨時可用的裁剪 Activity,并且它可以使用它內部的裁剪視圖。換句話說,這個庫包含一個 Activity,里面包含了一個裁剪視圖和一些附加組件。

裁剪的視圖

計劃構建這組特性,決定將邏輯視圖分為三層。

  1. TransformImageView extends ImageView .

    必須可以:

    1. 從源設置圖片
    2. 在當前圖片上應用變換(位移、縮放和旋轉)矩陣
  2. CropImageView extends TransformImageView .

    包括:

    1. 繪制裁剪框和網格
    2. 給裁剪區設置圖片(如果用戶放大或是旋轉圖片導致在裁剪框內出現空白區域,圖片將會自動移動或/且縮放回來,來適應至裁剪框沒有空白區域)
    3. 更具體規則的變換矩陣的擴展方法(如限制最大和最小的縮放等..)
    4. 添加進出縮放動畫的方法(動畫變換)
    5. 裁剪圖片

    這一層差不多有我們想要去變換和裁剪圖片的所有事情。但它僅僅只是指定方法來做這里所有的事情,我們還要支持手勢呢。

  3. GestureImageView extends CropImageView .

    這層的功能是:

    1. 監聽用戶的手勢,調用相應的方法。

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. 隨意讓用戶移動圖片,但是當圖片被釋放后自動修復它的位置和尺寸。

第一種操作的用戶體驗很糟糕,所以我選擇了第二個。

這樣呢,我必須解決兩個問題: (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());
}

你也許注意到尚未定義的 mMidPntXmMidPntY 變量和 getDoubleTapTargetScale() 方法。實際上, mMidPntXmMidPntY 是設備屏幕上的兩個手指之間的點的坐標,它幫助圖像矩陣正確的應用圖片變換。 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 庫。就是說,它也一定會有新的版本。我們已經計劃了下一版的幾個更新點了,也許更多。為什么我們不結合幾個庫來選擇、編輯以及應用圖片效果?鬼知道,也許我們會呢?

 

 

 本文由用戶 Stu30J 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!