Android換膚總結

換膚方案

據我所知目前Android換膚有兩種類型,靜態換膚和動態換膚;靜態換膚就是將所有的皮膚方案放到項目中,而動態換膚則就是從網絡加載皮膚包動態切換;
通常靜態換膚是通過Theme實現,通過在項目中定義多套主題,使用setTheme方法切換的方式實現換膚;
動態換膚是通過替換系統的Resouce動態加載下載到本地的資源包實現換膚。
實際上靜態換膚還有一種方式,使用系統自帶的UiModeManager,只是它只能用來實現夜間模式。
下面我們對這個三種換膚方式進行講解。

Theme換膚

這種方式是谷歌官方推薦的方式,很多google的app都是使用的這種方式,據說知乎也是使用的這種方式,這種方式的優點就是使用很簡單,首先在res/color.xml下定義多套顏色資源(需要幾套皮膚就定義幾套,我們這裏用兩套):

<?xml version="1.0" encoding="utf-8"?>
<resources>
    //日間模式
    <color name="colorPrimary">#3F51B5</color>
    <color name="colorPrimaryDark">#303F9F</color>
    <color name="colorAccent">#FF4081</color>

    //夜間模式
    <color name="nightColorPrimary">#3b3b3b</color>
    <color name="nightColorPrimaryDark">#383838</color>
    <color name="nightColorAccent">#a72b55</color>
</resources>

然後在定義兩套主題,分別引用不同的顏色資源:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!-- Base application theme. -->
    //日間模式主題
    <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
        <item name="colorPrimary">@color/colorPrimary</item>
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <item name="colorAccent">@color/colorAccent</item>
        <!--自定義的屬性 用作背景色-->
        <item name="ColorBackground">@color/backgroundColor</item>
        <!--文字顏色-->
        <item name="android:textColor">@color/textColor</item>
    </style>

    //夜間模式主題
    <style name="NightAppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
        <item name="colorPrimary">@color/nightColorPrimary</item>
        <item name="colorPrimaryDark">@color/nightColorPrimaryDark</item>
        <item name="colorAccent">@color/nightColorAccent</item>
        <!--自定義的屬性 用作背景色-->
        <item name="ColorBackground">@color/nightColorPrimary</item>
        <!--文字顏色-->
        <item name="android:textColor">@android:color/white</item>
    </style>
</resources>

接着在佈局文件中通過下面的方式引用資源文件

android:background="?attr/colorPrimary"

最後在Activity的setContentView方法前設置想要的主題就可以了。

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        if () {
            setTheme(R.style.AppTheme);
        }
        setContentView(R.layout.activity_main2);
    }

這裏有幾個問題:
1.當我們在應用中切換主題時只有重新創建的Activity纔會使用新的主題,所以這裏就需要我們手動調用一下recreate()方法,這樣就可以重新創建頁面切換主題,但是這樣做有兩個弊端,一是屏幕會出現一下閃爍,二是頁面重新創建之後要考慮數據的恢復。這個問題也是這個方案最大的弊端,我們可以不重新創建Activity而是在切換主題後手動修改已創建Activity的View的顏色等信息,但是我們不能在每個頁面都寫上修改頁面View信息的方法,這樣的工作量太大了,我們把切換主題的頁面入口放到最底層,也就是說如果你想切換主題必須回到主頁面,這樣我們只需要在主頁面添加這樣的方法就可以了,這也算一個取巧的方法。

2.這個方案如果用在新項目上貌似沒有什麼不妥,但是如果一個老項目想用這個方案就很難受了,因爲除了要定義多套資源外,我們還要把佈局文件中的資源引用全部修改一遍,反正我是不想這麼做。

Resouce換膚

動態換膚的一般步驟爲:

  1. 下載並加載皮膚包
  2. 拿到皮膚包Resource對象
  3. 標記需要換膚的View
  4. 緩存需要換膚的View
  5. 切換時即時刷新頁面
  6. 製作皮膚包

