Android 無縫換膚深入瞭解與使用

奏響曲發表於2018-04-22

思路整體結構

Android 換膚

方案及輪子

  1. 內部資源載入方案
    • 通過在BaseActivity中setTheme
    • 不好實時的重新整理,需要重新建立頁面
    • 存在需要解決哪些Vew需要重新整理的問題
  2. 自定義View
    • MultipleTheme
    • 通過自定義View配合setTheme後立即重新整理資源。
    • 需要替換所有需要換膚的view
  3. 自定義xml屬性,Java中繫結view
    • Colorful
    • 首先通過在java程式碼中新增view
    • 然後setTheme設定當前頁面主題
    • 最後通過內部引用的上下文getTheme遍歷view來修改資源
  4. 動態資源載入方案
    • Android-Skin-Loader
    • ThemeSkinning(是上面那個框架的衍生,整篇就是研究的這框架)
    • resource替換:通過單獨打包一個資源apk,只用來訪問資源,資源名得與本身對應
    • 無需關心皮膚多少,可下載,等等
    • 準備採用該方案

採用方案的技術點

  1. 獲取皮膚資源包apk的資源
  2. 自定義xml屬性,用來標記需要換膚的view
  3. 獲取並相應有換膚需求的佈局
  4. 其他
    • 擴充套件可自行新增所支援換膚的屬性
    • 改變狀態列顏色
    • 改變字型

採用方案的實現過程

實現過程

載入皮膚apk獲取裡面的資源(為了得到皮膚apk Resources物件)

下面所有的程式碼位置,包括處理一些特殊問題的方案等等!

https://github.com/xujiaji/ThemeSkinning

通過皮膚apk的全路徑,可知道其包名(需要用包名來獲取它的資源id)

  • skinPkgPath是apk的全路徑,通過mInfo.packageName就可以得到包名
  • 程式碼位置:SkinManager.java
    PackageManager mPm = context.getPackageManager();
    PackageInfo mInfo = mPm.getPackageArchiveInfo(skinPkgPath, PackageManager.GET_ACTIVITIES);
    skinPackageName = mInfo.packageName;
複製程式碼

通過反射新增路徑可以建立皮膚apk的AssetManager物件

  • skinPkgPath是apk的全路徑,新增路徑的方法是AssetManager裡一個隱藏的方法通過反射可以設定。
  • 此時還可以用assetManager來訪問apk裡assets目錄的資源。
  • 想想如果更換的資源是放在assets目錄下的,那麼我們可以在這裡動動手腳。
    AssetManager assetManager = AssetManager.class.newInstance();
    Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
    addAssetPath.invoke(assetManager, skinPkgPath);
複製程式碼

建立皮膚apk的資源物件

  • 獲取當前的app的Resources,主要是為了建立apk的Resources
    Resources superRes = context.getResources();
    Resources skinResource = new Resources(assetManager, superRes.getDisplayMetrics(), superRes.getConfiguration());
複製程式碼

當要通過資源id獲取顏色的時候

  1. 先獲取內建的顏色int originColor = ContextCompat.getColor(context, resId);
  2. 如果沒有外接皮膚apk資源或就用預設資源的情況下直接返回內建顏色
  3. 通過 context.getResources().getResourceEntryName(resId);獲取資源id獲取它的名字
  4. 通過mResources.getIdentifier(resName, "color", skinPackageName)得到皮膚apk中該資源id。(resName:就是資源名字;skinPackegeName就是皮膚apk的包名)
  5. 如果沒有獲取到皮膚apk中資源id(也就是等於0)返回原來的顏色,否則返回mResources.getColor(trueResId)

通過getIdentifier方法可以通過名字來獲取id,比如將第二個引數修改為layoutmipmapdrawablestring就是通過資源名字獲取對應layout目錄mipmap目錄drawable目錄string檔案裡的資源id

    public int getColor(int resId) {
        int originColor = ContextCompat.getColor(context, resId);
        if (mResources == null || isDefaultSkin) {
            return originColor;
        }

        String resName = context.getResources().getResourceEntryName(resId);

        int trueResId = mResources.getIdentifier(resName, "color", skinPackageName);
        int trueColor;
        if (trueResId == 0) {
            trueColor = originColor;
        } else {
            trueColor = mResources.getColor(trueResId);
        }
        return trueColor;
    }
