【靶點(diǎn)突破】網(wǎng)易云換膚方案探討

【靶點(diǎn)突破】網(wǎng)易云換膚方案探討

  • 老方案
  • 網(wǎng)易云音樂(lè)換膚方案原理
  • 動(dòng)手實(shí)現(xiàn)一個(gè)網(wǎng)易云換膚方案的demo
  • 動(dòng)手打造換膚方案的輪子
  • 黑白夜模式切換

??Hello,大家好旱易,我是Ellen,這是Android靶點(diǎn)突破系列文章阀坏,旨在幫助你更加了解Android技術(shù)開(kāi)發(fā)的同時(shí),把業(yè)務(wù)做到精致浸船。思考自己的職業(yè)生涯箫老,想成為怎樣的技術(shù)人耍鬓,想追求怎么樣的生活。

至尊寶腳踏七彩祥云娶了紫霞涣达,希望你也能成為她的自尊寶。
| from Ellen緣言

1.老方案

??App皮膚切換老方案分為2點(diǎn):

  • 1.設(shè)置不同的Style鸦概,結(jié)合Activity的recreate & setTheme方法
  • 2.通過(guò)全局Setting進(jìn)行修改,回調(diào)通知所有存活的Activity & Fragment & Dialog等

??如果是老的項(xiàng)目突然需要添加換膚功能,那么這將是一個(gè)極大的勞動(dòng)工程扎拣,費(fèi)時(shí)又費(fèi)力,而且隨著皮膚的增多刊愚,你的資源文件會(huì)越來(lái)越大,這首先很不方便管理牡借,而且還會(huì)讓apk的體積越來(lái)越大,開(kāi)發(fā)起來(lái)吃力沈矿,用戶體驗(yàn)也不好。
??對(duì)于老方案的實(shí)現(xiàn)代碼我這里就不講解了陵像,我會(huì)貼一個(gè)Github項(xiàng)目代碼,讀者可以自行去看看瞧瞧,代碼注釋寫(xiě)的很清晰疏日,注意的是這里筆者只實(shí)現(xiàn)了Style & Setting兩種方式睬辐,Style方式是切換Theme的方式,需要配置不同的style和自定義屬性丰刊,Setting方式則更為靈活隘谣,它是通過(guò)屬性對(duì)界面的皮膚進(jìn)行控制,每個(gè)界面收到回調(diào)然后進(jìn)行切換啄巧,還有其它很多實(shí)現(xiàn)方式寻歧,但核心缺點(diǎn)都是一樣的,包體積越來(lái)越臃腫秩仆,管理性越來(lái)越差码泛,我們重點(diǎn)要實(shí)現(xiàn)網(wǎng)易云音樂(lè)的換膚方案铅搓,這才是換膚的王道。當(dāng)然你可以通過(guò)后端配置方式將資源都放在接口里蜀踏,比較占apk的圖片資源用url的方式舰罚,但是無(wú)疑增加皮膚切換的業(yè)務(wù)邏輯復(fù)雜度候醒,隨著項(xiàng)目業(yè)務(wù)越來(lái)越多,負(fù)責(zé)皮膚的bean對(duì)象也許會(huì)越來(lái)越多的屬性悠夯。

??老方案:OldSwitchSkinDemo

2.網(wǎng)易云音樂(lè)換膚方案原理

??網(wǎng)易云音樂(lè)相信你使用過(guò)匣摘,它的換膚可以算是秒切嘴办,那么它是怎樣做到的呢?我們先來(lái)看看它的原理,然后追求精致,我們也要實(shí)現(xiàn)這種秒切皮膚的效果古戴。
??我們來(lái)看看,它的原理需要了解的如下:

  • 1.LayoutInflater mFactory & mFactory2 反射替代成自定義的
  • 2.解析空殼apk獲取Resource替代原有的App Resource

步驟1:LayoutInflater mFactory & mFactory2 反射替代成自定義的

??LayoutInflater通常我們用來(lái)解析布局文件的头谜,將布局文件映射成一個(gè)一個(gè)的控件對(duì)象妹笆,下列代碼就是將布局item_skin_manager映射為一個(gè)View對(duì)象:

LayoutInflater.from(parent.getContext()).inflate(R.layout.item_skin_manager, parent, false);

??那么它是如何將布局文件映射為View對(duì)象的呢婿奔,我們來(lái)看看Android SDK版本31下inflate方法的源碼:

public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
    //**********注意點(diǎn)1
    final Resources res = getContext().getResources();
    if (DEBUG) {
        Log.d(TAG, "INFLATING from resource: \"" + res.getResourceName(resource) + "\" ("
              + Integer.toHexString(resource) + ")");
    }

    View view = tryInflatePrecompiled(resource, res, root, attachToRoot);
    if (view != null) {
        return view;
    }
    //**********注意點(diǎn)2
    XmlResourceParser parser = res.getLayout(resource);
    try {
        //**********注意點(diǎn)3
        return inflate(parser, root, attachToRoot);
    } finally {
        parser.close();
    }
}

??請(qǐng)注意上方代碼筆者標(biāo)注的"注意點(diǎn)1"和"注意點(diǎn)2"以及"注意點(diǎn)3",后面我直接簡(jiǎn)稱為點(diǎn)1和點(diǎn)2以及點(diǎn)3项乒,從點(diǎn)1中我們可以看到它是獲取了一個(gè)Resource res,再?gòu)狞c(diǎn)2看到啃憎,它獲取了一個(gè)XML解析負(fù)責(zé)相關(guān)的類(lèi)XmlResourceParser parser,這個(gè)parser應(yīng)該提供了XML解析相關(guān)的,那么我們接下來(lái)看看點(diǎn)3標(biāo)注的inflate方法:

public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
    synchronized (mConstructorArgs) {
        Trace.traceBegin(Trace.TRACE_TAG_VIEW, "inflate");

        final Context inflaterContext = mContext;
        final AttributeSet attrs = Xml.asAttributeSet(parser);
        Context lastContext = (Context) mConstructorArgs[0];
        mConstructorArgs[0] = inflaterContext;
        View result = root;

        try {
            advanceToRootNode(parser);
            final String name = parser.getName();

            if (DEBUG) {
                System.out.println("**************************");
                System.out.println("Creating root view: "
                        + name);
                System.out.println("**************************");
            }

            if (TAG_MERGE.equals(name)) {
                if (root == null || !attachToRoot) {
                    throw new InflateException("<merge /> can be used only with a valid "
                            + "ViewGroup root and attachToRoot=true");
                }

                rInflate(parser, root, inflaterContext, attrs, false);
            } else {
                //**********注意點(diǎn)4
                // Temp is the root view that was found in the xml
                final View temp = createViewFromTag(root, name, inflaterContext, attrs);

                ViewGroup.LayoutParams params = null;

                if (root != null) {
                    if (DEBUG) {
                        System.out.println("Creating params from root: " +
                                root);
                    }
                    // Create layout params that match root, if supplied
                    params = root.generateLayoutParams(attrs);
                    if (!attachToRoot) {
                        // Set the layout params for temp if we are not
                        // attaching. (If we are, we use addView, below)
                        temp.setLayoutParams(params);
                    }
                }

                if (DEBUG) {
                    System.out.println("-----> start inflating children");
                }

                // Inflate all children under temp against its context.
                rInflateChildren(parser, temp, attrs, true);

                if (DEBUG) {
                    System.out.println("-----> done inflating children");
                }

                // We are supposed to attach all the views we found (int temp)
                // to root. Do that now.
                if (root != null && attachToRoot) {
                    root.addView(temp, params);
                }

                // Decide whether to return the root that was passed in or the
                // top view found in xml.
                if (root == null || !attachToRoot) {
                    result = temp;
                }
            }

        } catch (XmlPullParserException e) {
            final InflateException ie = new InflateException(e.getMessage(), e);
            ie.setStackTrace(EMPTY_STACK_TRACE);
            throw ie;
        } catch (Exception e) {
            final InflateException ie = new InflateException(
                    getParserStateDescription(inflaterContext, attrs)
                    + ": " + e.getMessage(), e);
            ie.setStackTrace(EMPTY_STACK_TRACE);
            throw ie;
        } finally {
            // Don't retain static reference on context.
            mConstructorArgs[0] = lastContext;
            mConstructorArgs[1] = null;

            Trace.traceEnd(Trace.TRACE_TAG_VIEW);
        }

        return result;
    }
}

??看點(diǎn)4老外注釋的捏肢, Temp is the root view that was found in the xml藕赞,大概意思就是說(shuō)Temp 是在 xml 中找到的根視圖,原來(lái)我們的xml布局是這樣的解析的哦卖局,我們?cè)賮?lái)看看createViewFromTag方法:

View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
        boolean ignoreThemeAttr) {
    if (name.equals("view")) {
        name = attrs.getAttributeValue(null, "class");
    }

    // Apply a theme wrapper, if allowed and one is specified.
    if (!ignoreThemeAttr) {
        final TypedArray ta = context.obtainStyledAttributes(attrs, ATTRS_THEME);
        final int themeResId = ta.getResourceId(0, 0);
        if (themeResId != 0) {
            context = new ContextThemeWrapper(context, themeResId);
        }
        ta.recycle();
    }

    try {
        //***********注意點(diǎn)5
        View view = tryCreateView(parent, name, context, attrs);

        if (view == null) {
            final Object lastContext = mConstructorArgs[0];
            mConstructorArgs[0] = context;
            try {
                if (-1 == name.indexOf('.')) {
                    view = onCreateView(context, parent, name, attrs);
                } else {
                    view = createView(context, name, null, attrs);
                }
            } finally {
                mConstructorArgs[0] = lastContext;
            }
        }

        return view;
    } catch (InflateException e) {
        throw e;

    } catch (ClassNotFoundException e) {
        final InflateException ie = new InflateException(
                getParserStateDescription(context, attrs)
                + ": Error inflating class " + name, e);
        ie.setStackTrace(EMPTY_STACK_TRACE);
        throw ie;

    } catch (Exception e) {
        final InflateException ie = new InflateException(
                getParserStateDescription(context, attrs)
                + ": Error inflating class " + name, e);
        ie.setStackTrace(EMPTY_STACK_TRACE);
        throw ie;
    }
}

??我們?cè)倏袋c(diǎn)5斧蜕,它通過(guò)tryCreateView方法獲取到一個(gè)View,這個(gè)View就是Temp了,也就是解析布局獲取到的View對(duì)象砚偶,我們?cè)趤?lái)看看tryCreateView方法:

public final View tryCreateView(@Nullable View parent, @NonNull String name,
    @NonNull Context context,
    @NonNull AttributeSet attrs) {
    if (name.equals(TAG_1995)) {
        // Let's party like it's 1995!
        return new BlinkLayout(context, attrs);
    }

    View view;
    if (mFactory2 != null) {
        //*******注意點(diǎn)6
        view = mFactory2.onCreateView(parent, name, context, attrs);
    } else if (mFactory != null) {
        //*******注意點(diǎn)7
        view = mFactory.onCreateView(name, context, attrs);
    } else {
        view = null;
    }

    if (view == null && mPrivateFactory != null) {
        view = mPrivateFactory.onCreateView(parent, name, context, attrs);
    }

    return view;
}

