Fresco 圖片圓角實現原理及 Android 中圖片圓角實現方法

上篇文章 介紹了 Fresco 基礎使用和實現圖片圓角的方法,可以通過兩種方式來實現圓角:BITMAP_ONLY 模式和 OVERLAY_COLOR 模式。本文通過分析 Fresco 源碼來介紹這兩種方式實現圓角的原理,並總結 Android 中常用的實現圖片圓角的方法。

本文重點分析 Fresco 中實現圖片圓角的源碼,其他部分的源碼,將在後續文章中介紹。

Fresco 中圓角實現原理

在 com.facebook.drawee.drawable 包中有如下文件

  • Rounded.java
  • RoundedBitmapDrawable.java
  • RoundedColorDrawable.java
  • RoundedCornersDrawable.java

其中 Rounded 是圓角實現類的接口,定義了圓角類實現的方法:

public interface Rounded {

  void setCircle(boolean isCircle);

  void setRadius(float radius);

  void setRadii(float[] radii);

  void setBorder(int color, float width);
}

其他三個類實現了 Rounded 接口,來實現兩種不同模式的圓角,RoundedCornersDrawable.java 用於實現 OVERLAY_COLOR 模式的圓角,而 RoundedBitmapDrawable.java 和 RoundedCorlorDrawable.java 都是用於實現 BITMAP_ONLY 模式的圓角,兩者的區別在於傳入的資源類型不同,前者是對 BitmapDrawable 進行圓角處理,而後者是對 ColorDrawable 進行處理。

瞭解了源碼中實現圖片圓角的結構,下面開始進入到具體的代碼中瞭解具體的實現過程

BITMAP_ONLY 模式

作爲默認的實現模式,首先來了解下這種模式的實現過程

進入到 RoundedBitmapDrawable.java 中,首先看它的繪製過程,找到 draw() 方法:

@Override
  public void draw(Canvas canvas) {
    updateTransform();//更新圖片變換矩陣
    updateNonzero();//更新 0 值,判斷有沒有 設置圓形,圓角,邊框等屬性
    if (!mIsNonzero) {//如果沒有設置以上屬性,則 mIsNonzero 返回 false,直接調用父類的繪製
      super.draw(canvas);
      return;
    }
    updatePath();//更新 Path
    updatePaint();//更新畫筆
    int saveCount = canvas.save();//保存畫布狀態
    canvas.concat(mInverseTransform);//設置變換矩陣
    canvas.drawPath(mPath, mPaint);//繪製 Path
    if (mBorderWidth != 0) {//繪製邊框
      mBorderPaint.setStrokeWidth(mBorderWidth);
      mBorderPaint.setColor(DrawableUtils.multiplyColorAlpha(mBorderColor, mPaint.getAlpha()));
      canvas.drawPath(mPath, mBorderPaint);
    }
    canvas.restoreToCount(saveCount);//合併圖像
  }

從 draw() 方法可以瞭解到圓角圖片的繪製過程:

  • 更新變換矩陣,用於圖片大小縮放適配
  • 判斷有沒有設置屬性,如果沒有則直接繪製,如果有則進行下一步
  • 更新 Path,根據屬性確定繪製的形狀
  • 更新 Paint,將圖片資源填充到畫筆
  • 繪製圖片,繪製邊框

本文的重點是瞭解圓角的實現過程,所以接下來就進入到 updatePath() 和 updatePaint() 中看看 Path 和 Paint 是怎樣實現圓角的

 private void updatePath() {
    if (mIsPathDirty) {//大概是說如果有對圖片進行設置
      mPath.reset();//重置 Path
      mRootBounds.inset(mBorderWidth/2, mBorderWidth/2);//矩形向內縮進半個邊框寬度,避免邊框遮擋圖片
      if (mIsCircle) {//如果設置爲圓形圖片,則 Path 設置爲圓形,否則就設置爲矩形
        mPath.addCircle(
            mRootBounds.centerX(),
            mRootBounds.centerY(),
            Math.min(mRootBounds.width(), mRootBounds.height())/2,
            Path.Direction.CW);
      } else {
        mPath.addRoundRect(mRootBounds, mCornerRadii, Path.Direction.CW);
      }
      mRootBounds.inset(-(mBorderWidth/2), -(mBorderWidth/2));//Path 設置完成,恢復矩形
      mPath.setFillType(Path.FillType.WINDING);
      mIsPathDirty = false;
    }
  }

