Android App外掛式換膚實現方案

生活常識發表於2017-11-29

背景

目前很多app都具有換膚功能,使用者可以根據需要切換不同的皮膚,為使我們的App支援換膚功能,給使用者提供更好的體驗,在這裡對換膚原理進行研究總結,並選擇一個合適的換膚解決方案。

     

換膚介紹

App換膚主要涉及的有頁面中文字的顏色、控制元件的背景顏色、一些圖片資源和主題顏色等資源。

為了實現換膚資源不與原專案混淆,儘量降低風險,可以將這些資源封裝在一個獨立的Apk資原始檔中。在App執行時,主程式動態的從Apk皮膚包中讀取相應的資源,無需Acitvity重啟即可實現皮膚的實時更換,皮膚包與原安裝包相分離,從而實現外掛式換膚。

 

換膚原理

1.     如何載入皮膚資原始檔

使用外掛式換膚,皮膚資源肯定不會在被封裝到主工程中,要怎麼載入外部的皮膚資源呢?

先看下 Apk 的打包流程

 

 

這裡流程中,有兩個關鍵點
1.R檔案的生成
    R檔案是一個Java檔案,通過R檔案我們就可以找到對應的資源。R檔案就像一張對映表,幫助我們找到資原始檔。
2.資原始檔的打包生成

資原始檔經過壓縮打包,生成 resources 檔案,通過R檔案找到裡面儲存的對映的資原始檔。在 App 內部,我們一般通過下面程式碼,獲取資源:

 

1
2
3
context.getResource.getString(R.string.hello);
context.getResource.getColor(R.color.black);
context.getResource.getDrawable(R.drawable.splash);

  

這個時獲取 App 內部的資源,能我們家在皮膚資源什麼思路嗎?載入外部資源的 Resources 能通過類似的思路嗎?
   我們檢視下 Resources 類的原始碼,發現 Resources 的建構函式

1
2
3
public Resources(AssetManager assets, DisplayMetrics metrics, Configuration config) {
      this(assets, metrics, config, CompatibilityInfo.DEFAULT_COMPATIBILITY_INFO);
  }

  

這裡關鍵是第一個引數如何獲取,第二和第三個引數可以通過 Activity 獲取到。我們再去看下 AssetManager 的程式碼,同時會發現下面的這個

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
     * Add an additional set of assets to the asset manager.  This can be
     * either a directory or ZIP file.  Not for use by applications.  Returns
     * the cookie of the added asset, or 0 on failure.
     * {@hide}
     */
  public final int addAssetPath(String path) {
        synchronized (this) {
            int res = addAssetPathNative(path);
            makeStringBlocks(mStringBlocks);
            return res;
        }
    }

  

AssetManager 可以載入一個zip 格式的壓縮包,而 Apk 檔案不就是一個 壓縮包嗎。我們通過反射的方法,拿到 AssetManager,載入 Apk 內部的資源,獲取到 Resources 物件,這樣再想辦法,把 R檔案裡面儲存的ID獲取到,這樣既可以拿到對應的資原始檔了。理論上我們的思路時成立的。
   我們看下,如何通過程式碼獲取 Resources 物件。

1
2
3
4
5
6
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());

  

2.     如何標記需要換膚的View

找到資原始檔之後,我們要接著標記需要換膚的 View 。

找到需要換膚的 View
怎麼尋找哪些是我們要關注的 View 呢? 我們還是重 View 的建立時機尋找機會。我們新增一個佈局檔案時,會使用 LayoutInflater的 Inflater方法,我們看下這個方法是怎麼講一個View新增到Activity 中的。
LayoutInflater 中有個介面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public interface Factory {
        /**
         * Hook you can supply that is called when inflating from a LayoutInflater.
         * You can use this to customize the tag names available in your XML
         * layout files.
         *
         * <p>
         * Note that it is good practice to prefix these custom names with your
         * package (i.e., com.coolcompany.apps) to avoid conflicts with system
         * names.
         *
         * @param name Tag name to be inflated.
         * @param context The context the view is being created in.
         * @param attrs Inflation attributes as specified in XML file.
         *
         * @return View Newly created view. Return null for the default
         *         behavior.
         */
        public View onCreateView(String name, Context context, AttributeSet attrs);
    }

  

