Android 無縫換膚深刻了解與使用

思路總體結構java

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對象)

下面全部的代碼位置,包括處理一些特殊問題的方案等等!android

https://github.com/xujiaji/ThemeSkinninggit

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

  • skinPkgPath是apk的全路徑,經過mInfo.packageName就能夠獲得包名
  • 代碼位置:SkinManager.java
PackageManager mPm = context.getPackageManager();
    PackageInfo mInfo = mPm.getPackageArchiveInfo(skinPkgPath, PackageManager.GET_ACTIVITIES);
    skinPackageName = mInfo.packageName;
複製代碼

經過反射添加路徑能夠建立皮膚apk的AssetManager對象web

  • 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獲取圖片的時候app

  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圖片是測試項目運行的效果圖:

相關文章
相關標籤/搜索