Android 多主題切換及 selector/drawable 沒法引用 ?attr 屬性的問題

需求:

最近須要實現應用內多主題的需求: 要求應用內預置 10 個左右的主題配色方案, 用戶可按需切換.
剛一拿到需求, 以爲這簡單, 用 Android 的 theme + style 就能夠搞定了. 沒過多久就遇到了 attr 沒法被 selector, drawable 等 xml 資源引用的大坑.
主題色切換的方案中文網絡上一搜一大堆, 但沒有哪位博主好心的提起這裏還有這麼深一個坑的...
這裏先把解決方案簡要敘述一下.html

Android 預置多主題解決方案:

首先定義主題配色相關屬性, 我將之單獨寫在 values/style_themes_attrs.xml 裏.java

<?xml version="1.0" encoding="utf-8"?>
<resources>

    <attr name="color_bkg_main" format="color" />

    <attr name="color_action_bar" format="color" />
    <attr name="color_action_bar_text" format="color" />

    <attr name="color_primary" format="color" />
    <attr name="color_primary_pressed" format="color" />
    <attr name="color_primary_disabled" format="color" />

    <attr name="color_text" format="color" />
    <attr name="color_text_pressed" format="color" />
    <attr name="color_text_disabled" format="color" />
    <attr name="color_text_sub" format="color" />
    <attr name="color_text_hint" format="color" />

    <attr name="color_divider" format="color" />

</resources>

這些屬性是應用全局的, 爲便於引用, 不該該寫在 <declare-styleable> 標籤裏.android

而後定義各個主題配色的具體顏色值 (即給以上屬性賦值), 我將之單獨寫在 values/style_themes.xml 裏.編程

<?xml version="1.0" encoding="utf-8"?>
<resources>

    <style name="theme_default" parent="BaseAppTheme">
        <item name="color_bkg_main">#f1f1f1</item>
        <item name="color_action_bar">#ee9c18</item>
        <item name="color_action_bar_text">#fff</item>
        <item name="color_primary">#ee9c18</item>
        <item name="color_primary_pressed">#80ee9c18</item>
        <item name="color_primary_disabled">#666</item>
        <item name="color_text">#202020</item>
        <item name="color_text_pressed">#80202020</item>
        <item name="color_text_disabled">#666</item>
        <item name="color_text_sub">#717171</item>
        <item name="color_text_hint">#b6b6b6</item>
        <item name="color_divider">#e2e2e2</item>
    </style>

    <style name="theme_sky" parent="theme_default">
        <item name="color_action_bar">#02a8f3</item>
        <item name="color_primary">#02a8f3</item>
        <item name="color_primary_pressed">#8002a8f3</item>
    </style>

    <style name="theme_grass" parent="theme_default">
        <item name="color_action_bar">#63d64a</item>
        <item name="color_primary">#63d64a</item>
        <item name="color_primary_pressed">#8063d64a</item>
    </style>

</resources>

theme_default 繼承的 BaseAppTheme 是接到此需求前就已經在 AndroidManifest.xml 中賦值給 App 的 theme. 這個不重要, 你也能夠繼承系統自帶的一些主題, 也能夠不繼承任何主題, 與實現需求關係不大, 怎麼方便怎麼來就成.
下面兩個主題 theme_sky 和 theme_grass 繼承自 theme_default , 這樣作是爲了避免至於重複給顏色屬性賦值, 好比文字顏色在這三個主題中是同樣的, 繼承了就能夠避免再寫一遍.
注意: 要想在 App 全局使用主題屬性, 就必須保證每一個 style 內的各個屬性都是全的. 好比 theme_grass 裏若是沒有 color_primary 這個屬性, 那麼有代碼引用這個屬性時將發生異常. 注意是運行時異常哦~, 編譯時不會報錯的. 所以穩妥的作法仍是寫一個 base style , 而後其餘 style 繼承之, 這樣起碼不會崩潰.網絡

最後將主題顏色屬性用於各個View.app