從上面的代碼中可以大致瞭解到其主要是根據屬性值 (mIsCircle) 來配置 Path,主要使用到 Path 的兩個方法:addCircle() 和 addRoundRect(),這兩個方法分別實現繪製圓形和繪製矩形,其參數描述如下:

  • addCircle(float x,float y,float radius,Direction dir)
    • 圓心 x 座標
    • 圓心 y 座標
    • 圓的半徑
    • 繪製圓的方向,值 Direction.CCW 爲逆時針方向,值 Directin.CW 爲順時針方向
  • addRoundRect(RectF rect,float[] radii,Direction dir)
    • 外接矩形
    • 圓角半徑數組,共 8 個值,每兩個爲一對,順序爲:左上 -> 右上 -> 右下 -> 左下
    • 繪製圓的方向,同上

以上,便完成了 Path 的更新,接下來是對 Paint 的更新

private void updatePaint() {
    Bitmap bitmap = getBitmap();//獲取需要繪製的 Bitmap
    if (mLastBitmap == null || mLastBitmap.get() != bitmap) {//防止重複引用
      mLastBitmap = new WeakReference<Bitmap>(bitmap);//新建一個弱引用 Bitmap 對象
      mPaint.setShader(new BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP));//設置 Shader
      mIsShaderTransformDirty = true;
    }
    if (mIsShaderTransformDirty) {//設置變換矩陣
      mPaint.getShader().setLocalMatrix(mTransform);
      mIsShaderTransformDirty = false;
    }
  }

從這裏大概清楚了這種方式是通過 BitmapShader 方式來實現圖片圓角的。於是這裏產生了一個疑問:既然可以直接使用 BitmapShader 來實現圓角,那如果接下來再直接使用 canvas 的 drawCircle() 和 drawRoundRect()也能實現圓形圖片和圓角圖片,爲什麼還要多此一舉的使用 Path 來繪製圓形和圓角矩形呢? 我對此的理解是:canvas 的drawRoundRect() 沒有辦法實現四個不同大小的圓角,而通過 Path 的 addRoundRect() 方法是能夠實現不同圓角的圖片,使用Path是爲了滿足這個需求。至於爲什麼不能在 xml 佈局文件中設置不同大小的圓角而只能在代碼中設置這個問題,依然不明白,期待在後續的分析中能夠解決這個問題。

以上,是實現圖片圓角過程中主要的步驟,至於後面的繪製邊框,沒太大的難度,這裏就不再敘述。

OVERLAY_COLOR 模式

這種模式的實現在 RoundedCornersDrawable.java 文件中,可以看到,其實這個類中還存在着另外一種模式:CLIPPING 模式,從官方文檔中可以瞭解到不使用這個模式的原因,這裏就不再敘述。直接看使用 OVERLAY_COLOR 模式的代碼實現,同樣的,首先找到繪製過程 draw() 方法:

 @Override
  public void draw(Canvas canvas) {
    Rect bounds = getBounds();
    switch (mType) {
      case CLIPPING://暫不支持這種方式,跳過
        // clip, note: doesn't support anti-aliasing
        int saveCount = canvas.save();
        mPath.setFillType(Path.FillType.EVEN_ODD);
        canvas.clipPath(mPath);
        super.draw(canvas);
        canvas.restoreToCount(saveCount);
        break;
      case OVERLAY_COLOR:
        super.draw(canvas);//首先讓父類繪製圖像
        mPaint.setColor(mOverlayColor);//設置畫筆顏色
        mPaint.setStyle(Paint.Style.FILL);//設置畫筆樣式爲填充
        mPath.setFillType(Path.FillType.INVERSE_EVEN_ODD);//設置 Path 的填充模式
        canvas.drawPath(mPath, mPaint);//畫遮蓋圖層

        if (mIsCircle) {//如果是圓形,則用 Canvas 畫一個圓形
          // INVERSE_EVEN_ODD will only draw inverse circle within its bounding box, so we need to
          // fill the rest manually if the bounds are not square.
          float paddingH = (bounds.width() - bounds.height() + mBorderWidth) / 2f;
          float paddingV = (bounds.height() - bounds.width() + mBorderWidth) / 2f;
          if (paddingH > 0) {
            canvas.drawRect(bounds.left, bounds.top, bounds.left + paddingH, bounds.bottom, mPaint);
            canvas.drawRect(
                bounds.right - paddingH,
                bounds.top,
                bounds.right,
                bounds.bottom,
                mPaint);
          }
          if (paddingV > 0) {
            canvas.drawRect(bounds.left, bounds.top, bounds.right, bounds.top + paddingV, mPaint);
            canvas.drawRect(
                bounds.left,
                bounds.bottom - paddingV,
                bounds.right,
                bounds.bottom,
                mPaint);
          }
        }
        break;
    }

    if (mBorderColor != Color.TRANSPARENT) {//畫邊框
      mPaint.setStyle(Paint.Style.STROKE);
      mPaint.setColor(mBorderColor);
      mPaint.setStrokeWidth(mBorderWidth);
      mPath.setFillType(Path.FillType.EVEN_ODD);
      canvas.drawPath(mPath, mPaint);
    }
  }