下面的代碼參考一個動態換膚框架
Android-Skin-Loader

2.拿到皮膚包Resource對象

public Resources getSkinResources(Context context){
    /**
     * 插件apk路徑
     */
    String apkPath = Environment.getExternalStorageDirectory()+"/skin.apk";
    AssetManager assetManager = null;
    try {
        AssetManager assetManager = AssetManager.class.newInstance();
        AssetManager.class.getDeclaredMethod("addAssetPath", String.class).invoke(assetManager, apkPath);
    } catch (Throwable th) {
        th.printStackTrace();
    }
    return new Resources(assetManager, context.getResources().getDisplayMetrics(), context.getResources().getConfiguration());
}

構造一個AssetManager對象並調用addAssetPath方法設置資源文件的路徑,由於addAssetPath方法是hide註解的,我們不能直接調用,所以我們通過反射來調用這個方法,最後使用我們構造的AssetManager對象和原來的DisplayMetrics、Configuration構造一個Resources對象。
拿到Resoures對象通過下面的方法獲取需要的資源:

getIdentifier(String name, String defType, String defPackage)

第一個參數是資源的名稱,比如R.color.red,其中red就是name
第二個參數是資源類型,比如R.String.appname,其中String就是類型
第三個參數是資源所在的包名,這是打皮膚包設置的,一般是自己應用的包名

3.標記需要換膚的View

在佈局文件中自定義一個屬性,例如:

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:skin="http://schemas.android.com/android/skin"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    skin:enable="true" 
    android:background="@color/color_app_bg" >

        <TextView
            android:id="@+id/detail_text"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            skin:enable="true"  />

</RelativeLayout>

skin:enable=“true” 就是我們自定義的屬性,名字什麼的都無所謂。

4.緩存需要換膚的View

這裏需要用到一個工具LayoutInflaterFactory,它可以攔截替換View的創建過程,具體的介紹看一下這篇文章Android技能樹 — LayoutInflater Factory小結,我們在view創建之前(一般是setContentView方法之前)設置自定義的LayoutInflaterFactory就可以拿到並緩存被標記的View。

public class SkinInflaterFactory implements LayoutInflater.Factory {
//緩存View
private List<SkinItem> mSkinItems = new ArrayList<SkinItem>();

    @Override
    public View onCreateView(String name, Context context, AttributeSet attrs) {
        // if this is NOT enable to be skined , simplly skip it
        boolean isSkinEnable = attrs.getAttributeBooleanValue(SkinConfig.NAMESPACE, SkinConfig.ATTR_SKIN_ENABLE, false);
        if (!isSkinEnable) {
            return null;
        }
        //創建view
        View view = createView(context, name, attrs);

        if (view == null) {
            return null;
        }
        //緩存view
        parseSkinAttr(context, attrs, view);

        return view;
    }
}
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        getLayoutInflater().setFactory(new SkinInflaterFactory());
        setContentView(R.layout.activity_main2);
    }

如果Acitivity繼承的是AppCompatActivity,可以直接調用:

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        LayoutInflaterCompat.setFactory(LayoutInflater.from(this),new SkinInflaterFactory());
        setContentView(R.layout.activity_main2);
    }

在Android3.0之後新增了LayoutInflater.Factory2接口,我們最好使用新的接口,使用起來是類似的,只是把Factory改成Factory2:

public class SkinInflaterFactory implements LayoutInflater.Factory2 {
//緩存View
private List<SkinItem> mSkinItems = new ArrayList<SkinItem>();

    @Override
    public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
        // if this is NOT enable to be skined , simplly skip it
        boolean isSkinEnable = attrs.getAttributeBooleanValue(SkinConfig.NAMESPACE, SkinConfig.ATTR_SKIN_ENABLE, false);
        if (!isSkinEnable) {
            return null;
        }
        //創建view
        View view = createView(context, name, attrs);

        if (view == null) {
            return null;
        }
        //緩存view
        parseSkinAttr(context, attrs, view);

