1. 程式人生 > >Android App切換主題的實現原理剖析

Android App切換主題的實現原理剖析

今天再給大家帶來一篇乾貨。 Android的主題換膚 ,可外掛化提供面板包,無需Activity的重啟直接實現無縫切換,可高仿網易雲音樂的主題換膚。

這個連結是本次的Demo打包出來的樣本SkinChangeDemo,可以去下載下來先試試效果,面板檔案需放到儲存卡的根目錄下。

關於Android的主題換膚都是個老生常談的問題了。網上給出的方案也是層出不窮,最近我也是很想去了解這方面的知識,所以我去搜一下就會有一大堆介紹這方面的文章,但是最後的結果都是不盡人意的,有的確實是給出了一些比較好的解決方案,但是沒有一個實質性的Demo可以參考,所以也只能是紙上談兵罷了,有的呢,確實是給出了一個參考的Demo但是最後的結果不是我想要的。關於Android的換膚方案技術的總結,這篇文章還是挺有參考價值的

Android換膚技術總結。感興趣的同學可以去了解下,就當做是一個知識的普及。

今天我要實現的一個換膚方案是基於github上的這個開源框架Android-Skin-Loader這個框架的換膚機制是使用動態載入的機制去載入面板包裡面的內容,無需Acitvity重啟即可實現面板的實時更換,面板包是可以與原安裝包相分離的,需要自己定做(這個面板包其實也就是一個普通的Android專案,只是只有資原始檔沒有類檔案而已),這樣做的好處就是可以線上提供面板包供使用者去下載,也可以大大的減少安裝包的體積,同時也很好的實現了外掛化。其實這個框架是可以拿來直接來用的,直接幾行程式碼基本上就可以解決Android的主題換膚,但是作為一個程式設計師怎麼可以只是簡單的知道怎麼用就行了嗎?如果真是這樣就真的太low了。遇到一個好的開源專案我們至少需要把他的原始碼大致看一下,走一下基本的流程,瞭解一下他的基本原理,這樣我們在技術上才會有所提升。本文實現的Demo是基於在我前段時間釋出的

Android Material Design 相容庫的使用詳解一文中的Demo改進的。最後實現的App也是MaterialDesign的設計風格。

好了說了這麼多,通過本文你可以學到什麼,這個可能是大家比較關心的一點

  • 設計出一個基於MaterialDesign風格的App
  • 自己實現一個主題換膚的框架
  • 高仿網易雲音樂的主題換膚(ps:其實本來我想以這個作為標題的,這樣做也可以增加流量,可我不想單純的做個標題黨,給大家帶來乾貨才是最重要的)
  • 讓你的技術更上一層樓(這個說了也是白說)

說了這麼久可能就會有人按捺不住了:我是來看乾貨的,不是來這聽你瞎BB的。不要急乾貨馬上來。如果實在感覺枯燥可以直接跳到文末去看原始碼。下面先來幾張效果圖來爽一下


網易雲音樂換膚介面

這個是網易雲音樂的換膚介面,他提供了幾個預設的,也提供了可以線上下載的主題,他的切換效果還是非常讚的,用過這個軟體的同學肯定是知道的。學習完本文後就可以做出類似於這個換膚效果。


Demo最終效果圖

這個動態圖是最終我們這個Demo實現的效果,這個Demo總體來說還是比較簡單的,只提供了三種皮膚。實現了一個基本的換膚效果,主要還是用於拿來學習使用。當然更復雜的換膚基於這個Demo也是可以辦到的,這裡主要還是去講解原理。

在介紹之前還需要先給大家普及一下LayoutInflaterFactory相關的知識。如果已經知道了這方面的知識點,下面這一段可以直接略過。

對於LayoutInflater大家可能都不太陌生,當你需要把xml檔案轉化成對應View的時候就必須用到它,我想對於他怎麼使用的就不用我介紹了。LayoutInflater 提供了setFactory(LayoutInflater.Factory factory)和setFactory2(LayoutInflater.Factory2 factory)兩個方法可以讓你去自定義佈局的填充(有點類似於過濾器,我們在填充這個View之前可以做一些額外的事,但不完全是),Factory2 是在API 11才新增的。
他們提供了下面的方法讓你去重寫。在這裡面你完全可以自己去定義去建立你所想要的View,如果在你在重寫的方法中返回null的話,就會以系統預設的方式去建立View。

View onCreateView(String name, Context context, AttributeSet attrs)//LayoutInflater.Factory

View onCreateView(View parent, String name, Context context, AttributeSet attrs)//LayoutInflater.Factory2

LayoutInflater都被設定了一個預設的Factory,Activity 是實現了LayoutInflater.Factory介面的,因此在你的Activity中直接重寫onCreateView就可以自定義View的填充了。

下面這句是對LayoutInflater.Factory一個比較好的理解

Inflating your own custom views, instead of letting the system do it

這個也是這個Demo其中的一個比較重要技術點。如果有想更詳細瞭解的文末會有參考連結。

下面就正式開始介紹怎麼去做這個主題換膚吧。

先來看看這個Demo的專案結構:


專案結構圖

至於xRecyclerView可以不用管,這裡我們用不到(這是之前用到的,與本次無關),他只是一個RecyclerView的一個擴充套件框架,支援下拉重新整理和上拉載入,是一個在github上的一個開源專案。

這裡我們直接來看看lib_skinloader這個庫吧(這裡面的內容大部分是來源於Android-Skin-Loader這個框架,我只做了部分修改,主要是適配AppCompatActivity,原框架是基於最初的Activty開發的,在這裡再次感謝開源作者),這個庫就是今天所講的核心內容


lib_skinloader包結構圖

我們都知道在Android中如果想去獲取資原始檔都必須通過Resources去獲取。這個庫的核心思想就是動態的去載入第三方包裡面的包,獲取到其Resources然後以獲取到的這個Resources去獲取第三方包裡面的資源內容,最後設定到我們有需響應面板更改的View上。

這裡我就只介紹load和base兩個包,其他包的內容在講解的時候會涉及到

1.load包

我們先來看看這個load包裡面的內容(其實這裡就是今天核心內容的核心)。


load包

裡面有兩個類檔案:SkinInflaterFactory、SkinManager

我們先來看看SkinManager的實現,直接跳到load方法

 public void load(String skinPackagePath, final ILoaderListener callback) {
        new AsyncTask<String, Void, Resources>() {
            protected void onPreExecute() {
                if (callback != null) {
                    callback.onStart();
                }
            }
            @Override
            protected Resources doInBackground(String... params) {
                try {
                    if (params.length == 1) {
                        String skinPkgPath = params[0];
                        Log.i("loadSkin", skinPkgPath);
                        File file = new File(skinPkgPath);
                        if (file == null || !file.exists()) {
                            return null;
                        }
                        PackageManager mPm = context.getPackageManager();
                        PackageInfo mInfo = mPm.getPackageArchiveInfo(skinPkgPath, PackageManager.GET_ACTIVITIES);
                        skinPackageName = mInfo.packageName;
                        AssetManager assetManager = AssetManager.class.newInstance();
                        Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
                        addAssetPath.invoke(assetManager, skinPkgPath);
                        Resources superRes = context.getResources();
                        Resources skinResource = new Resources(assetManager, superRes.getDisplayMetrics(), superRes.getConfiguration());
                        SkinConfig.saveSkinPath(context, skinPkgPath);
                        skinPath = skinPkgPath;
                        isDefaultSkin = false;
                        return skinResource;
                    }
                    return null;
                } catch (Exception e) {
                    e.printStackTrace();
                    return null;
                }
            }
            protected void onPostExecute(Resources result) {
                mResources = result;
                if (mResources != null) {
                    if (callback != null) callback.onSuccess();
                    notifySkinUpdate();
                } else {
                    isDefaultSkin = true;
                    if (callback != null) callback.onFailed();
                }
            }
        }.execute(skinPackagePath);
    }

這個方法有兩個引數,第一個是面板包的路徑,第二個就是一個簡單的回撥

其中doInBackground方法裡面就實現了動態的去獲取面板包的Resources,當獲取成功之後,在onPostExecute方法中就將這個Resources賦值到我們定義好的變數中去,以方便我們之後的使用,注意到當獲取到的這個Resources不為空時,也就是我們已經獲取到了面板包裡面的資源,我們就呼叫notifySkinUpdate()這個方法來通知介面去更改面板,如果為空就還是使用預設的面板。

我們來看看notifySkinUpdate()的實現


notifySkinUpdate

這裡很簡單,就是去遍歷mSkinObservers這個集合,然後去通知更新。對於ISkinUpdate是一個介面,每個需要面板更新的Activity都需要去實現這個介面。

SkinManager這個類裡面還有諸如getColor(int resId)、getDrawable(int resId)這樣的方法,就是去獲取第三方包對應的資原始檔,值得注意的是如果你的第三方包裡沒有對應的資原始檔,那麼就會使用預設的資原始檔,如果你有需求,你完全可以去新增一些類似getMipmap(int resID)這樣的方法。

對了,還有一個比較重要的方法忘了講


restoreDefaultTheme


這個方法就是恢復到系統的預設主題,原理和load都差不多,實現還簡單了很多。SkinManager這個類就說這麼多,詳細實現請到原始碼中去檢視,很多地方我都給了註釋。

我們再來看看SkinInflaterFactory,在這裡面主要就是做一些填充View相關的一些工作。我實現的是LayoutInflaterFactory這個介面而不是文章之前提到的LayoutInflater.Factory這個介面是因為這裡需要與AppCompatActivity相容,如果你還是用之前的那個就會出現一些錯誤,反正我剛弄的時候是折騰了很久的。不管怎麼樣原理始終是一樣的。SkinInflaterFactory的作用就是去搜集那些有需要響應面板更改的View。
我們來看看onCreateView的實現


onCreateView

首先我們先去判斷這個當前將要View是否有更改面板的需求,如果沒有我們就返回預設的實現。如果有,我們就自己去處理
來看看createView方法的實現


createView