??我們看到點(diǎn)6和點(diǎn)7批销,原來(lái)我們的View都是通過(guò)mFactory2或 mFactory創(chuàng)建出來(lái)的,我們看看下面代碼:

public interface Factory2 extends Factory {
    /**
     * Version of {@link #onCreateView(String, Context, AttributeSet)}
     * that also supplies the parent that the view created view will be
     * placed in.
     *
     * @param parent The parent that the created view will be placed
     * in; <em>note that this may be null</em>.
     * @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.
     */
    @Nullable
    View onCreateView(@Nullable View parent, @NonNull String name,
            @NonNull Context context, @NonNull AttributeSet attrs);
}

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.
     */
    @Nullable
    View onCreateView(@NonNull String name, @NonNull Context context,
            @NonNull AttributeSet attrs);
}

??可以看到Factory和Factory2都是接口染坯,那么mFactory2或 mFactory是啥呢均芽?

@UnsupportedAppUsage
private Factory mFactory;
@UnsupportedAppUsage
private Factory2 mFactory2;

??它是 LayoutInflater內(nèi)私有屬性成員,那么我們是否可以通過(guò)反射攔截XML解析成具體控件對(duì)象的過(guò)程呢单鹿?只要攔截了掀宋,那么我們是否可以拿到控件對(duì)象任性設(shè)置自己要的皮膚屬呢?如果是通過(guò)設(shè)置屬性的方式進(jìn)行切換仲锄,那么我們估計(jì)也還是會(huì)像老方案那樣劲妙,只會(huì)越來(lái)越復(fù)雜,那么怎么辦呢儒喊?我們拿到控件對(duì)象啦镣奋,還記得前面提到的Resource,它是負(fù)責(zé)整個(gè)控件體系的資源設(shè)置的類(lèi),同樣的原理澄惊,我們是否可以通過(guò)我們的Resource來(lái)進(jìn)行設(shè)置呢唆途,我們?cè)賮?lái)看看Resource是如何來(lái)的:

步驟2:解析空殼apk獲取Resource替代原有的App Resource

??通過(guò)上圖我們可以確定Resource通過(guò)AssetManager來(lái)加載的,Asset是不是很熟悉掸驱,它是asset目錄啊肛搬,怎么會(huì)加載項(xiàng)目的資源呢?難道它還可以解析目錄下資源嗎毕贼?
我們接著看看這個(gè)方法:

    //這里的path就是apk所在目錄
    public int addAssetPath(String path) {
        return addAssetPathInternal(path, false /*overlay*/, false /*appAsLib*/);
    }

??雖然這個(gè)方法是public的温赔,但是被隱藏掉了,我們只能通過(guò)反射進(jìn)行調(diào)用鬼癣,也就是方案已經(jīng)很明了陶贼,就是我們將每個(gè)皮膚的資源打進(jìn)空殼apk內(nèi)啤贩,然后通過(guò)AssetManager的addAssetPath方法解析空殼apk的資源,獲取到一個(gè)Resource,然后我們通過(guò)反射LayoutInflater賦值自定義的mFactory&mFactory2來(lái)攔截控件創(chuàng)建過(guò)程拜秧,進(jìn)行屬性的替換痹屹,眼下我們還存在一個(gè)問(wèn)題,那么如何new一個(gè)Resource對(duì)象枉氮,并且將空殼apk的資源打進(jìn)去呢志衍?我們看看Resource的構(gòu)造器:

public Resources(AssetManager assets, DisplayMetrics metrics, Configuration config) {
    this(null);
    mResourcesImpl = new ResourcesImpl(assets, metrics, config, new DisplayAdjustments());
}

??驚喜且意外的發(fā)現(xiàn)Resources(AssetManager assets, DisplayMetrics metrics, Configuration config)這個(gè)構(gòu)造器完全滿足我們的需求,但是metrics和config是啥呢聊替,沒(méi)關(guān)系楼肪,我們通過(guò)獲取當(dāng)前的Resource,將當(dāng)前的Resource的metrics和config傳進(jìn)去即可,我們只需要設(shè)置我們重要的解析空客apk的assets即可惹悄,然后通過(guò)Resource為我們提供的解析資源的api給攔截的控件對(duì)象設(shè)置對(duì)應(yīng)的皮膚屬性即可春叫。

3.動(dòng)手實(shí)現(xiàn)一個(gè)網(wǎng)易云換膚方案的demo

??經(jīng)過(guò)網(wǎng)易云音樂(lè)換膚方案原理分析,我們要實(shí)現(xiàn)換膚的步驟如下:

  • 0.準(zhǔn)備好換膚對(duì)應(yīng)的界面
  • 1.反射賦值LayoutInflater mFactory & mFactory2,攔截控件對(duì)象創(chuàng)建過(guò)程
  • 2.過(guò)濾出我們需要換膚的控件的屬性
  • 3.下載服務(wù)器空殼apk資源泣港,加載空殼apk獲取到一個(gè)當(dāng)前皮膚的Resource skinResource
  • 4.通過(guò)解析換膚屬性的資源id在skinResource中尋找對(duì)應(yīng)的值暂殖,并設(shè)置給控件對(duì)象