複製程式碼

當要通過資源id獲取圖片的時候

  1. 和上面獲取顏色是差不多的
  2. 只是在圖片在drawable目錄還是mipmap目錄進行了判斷
    public Drawable getDrawable(int resId) {
        Drawable originDrawable = ContextCompat.getDrawable(context, resId);
        if (mResources == null || isDefaultSkin) {
            return originDrawable;
        }
        String resName = context.getResources().getResourceEntryName(resId);
        int trueResId = mResources.getIdentifier(resName, "drawable", skinPackageName);
        Drawable trueDrawable;
        if (trueResId == 0) {
            trueResId = mResources.getIdentifier(resName, "mipmap", skinPackageName);
        }
        if (trueResId == 0) {
            trueDrawable = originDrawable;
        } else {
            if (android.os.Build.VERSION.SDK_INT < 22) {
                trueDrawable = mResources.getDrawable(trueResId);
            } else {
                trueDrawable = mResources.getDrawable(trueResId, null);
            }
        }
        return trueDrawable;
    }
複製程式碼

對所有view進行攔截處理

  • 自己實現LayoutInflater.Factory2介面來替換系統預設的

那麼如何替換呢?

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        mSkinInflaterFactory = new SkinInflaterFactory(this);//自定義的Factory
        LayoutInflaterCompat.setFactory2(getLayoutInflater(), mSkinInflaterFactory);
        super.onCreate(savedInstanceState);
    }
複製程式碼

我們使用的Activity一般是AppCompatActivity在裡面的onCreate方法中也有對其的設定和初始化,但是setFactory方法只能被呼叫一次,導致預設的一些初始化操作沒有被呼叫,這麼操作?

  • 這是實現了LayoutInflater.Factory2介面的類,看onCreateView方法中。在進行其他操作前呼叫delegate.createView(parent, name, context, attrs)處理系統的那一套邏輯。
  • attrs.getAttributeBooleanValue獲取當前view是否是可換膚的,第一個引數是xml名字空間,第二個引數是屬性名,第三個引數是預設值。這裡相當於是attrs.getAttributeBooleanValue("http://schemas.android.com/android/skin", "enable", false)
  • 程式碼位置:SkinInflaterFactory.java
public class SkinInflaterFactory implements LayoutInflater.Factory2 {

    private AppCompatActivity mAppCompatActivity;

    public SkinInflaterFactory(AppCompatActivity appCompatActivity) {
        this.mAppCompatActivity = appCompatActivity;
    }
    @Override
    public View onCreateView(String s, Context context, AttributeSet attributeSet) {
        return null;
    }

    @Override
    public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {

        boolean isSkinEnable = attrs.getAttributeBooleanValue(SkinConfig.NAMESPACE, SkinConfig.ATTR_SKIN_ENABLE, false);//是否是可換膚的view
        AppCompatDelegate delegate = mAppCompatActivity.getDelegate();
        View view = delegate.createView(parent, name, context, attrs);//處理系統邏輯
        if (view instanceof TextView && SkinConfig.isCanChangeFont()) {
            TextViewRepository.add(mAppCompatActivity, (TextView) view);
        }

        if (isSkinEnable || SkinConfig.isGlobalSkinApply()) {
            if (view == null) {
                view = ViewProducer.createViewFromTag(context, name, attrs);
            }
            if (view == null) {
                return null;
            }
            parseSkinAttr(context, attrs, view);
        }
        return view;
    }
}
複製程式碼

當內部的初始化操作完成後,如果判斷沒有建立好view,則需要我們自己去建立view

  • 看上一步是通過ViewProducer.createViewFromTag(context, name, attrs)來建立
  • 那麼直接來看一下這個類ViewProducer,原理功能請看程式碼註釋
  • 在AppCompatViewInflater中你可以看到相同的程式碼
  • 程式碼位置:ViewProducer.java