從上面的代碼中可以瞭解到這種方式的實現原理大致如下:

  • 每次設置屬性的時候都更新一下 Path ,也就是根據屬性決定 Path 是畫圓形還是圓角矩形
  • 將圖片按照正常的方式先畫出來(調用super.draw())
  • 對Path設置填充模式爲 INVERSE_EVEN_ODD,取 Path 未佔用的區域(佔用的是圓形或者圓角矩形區域)
  • 畫出 Path,這樣未佔用的區域就是指定的背景色了,佔用區域就是圓角圖片
  • 判斷如果是圓形,則再畫一個圓形,按照上述填充規則
  • 最後如果有邊框就畫邊框,同樣的規則

下面通過一幅示意圖簡單描述一下這種方式的實現原理:
實現原理

繪製過程大致是這樣,我認爲只要理解了 Path 的填充模式,這個原理就很好理解的,關於 Path 的填充模式,這篇文章中有詳細介紹,可以參考一下。

以上,通過源碼分析的方式理解了 Fresco 中圓角矩形的實現原理,在分析這些代碼的時候我查閱了一些資料,包括 Drawable 類,Path 類,Paint 類等一些類的使用方法,然後結合源碼,跟着思路慢慢得出結果,並實現了一個簡單的自定義 View 來驗證結果。效果如下,代碼在 GitHub

運行效果

Android 中實現圓角的方案

在 Android 中有很多方法能夠實現圓角矩形。根據現實生活中的經驗,要對一張圖片實現圓角,無非兩種方式,一種是剪出圓角,另一種是遮住圓角。因此,可以簡單的將實現圓角的方案分成兩類:

  • 剪裁:從原始圖片中剪出一個圓角圖片
  • 覆蓋:在原始圖片上遮蓋住圓角多餘的部分,剩下的可見部分就是圓角矩形了

基於這兩類方法,我大概總結了一些可以實現圓角矩形的方法,如下

方案一:將原始圖片中截取的圓角矩形圖片放在一個新建的Bitmap中

這種方式大致是 剪裁 類的方式,主要代碼如下:

public static Bitmap toRoundCorner(Bitmap bitmap, int pixels) {
    Bitmap output = Bitmap.createBitmap(bitmap.getWidth(),
            bitmap.getHeight(), Config.ARGB_8888);
    Canvas canvas = new Canvas(output);
    final int color = 0xff424242;
    final Paint paint = new Paint();
    final Rect rect = new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight());
    final RectF rectF = new RectF(rect);
    final float roundPx = pixels;
    paint.setAntiAlias(true);
    canvas.drawARGB(0, 0, 0, 0);
    paint.setColor(color);
    canvas.drawRoundRect(rectF, roundPx, roundPx, paint);
    paint.setXfermode(new PorterDuffXfermode(Mode.SRC_IN));
    canvas.drawBitmap(bitmap, rect, rect, paint);
    return output;
}

可以看到,這個方法中新建了一個指定寬高的 Bitmap 對象,然後創建了一個相同大小的矩形,利用畫布繪製時指定圓角大小,這樣在畫布上就有了一個圓角矩形,然後設置畫筆的剪裁方式爲 Mode.SRC_IN,將原圖添加到畫布上,就形成了一個圓角矩形的圖片。不推薦使用這種方式來實現圖片圓角,因爲這種方式會對每一個要實現圓角的圖片生成一個新的 Bitmap 對象,將會增加內存消耗,在需要加載大量圖片的時候就會很可能引發內存泄漏。

方案二:通過 Xfermode 實現

這種方式是一種 覆蓋 類的方式,關於 Xfremode 有一張比較經典的示意圖可以很好的解釋他是做什麼的
xFremode
使用 Xfremode 的具體實現就不多敘述,感興趣的可以參考這篇文章

方案三:通過對 ViewGroup 進行設置,使包裹在內部的圖片呈現圓角矩形