<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="?attr/color_bkg_main"
    android:orientation="vertical" >

        ......
</LinearLayout>

或者省略 attr/ 也是能夠的:ide

<TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:textSize="16sp"
    android:textColor="?color_text"
    android:text="Text Normal" />

最最後, 就是動態切換主題的代碼了.佈局

package lx.af.demo.activity.test;

import android.app.Activity;
import android.content.res.Resources;
import android.os.Bundle;
import android.support.annotation.AttrRes;
import android.support.annotation.ColorInt;
import android.util.TypedValue;
import android.view.View;

import lx.af.demo.R;

/**
 * author: lx
 * date: 17-2-24
 */
public class ThemeChangeActivity extends Activity implements View.OnClickListener {

    private static int sCurrentTheme = R.style.theme_default;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // 這句必須放在 setContentView() 以前, 不然不生效.
        // 通常的作法是把這句話放在你的 BaseActivity 裏面.
        setTheme(sCurrentTheme);

        setContentView(R.layout.activity_change_theme);
        findViewById(R.id.btn_switch_theme_default).setOnClickListener(this);
        findViewById(R.id.btn_switch_theme_sky).setOnClickListener(this);
        findViewById(R.id.btn_switch_theme_grass).setOnClickListener(this);

        // 演示如何用代碼獲取 attr 定義的主題相關的顏色
        View primaryColorPanel = findViewById(R.id.primary_color_panel);
        primaryColorPanel.setBackgroundColor(getCurrentPrimaryColor());
    }

    public void onClick(View view) {
        switch (view.getId()) {
            case R.id.btn_switch_theme_sky:
                changeTheme(R.style.theme_sky);
                break;
            case R.id.btn_switch_theme_grass:
                changeTheme(R.style.theme_grass);
                break;
            case R.id.btn_switch_theme_default:
                changeTheme(R.style.theme_default);
                break;
        }
    }

    private void changeTheme(int theme) {
        // 改變主題時應該把當前主題設置保存進 SharedPreferences 裏去.
        // 好比給這三個主題編號 101, 102, 103, 而後保存該編號, 供下次啓動時設置爲對應主題.
        // 這裏省略了這部分邏輯, 只留主題相關邏輯.
        sCurrentTheme = theme;
        
        // 調用 Activity.recreate() 方法便可從 Activity.onCreate() 開始從新加載界面.
        // 該方法不會啓動界面過場動畫, 但重啓時會有一下閃爍.
        recreate();
    }

    // 直接獲取主題的主色顏色值
    public int getCurrentPrimaryColor() {
        return getColorByAttributeId(R.attr.color_primary);
    }

    // 使用代碼獲取主題屬性顏色值的方法
    @ColorInt
    private int getColorByAttributeId(@AttrRes int attrIdForColor){
        TypedValue typedValue = new TypedValue();
        Resources.Theme theme = getTheme();
        theme.resolveAttribute(attrIdForColor, typedValue, true);
        return typedValue.data;
    }
}

這個 Activity 對應的佈局文件以下:學習

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="?attr/color_bkg_main"
    android:orientation="vertical" >

    <TextView
        android:id="@+id/btn_switch_theme_sky"
        android:layout_width="match_parent"
        android:layout_height="45dp"
        android:layout_marginLeft="10dp"
        android:layout_marginRight="10dp"
        android:layout_marginTop="10dp"
        android:background="?attr/color_primary"
        android:gravity="center"
        android:textColor="#fff"
        android:text="change theme sky" />

    <TextView
        android:id="@+id/btn_switch_theme_grass"
        android:layout_width="match_parent"
        android:layout_height="45dp"
        android:layout_marginLeft="10dp"
        android:layout_marginRight="10dp"
        android:layout_marginTop="10dp"
        android:background="?attr/color_primary"
        android:gravity="center"
        android:textColor="#fff"
        android:text="change theme grass" />

    <TextView
        android:id="@+id/btn_switch_theme_default"
        android:layout_width="match_parent"
        android:layout_height="45dp"
        android:layout_marginLeft="10dp"
        android:layout_marginRight="10dp"
        android:layout_marginTop="10dp"
        android:background="?attr/color_primary"
        android:gravity="center"
        android:textColor="#fff"
        android:text="change theme default" />

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginLeft="10dp"
        android:layout_marginRight="10dp"
        android:layout_marginTop="10dp"
        android:padding="25dp"
        android:background="?attr/color_primary"
        android:orientation="vertical" >

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textSize="16sp"
            android:textColor="?attr/color_text"
            android:text="Text Normal" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textSize="16sp"
            android:textColor="?attr/color_text_pressed"
            android:text="Text pressed" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textSize="16sp"
            android:textColor="?attr/color_text_sub"
            android:text="Text sub" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textSize="16sp"
            android:textColor="?attr/color_text_hint"
            android:text="Text hint" />
    </LinearLayout>

    <FrameLayout
        android:id="@+id/primary_color_panel"
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:layout_marginLeft="10dp"
        android:layout_marginRight="10dp"
        android:layout_marginTop="10dp" />

</LinearLayout>

這樣就解決了多主題的問題. 新加入一個主題也很是簡單, 只要繼承 theme_default, 複寫一些顏色值就能夠了.
下面開始掉坑.字體

坑!

此坑很凌亂, 各類資源對象在各個版本表現都不同. 但成坑緣由其實基本一致. 大致就是, xml 資源中 (好比 drawable) 若是引用了 attr 定義的顏色, 再引用該 xml 資源時都有可能有問題. 咱們暫且稱此類資源爲 attr-xml 資源.

注意: 由於手頭最低版本的設備爲 Android 4.1.2 (API level 16), 最高爲 Android 7.0 (API level 24), 超出這個範圍就沒有實測了.

AppCompat

在 Android 6.0 (API level 23) 如下設備上, 若是 Activity 不是繼承自 v23.0 以上的 AppCompat 包中的 AppCompatActivity 的話, 使用 attr-xml 資源可能出現各類奇怪問題.
好比 ColorStateList 的 xml 資源在最終顯示時會被渲染爲紅色.
由於解決辦法很簡單, 因此不對由此產生的各類問題再作具體討論了.

解決辦法:
舉例, 好比有如下 ColorStateList 資源 res/color/color_selector_primary.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="?attr/color_primary_pressed"/>
    <item android:state_enabled="false" android:color="?attr/color_primary_disabled"/>
    <item android:color="?attr/color_primary"/>
</selector>

被 TextView 的 android:textColor=@color/color_selector_primary 引用就會渲染爲紅色字體.

方法1: Activity 繼承 AppCompatActivity 就能夠了 (v7兼容庫版本大於 v23.0).
compile 'com.android.support:appcompat-v7:25.2.0'
public class ThemeChangeActivity extends AppCompatActivity { ... }

方法2: 改成使用相關的兼容庫控件, 好比 TextView 改成 AppCompatTextView:

<android.support.v7.widget.AppCompatTextView
        android:id="@+id/btn_switch_theme_grass"
        android:layout_width="match_parent"
        android:layout_height="45dp"
        android:layout_marginLeft="10dp"
        android:layout_marginRight="10dp"
        android:layout_marginTop="10dp"
        android:gravity="center"
        android:textColor="@color/color_selector_primary"
        android:text="change theme grass" />

很顯然, 第一種方法更簡單. 除了 dialog 等少數地方不能用這個方法解決, 其餘地方用繼承 AppCompatActivity 的方法解決最方便. 第二種方法還得逐個改.
下文中的討論都是在 Activity 已經繼承了 AppCompatActivity 的基礎上進行的.

background

這個纔是真坑...
上面講爲了 android:textColor 屬性設置 selector (ColorStateList), 但其實咱們更經常使用的是爲 android:background 屬性設置 selector. 而 background 只能接受 drawable 類型的 selector.
舉例, 有以下 selector 類型的 drawable res/drawable/selector_primary.xml:

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_pressed="true">
        <shape>
            <solid android:color="?attr/color_primary_pressed" />
        </shape>
    </item>

    <item>
        <shape>
            <solid android:color="?attr/color_primary" />
        </shape>
    </item>
</selector>

在 Android 5.0 (API level 21) 如下機器上, drawable xml 資源中引用 attr , 若是在 layout 佈局中引用這樣的 drawable 資源, 則會引起崩潰. 如下爲截取的崩潰 trace 中的片斷:

03-14 11:21:30.092  4920  4920 E AndroidRuntime: Caused by: java.lang.UnsupportedOperationException: Can't convert to color: type=0x2
03-14 11:21:30.092  4920  4920 E AndroidRuntime:     at android.content.res.TypedArray.getColor(TypedArray.java:326)
03-14 11:21:30.092  4920  4920 E AndroidRuntime:     at android.graphics.drawable.GradientDrawable.inflate(GradientDrawable.java:951)
03-14 11:21:30.092  4920  4920 E AndroidRuntime:     at android.graphics.drawable.Drawable.createFromXmlInner(Drawable.java:895)
03-14 11:21:30.092  4920  4920 E AndroidRuntime:     at android.graphics.drawable.StateListDrawable.inflate(StateListDrawable.java:183)
03-14 11:21:30.092  4920  4920 E AndroidRuntime:     at android.graphics.drawable.Drawable.createFromXmlInner(Drawable.java:895)
03-14 11:21:30.092  4920  4920 E AndroidRuntime:     at android.graphics.drawable.Drawable.createFromXml(Drawable.java:828)
03-14 11:21:30.092  4920  4920 E AndroidRuntime:     at android.content.res.Resources.loadDrawable(Resources.java:1933)
03-14 11:21:30.092  4920  4920 E AndroidRuntime:     ... 31 more

博主目前暫未找到解決該問題的辦法. 若有好的方法, 還請指點.
所以只能嘗試繞過, 方式是在代碼中手動組裝 ColorStateList, Drawable 等可用作 background 背景的資源.

ColorStateList 能夠這麼組裝:

private static ColorStateList createColorStateList(Context context) {
  return new ColorStateList(
      new int[][]{
          new int[]{android.R.attr.state_pressed}, // pressed state.
          StateSet.WILD_CARD,                      // other state.
      },
      new int[]{
          getThemeAttrColor(context, R.attr.color_primary_pressed),  // pressed state.
          getThemeAttrColor(context, R.attr.color_primary),          // other state.
      });
}

drawable 能夠這麼組裝:

public static Drawable createDrawableSelector(Context context) {
    StateListDrawable stateDrawable = new StateListDrawable();
    GradientDrawable normalDrawable = new GradientDrawable();
    GradientDrawable pressedDrawable = new GradientDrawable();
    GradientDrawable disabledDrawable = new GradientDrawable();

    int[][] states = new int[4][];
    states[0] = new int[]{android.R.attr.state_enabled, android.R.attr.state_pressed};
    states[1] = new int[]{android.R.attr.state_enabled, android.R.attr.state_focused};
    states[3] = new int[]{-android.R.attr.state_enabled}; // disabled state
    states[2] = new int[]{android.R.attr.state_enabled};

    // 爲各類狀態下的 drawable 設置 attr 顏色值
    normalDrawable.setColor(getColorByAttrId(context, R.attr.color_primary));
    pressedDrawable.setColor(getColorByAttrId(context, R.attr.color_primary_pressed));
    disabledDrawable.setColor(getColorByAttrId(context, R.attr.color_primary_disabled));

    // 爲各類狀態下的 drawable 設置圓角等屬性. 僅舉一例, 不詳述.
    normalDrawable.setCornerRadius(5);
    pressedDrawable.setCornerRadius(5);
    disabledDrawable.setCornerRadius(5);

    stateDrawable.addState(states[0], pressedDrawable);
    stateDrawable.addState(states[1], pressedDrawable);
    stateDrawable.addState(states[3], disabledDrawable);
    stateDrawable.addState(states[2], normalDrawable);

    return stateDrawable;
}