class ViewProducer {
    //該處定義的是view構造方法的引數,也就是View兩個引數的構造方法:public View(Context context, AttributeSet attrs)
    private static final Object[] mConstructorArgs = new Object[2];
    //存放反射得到的構造器
    private static final Map<String, Constructor<? extends View>> sConstructorMap
            = new ArrayMap<>();
    //這是View兩個引數的構造器所對應的兩個引數
    private static final Class<?>[] sConstructorSignature = new Class[]{
            Context.class, AttributeSet.class};
    //如果是系統的View或ViewGroup在xml中並不是全路徑的,通過反射來例項化是需要全路徑的,這裡列出來它們可能出現的位置
    private static final String[] sClassPrefixList = {
            "android.widget.",
            "android.view.",
            "android.webkit."
    };

    static View createViewFromTag(Context context, String name, AttributeSet attrs) {
        if (name.equals("view")) {//如果是view標籤,則獲取裡面的class屬性(該View的全名)
            name = attrs.getAttributeValue(null, "class");
        }

        try {
            //需要傳入構造器的兩個引數的值
            mConstructorArgs[0] = context;
            mConstructorArgs[1] = attrs;

            if (-1 == name.indexOf('.')) {//如果不包含小點,則是內部View
                for (int i = 0; i < sClassPrefixList.length; i++) {//由於不知道View具體在哪個路徑,所以通過迴圈所有路徑,直到能例項化或結束
                    final View view = createView(context, name, sClassPrefixList[i]);
                    if (view != null) {
                        return view;
                    }
                }
                return null;
            } else {//否則就是自定義View
                return createView(context, name, null);
            }
        } catch (Exception e) {
            //如果丟擲異常,則返回null,讓LayoutInflater自己去例項化
            return null;
        } finally {
            // 清空當前資料,避免和下次資料混在一起
            mConstructorArgs[0] = null;
            mConstructorArgs[1] = null;
        }
    }

    private static View createView(Context context, String name, String prefix)
            throws ClassNotFoundException, InflateException {
        //先從快取中獲取當前類的構造器
        Constructor<? extends View> constructor = sConstructorMap.get(name);
        try {
            if (constructor == null) {
                // 如果快取中沒有建立過,則嘗試去建立這個構造器。通過類載入器載入這個類,如果是系統內部View由於不是全路徑的,則前面加上
                Class<? extends View> clazz = context.getClassLoader().loadClass(
                        prefix != null ? (prefix + name) : name).asSubclass(View.class);
                //獲取構造器
                constructor = clazz.getConstructor(sConstructorSignature);
                //將構造器放入快取
                sConstructorMap.put(name, constructor);
            }
            //設定為無障礙(設定後即使是私有方法和成員變數都可訪問和修改,除了final修飾的)
            constructor.setAccessible(true);
            //例項化
            return constructor.newInstance(mConstructorArgs);
        } catch (Exception e) {
            // We do not want to catch these, lets return null and let the actual LayoutInflater
            // try
            return null;
        }
    }
}
複製程式碼
  • 當然還有另外的方式來建立,就是直接用LayoutInflater內部的那一套
  • view = ViewProducer.createViewFromTag(context, name, attrs);刪除,換成下方程式碼:
  • 程式碼位置:SkinInflaterFactory.java
    LayoutInflater inflater = mAppCompatActivity.getLayoutInflater();
    if (-1 == name.indexOf('.'))//如果為系統內部的View則,通過迴圈這幾個地方來例項化View,道理跟上面ViewProducer裡面一樣
    {
        for (String prefix : sClassPrefixList)
        {
            try
            {
                view = inflater.createView(name, prefix, attrs);
            } catch (ClassNotFoundException e)
            {
                e.printStackTrace();
            }
            if (view != null) break;
        }
    } else
    {
        try
        {
            view = inflater.createView(name, null, attrs);
        } catch (ClassNotFoundException e)
        {
            e.printStackTrace();
        }
    }