根據這裡的註釋描述,我們可以自己實現這個介面,在 onCreateView 方法中選擇我們需要標記的View,根據 AttributeSet 值,過濾不需要關注的View。
標記 View 與對應的資源
我們在 View 建立時,通過過濾 Attribute 屬性,找到我們要標記的 View ,下面我們就把這些View的屬性記下來

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
for (int i = 0; i < attrs.getAttributeCount(); i++){
            String attrName = attrs.getAttributeName(i);
            String attrValue = attrs.getAttributeValue(i);
            if(!AttrFactory.isSupportedAttr(attrName)){
                continue;
            
            if(attrValue.startsWith("@")){
                try {
                    int id = Integer.parseInt(attrValue.substring(1));
                    String entryName = context.getResources().getResourceEntryName(id);
                    String typeName = context.getResources().getResourceTypeName(id);
                    SkinAttr mSkinAttr = AttrFactory.get(attrName, id, entryName, typeName);
                    if (mSkinAttr != null) {
                        viewAttrs.add(mSkinAttr);
                    }
                catch (NumberFormatException e) {
                    e.printStackTrace();
                catch (NotFoundException e) {
                    e.printStackTrace();
                }
            }
        }

  

然後把這些 View 和屬性值,一起封裝儲存起來

1
2
3
4
5
6
7
8
9
if(!ListUtils.isEmpty(viewAttrs)){
            SkinItem skinItem = new SkinItem();
            skinItem.view = view;
            skinItem.attrs = viewAttrs;
            mSkinItems.add(skinItem);
            if(SkinManager.getInstance().isExternalSkin()){
                skinItem.apply();
            }
    }

  

3.     如何做到及時更新UI

由於我們把需要更新的View 以及屬性值都儲存起來了,更新的時候只要把他們取出來遍歷一遍即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Override
    public void onThemeUpdate() {
        if(!isResponseOnSkinChanging){
            return;
        }
        mSkinInflaterFactory.applySkin();
    }
//applySkin 的具體實現
public void applySkin(){
        if(ListUtils.isEmpty(mSkinItems)){
            return;
        }  
        for(SkinItem si : mSkinItems){
            if(si.view == null){
                continue;
            }
            si.apply();
        }
    }

 

4.     如何製作皮膚包

皮膚包製作相對簡單
1.建立獨立工程 model,包名任意。
2.新增資原始檔到 model 中,不需要 java 程式碼
3.執行 build.gradle 指令碼,打包命令,生成apk檔案,修改名稱為 xxx.skin 皮膚包即可。

基於ThemeSkinning的換膚框架

根據以上換膚原理,在github上面選擇了一個第三方開源框架ThemeSkinning,具體使用方法如下:

1. 整合步驟:

1)     新增依賴 compile 'com.solid.skin:skinlibrary:1.3.1'

2)     使專案中的Application繼承於SkinBaseApplication

3)     使專案中的Activity繼承於SkinBaseActivity,如果使用了Fragment則繼承於SkinBaseFragment

4)     在需要換膚的根佈局上新增 xmlns:skin="http://schemas.android.com/android/skin" ,然後在需要換膚的View上加上 skin:enable="true"

5)     新建一個專案模組(只包含有資原始檔),其中包含的資原始檔的name一定要和原專案中有換膚需求的View所使用的資源name一致。

6)     打包皮膚檔案,放入assets中的skin目錄下(skin目錄是自己新建的)

7)       呼叫換膚方法:

  • 在 assets/skin 資料夾中的皮膚

 

 

2.換膚屬性的擴充套件

該開源庫預設僅支援View的textColor和background兩個屬性的換膚,如果需要對其他屬性進行換膚,那麼就需要去自定義了。

那麼如何自定義呢?看下面這個例子:

ImageView大家應該都用過吧。它的src屬性就是定義圖片資源引用,

新建一個ImageSrcAttr繼承於 SkinAttr,然後重寫apply方法。apply方法在換膚的時候就會被呼叫,程式碼的詳細實現:

public class ImageSrcAttr extends SkinAttr {

  @Override

  public void apply(View view) {

      if (view instanceof ImageView) {

            ImageView iv = (ImageView) view;

            if (RES_TYPE_NAME_DRAWABLE.equals(attrValueTypeName)) {

                Drawable drawable = SkinResourcesUtils.getDrawable(attrValueRefId);

                iv.setImageDrawable(drawable);

            }

        }      }

  }

注:attrValueRefId:就是資源id。SkinResourcesUtils是用來獲取皮膚包裡的資源,這裡設定color或者drawable一定要使用本工具類。

當上面的工作完成之後,就到我們自己的Application的onCreate方法中加入         SkinConfig.addSupportAttr("src", new ImageSrcAttr());我們就可以正常使用了src屬性了。

此外,對於動態建立的view,我們需要動態新增支援,呼叫

dynamicAddSkinEnableView(View view, String attrName, int attrValueResId)方法新增支援。

3.其他一些重要的api

  1. SkinConfig.isDefaultSkin(context):判斷當前皮膚是否是預設皮膚
  2. SkinManager.getInstance().restoreDefaultTheme(): 重置預設皮膚

相關文章