欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  移动技术

Android:换肤技术讲解

程序员文章站 2022-07-04 10:58:03
主题,是许多app必备的一个功能,用户可以根据自己的喜好,来切换具有个性的主题,同时能让我们的app更具把玩性。这篇博文就来聊聊皮肤切换的原理,效果图如下: 这里为了便于理解,在换肤的时候,只是简...

主题,是许多app必备的一个功能,用户可以根据自己的喜好,来切换具有个性的主题,同时能让我们的app更具把玩性。这篇博文就来聊聊皮肤切换的原理,效果图如下:
Android:换肤技术讲解

这里为了便于理解,在换肤的时候,只是简单切换背景图片,文件颜色和背景色
这篇博文将用到一下知识点:

classloader:实例化控件 packagemanager:拿到插件的包信息 反射:拿到插件的resource layoutinflaterfactory:解析xml

一、思路

首先通过layoutinflatercompat的setfactory方法设置自定义的layoutinflaterfactory,并实现oncreateview,我们可以在该方法中解析xml的每一个节点(即view ),先通过组件名创建对应的view ,再遍历每一个view的 attrs属性和值,并以map保存,以便后续调用(即皮肤资源的切换)。
我们知道,在android中是通过resource来获取资源的,若能获取插件的resource对象,那么就可以获取其图片等资源,到达换肤的目的,说得简单点,换肤就是换resource和packname。
在拿到插件的resource之后,就可以通过resource id 来给每一个view设置其属性(如background)
当然,道理想必大家都懂,show me your code ~

二、偷梁换柱,换掉应用的layoutinflaterfactory

我们需要持切换皮肤的组件,因此创建skinfactory类实现layoutinflaterfactory接口,并实现该接口中的方法oncreateview

 /**
     * 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.
     *
     * @param parent the parent that the created view will be placed
     * in; note that this may be null.
     * @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.
     */
    view oncreateview(view parent, string name, context context, attributeset attrs);

显然这是一个hook, 执行layoutinflater.inflate()的时候调用,如上所述,我们可以通过该方法获取每一个节点的属性和值(即资源id),资源类型(drawable 、color 等)。先简单介绍这四个参数:

parent:即当前节点的父类节点,可能为null name :节点名,列如 textview context :该执行过程的上下文 attrs:该节点的属性集合,例如 background属性

那么,我们怎么通过节点来创建对应的组件对象呢?我们都知道在android.widget包下的button在布局文件中的节点名只有button,并不是完整的包路径,例如


以及android.view包下的surfaceview等等。


想必读者明白列举以上的用意了,对,我们需要先对获取到的节点名字进行处理,判断获取到的节点名是组件,还是自定义组件,从而构建完整的class name 。如下代码

    private static final string[] prefixlist = {
            "android.widget.",
            "android.view.",
            "android.webkit."
    };  //这些都是系统组件
    @override
    public view oncreateview(view parent, string name, context context, attributeset attrs) {
        view view = null;
        if (name.indexof(".") == -1) {
            //系统控件
            for (string prix : prefixlist) {
                view = createview(context, attrs, prix + name);
                if (null != view) {
                    break;
                }
            }
        } else {
            //自定义控件
            view = createview(context, attrs, name);
        }
        if (null != view) {
            parseskinview(view, context, attrs);
        }
        return view;
    }

这里需要我们返回一个view,即该组件对应的view,既然能拿到组件对应的class name,那就好办,直接通过classloader去load一个class即可

    //创建一个view
    private view createview(context context, attributeset attrs, string name) {
        try {
            //实例化一个控件
            class clarr = context.getclassloader().loadclass(name);
            constructor constructor =
                    clarr.getconstructor(new class[]{context.class, attributeset.class});
            constructor.setaccessible(true);
            return constructor.newinstance(context, attrs);
        } catch (classnotfoundexception e) {
            e.printstacktrace();
        } catch (nosuchmethodexception e) {
            e.printstacktrace();
        } catch (illegalaccessexception e) {
            e.printstacktrace();
        } catch (instantiationexception e) {
            e.printstacktrace();
        } catch (invocationtargetexception e) {
            e.printstacktrace();
        }
        return null;
    }

在拿到该控件后,需要遍历其需要替换值的属性,例如background,存放在list集合中。

    //找到需要换肤的控件
    private void parseskinview(view view, context context, attributeset attrs) {
        list attrlist = new arraylist<>();
        for (int i = 0; i < attrs.getattributecount(); i++) {
            //拿到属性名
            string attrname = attrs.getattributename(i);
            string attrvalue = attrs.getattributevalue(i);
            int id =-1;
            string entryname ="";

            string typename ="";

            skininterface skininterface = null ;

            switch (attrname) {
                case "background"://需要进行换肤
                    id = integer.parseint(attrvalue.substring(1));
                    entryname = context.getresources().getresourceentryname(id);
                    typename = context.getresources().getresourcetypename(id);
                    skininterface = new backgroundskin(attrname,id,entryname,typename);
                    break;
                case "textcolor":
                    id = integer.parseint(attrvalue.substring(1));
                    entryname = context.getresources().getresourceentryname(id);
                    typename = context.getresources().getresourcetypename(id);
                    skininterface = new textskin(attrname,id,entryname,typename);
                    break;
                default:
                    break;
            }
            if(null != skininterface){
                attrlist.add(skininterface);
            }

        }
        skinitem skinitem = new skinitem(attrlist,view);
        map.put(view,skinitem);
        //在这里进行应用,判断是皮肤资源还是本地资源
        skinitem.apply();
    }

为了方便属性的替换,这里用skinitem对象来持有view和view对应的属性集合list。

  class skinitem {
        public skinitem(list attrlist, view view) {
            this.attrlist = attrlist;
            this.view = view;
        }

        public list attrlist;
        public view view;
        //更新组件资源,调用skininterface 的实现类
        public void apply() {
            for (skininterface skininterface : attrlist) {
                skininterface.apply(view);
            }
        }
    }

在进行皮肤切换的时候,有设置background的,有设置textcolor的,但他们都需要以下参数

组件的属性名称,例如 background 组件引用资源的id (integer 类型) 组件引用资源的名称,例如 app_icon 组件引用资源的类型,例如 drawable

所以我们这里可以抽象出一个类skininterface,所有需要换肤的实现类都继承该类

public abstract class skininterface {
    string attrname;
    int refid = 0;
    string attrvaluename;
    string attrtype;
    public skininterface(string attrname, int refid, string attrvaluename, string attrtype) {
        this.attrname = attrname;
        this.refid = refid;
        this.attrtype = attrtype;
        this.attrvaluename = attrvaluename;
    }
   /**
     * 执行具体切换工作 
     * @param view 作用对象
     */
    public abstract void apply(view view);
}

列如skininterface的继承类 textskin

public class textskin extends skininterface {
    public textskin(string attrname, int refid, string attrvaluename, string attrtype) {
        super(attrname, refid, attrvaluename, attrtype);
    }

    @override
    public void apply(view view) {
        if(view instanceof textview){
            textview textview = (textview)view ;
            textview.settextcolor(skinmanager.getinstance().getcolor(refid));
        }
    }
}

还有backgroundskin类的实现

public class backgroundskin extends skininterface{
    private static final string tag = "backgroundskin";

    public backgroundskin(string attrname, int refid, string attrvaluename, string attrtype) {
        super(attrname, refid, attrvaluename, attrtype);
    }

    @override
    public void apply(view view) {
        if("color".equals(attrtype)){
            view.setbackgroundcolor(skinmanager.getinstance().getcolor(refid));
        }else if("drawable".equals(attrtype)){
            view.setbackgrounddrawable(skinmanager.getinstance().getdrawable(refid));
        }
    }
}

最后在skinfactory中提供一个更新的方法,来实现资源的替换工作

   public void update() {
        for(view view : map.keyset()){
            if(null == view){
                continue;
            }
            map.get(view).apply();
        }
    }

总之一句话,skinfactory 负责创建view并获取其属性名和值,以及后续的切换资源工作

三、resource的中心枢纽——skinmanager

上一节在讲到皮肤切换具体实现类的时候,涉及到skinmanager对象,他就是resource的主要负责人,负责返回组件所需要的资源。
回想一下,我们是如何在activity中获取资源的?是不是通过getresources().get……方法?显然我们需要获取插件的resource对象,才能拿到插件里的资源,先来看看resource的构造函数

  @deprecated
    public resources(assetmanager assets, displaymetrics metrics, configuration config) {
        this(null);
        mresourcesimpl = new resourcesimpl(assets, metrics, config, new displayadjustments());
    }

