【靶点突破】网易云换肤方案探讨

【靶点突破】网易云换肤方案探讨

  • 老方案
  • 网易云音乐换肤方案原理
  • 动手实现一个网易云换肤方案的demo
  • 动手打造换肤方案的轮子
  • 黑白夜模式切换

  Hello,大家好,我是Ellen,这是Android靶点突破系列文章,旨在帮助你更加了解Android技术开发的同时,把业务做到精致。思考自己的职业生涯,想成为怎样的技术人,想追求怎么样的生活。

至尊宝脚踏七彩祥云娶了紫霞,希望你也能成为她的自尊宝。
| from Ellen缘言

1.老方案

  App皮肤切换老方案分为2点:

  • 1.设置不同的Style,结合Activity的recreate & setTheme方法
  • 2.通过全局Setting进行修改,回调通知所有存活的Activity & Fragment & Dialog等

  如果是老的项目突然需要添加换肤功能,那么这将是一个极大的劳动工程,费时又费力,而且随着皮肤的增多,你的资源文件会越来越大,这首先很不方便管理,而且还会让apk的体积越来越大,开发起来吃力,用户体验也不好。
  对于老方案的实现代码我这里就不讲解了,我会贴一个Github项目代码,读者可以自行去看看瞧瞧,代码注释写的很清晰,注意的是这里笔者只实现了Style & Setting两种方式,Style方式是切换Theme的方式,需要配置不同的style和自定义属性,Setting方式则更为灵活,它是通过属性对界面的皮肤进行控制,每个界面收到回调然后进行切换,还有其它很多实现方式,但核心缺点都是一样的,包体积越来越臃肿,管理性越来越差,我们重点要实现网易云音乐的换肤方案,这才是换肤的王道。当然你可以通过后端配置方式将资源都放在接口里,比较占apk的图片资源用url的方式,但是无疑增加皮肤切换的业务逻辑复杂度,随着项目业务越来越多,负责皮肤的bean对象也许会越来越多的属性。

  老方案:OldSwitchSkinDemo

2.网易云音乐换肤方案原理

  网易云音乐相信你使用过,它的换肤可以算是秒切,那么它是怎样做到的呢?我们先来看看它的原理,然后追求精致,我们也要实现这种秒切皮肤的效果。
  我们来看看,它的原理需要了解的如下:

  • 1.LayoutInflater mFactory & mFactory2 反射替代成自定义的
  • 2.解析空壳apk获取Resource替代原有的App Resource
步骤1:LayoutInflater mFactory & mFactory2 反射替代成自定义的

  LayoutInflater通常我们用来解析布局文件的,将布局文件映射成一个一个的控件对象,下列代码就是将布局item_skin_manager映射为一个View对象:

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

  那么它是如何将布局文件映射为View对象的呢,我们来看看Android SDK版本31下inflate方法的源码:

public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
	//**********注意点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;
    }
	//**********注意点2
    XmlResourceParser parser = res.getLayout(resource);
    try {
	    //**********注意点3
        return inflate(parser, root, attachToRoot);
    } finally {
        parser.close();
    }
}

  请注意上方代码笔者标注的"注意点1"和"注意点2"以及"注意点3",后面我直接简称为点1和点2以及点3,从点1中我们可以看到它是获取了一个Resource res,再从点2看到,它获取了一个XML解析负责相关的类XmlResourceParser parser,这个parser应该提供了XML解析相关的,那么我们接下来看看点3标注的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 {
				//**********注意点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;
    }
}

  看点4老外注释的, Temp is the root view that was found in the xml,大概意思就是说Temp 是在 xml 中找到的根视图,原来我们的xml布局是这样的解析的哦,我们再来看看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 {
		//***********注意点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;
    }
}

  我们再看点5,它通过tryCreateView方法获取到一个View,这个View就是Temp了,也就是解析布局获取到的View对象,我们在来看看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) {
		//*******注意点6
        view = mFactory2.onCreateView(parent, name, context, attrs);
    } else if (mFactory != null) {
		//*******注意点7
        view = mFactory.onCreateView(name, context, attrs);
    } else {
        view = null;
    }

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

    return view;
}

  我们看到点6和点7,原来我们的View都是通过mFactory2或 mFactory创建出来的,我们看看下面代码:

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内私有属性成员,那么我们是否可以通过反射拦截XML解析成具体控件对象的过程呢?只要拦截了,那么我们是否可以拿到控件对象任性设置自己要的皮肤属呢?如果是通过设置属性的方式进行切换,那么我们估计也还是会像老方案那样,只会越来越复杂,那么怎么办呢?我们拿到控件对象啦,还记得前面提到的Resource,它是负责整个控件体系的资源设置的类,同样的原理,我们是否可以通过我们的Resource来进行设置呢,我们再来看看Resource是如何来的:

步骤2:解析空壳apk获取Resource替代原有的App Resource

  通过上图我们可以确定Resource通过AssetManager来加载的,Asset是不是很熟悉,它是asset目录啊,怎么会加载项目的资源呢?难道它还可以解析目录下资源吗?
我们接着看看这个方法:

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

  虽然这个方法是public的,但是被隐藏掉了,我们只能通过反射进行调用,也就是方案已经很明了,就是我们将每个皮肤的资源打进空壳apk内,然后通过AssetManager的addAssetPath方法解析空壳apk的资源,获取到一个Resource,然后我们通过反射LayoutInflater赋值自定义的mFactory&mFactory2来拦截控件创建过程,进行属性的替换,眼下我们还存在一个问题,那么如何new一个Resource对象,并且将空壳apk的资源打进去呢?我们看看Resource的构造器:

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

  惊喜且意外的发现Resources(AssetManager assets, DisplayMetrics metrics, Configuration config)这个构造器完全满足我们的需求,但是metrics和config是啥呢,没关系,我们通过获取当前的Resource,将当前的Resource的metrics和config传进去即可,我们只需要设置我们重要的解析空客apk的assets即可,然后通过Resource为我们提供的解析资源的api给拦截的控件对象设置对应的皮肤属性即可。

3.动手实现一个网易云换肤方案的demo

  经过网易云音乐换肤方案原理分析,我们要实现换肤的步骤如下:

  • 0.准备好换肤对应的界面
  • 1.反射赋值LayoutInflater mFactory & mFactory2,拦截控件对象创建过程
  • 2.过滤出我们需要换肤的控件的属性
  • 3.下载服务器空壳apk资源,加载空壳apk获取到一个当前皮肤的Resource skinResource
  • 4.通过解析换肤属性的资源id在skinResource中寻找对应的值,并设置给控件对象
步骤0:准备好换肤对应的界面

  由于只是例子讲解,笔者就不搞的太复杂,就弄一个Activity & 3个Fragment进行实现,通过res资源color.xml文件中"main_color属性进行更换",代码请到SwitchSkinDemo查看,这里不在啰嗦。

  demo 演示gif如下所示: 待上传

  点击下载apk体验

步骤1:反射赋值LayoutInflater mFactory & mFactory2,拦截控件对象创建过程

  要想反射赋值到mFactory & mFactory2,我们首先要先获取Activity对应的LayoutInflater,因为需要每个存活的Activity都需要进行反射赋值,很容易联想到,我们可以通过Application的registerActivityLifecycleCallbacks方法做到,话不多说我们上代码:

//皮肤管理类
public class SkinManager {

    //单例对象
    private volatile static SkinManager INSTANCE;
    //Application对象
    private Application application;
    //皮肤名字集合
    private List<String> skinNames = new ArrayList<>();
    //记录当前应用的皮肤名
    private String currentSkin = "skin_default.apk";
    //记录默认的皮肤名
    private static final String DEFAULT_SKIN_NAME = "skin_default.apk";
    //应用Activity生命周期监听
    private SkinActivityLifecycle skinActivityLifecycle;

    private SkinManager(){
        //初始化皮肤数据,当然这里可以网络下载即可,但是为了方便
        //笔者就用assets目录copy到本地目录的方式模拟网络加载皮肤过程
        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();
    }