        return view;
    }
}
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        getLayoutInflater().setFactory2(new SkinInflaterFactory());
        setContentView(R.layout.activity_main2);
    }

如果Acitivity繼承的是AppCompatActivity,可以直接調用:

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        LayoutInflaterCompat.setFactory2(LayoutInflater.from(this),new SkinInflaterFactory());
        setContentView(R.layout.activity_main2);
    }

如果skin:enbale不爲true則直接返回null交給系統默認去創建。而如果爲true,則自己去創建這個View,並將這個VIew的所有屬性比如id, width height,textColor,background等與支持換膚的屬性進行對比。比如我們支持換background textColor listSelector等, android:background="@color/hall_back_color" 這個屬性,在進行換膚的時候,如果皮膚包裏存在hall_back_color這個值的設置,就將這個顏色值替換爲皮膚包裏的顏色值,以完成換膚的需求。同時,也會將這個需要換膚的View保存起來。

如果在切換換膚之後,進入一個新的頁面,就在進入這個頁面Activity的 InlfaterFacory的onCreateView里根據skin:enable=“true” 這個標記,進行判斷。爲true則進行換膚操作。而對於切換換膚操作時,已經存在的頁面,就對這幾個存在頁面保存好的需要換膚的View進行換膚操作。
這裏並沒有把所有方法都貼出來,想繼續深入的可以去看框架的源碼。

5.切換時即時刷新頁面

每個Activity的SkinInflaterFactory中都有着一個緩存View的集合,使用觀察者模式在換膚成功之後通知到每個Activity去刷新View。

6.製作皮膚包

  1. 新建工程project
  2. 將換膚的資源文件添加到res文件下,無java文件
  3. 直接運行build.gradle,生成apk文件(注意,運行時Run/Redebug configurations 中Launch Options選擇launch nothing),否則build 會報 no default Activty的錯誤。
  4. 將apk文件重命名如black.apk,重命名爲black.skin防止用戶點擊安裝

UiModeManager換膚

UiModeManager是在API8添加的,它用來管理界面顯示模式的服務,我們實現夜間模式主要用到他的setNightMode方法,我們看一下這個方法的註釋:

On API 22 and below, changes to the night mode
* are only effective when the {@link Configuration#UI_MODE_TYPE_CAR car}
* or {@link Configuration#UI_MODE_TYPE_DESK desk} mode is enabled on a
* device. Starting in API 23, changes to night mode are always effective.
*/
public void setNightMode(@NightMode int mode)

在API22及其以下的Android版本,只有在設置了UI_MODE_TYPE_CAR或者UI_MODE_TYPE_DESK之後,設置night模式纔會有效。(翻譯的我都不懂了),簡單說就是如果你想設置night也就是夜間模式,必須先設置UI_MODE_TYPE_CAR或者UI_MODE_TYPE_DESK其中一個,第一個是駕駛模式,第二個我也搞不懂是什麼,反正設置了這兩個flag之後系統UI會有變動我們不能設置。那怎麼辦呢?當然有辦法。
分析了源碼之後發現setNightMode最終是通過設置Configuration的uiMode屬性來實現的夜間模式,那不就簡單了,我們自己也可以設置呀,這樣就可以跳過駕駛模式了:

public static void updateNightMode(boolean on) {
    DisplayMetrics dm = sRes.getDisplayMetrics();
    Configuration config = sRes.getConfiguration();
    config.uiMode &= ~Configuration.UI_MODE_NIGHT_MASK;
    config.uiMode |= on ? Configuration.UI_MODE_NIGHT_YES : Configuration.UI_MODE_NIGHT_NO;
    sRes.updateConfiguration(config, dm);
}

調用這個方法就可以實現夜間模式,哦,不對,我們還沒放資源文件呢 ,這裏就非常簡單了,是我最喜歡的一種換膚方式,直接在res下創建-night結尾的資源文件夾,然後放入你想修改的資源就可以了,比如我想修改color.xml下的colorPrimary,先創建values-night文件夾,然後將values下的color.xml拷貝過去,再修改values-night文件夾下的colorPrimary,這樣切換到夜間模式就會直接使用values-night下的colorPrimary了。注意這裏還有一個問題,我們切換到夜間模式時已經創建的Activity不會改變,還是要重新創建。
我封裝了一個簡單的類用來實現這種方式:

public class NightModeHelper {
    private static final String TAG = "NightModeHelper";

    private static final String PREF_KEY = "nightModeState";

    public static void updateConfig(Context context) {
        int currentMode = (context.getResources().getConfiguration()
                .uiMode & Configuration.UI_MODE_NIGHT_MASK);
        SharedPreferences mPrefs = PreferenceManager.getDefaultSharedPreferences(context);
        updateConfig(context, mPrefs.getInt(PREF_KEY, currentMode));
    }

    private static void updateConfig(Context context, int newNightMode) {
        if (context == null) {
            return;
        }
        Resources res = context.getResources();
        Configuration conf = res.getConfiguration();
        int currentNightMode = conf.uiMode & Configuration.UI_MODE_NIGHT_MASK;
        if (currentNightMode != newNightMode) {
            Configuration config = new Configuration(conf);
            DisplayMetrics metrics = res.getDisplayMetrics();
            config.uiMode = newNightMode | (config.uiMode & ~Configuration.UI_MODE_NIGHT_MASK);
            res.updateConfiguration(config, metrics);
//        if (!(Build.VERSION.SDK_INT >= 26)) {
//            ResourcesFlusher.flush(res);
//        }
        } else {
            Log.d(TAG, "applyNightMode() | Skipping. Night mode has not changed: " + newNightMode);
        }
    }

    public static int getCurrentMode(Context context) {
        SharedPreferences mPrefs = PreferenceManager.getDefaultSharedPreferences(context);
        return mPrefs.getInt(PREF_KEY, context.getResources().getConfiguration()
                .uiMode & Configuration.UI_MODE_NIGHT_MASK);
    }

    /**
     * 手動設置模式
     * @param context
     * @param mode {@link Configuration#UI_MODE_NIGHT_YES} {@link Configuration#UI_MODE_NIGHT_NO}
     */
    public static void setMode(Context context, int mode) {
        SharedPreferences mPrefs = PreferenceManager.getDefaultSharedPreferences(context);
        mPrefs.edit()
                .putInt(PREF_KEY, mode)
                .apply();
    }

    /**
     * 切換模式
     *
     * @param context
     */
    public static void toggle(Context context) {
        SharedPreferences mPrefs = PreferenceManager.getDefaultSharedPreferences(context);
        if (getCurrentMode(context) == Configuration.UI_MODE_NIGHT_YES) {
            mPrefs.edit()
                    .putInt(PREF_KEY, Configuration.UI_MODE_NIGHT_NO)
                    .apply();
        } else {
            mPrefs.edit()
                    .putInt(PREF_KEY, Configuration.UI_MODE_NIGHT_YES)
                    .apply();
        }
    }
}

注意這裏我並沒有做Activity的重新創建工作。

如果你的Acitivty是繼承AppCompatActivity的,那麼可以使用AppCompatActivity封裝的代碼進行夜間模式切換:

getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES);

或者:

AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES);

這兩行代碼的效果是一樣的,並且他們會自動重新創建Activity。

總結

  1. 靜態換膚的優勢是簡單穩定,但是將所有主題寫在應用裏會增大安裝包的體積,並且不利於擴展。
  2. 動態換膚雖然不會佔用安裝包的體積,並且可以隨意擴展,但是他畢竟侵入了系統,有潛在的風險,而且實現起來也有一點麻煩,但是利大於弊,所以目前大多數app都是使用的這種方式。
  3. 如果只是想實現夜間模式,那麼第三種方案我認爲是最好的,而且在老項目上實現這個功能也不會很繁瑣。
  4. 對於靜態換膚的重新創建Activity問題我建議重啓App,這樣可以省去很多多餘的操作。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章