步驟0:準(zhǔn)備好換膚對(duì)應(yīng)的界面

??由于只是例子講解,筆者就不搞的太復(fù)雜当纱,就弄一個(gè)Activity & 3個(gè)Fragment進(jìn)行實(shí)現(xiàn)央星,通過(guò)res資源color.xml文件中"main_color屬性進(jìn)行更換",代碼請(qǐng)到SwitchSkinDemo查看,這里不在啰嗦惫东。

??demo 演示gif如下所示: 待上傳

??點(diǎn)擊下載apk體驗(yàn)

步驟1:反射賦值LayoutInflater mFactory & mFactory2,攔截控件對(duì)象創(chuàng)建過(guò)程

??要想反射賦值到mFactory & mFactory2,我們首先要先獲取Activity對(duì)應(yīng)的LayoutInflater,因?yàn)樾枰總€(gè)存活的Activity都需要進(jìn)行反射賦值毙石,很容易聯(lián)想到廉沮,我們可以通過(guò)Application的registerActivityLifecycleCallbacks方法做到,話不多說(shuō)我們上代碼:

//皮膚管理類(lèi)
public class SkinManager {

    //單例對(duì)象
    private volatile static SkinManager INSTANCE;
    //Application對(duì)象
    private Application application;
    //皮膚名字集合
    private List<String> skinNames = new ArrayList<>();
    //記錄當(dāng)前應(yīng)用的皮膚名
    private String currentSkin = "skin_default.apk";
    //記錄默認(rèn)的皮膚名
    private static final String DEFAULT_SKIN_NAME = "skin_default.apk";
    //應(yīng)用Activity生命周期監(jiān)聽(tīng)
    private SkinActivityLifecycle skinActivityLifecycle;

    private SkinManager(){
        //初始化皮膚數(shù)據(jù)徐矩,當(dāng)然這里可以網(wǎng)絡(luò)下載即可滞时,但是為了方便
        //筆者就用assets目錄copy到本地目錄的方式模擬網(wǎng)絡(luò)加載皮膚過(guò)程
        skinNames.add("skin_blue.apk");
        skinNames.add("skin_red.apk");
        skinNames.add("skin_black.apk");
        skinNames.add("skin_green.apk");
        skinNames.add("skin_default.apk");
    }

    public List<String> getSkinData(){
        return skinNames;
    }

    /**
     * 切換皮膚
     * @param skinName
     */
    public void switchSkin(String skinName){
        this.currentSkin = skinName;
        skinActivityLifecycle.switchSkin();
    }

    /**
     * 是否是默認(rèn)皮膚
     * @return
     */
    public boolean isDefaultSkin(){
        return currentSkin.equals(DEFAULT_SKIN_NAME);
    }

    /**
     * 獲取到當(dāng)前的皮膚名
     * @return
     */
    public String getCurrentSkin(){
        return currentSkin;
    }

    public static SkinManager getInstance(){
        if(INSTANCE == null){
            synchronized (SkinManager.class){
                if(INSTANCE == null){
                    INSTANCE = new SkinManager();
                }
            }
        }

        return INSTANCE;
    }

    public Application getApplication(){
        return application;
    }

    /**
     * 皮膚管理初始化
     * @param app
     */
    public void initApp(Application app){
        this.application = app;
        //對(duì)所有Activity的聲明周期進(jìn)行監(jiān)聽(tīng)
        app.registerActivityLifecycleCallbacks(skinActivityLifecycle = new SkinActivityLifecycle());
    }

}

??因?yàn)榉?wù)器下載空殼apk的接口沒(méi)有做,這里筆者用asset目錄copy到本地目錄的方式去模擬從服務(wù)器下載空殼apk的過(guò)程滤灯,請(qǐng)讀者仔細(xì)閱讀以上代碼坪稽,筆者的皮膚切換機(jī)制里帶有5種皮膚,分別是:

  • skin_default.apk【黃色】
  • skin_blue.apk【藍(lán)色】
  • skin_red.apk【紅色】
  • skin_black.apk【黑色】
  • skin_green.apk【綠色】

??筆者皮膚的屬性只把包含color.xml下"main_color"這個(gè)資源字段鳞骤,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="purple_200">#FFBB86FC</color>
    <color name="purple_500">#FF6200EE</color>
    <color name="purple_700">#FF3700B3</color>
    <color name="teal_200">#FF03DAC5</color>
    <color name="teal_700">#FF018786</color>
    <color name="black">#FF000000</color>
    <color name="white">#FFFFFFFF</color>

    //皮膚主色
    <color name="main_color">#FFA500</color>

</resources>

??并且筆者先將這個(gè)"main_color"修改為對(duì)應(yīng)皮膚的顏色值窒百,然后進(jìn)行空殼打包,打完的包放進(jìn)了項(xiàng)目目錄下的assets目錄下豫尽,然后我們把皮膚空殼apk準(zhǔn)備好了篙梢,接下來(lái)我們就看看如何拿到每個(gè)Activity的LayoutInflater,然后反射賦值mFactory & mFactory2那兩個(gè)屬性美旧,請(qǐng)看筆者上述SkinManager類(lèi)中的initApp方法:

/**
     * 皮膚管理初始化
     * @param app
     */
    public void initApp(Application app){
        this.application = app;
        //對(duì)所有Activity的聲明周期進(jìn)行監(jiān)聽(tīng)
        app.registerActivityLifecycleCallbacks(skinActivityLifecycle = new SkinActivityLifecycle());
    }