    /**
     * 是否是默认皮肤
     * @return
     */
    public boolean isDefaultSkin(){
        return currentSkin.equals(DEFAULT_SKIN_NAME);
    }

    /**
     * 获取到当前的皮肤名
     * @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;
        //对所有Activity的声明周期进行监听
        app.registerActivityLifecycleCallbacks(skinActivityLifecycle = new SkinActivityLifecycle());
    }

}

  因为服务器下载空壳apk的接口没有做,这里笔者用asset目录copy到本地目录的方式去模拟从服务器下载空壳apk的过程,请读者仔细阅读以上代码,笔者的皮肤切换机制里带有5种皮肤,分别是:

  • skin_default.apk【黄色】
  • skin_blue.apk【蓝色】
  • skin_red.apk【红色】
  • skin_black.apk【黑色】
  • skin_green.apk【绿色】

  笔者皮肤的属性只把包含color.xml下"main_color"这个资源字段,如下所示:

<?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>

  并且笔者先将这个"main_color"修改为对应皮肤的颜色值,然后进行空壳打包,打完的包放进了项目目录下的assets目录下,然后我们把皮肤空壳apk准备好了,接下来我们就看看如何拿到每个Activity的LayoutInflater,然后反射赋值mFactory & mFactory2那两个属性,请看笔者上述SkinManager类中的initApp方法:

/**
     * 皮肤管理初始化
     * @param app
     */
    public void initApp(Application app){
        this.application = app;
        //对所有Activity的声明周期进行监听
        app.registerActivityLifecycleCallbacks(skinActivityLifecycle = new SkinActivityLifecycle());
    }

  我们可以看到笔者是通过SkinActivityLifecycle对所有的Activity进行生命周期监听的,其代码如下:

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及以上已经失效-> 报not field 异常
        //Android Q以上setFactory2问题
        //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对象通过反射赋值给了mFactory & mFactory2,那么SkinLayoutFactory我们应该在它里面写哪些逻辑呢,聪明的你应该知道mFactory2 & mFactory不过只是负责将XML中的控件标签映射为具体内存中的控件对象,我们不仅要实现这个,还要实现拦截并设置我们需要更换皮肤的属性,接下来我们就来看看如何实现。

步骤2:过滤出我们需要换肤的控件的属性
public class SkinLayoutFactory implements LayoutInflater.Factory2 {

    //具体拦截逻辑都在该类里
    private SkinAttribute skinAttribute;

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

    //系统自带的控件名包名路径
    //因为布局中会直接使用<TextView没带全路径的,所以我们该手动加上
    private static final String[] systemViewPackage = {
            "androidx.widget.",
            "androidx.view.",
            "androidx.webkit.",
            "android.widget.",
            "android.view.",
            "android.webkit."
    };

    //反射控件对应的构造器而使用
    private static final Class[] mConstructorSignature = new Class[]{Context.class,AttributeSet.class};
    //存储控件的构造器,避免重复创建
    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;
    }


    /**
     * 通过反射构建控件对象
     * @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){
           //说明XML中该控件带有包名全路径
        }
        View view = null;
        for(String packageName:systemViewPackage){
            view = onCreateView(packageName+name,context,attributeSet);
            if(view != null){
                break;
            }
        }
        return view;
    }
}

  这个类的作用不用笔者多说了,仔细看下代码就会一目了然,它存在以下作用:

  • 1.将XML对应的控件标签映射为对应的具体控件对象,有具体包名则直接进行反射构建,无包名则需要先拼接对应的全路径包名然后再反射,例如TextView->android.widget.TextView
  • 2.拦截构建出的控件对象,设置对应的皮肤属性

  看以上代码,如下所示:

    @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类具体负责拦截逻辑,具体代码如下所示:

public class SkinAttribute {

    //过滤出皮肤需要的属性
    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值,无需修改
                } 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里进行拷贝到SD卡【模拟从服务器下载过程】
                            if (!skinFile.exists()) {
                                //复制文件
                                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));
                        }
                    }
                }
            }
        }

    }

}

  主要拦截设置皮肤属性的逻辑都在loadView方法里,先遍历控件对象对应的AttributeSet,然后过滤出自己需要的皮肤属性,负责过滤的集合是ATTRIBUTE,拿到我们需要更改的控件对象以及需要修改的皮肤属性,我们思考一个问题,如果想设置对应的皮肤属性,首先我们是不是要确定这个属性使用哪个资源id?,如果你XML用了"?"方式使用了Style的资源,那么这时又该如何正确获取该属性使用的资源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使用了?访问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;
    }
}

  接下来我们是不是该解析空壳apk,然后再拿到对应的Resource,然后通过对应的Resource api已经对应的皮肤属性名和资源id,这样我们就能更改皮肤控件对应的皮肤属性值啦,从loadView方法看以下代码:

if (resId != 0) {
    String skinName = SkinManager.getInstance().getCurrentSkin();
    File skinFile = new File(view.getContext().getCacheDir(), skinName);
    //拿到空壳App资源
    if (!SkinManager.getInstance().isDefaultSkin()) {
        //如果皮肤包不存在,那么先从asset里进行拷贝到SD卡【模拟从服务器下载过程】
        if (!skinFile.exists()) {
             //复制文件
            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类就是我们负责加载空壳apk的类,接下来我们看看如何解析空壳apk获取一个Resource对象:

3.下载服务器空壳apk资源,加载空壳apk获取到一个当前皮肤的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()){
                //使用默认资源,当前应用的Resource就是皮肤Resource
                skinResources = appResources;
            }else {
				//反射addAssetPath方法进行解析空壳apk
                AssetManager assetManager = AssetManager.class.newInstance();
                Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
                addAssetPath.invoke(assetManager, apkPath);

                //使用空壳Apk资源,并传入当前App Resource的Metrics,Configuration获取Resource
                skinResources = new Resources(assetManager,
                        appResources.getDisplayMetrics(), appResources.getConfiguration());
            }
        } catch (Exception e) {
            Log.d("Skin","发生异常");
        }
    }
}
步骤4:通过解析换肤属性的资源id在skinResource中寻找对应的值,并设置给控件对象

  空壳apk的Resource赋值到skinResources中了,SkinAttribute的loadView方法只需要传入空壳apk的路径即可获取到皮肤对应的Resource,接下来通过Resource的api,控件对象,资源id设置对应的属性值:

String skinName = SkinManager.getInstance().getCurrentSkin();
File skinFile = new File(view.getContext().getCacheDir(), skinName);
 //拿到空壳App资源
 if (!SkinManager.getInstance().isDefaultSkin()) {
    //如果皮肤包不存在,那么先从asset里进行拷贝到SD卡【模拟从服务器下载过程】
    if (!skinFile.exists()) {
        //复制文件
        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));
}

  这里还要说明一点,demo中皮肤管理界面切换相应的皮肤时,会出现短暂的黑屏闪烁现象,其原因是调用了该界面的recreate方法导致的,为了更好的用户体验,此界面需要手动在Activity添加逻辑进行皮肤改变,这样用户在此界面切换皮肤时不会出现闪屏,并完成了皮肤切换效果,也就达到了网易云那种秒切效果。整体代码如下:

Github整体代码demo:SwitchSkinDemo

4.动手打造换肤的轮子

  目前换肤笔者已经封装完毕,只是文档没有写,没有发布到Jitpack上,等文档写了,发布到Jitpack后,你就可以用到自己项目中啦,GitHub地址如下所示:

基于网易云换肤方案打造的轮子:LmySkinSwitcher

5.黑白夜模式切换

  以上已经讲解完了网易云换肤方案的原理,而且还实践了代码,最后造成一个可以换肤的轮子,那么黑白夜模式切换自然也是一个水到渠成的事情,用上面的轮子去实践一把吧,打两个空壳apk,一个负责黑夜模式,一个负责白天模式,还有个问题是否跟随系统的黑白夜模式?在Application中提供了一个方法onConfigurationChanged用来判断当前系统处于黑夜还是白天模式,代码如下:

@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) {
        //黑夜模式
    }
}

  详细的代码笔者这里就不演示了,请读者自行实践哦!

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值