这里需要三个参数?有什么办法,那就给他咯~
首先看assetmanager ,他有两个构造函数,一个是hide的,一个是private的,均不能直接new 出来,这个好办~~

assetmanager  assetmanager = assetmanager.class.newinstance();

assetmanager 有一个addassetpath方法可以通过文件路径来加载资源,但也是hide状态,怎么办?easy !反射啦~~

 method method = assetmanager.class.getmethod("addassetpath", string.class);
 method.invoke(assetmanager, path);

这样我们就顺利滴拿到了插件的assetmanager对象,剩下的两个参数就直接使用宿主项目上下文的resource的默认值即可

resources resources = context.getresources();
resources  skinresource = new resources(assetmanager, resources.getdisplaymetrics(),
                    resources.getconfiguration());

于是乎,就这样顺利的拿到了插件的resource对象,但是我们还需要获取插件的包名

packagemanager packagemanager = context.getpackagemanager();
packageinfo packageinfo = packagemanager.getpackagearchiveinfo(path, packagemanager.get_activities);
string skinpackage = packageinfo.packagename;

获取资源不是通过resource吗,为什么还需要插件的packagename 呢?接着往下看

在获取resource对象后,就可以提供接口给其他类获取资源了,例如获取color

 public int getcolor(int refid) {
        if (null == skinresource) {
            return refid;
        }
        string resname = context.getresources().getresourceentryname(refid);
        int realid = skinresource.getidentifier(resname, "color", skinpackage);
        int color;
        if (android.os.build.version.sdk_int >= android.os.build.version_codes.m) {
            color = skinresource.getcolor(realid, null);
        } else {
            color = skinresource.getcolor(realid);
        }
        return color;
    }

看到这里,想必大家知道packagename的用处了吧?就是获取插件同资源名对应的id,然后再通过插件的id 获取对应的资源,获取drawable同理

   public drawable getdrawable(@drawableres int refid) {
        drawable drawable = contextcompat.getdrawable(context, refid);
        if (null == skinresource) {
            return drawable;
        }
        string resname = context.getresources().getresourceentryname(refid);
        int resid = skinresource.getidentifier(resname, "drawable", skinpackage);
        return skinresource.getdrawable(resid);
    }

这样skinmanager 就创建完成了

四、创建基类

大家说得好,万物基于…..基类~~,这里我们需要创建一个抽象的skinbaseactivity,凡是需要进行换肤的activity都要继承该类

public abstract class skinbaseactivity extends activity {
    private skinfactory skinfactory ;
    @override
    protected void oncreate(bundle savedinstancestate) {
        super.oncreate(savedinstancestate);
        skinfactory = new skinfactory();
        //设置当前activity解析xml的工厂类
        layoutinflatercompat.setfactory(getlayoutinflater(),skinfactory );//layoutinflaterfactory
    }
    //手动更换皮肤
    public void update(){
        skinfactory.update();
    }

}

然后在mainactivity中继承该类,并将skinmanager初始化

public class mainactivity extends skinbaseactivity {

    private static final string tag = "mainactivity ";

    @override
    protected void oncreate(bundle savedinstancestate) {
        super.oncreate(savedinstancestate);
        skinmanager.getinstance().init(this);
        setcontentview(r.layout.activity_main);
    }
}

在进行皮肤切换的时候执行(要确保file的路径正确,否则会出错)

   public void change(view view) {
        string path = new file(environment.getexternalstoragedirectory(), "skin.apk").getabsolutepath();
        skinmanager.getinstance().loadskin(path);
        update();
    }

这里要注意加上权限,并到权限管理中心给该应用读写权限

    
    

那么,如何恢复默认皮肤呢?很简单,将skinmanager中的resource,packagename替换为当前应用的即可

   public void back(view view) {
        skinmanager.getinstance().setskinresource(getresources());
        update();
    }

五、run it!

创建一个module,按照宿主apk的资源名重新建立新的资源即可,选择module,打包成apk,再将apk copy到手机的根目录下,在as中切换到宿主apk,将宿主项目打包到手机,即可
至此,换肤技术就讲解完毕