這種方式依然是一種 覆蓋 的方式,只不過不是對當前要顯示的圖片進行覆蓋,而是上升到父容器,對父容器進行設置,內部的圖片不做任何改變。依然不推薦這種方式,因爲通過這種方式實現圓角圖片會增加布局的層級,在Android性能優化中有提到過儘量減少佈局的層次嵌套,因此這種方式僅作參考,下面是實現代碼:

public class RoundRelativeLayout extends RelativeLayout {

    private float radius;
    private boolean isPathValid;
    private Path mPath = new Path();

    public RoundRelativeLayout(Context context) {
        super(context);
    }

    public RoundRelativeLayout(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public RoundRelativeLayout(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.RoundRelativeLayout);
        radius = ta.getDimension(R.styleable.RoundRelativeLayout_radius, 0);
        ta.recycle();
    }


    @Override
    protected void dispatchDraw(Canvas canvas) {
        canvas.clipPath(getRoundRectPath());
        super.dispatchDraw(canvas);
    }

    @Override
    public void draw(Canvas canvas) {
        canvas.clipPath(getRoundRectPath());
        super.draw(canvas);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int oldWidth = getMeasuredWidth();
        int oldHeight = getMeasuredHeight();
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int newWidth = getMeasuredWidth();
        int newHeight = getMeasuredHeight();
        if (newWidth != oldWidth || newHeight != oldHeight) {
            isPathValid = false;
        }
    }

    private Path getRoundRectPath() {
        if (isPathValid) {
            return mPath;
        }

        mPath.reset();

        int width = getWidth();
        int height = getHeight();

        RectF bounds = new RectF(0, 0, width, height);
        mPath.addRoundRect(bounds, radius, radius, Path.Direction.CW);

        isPathValid = true;
        return mPath;
    }
}

在使用時,只需要將要實現圓角的圖片放在這個自定義Layout內部就行了:

 <com.tc.view.RoundRelativeLayout
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:radius="20dp">
            <ImageView
                android:layout_width="80dp"
                android:layout_height="80dp"
                android:src="@mipmap/img_test"/>
        </com.tc.view.RoundRelativeLayout>

方案四:使用形狀 Shape 覆蓋

上層是一個圓角矩形的圓環形狀,覆蓋在下層要顯示的 ImageView 上,這是一種 覆蓋 的方式。作爲了解,這種方式也不推薦,因爲要使用兩個ImageView來顯示一張圖片,上層圓角矩形圓環形狀需要先定製,包括圓角大小,顏色等屬性,導致可定製性不強,使用不方便,僅作參考。實現代碼如下:
首先在 /res/drawable 目錄下新建形狀 frame.xml :

<?xml version="1.0" encoding="utf-8"?>
    <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
         <solid android:color="#00ffffff" />
         <corners android:radius="12dp" />
         <stroke android:width="6dp" android:color="#ffffffff" />
    </shape>

接着在使用時將兩個 ImageView 放在同一個 ViewGroup 中即可:

  <FrameLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content">

        <ImageView
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:padding="6dp"
            android:src="@mipmap/img_test"/>

        <ImageView
             android:src="@drawable/frame"
             android:layout_width="match_parent"
             android:layout_height="match_parent" />

    </FrameLayout>

方案五:使用 Android 自帶的剪切方法

這種方式僅支持 API 21 及以上版本,具體使用方法如下:
首先在 res/drawable/ 目錄下創建 形狀文件 round_corners.xml:

<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <corners android:radius="10dp" />
</shape>

接着在使用時,只需要對 View 設置 background 屬性爲這個 形狀文件即可:

 <ImageView
            android:id="@+id/clip_img"
            android:layout_width="80dp"
            android:layout_height="80dp"
            android:src="@mipmap/img_test"
            android:background="@drawable/round_corners"/>

接着在 java 代碼中 findView 並設置 ImageView.setClipToOutline(true);

 ImageView clipImg= (ImageView) findViewById(R.id.clip_img);
 clipImg.setClipToOutline(true);

方案六: .9.png 實現

通過添加一個.9.png 的背景來實現圖片的圓角,關於.9.png 圖的製作,可以參考網上的一些資料教程,難度不大,這裏只做介紹,感興趣的朋友可以嘗試一下。

以上,介紹了 Fresco 中實現圓角圖片的兩種方式,並總結了一些 Android 中實現圓角圖片的方案,文章中用到的代碼放在 GitHub 上。水平有限,如有錯誤或疏漏,還望不吝賜教。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章