??我們可以看到筆者是通過(guò)SkinActivityLifecycle對(duì)所有的Activity進(jìn)行生命周期監(jiān)聽(tīng)的,其代碼如下:

public class SkinActivityLifecycle implements Application.ActivityLifecycleCallbacks {

    private List<Activity> activeActivityList = new ArrayList<>();

    @Override
    @SuppressLint("SoonBlockedPrivateApi")
    public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle bundle) {
        activeActivityList.add(activity);
        LayoutInflater layoutInflater = LayoutInflater.from(activity);
        //反射setFactory2,Android Q及以上已經(jīng)失效-> 報(bào)not field 異常
        //Android Q以上setFactory2問(wèn)題
        //http://www.javashuo.com/article/p-sheppkca-ds.html
        forceSetFactory2(layoutInflater);
    }

    /**
     * 最新的方式渤滞,適配Android Q
     * @param inflater
     */
    private static void forceSetFactory2(LayoutInflater inflater) {
        Class<LayoutInflaterCompat> compatClass = LayoutInflaterCompat.class;
        Class<LayoutInflater> inflaterClass = LayoutInflater.class;
        try {
            Field sCheckedField = compatClass.getDeclaredField("sCheckedField");
            sCheckedField.setAccessible(true);
            sCheckedField.setBoolean(inflater, false);
            Field mFactory = inflaterClass.getDeclaredField("mFactory");
            mFactory.setAccessible(true);
            Field mFactory2 = inflaterClass.getDeclaredField("mFactory2");
            mFactory2.setAccessible(true);
            //自定義的Factory2
            SkinLayoutFactory skinLayoutFactory = new SkinLayoutFactory();
            mFactory2.set(inflater, skinLayoutFactory);
            mFactory.set(inflater, skinLayoutFactory);
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        }
    }

    @Override
    public void onActivityStarted(@NonNull Activity activity) {

    }

    @Override
    public void onActivityResumed(@NonNull Activity activity) {

    }

    @Override
    public void onActivityPaused(@NonNull Activity activity) {

    }

    @Override
    public void onActivityStopped(@NonNull Activity activity) {

    }

    @Override
    public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle bundle) {

    }

    @Override
    public void onActivityDestroyed(@NonNull Activity activity) {
       activeActivityList.remove(activity);
    }

    public void switchSkin(){
        for(Activity activity:activeActivityList){
            //重新使用資源
            if(!(activity instanceof SkinManagerActivity)) {
                activity.recreate();
            }
        }
    }
}

??在上述代碼中我們完成了mFactory & mFactory2的反射賦值贬墩,我們看到forceSetFactory2方法中,我們將SkinLayoutFactory對(duì)象通過(guò)反射賦值給了mFactory & mFactory2妄呕,那么SkinLayoutFactory我們應(yīng)該在它里面寫(xiě)哪些邏輯呢陶舞,聰明的你應(yīng)該知道m(xù)Factory2 & mFactory不過(guò)只是負(fù)責(zé)將XML中的控件標(biāo)簽映射為具體內(nèi)存中的控件對(duì)象,我們不僅要實(shí)現(xiàn)這個(gè)绪励,還要實(shí)現(xiàn)攔截并設(shè)置我們需要更換皮膚的屬性肿孵,接下來(lái)我們就來(lái)看看如何實(shí)現(xiàn)。

步驟2:過(guò)濾出我們需要換膚的控件的屬性

public class SkinLayoutFactory implements LayoutInflater.Factory2 {

    //具體攔截邏輯都在該類(lèi)里
    private SkinAttribute skinAttribute;

    public SkinLayoutFactory(){
        skinAttribute = new SkinAttribute();
    }

    //系統(tǒng)自帶的控件名包名路徑
    //因?yàn)椴季种袝?huì)直接使用<TextView沒(méi)帶全路徑的优炬,所以我們?cè)撌謩?dòng)加上
    private static final String[] systemViewPackage = {
            "androidx.widget.",
            "androidx.view.",
            "androidx.webkit.",
            "android.widget.",
            "android.view.",
            "android.webkit."
    };

    //反射控件對(duì)應(yīng)的構(gòu)造器而使用
    private static final Class[] mConstructorSignature = new Class[]{Context.class,AttributeSet.class};
    //存儲(chǔ)控件的構(gòu)造器颁井,避免重復(fù)創(chuàng)建
    private static final HashMap<String, Constructor<? extends View>> mConstructor = new HashMap<>();

    @Nullable
    @Override
    public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attributeSet) {
        View view = onCreateViewFromTag(name,context,attributeSet);
        if(view == null){
            view = onCreateView(name, context, attributeSet);
        }
        //篩選符合屬性的View
        skinAttribute.loadView(view,attributeSet);
        return view;
    }


    /**
     * 通過(guò)反射構(gòu)建控件對(duì)象
     * @param name
     * @param context
     * @param attributeSet
     * @return
     */
    @Nullable
    @Override
    public View onCreateView(@NonNull String name, @NonNull Context context, @NonNull AttributeSet attributeSet) {
        Constructor<? extends View> constructor = mConstructor.get(name);
        View view = null;
        if(constructor == null){
            try {
                Class<? extends View> viewClass = context.getClassLoader().loadClass(name).asSubclass(View.class);
                constructor = viewClass.getConstructor(mConstructorSignature);
                mConstructor.put(name,constructor);
            } catch (ClassNotFoundException | NoSuchMethodException e) {
                e.printStackTrace();
            }
        }
        if(constructor != null){
            try {
                view = constructor.newInstance(context,attributeSet);
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            } catch (InstantiationException e) {
                e.printStackTrace();
            } catch (InvocationTargetException e) {
                e.printStackTrace();
            }
        }
        return view;
    }

    private View onCreateViewFromTag(@NonNull String name, @NonNull Context context, @NonNull AttributeSet attributeSet){
        if(name.indexOf(".") > 0){
           //說(shuō)明XML中該控件帶有包名全路徑
        }
        View view = null;
        for(String packageName:systemViewPackage){
            view = onCreateView(packageName+name,context,attributeSet);
            if(view != null){
                break;
            }
        }
        return view;
    }
}

??這個(gè)類(lèi)的作用不用筆者多說(shuō)了,仔細(xì)看下代碼就會(huì)一目了然蠢护,它存在以下作用:

  • 1.將XML對(duì)應(yīng)的控件標(biāo)簽映射為對(duì)應(yīng)的具體控件對(duì)象雅宾,有具體包名則直接進(jìn)行反射構(gòu)建,無(wú)包名則需要先拼接對(duì)應(yīng)的全路徑包名然后再反射葵硕,例如TextView->android.widget.TextView
  • 2.攔截構(gòu)建出的控件對(duì)象眉抬,設(shè)置對(duì)應(yīng)的皮膚屬性

??看以上代碼,如下所示:

    @Nullable
    @Override
    public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attributeSet) {
        View view = onCreateViewFromTag(name,context,attributeSet);
        if(view == null){
            view = onCreateView(name, context, attributeSet);
        }
        //篩選符合屬性的View
        skinAttribute.loadView(view,attributeSet);
        return view;
    }

??SkinAttribute類(lèi)具體負(fù)責(zé)攔截邏輯,具體代碼如下所示:

public class SkinAttribute {

    //過(guò)濾出皮膚需要的屬性
    private static final List<String> ATTRIBUTE = new ArrayList<>();

    static {
        ATTRIBUTE.add("background");
        ATTRIBUTE.add("src");

        ATTRIBUTE.add("textColor");
        ATTRIBUTE.add("SkinTypeface");

        //TabLayout
        ATTRIBUTE.add("tabIndicatorColor");
        ATTRIBUTE.add("tabSelectedTextColor");
    }

    public void loadView(View view, AttributeSet attributeSet) {
        for (int i = 0; i < attributeSet.getAttributeCount(); i++) {
            String attributeName = attributeSet.getAttributeName(i);
            if (ATTRIBUTE.contains(attributeName)) {
                String attributeValue = attributeSet.getAttributeValue(i);
                if (attributeValue.startsWith("#")) {
                    //固定的Color值懈凹,無(wú)需修改
                } else {
                    int resId = 0;
                    //判斷前綴是否為蜀变?
                    int attrId = Integer.parseInt(attributeValue.substring(1));
                    if (attributeValue.startsWith("?")) {
                        int[] array = {attrId};
                        resId = SkinThemeUtils.getResId(view.getContext(), array)[0];
                    } else {
                        resId = attrId;
                    }
                    if (resId != 0) {
                        String skinName = SkinManager.getInstance().getCurrentSkin();
                        File skinFile = new File(view.getContext().getCacheDir(), skinName);
                        //拿到空殼App資源
                        if (!SkinManager.getInstance().isDefaultSkin()) {
                            //如果皮膚包不存在,那么先從asset里進(jìn)行拷貝到SD卡【模擬從服務(wù)器下載過(guò)程】
                            if (!skinFile.exists()) {
                                //復(fù)制文件
                                FileUtils.copyFileFromAssets(view.getContext(), skinName,
                                        view.getContext().getCacheDir().getAbsolutePath(), skinName);
                            }
                        }
                        SkinLoadApkPath skinLoadApkPath = new SkinLoadApkPath();
                        skinLoadApkPath.loadEmptyApkPath(skinFile.getAbsolutePath());
                        Resources skinResource = skinLoadApkPath.getSkinResource();
                        if (attributeName.equals("textColor")) {
                            TextView textView = (TextView) view;
                            textView.setTextColor(skinResource.getColorStateList(resId));
                        }
                        if (attributeName.equals("background")) {
                            view.setBackgroundColor(skinResource.getColor(resId));
                        }
                        if (attributeName.equals("tabIndicatorColor")) {
                            //TabLayout下劃線顏色
                            TabLayout tabLayout = (TabLayout) view;
                            tabLayout.setSelectedTabIndicatorColor(skinResource.getColor(resId));
                        }
                        if (attributeName.equals("tabSelectedTextColor")) {
                            //TabLayout選中文本顏色
                            TabLayout tabLayout = (TabLayout) view;
                            tabLayout.setTabTextColors(Color.BLACK, skinResource.getColor(resId));
                        }
                    }
                }
            }
        }

    }

}

??主要攔截設(shè)置皮膚屬性的邏輯都在loadView方法里介评,先遍歷控件對(duì)象對(duì)應(yīng)的AttributeSet库北,然后過(guò)濾出自己需要的皮膚屬性,負(fù)責(zé)過(guò)濾的集合是ATTRIBUTE们陆,拿到我們需要更改的控件對(duì)象以及需要修改的皮膚屬性寒瓦,我們思考一個(gè)問(wèn)題,如果想設(shè)置對(duì)應(yīng)的皮膚屬性坪仇,首先我們是不是要確定這個(gè)屬性使用哪個(gè)資源id?,如果你XML用了"?"方式使用了Style的資源杂腰,那么這時(shí)又該如何正確獲取該屬性使用的資源id呢?其具體代碼邏輯如下:

 int attrId = Integer.parseInt(attributeValue.substring(1));
 if (attributeValue.startsWith("?")) {
    int[] array = {attrId};
    resId = SkinThemeUtils.getResId(view.getContext(), array)[0];
 } else {
    resId = attrId;
 }