@ColorInt
public static int getColorByAttrId(Context context, @AttrRes int attrIdForColor) {
    TypedValue typedValue = new TypedValue();
    Resources.Theme theme = context.getTheme();
    theme.resolveAttribute(attrIdForColor, typedValue, true);
    return typedValue.data;
}

把這種由代碼生成的 drawable 經過 View.setBackground(Drawable background) 方法設置, 效果便可生效.
能夠據此封裝一些經常使用控件出來, 好比 TextView, 以簡化相關工做.
但 workaround 的解決方案, 不管怎麼簡化, 都是比較噁心的.

文末 大神的文章 中提到, 使用 6.0 的矢量圖 (vector drawable, 可經過 AppCompat 包向下兼容) 能夠不受此問題影響, 能夠解決 drawable 引用 attr 顏色問題. 這個沒有實測. 由於即便這種方法可用, 博主也沒有時間將全部 drawable xml 所有改成矢量圖 (會被 UI 打死的).
小夥伴們有興趣也能夠試下這種方法.

緣由:

不關心原由的同窗可略過此節.
簡單來講, 這個問題是 API < 21 的系統中, Resources.getColor() 方法沒有接收 theme 參數致使的.
在 Android 5.0 如下, 咱們在代碼中獲取顏色值, 只能用這個方法:
Resoueces.getColor(@ColorRes int id)
上面那條語句在 5.0 以上的SDK, android studio (lint) 會給咱們一條警告, 告誡咱們方法已 Deprecated, 建議使用下面的方法:
Resources.getColor(@ColorRes int id, @Nullable Theme theme)
ContextCompat 類中也提供了一個兼容的方法:
ContextCompat.getColor(Context context, @ColorRes int id)

這個方法最終就是在調用相似下面的羅輯 (之因此說 "相似", 是由於各版本的兼容包略有不一樣):

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
  return context.getResources().getColor(id, context.getTheme());
} else {
  return context.getResources().getColor(id);
}

而咱們定義的 attr 顏色值, 是直接和主題 theme 相關的. 所以在早於 5.0 的版本上, theme 參數沒有被逐層傳遞下去, 相關控件就確定取不到對應的顏色值. drawable 的問題是相似的.

又一個坑

這是一個小坑, 或者說, 這是一個咱們編程時應避開的錯誤. 這裏一併提出, 以避免有小夥伴踩到坑.
本文討論的 attr 資源, 由於都是和主題直接相關的, 所以必定要注意, 不一樣的 Context 獲取到的資源會有不一樣!
好比有時候咱們爲了方便, 在應用全局保存了一個 Application 的實例, 這樣就能夠用靜態方法取到顏色等資源. 好比有如下簡單的幫助類:

public final class ResourceUtils {

    private static Application sApp;
    private static Resources sRes;

    private ResourceUtils() {
    }

    public static void init(Application app) {
        sApp = app;
        sRes = app.getResources();
    }

    public static String getString(int resId) {
        return sRes.getString(resId);
    }

    public static int getColor(int resId) {
        return sRes.getColor(resId);
    }
}

上面這個類能夠在 Application.onCreate() 裏面初始化, 而後就能夠愉快的以靜態方法獲取資源了.
但用這種方法, 是在用 ApplicationContext 獲取資源, 其行爲和用 Activity 的 Context 獲取資源會有不一樣, 在資源和主題 theme 相關聯時, 其取到的資源也會不一樣. 具體請學習 ContextWrapper 相關知識 (博主還沒來得及細研究, 就不展開寫了, 以避免誤導你們...) .
所以 取和主題相關的資源時, 儘可能用當前 Activity 的 Context 就是了.


參考:
google code issue
stackoverflow question
Styling Colors & Drawables w/ Theme Attributes
相關文章
相關標籤/搜索