從使用到原始碼,細說 Android 中的 tint 著色器

亦楓發表於2017-04-03

自 API 21 (Android L)開始,Android SDK 引入 tint 著色器,可以隨意改變安卓專案中圖示或者 View 背景的顏色,一定程度上可以減少同一個樣式不同顏色圖示的數量,從而起到 Apk 瘦身的作用。不過使用 tint 存在一定的相容性問題,且聽本文慢慢說來。

xml 中的 tint 和 tintMode 屬性


  • android:tint:給圖示著色的屬性,值為所要著色的顏色值,沒有版本限制;通常用於給透明通道的 png 圖示或者點九圖著色。

  • android:tintMode:圖示著色模式,值為列舉型別,共有 六種可選值(add、multiply、screen、src_over、src_in、src_atop),僅可用於 API 21 及更高版本。

對應於給圖片著色的這兩個屬性,給 View 背景著色也有兩個屬性:backgroundTintbackgroundTintMode,用法相同,只是作用於 android:background 屬性。需要注意的是,這兩個屬性也只是作用於 API 21 及更高版本。

這裡我們在使用預設 tintMode 的情況下,演示一下圖示著色和背景著色的前後對比情況:

從使用到原始碼,細說 Android 中的 tint 著色器

原圖:不做任何處理的 ImageButton,程式碼如下:

<ImageButton
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:src="@mipmap/ic_home"
    android:background="@android:color/transparent"/>複製程式碼

圖示著色:使用 android:tint 屬性對 src 屬性指向的圖示著色處理,程式碼如下:

<ImageButton
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:tint="@android:color/black"
    android:src="@mipmap/ic_home"
    android:background="@android:color/transparent"/>複製程式碼

背景著色:使用 backgroundTint 屬性對 background 屬性賦予的背景色著色處理,程式碼如下(這裡只是為了演示,實際上直接改變 background 背景色即可):

<ImageButton
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:backgroundTint="@android:color/black"
    android:src="@mipmap/ic_home"
    android:background="@android:color/white"/>複製程式碼

這裡 android:background 屬性值使用的是顏色值,如果是圖片的話,一樣可以著色處理。並且,背景使用圖片時著色的需求更現實一些。

注意:tint 或 backgroundTint 屬性,與 src 或 background 屬性一定是對應成對出現的。這個不難理解,要有處理源嘛。

java 程式碼中的 DrawableCompat


通過 xml 中的屬性或者對應的 Java 程式碼中的 API 方法可以改變 View 所用到的圖片顏色,但是存在一定的相容性問題。好在有相應的相容性 API 可以適配 6.0 之前的系統,也就是 DrawableCompat 類。直接看程式碼吧:

Drawable originalDrawable = ContextCompat.getDrawable(this, R.mipmap.ic_home);
Drawable tintDrawable = DrawableCompat.wrap(originalDrawable).mutate();
DrawableCompat.setTint(tintDrawable, Color.parseColor("#000000"));
mSamplesIv.setImageDrawable(tintDrawable);複製程式碼

可以看出,DrawableCompat 通過 setTint() 方法對 drawable 物件著色處理。值得注意的是,這裡有兩個特殊的方法需要特別說明一下:

DrawableCompat.wrap()

為了在不同的系統 API 上使用 DrawableCompat.setTint() 做圖示的著色處理,必須使用這個方法處理現有的 drawable 物件。並且,要將處理結果重新通過 setImageDrawable() 或者 setBackground() 賦值給 View 才能見效;

drawable.mutate()

我們先來看一個有趣的現象:如果我們有兩個 ImageView 使用相同一個圖片資源作為 src 或者 background 的屬性值,然後在 Java 程式碼中通過 DrawableCompat 類對其中一個做著色處理,就像上面所寫的程式碼這樣,執行後你會發現,只有當前被賦值的 ImageView 顯示的是被著色處理後的圖片;但是去掉 mutate() 方法時,再次執行,兩個 ImageView 都顯示的是被著色處理後的圖片!事實上,不僅是兩個,應用中所有使用到該圖片資源的地方,都會顯示成被著色處理過的樣式。

這就是 mutate() 存在的必要性。要說到這個方法,就大有講頭啦。在此之前,我們必須先了解一下 constant state 這個概念。

Android 系統為了減少記憶體消耗,將應用中所用到的相同 drawable (可以理解為相同資源)共享同一個 state,並稱之為 constant state。這裡用圖表演示一下,兩個 View 載入同一個圖片資源,建立兩個 drawables 物件,但是共享同一個 constant state 的場景:

從使用到原始碼,細說 Android 中的 tint 著色器

這種設計當然大大節省記憶體,但也存在一個弊端。就是,當 constant state 屬性發生變化時,所有使用相同資源的關聯 drawable 都會隨之改變,比如前面所說的這種現象。

而 mutate() 方法的出現就是為了解決這種問題的。你可以理解為 mutate() 方法就是複製一份 constant state,允許你隨意改變屬性,同時不對其他 drawable 有任何影響。如圖:

從使用到原始碼,細說 Android 中的 tint 著色器

這種設計在早期的官方文件上也有介紹,參考 drawable-mutations

再回到本文主題,可見,drawable 的著色處理必然要使用到 wrap() 和 mutate() 兩個方法,也就順理成章啦。

注意:為了起到相容所有 API 的作用,著色處理時,建議同時使用 wrap() 和 mutate() 方法。可能,你在實際測試時,某些級別的系統 API 中,不會存在這種問題。

上面我們使用 setTint() 方法直接改變 drawable 的顏色,但是有時候,我們會給 Drawable 新增各種選擇狀態,比如點選時的 state_pressed 狀態。DrawableCompat 類也提供有 setTintList() 方法,需要用到 ColorStateList。

舉個例子,在 res/color 資源目錄下定義一個 selector_home.xml 檔案:

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">

    <item android:state_pressed="true" android:color="@android:color/black"/>
    <item android:color="@android:color/white"/>

</selector>複製程式碼

在程式碼中通過 ContextCompat.getColorStateList 獲取資源中的 ColorStateList 物件,並使用 DrawableCompat.setTintList() 方法著色處理即可:

Drawable originalDrawable = ContextCompat.getDrawable(this, R.mipmap.ic_home);
Drawable tintDrawable = DrawableCompat.wrap(originalDrawable).mutate();
DrawableCompat.setTintList(tintDrawable, ContextCompat.getColorStateList(this, R.color.selector_home));
mSamplesIv.setImageDrawable(tintDrawable);複製程式碼

當然你也可以直接在程式碼中手動建立一個 ColorStateList 物件:

int[] colors = new int[] { ContextCompat.getColor(this, android.R.color.black), ContextCompat.getColor(this, android.R.color.white)};
int[][] states = new int[2][];
states[0] = new int[] { android.R.attr.state_pressed};
states[1] = new int[] {};
ColorStateList colorStateList = new ColorStateList(states, colors);複製程式碼

效果都是一樣的,如圖:

從使用到原始碼,細說 Android 中的 tint 著色器

Tint 著色器原理


前面講到,使用 DrawableCompat 可以起到版本相容效果。實際上,還有一種辦法,就是使用 android.support.v7.widget 相容包中的 AppCompatXXX 控制元件,比如 AppCompatImageView。這種控制元件提供有如下方法可用於著色處理:

  • setSupportBackgroundTintList(@Nullable ColorStateList tint)
  • setSupportBackgroundTintMode(@Nullable PorterDuff.Mode tintMode)

其實,不管是 DrawableCompat 還是 AppCompatXXX 控制元件,底層實現原理都是一樣的。我們隨便找一個看一下,就拿 AppCompatImageView 來看。看下 setSupportBackgroundTintList() 原始碼:

@Override
public void setSupportBackgroundTintList(@Nullable ColorStateList tint) {
    if (mBackgroundTintHelper != null) {
        mBackgroundTintHelper.setSupportBackgroundTintList(tint);
    }
}複製程式碼

呼叫 AppCompatBackgroundHelper 類的 setSupportBackgroundTintList 方法,繼續深入原始碼:

void setSupportBackgroundTintList(ColorStateList tint) {
    if (mBackgroundTint == null) {
        mBackgroundTint = new TintInfo();
    }
    mBackgroundTint.mTintList = tint;
    mBackgroundTint.mHasTintList = true;
    applySupportBackgroundTint();
}複製程式碼

繼續深入 applySupportBackgroundTint() 方法的原始碼:

void applySupportBackgroundTint() {
    final Drawable background = mView.getBackground();
    if (background != null) {
        if (shouldApplyFrameworkTintUsingColorFilter()
                && applyFrameworkTintUsingColorFilter(background)) {
            // This needs to be called before the internal tints below so it takes
            // effect on any widgets using the compat tint on API 21 (EditText)
            return;
        }

        if (mBackgroundTint != null) {
            AppCompatDrawableManager.tintDrawable(background, mBackgroundTint,
                    mView.getDrawableState());
        } else if (mInternalBackgroundTint != null) {
            AppCompatDrawableManager.tintDrawable(background, mInternalBackgroundTint,
                    mView.getDrawableState());
        }
    }
}複製程式碼

該方法的重心在於 AppCompatDrawableManager.tintDrawable() 方法,繼續深入:

static void tintDrawable(Drawable drawable, TintInfo tint, int[] state) {
    if (DrawableUtils.canSafelyMutateDrawable(drawable)
            && drawable.mutate() != drawable) {
        Log.d(TAG, "Mutated drawable is not the same instance as the input.");
        return;
    }

    if (tint.mHasTintList || tint.mHasTintMode) {
        drawable.setColorFilter(createTintFilter(
                tint.mHasTintList ? tint.mTintList : null,
                tint.mHasTintMode ? tint.mTintMode : DEFAULT_MODE,
                state));
    } else {
        drawable.clearColorFilter();
    }

    if (Build.VERSION.SDK_INT <= 23) {
        // Pre-v23 there is no guarantee that a state change will invoke an invalidation,
        // so we force it ourselves
        drawable.invalidateSelf();
    }
}複製程式碼

找到這裡,已經能看出一些端倪。原來是使用 drawable.setColorFilter() 進行顏色渲染處理的。並且通過 createTintFilter() 方法建立顏色過濾器:

private static PorterDuffColorFilter createTintFilter(ColorStateList tint,
        PorterDuff.Mode tintMode, final int[] state) {
    if (tint == null || tintMode == null) {
        return null;
    }
    final int color = tint.getColorForState(state, Color.TRANSPARENT);
    return getPorterDuffColorFilter(color, tintMode);
}

public static PorterDuffColorFilter getPorterDuffColorFilter(int color, PorterDuff.Mode mode) {
    // First, lets see if the cache already contains the color filter
    PorterDuffColorFilter filter = COLOR_FILTER_CACHE.get(color, mode);

    if (filter == null) {
        // Cache miss, so create a color filter and add it to the cache
        filter = new PorterDuffColorFilter(color, mode);
        COLOR_FILTER_CACHE.put(color, mode, filter);
    }

    return filter;
}複製程式碼

PorterDuffColorFilter 類!這就是我們要找的目標。PorterDuffColorFilter 可以獲取 drawable 中的畫素點,並使用相應的顏色過濾器予以處理。

知道原理之後,不妨試想一下,僅僅這樣一句程式碼,是不是也能幫助我們實現著色處理呢:

mSamplesIv.setColorFilter(new PorterDuffColorFilter(ContextCompat.getColor(this, android.R.color.black), PorterDuff.Mode.SRC_IN));複製程式碼

或者自定義 View 時也能將 AppCompatXXX 控制元件的相關原始碼複製過來,實現著色器功能。

當然,如果再去翻看 DrawableCompat 原始碼,雖然尋找路徑不同,但最終還是會走到 drawable.setColorFilter() 方法。並且從 DrawableCompat 原始碼中,你還能看到為什麼 wrap() 方法能夠相容處理不同系統 API 的原因。這裡就不細細展示啦,感興趣的朋友可以自己閱讀原始碼。

這就是 Android SDK 中的 tint 著色器相關知識。事實上,我們也經常用到這個東西。舉個最常見的例子,為什麼不同主題下 EditText 背景的底部顏色條會不一樣呢?其實,這也是一張點九圖,只是不同主題下使用不同顏色的著色器處理過而已。

關於我:亦楓,部落格地址:yifeng.studio/,新浪微博:IT亦楓

微信掃描二維碼,歡迎關注我的個人公眾號:安卓筆記俠

不僅分享我的原創技術文章,還有程式設計師的職場遐想

從使用到原始碼,細說 Android 中的 tint 著色器

相關文章