自 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 背景著色也有兩個屬性:backgroundTint 和 backgroundTintMode,用法相同,只是作用於 android:background 屬性。需要注意的是,這兩個屬性也只是作用於 API 21 及更高版本。
這裡我們在使用預設 tintMode 的情況下,演示一下圖示著色和背景著色的前後對比情況:
原圖:不做任何處理的 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 的場景:
這種設計當然大大節省記憶體,但也存在一個弊端。就是,當 constant state 屬性發生變化時,所有使用相同資源的關聯 drawable 都會隨之改變,比如前面所說的這種現象。
而 mutate() 方法的出現就是為了解決這種問題的。你可以理解為 mutate() 方法就是複製一份 constant state,允許你隨意改變屬性,同時不對其他 drawable 有任何影響。如圖:
這種設計在早期的官方文件上也有介紹,參考 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);複製程式碼
效果都是一樣的,如圖:
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亦楓
微信掃描二維碼,歡迎關注我的個人公眾號:安卓筆記俠
不僅分享我的原創技術文章,還有程式設計師的職場遐想