美文网首页
【靶点突破】网易云换肤方案探讨

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

作者: ClAndEllen | 来源:发表于2022-02-15 16:13 被阅读0次

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

    • 老方案
    • 网易云音乐换肤方案原理
    • 动手实现一个网易云换肤方案的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) {
            //黑夜模式
        }
    }
    

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

    相关文章

      网友评论

          本文标题:【靶点突破】网易云换肤方案探讨

          本文链接:https://www.haomeiwen.com/subject/puqhlrtx.html