??如果你的XML使用了椅文?訪問(wèn)XML資源喂很,那么就需要使用SkinThemeUtils工具將其映射為具體的資源id,其代碼如下:

public class SkinThemeUtils {

    public static int[] getResId(Context context, int[] attrs){
        int[] ints = new int[attrs.length];
        TypedArray typedArray = context.obtainStyledAttributes(attrs);
        for (int i = 0; i < typedArray.length(); i++) {
            ints[i] =  typedArray.getResourceId(i, 0);
        }
        typedArray.recycle();
        return ints;
    }
}

??接下來(lái)我們是不是該解析空殼apk,然后再拿到對(duì)應(yīng)的Resource,然后通過(guò)對(duì)應(yīng)的Resource api已經(jīng)對(duì)應(yīng)的皮膚屬性名和資源id,這樣我們就能更改皮膚控件對(duì)應(yīng)的皮膚屬性值啦皆刺,從loadView方法看以下代碼:

if (resId != 0) {
    String skinName = SkinManager.getInstance().getCurrentSkin();
    File skinFile = new File(view.getContext().getCacheDir(), skinName);
    //拿到空殼App資源
    if (!SkinManager.getInstance().isDefaultSkin()) {
        //如果皮膚包不存在少辣,那么先從asset里進(jìn)行拷貝到SD卡【模擬從服務(wù)器下載過(guò)程】
        if (!skinFile.exists()) {
             //復(fù)制文件
            FileUtils.copyFileFromAssets(view.getContext(), skinName,
                 view.getContext().getCacheDir().getAbsolutePath(), skinName);
        }
    }
    SkinLoadApkPath skinLoadApkPath = new SkinLoadApkPath();
    skinLoadApkPath.loadEmptyApkPath(skinFile.getAbsolutePath());
    Resources skinResource = skinLoadApkPath.getSkinResource();
    if (attributeName.equals("textColor")) {
        TextView textView = (TextView) view;
        textView.setTextColor(skinResource.getColorStateList(resId));
    }
    ...... 

??從以上代碼看出SkinLoadApkPath類(lèi)就是我們負(fù)責(zé)加載空殼apk的類(lèi),接下來(lái)我們看看如何解析空殼apk獲取一個(gè)Resource對(duì)象:

3.下載服務(wù)器空殼apk資源芹橡,加載空殼apk獲取到一個(gè)當(dāng)前皮膚的Resource skinResource

public class SkinLoadApkPath {

    private Resources skinResources;

    public Resources getSkinResource(){
        return skinResources;
    }

    /**
     * 加載空殼Apk資源
     *
     * @param apkPath
     */
    public void loadEmptyApkPath(String apkPath) {
        try {
            Resources appResources = SkinManager.getInstance().getApplication().getResources();
            if(SkinManager.getInstance().isDefaultSkin()){
                //使用默認(rèn)資源毒坛,當(dāng)前應(yīng)用的Resource就是皮膚Resource
                skinResources = appResources;
            }else {
                //反射addAssetPath方法進(jìn)行解析空殼apk
                AssetManager assetManager = AssetManager.class.newInstance();
                Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
                addAssetPath.invoke(assetManager, apkPath);

                //使用空殼Apk資源,并傳入當(dāng)前App Resource的Metrics,Configuration獲取Resource
                skinResources = new Resources(assetManager,
                        appResources.getDisplayMetrics(), appResources.getConfiguration());
            }
        } catch (Exception e) {
            Log.d("Skin","發(fā)生異常");
        }
    }
}

步驟4:通過(guò)解析換膚屬性的資源id在skinResource中尋找對(duì)應(yīng)的值,并設(shè)置給控件對(duì)象

??空殼apk的Resource賦值到skinResources中了,SkinAttribute的loadView方法只需要傳入空殼apk的路徑即可獲取到皮膚對(duì)應(yīng)的Resource,接下來(lái)通過(guò)Resource的api,控件對(duì)象,資源id設(shè)置對(duì)應(yīng)的屬性值:

String skinName = SkinManager.getInstance().getCurrentSkin();
File skinFile = new File(view.getContext().getCacheDir(), skinName);
 //拿到空殼App資源
 if (!SkinManager.getInstance().isDefaultSkin()) {
    //如果皮膚包不存在煎殷,那么先從asset里進(jìn)行拷貝到SD卡【模擬從服務(wù)器下載過(guò)程】
    if (!skinFile.exists()) {
        //復(fù)制文件
        FileUtils.copyFileFromAssets(view.getContext(), skinName,
            view.getContext().getCacheDir().getAbsolutePath(), skinName);
    }
}
SkinLoadApkPath skinLoadApkPath = new SkinLoadApkPath();
skinLoadApkPath.loadEmptyApkPath(skinFile.getAbsolutePath());
Resources skinResource = skinLoadApkPath.getSkinResource();
if (attributeName.equals("textColor")) {
    TextView textView = (TextView) view;
        textView.setTextColor(skinResource.getColorStateList(resId));
}
if (attributeName.equals("background")) {
    view.setBackgroundColor(skinResource.getColor(resId));
}
if (attributeName.equals("tabIndicatorColor")) {
    //TabLayout下劃線顏色
    TabLayout tabLayout = (TabLayout) view;
    tabLayout.setSelectedTabIndicatorColor(skinResource.getColor(resId));
}
if (attributeName.equals("tabSelectedTextColor")) {
    //TabLayout選中文本顏色
    TabLayout tabLayout = (TabLayout) view;
    tabLayout.setTabTextColors(Color.BLACK, skinResource.getColor(resId));
}

??這里還要說(shuō)明一點(diǎn)屯伞,demo中皮膚管理界面切換相應(yīng)的皮膚時(shí),會(huì)出現(xiàn)短暫的黑屏閃爍現(xiàn)象豪直,其原因是調(diào)用了該界面的recreate方法導(dǎo)致的劣摇,為了更好的用戶體驗(yàn),此界面需要手動(dòng)在Activity添加邏輯進(jìn)行皮膚改變弓乙,這樣用戶在此界面切換皮膚時(shí)不會(huì)出現(xiàn)閃屏末融,并完成了皮膚切換效果,也就達(dá)到了網(wǎng)易云那種秒切效果暇韧。整體代碼如下:

Github整體代碼demo:SwitchSkinDemo

4.動(dòng)手打造換膚的輪子

??目前換膚筆者已經(jīng)封裝完畢勾习,只是文檔沒(méi)有寫(xiě),沒(méi)有發(fā)布到Jitpack上懈玻,等文檔寫(xiě)了巧婶,發(fā)布到Jitpack后,你就可以用到自己項(xiàng)目中啦涂乌,GitHub地址如下所示:

基于網(wǎng)易云換膚方案打造的輪子:LmySkinSwitcher

5.黑白夜模式切換

??以上已經(jīng)講解完了網(wǎng)易云換膚方案的原理艺栈,而且還實(shí)踐了代碼,最后造成一個(gè)可以換膚的輪子湾盒,那么黑白夜模式切換自然也是一個(gè)水到渠成的事情湿右,用上面的輪子去實(shí)踐一把吧,打兩個(gè)空殼apk,一個(gè)負(fù)責(zé)黑夜模式罚勾,一個(gè)負(fù)責(zé)白天模式毅人,還有個(gè)問(wèn)題是否跟隨系統(tǒng)的黑白夜模式?在Application中提供了一個(gè)方法onConfigurationChanged用來(lái)判斷當(dāng)前系統(tǒng)處于黑夜還是白天模式尖殃,代碼如下:

@Override
public void onConfigurationChanged(Configuration newConfig) {
    super.onConfigurationChanged(newConfig);
    if ((newConfig.uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO) {
        //白天模式
    } else if ((newConfig.uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES) {
        //黑夜模式
    }
}

??詳細(xì)的代碼筆者這里就不演示了堰塌,請(qǐng)讀者自行實(shí)踐哦!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末分衫,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子般此,更是在濱河造成了極大的恐慌蚪战,老刑警劉巖,帶你破解...
    沈念sama閱讀 219,366評(píng)論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件铐懊,死亡現(xiàn)場(chǎng)離奇詭異邀桑,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī)科乎,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,521評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門(mén)壁畸,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人,你說(shuō)我怎么就攤上這事捏萍√ィ” “怎么了?”我有些...
    開(kāi)封第一講書(shū)人閱讀 165,689評(píng)論 0 356
  • 文/不壞的土叔 我叫張陵令杈,是天一觀的道長(zhǎng)走敌。 經(jīng)常有香客問(wèn)我,道長(zhǎng)逗噩,這世上最難降的妖魔是什么掉丽? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 58,925評(píng)論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮异雁,結(jié)果婚禮上捶障,老公的妹妹穿的比我還像新娘。我一直安慰自己纲刀,他們只是感情好项炼,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,942評(píng)論 6 392
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著柑蛇,像睡著了一般芥挣。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上耻台,一...
    開(kāi)封第一講書(shū)人閱讀 51,727評(píng)論 1 305
  • 那天空免,我揣著相機(jī)與錄音,去河邊找鬼盆耽。 笑死蹋砚,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的摄杂。 我是一名探鬼主播坝咐,決...
    沈念sama閱讀 40,447評(píng)論 3 420
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼析恢!你這毒婦竟也來(lái)了墨坚?” 一聲冷哼從身側(cè)響起,我...
    開(kāi)封第一講書(shū)人閱讀 39,349評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤映挂,失蹤者是張志新(化名)和其女友劉穎泽篮,沒(méi)想到半個(gè)月后,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體柑船,經(jīng)...
    沈念sama閱讀 45,820評(píng)論 1 317
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡帽撑,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,990評(píng)論 3 337
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了鞍时。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片亏拉。...
    茶點(diǎn)故事閱讀 40,127評(píng)論 1 351
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡扣蜻,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出及塘,到底是詐尸還是另有隱情莽使,我是刑警寧澤,帶...
    沈念sama閱讀 35,812評(píng)論 5 346
  • 正文 年R本政府宣布磷蛹,位于F島的核電站吮旅,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏味咳。R本人自食惡果不足惜庇勃,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,471評(píng)論 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望槽驶。 院中可真熱鬧责嚷,春花似錦、人聲如沸掂铐。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 32,017評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)全陨。三九已至爆班,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間辱姨,已是汗流浹背柿菩。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 33,142評(píng)論 1 272
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留雨涛,地道東北人枢舶。 一個(gè)月前我還...
    沈念sama閱讀 48,388評(píng)論 3 373
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像替久,于是被迫代替她去往敵國(guó)和親凉泄。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,066評(píng)論 2 355

推薦閱讀更多精彩內(nèi)容