複製程式碼
  • sClassPrefixList的定義
    private static final String[] sClassPrefixList = {
            "android.widget.",
            "android.view.",
            "android.webkit."
    };
複製程式碼

最後是最終的攔截獲取需要換膚的View的部分,也就是上面SkinInflaterFactory類的onCreateView最後呼叫的parseSkinAttr方法

  • 定義類一個成員來儲存所有需要換膚的View, SkinItem裡面的邏輯就是定義了設定換膚的方法。如:View的setBackgroundColor或setColor等設定換膚就是靠它。
private Map<View, SkinItem> mSkinItemMap = new HashMap<>();
複製程式碼
  • SkinAttr: 需要換膚處理的xml屬性,如何定義請參照官方文件:https://github.com/burgessjp/ThemeSkinning
    private void parseSkinAttr(Context context, AttributeSet attrs, View view) {
        //儲存需要換膚處理的xml屬性
        List<SkinAttr> viewAttrs = new ArrayList<>();
        //變數該view的所有屬性
        for (int i = 0; i < attrs.getAttributeCount(); i++) {
            String attrName = attrs.getAttributeName(i);//獲取屬性名
            String attrValue = attrs.getAttributeValue(i);//獲取屬性值
            //如果屬性是style,例如xml中設定:style="@style/test_style"
            if ("style".equals(attrName)) {
                //可換膚的屬性
                int[] skinAttrs = new int[]{android.R.attr.textColor, android.R.attr.background};
                //經常在自定義View時,構造方法中獲取屬性值的時候使用到。
                //這裡通過傳入skinAttrs,TypeArray中將會包含這兩個屬性和值,如果style裡沒有那就沒有 - -
                TypedArray a = context.getTheme().obtainStyledAttributes(attrs, skinAttrs, 0, 0);
                //獲取屬性對應資源的id,第一個引數這裡對應下標的就是上面skinAttrs陣列裡定義的下標,第二個引數是沒有獲取到的預設值
                int textColorId = a.getResourceId(0, -1);
                int backgroundId = a.getResourceId(1, -1);
                if (textColorId != -1) {//如果有顏色屬性
                    //<style name="test_style">
                        //<item name="android:textColor">@color/colorAccent</item>
                        //<item name="android:background">@color/colorPrimary</item>
                    //</style>
                    //以上邊的參照來看
                    //entryName就是colorAccent
                    String entryName = context.getResources().getResourceEntryName(textColorId);
                    //typeName就是color
                    String typeName = context.getResources().getResourceTypeName(textColorId);
                    //建立一換膚屬性實力類來儲存這些資訊
                    SkinAttr skinAttr = AttrFactory.get("textColor", textColorId, entryName, typeName);
                    if (skinAttr != null) {
                        viewAttrs.add(skinAttr);
                    }
                }
                if (backgroundId != -1) {//如果有背景屬性
                    String entryName = context.getResources().getResourceEntryName(backgroundId);
                    String typeName = context.getResources().getResourceTypeName(backgroundId);
                    SkinAttr skinAttr = AttrFactory.get("background", backgroundId, entryName, typeName);
                    if (skinAttr != null) {
                        viewAttrs.add(skinAttr);
                    }

                }
                a.recycle();
                continue;
            }
            //判斷是否是支援的屬性,並且值是引用的,如:@color/red
            if (AttrFactory.isSupportedAttr(attrName) && attrValue.startsWith("@")) {
                try {
                    //去掉屬性值前面的“@”則為id
                    int id = Integer.parseInt(attrValue.substring(1));
                    if (id == 0) {
                        continue;
                    }
                    //資源名字,如:text_color_selector
                    String entryName = context.getResources().getResourceEntryName(id);
                    //資源型別,如:color、drawable
                    String typeName = context.getResources().getResourceTypeName(id);
                    SkinAttr mSkinAttr = AttrFactory.get(attrName, id, entryName, typeName);
                    if (mSkinAttr != null) {
                        viewAttrs.add(mSkinAttr);
                    }
                } catch (NumberFormatException e) {
                    SkinL.e(TAG, e.toString());
                }
            }
        }
        //是否有需要換膚的屬性?
        if (!SkinListUtils.isEmpty(viewAttrs)) {
            SkinItem skinItem = new SkinItem();
            skinItem.view = view;
            skinItem.attrs = viewAttrs;
            mSkinItemMap.put(skinItem.view, skinItem);
            //是否換膚
            if (SkinManager.getInstance().isExternalSkin() ||
                    SkinManager.getInstance().isNightMode()) {//如果當前皮膚來自於外部或者是處於夜間模式
                skinItem.apply();//應用於這個view
            }
        }
    }
複製程式碼

採用方案的注意事項和疑問

  1. 可能系統會更改相關方法,但好處大於弊端
  2. 外掛化也是外接apk來載入,如何做到呢?
    • 佔時不去研究
  3. 皮膚從網路上下載到哪個目錄?如何斷定皮膚已經下載?
    • 可以通過SkinFileUtils工具類呼叫getSkinDir方法獲取皮膚的快取目錄
    • 下載的時候可以直接下載到這個目錄
    • 有沒有某個皮膚就判斷該資料夾下有沒有這個檔案了
  4. 如何不打包之前可以直接預覽?
    • 想要能在打包前提前預覽效果,而不每次想看一看效果就要打一個apk包
    • 首先,大家都應該知道分渠道的概念。通過分渠道打包,因為我們能把資源也分成不同渠道的,執行不同渠道,所得到的資源是不一樣的。
    • 然後,我們在:專案目錄\app\src,建立一個和渠道相同名字的目錄。比如說有個red渠道。
      渠道定義
      red渠道png
    • 最後,我們選編譯的渠道為red,然後直接執行就可以看到效果了。如果可以直接把res拷貝到皮膚專案打包就行了。
      選擇編譯渠道
  5. 換膚對應的屬性需要是View提供了set方法的的屬性!
    • 如果沒有提供則不能在java程式碼中設定值
    • 如果是自定義View那麼就新增對應方法
    • 如果是系統或類庫View,額(⊙o⊙)…
  6. 換膚的屬性值需要是@開頭的資料引用,如:@color/red
    • 原因是因為固定的值一般不可能是需要換膚的屬性,在SkinInfaterFactory的方法parseSkinAttr中有這樣一句來進行過濾沒有帶@的屬性值:
      過濾沒帶@的屬性值
    • 但此時,正好有一個自定義View沒有按照常路出牌,它的值就是圖片名字沒有型別沒有引用,通過java程式碼context.getResources().getIdentifier(name, "mipmap", context.getPackageName())來獲取圖片資源(參考這奇葩方式的庫)。但由於這個屬性是需要換膚更換的屬性,於是沒辦法,專門為這兩個屬性在SkinInfaterFactoryparseSkinAttr方法中寫了個判斷
      單獨判斷這兩屬性
      參考這程式碼

其他參考

  1. Android主題換膚 無縫切換 (主要參考物件,用的也是他修改Android-Skin-Loader後的框架ThemeSkinning
  2. Android換膚技術總結
  3. Android apk動態載入機制的研究

涉及及其延生

  1. 外掛化開發,既然能這樣獲取資源,也能獲取class檔案
  2. 通過對view的攔截可以把某個控制元件整體替換掉。 比如AppCompatActivity將TextView偷偷替換成了AppCompatTextView等等。

其他一些幫助資訊:

上面對應的程式碼片段都有對應路徑哦!

這篇文章的全部程式碼,測試專案位置:https://github.com/xujiaji/ThemeSkinning

測試專案中的首頁底部導航測試和修改位置:https://github.com/xujiaji/FlycoTabLayout

下面這張Gif圖片是測試專案執行的效果圖:

Android 無縫換膚深入瞭解與使用

相關文章