看起來很多,其實這個方法就是去動態的去建立View。

下面來看看parseSkinAttr的實現:


parseSkinAttr


這個方法其實就是去搜集View中換膚的時候可以更改的屬性,當我們換膚的時候就是去更改的這些屬性的值,這裡你必須要注意一點,這個屬性的值一定要是引用型別的(例如:@color/red),千萬不能寫死,第二個if的判斷就是這個作用。到這裡可能你就會有個疑問,我怎麼知道哪些屬性在換膚的時候需要更改。如果你細心一點肯定注意到了這行程式碼

SkinAttr mSkinAttr = AttrFactory.get(attrName, id, entryName, typeName);

這裡有個AttrFacory他的作用就是根據屬性名,動態的去建立SkinAttr。在AttrFacory中定義了一些類似於這樣的常量:



這就是我們換膚的時候可以更改的那些屬性。SkinAttr是一個抽象類,比如background就會去建立一個BackgroundAttr,本專案所用到的屬性全都在attr包中。SkinAttr是比較靈活的一個地方,如果你有哪個屬性在換膚的時候需要更改,你就去實現一個對應的SkinAttr。

在parseSkinAttr這個方法的最後我們將View和SkinAttr封裝成了一個SkinItem然後新增到一個集合中去,最後還需注意的是,如果當前面板不是預設面板,一定要去apply一下,這樣做主要是防止換了面板啟動一些新的頁面有可能導致換膚不及時的問題。SkinInflaterFactory這個類裡面還提供了動態的新增SkinItem的方法,原理都和這裡差不多,我就不過多的去說了。

load包裡面的這兩個類講的差不多了,這裡看懂了後面的內容也就是小菜一碟了,我相信你看了這裡再去看原始碼一定會輕鬆地多。

2.base包


base包結構

可以看見這個包裡面肯定就是Activity、Fragment、Application的實現,作用肯定就是封裝一些公用的方法和屬性在裡面。

下面我們一個一個來分析

  • SkinBaseApplication:

    SkinBaseApplication

可以看到這裡我們對SkinManager做了一些初始化的操作。以後我們有需要面板更改需求的應用一定要記得一定要繼承於SkinBaseApplication。

  • SkinBaseActivity
    我們來看看其onCreate方法

    SkinBaseActivity
    在這裡使用了我們之前自定義的View的InflaterFactory,來替換預設的Factory。記住一定要在super.onCreate(savedInstanceState);這個方法之前呼叫。SkinBaseActivity裡面還提供了動態新增可以響應面板更改需求的View的相關方法。當然需要響應換膚更改的Activity都需要繼承SkinBaseActivity。詳細實現請看原始碼。
  • SkinBaseFragment
    這個和SkinBaseActivity的思想差不多。具體實現看原始碼,這裡我只是給大家提供這個換膚框架的思想,讓大家在看原始碼的時候更輕鬆。

這個框架就介紹到這,下面我們來看看怎麼去使用。

在使用的時候一定要記得要Activity要去繼承於SkinBaseActivity,Fragment要繼承於SkinBaseFragment,Application要繼承於SkinBaseApplication。當然把這個框架做為你的專案依賴項肯定是必不可少的。為了Demo的簡單,這裡我只使用了下面三個顏色作為可以換膚的資源,當然如果你想要使用drawable檔案也是可以辦到的,前提是你一定要把這個Demo看懂。


來看一個佈局檔案



其中

是我們自定義的,在SkinConfig有。
我們只需在有面板更改需求的View中加入skin:enable="true" 就OK了。

再來看看MainActicvity的部分程式碼



這裡就是動態的新增有面板更改需求的View。

上面就介紹完了在佈局檔案中使用方法和在程式碼中使用方法。

我們應該怎麼去換膚呢?很簡單,只需呼叫SkinManager的load方法就可以了,把面板路徑傳進去就可以了,我的這個Demo為了簡單起見,沒有做線上換膚的功能,只是在本地提供了可以更換的面板,看到這裡我相信你對怎樣線上換膚已經有想法了。


怎樣去換膚

最最後我們來看看怎麼去開發面板包。其實這個是最簡單的,面板包實際上就是一個基本的Android專案,裡面不包含類檔案,只有資原始檔。這裡只需注意 這裡的資原始檔名字一定要和原專案中的相同,並且只用包含那些在面板更改時需要改變的那些就行了!例如我的這個Demo就只是簡單對上面的三種顏色做了簡單的切換。開發了棕色和黑色兩款面板,所以資原始檔中只有三個color的值,開發完成之後我們需要將其打包成apk檔案,為防止使用者點選安裝,我們將其後綴改成了skin,這樣做也具有標識性。如果還是不太清楚可以直接去原始碼中檢視。

這下再來看一看文章開頭效果圖是不是突然變得有思路了,快動起你的小手指去敲一個主題換膚的框架吧~~~


Demo最終效果圖

好了,本文到此結束。很感謝你的耐心看完!

原始碼傳送:MaterialDesignDemo 歡迎大家Star和Fork,bug肯定是在所難免的,有問題多多討論。

參考連結: