04插件化换肤技术实战

Posted 清风百草

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了04插件化换肤技术实战相关的知识,希望对你有一定的参考价值。

(1)使用插件化的方案为App换肤
(2)不需要重启App就能够换肤
(3)市场上所有的APP都可以当成自己的皮肤包来用。
(4)无闪烁
(5)便于扩展与维护,入侵性很小。
(6)只需要在Application初始化一次即可使用
(7)喜欢什么样的皮肤包,就可以将它的apk包拿过来就可以了。

【04】插件化换肤技术实战

1.插件化换肤需要了解的技术

1.1LayoutInflater.inflate

在这里插入图片描述

(1)从PhoneWindow中进入到setContentView方法
(2)installDecor()完成了之后,Activity的整个布局就是Activity上面放了一个PhoneWindow,PhoneWindow上面又放了一个DecorView.
(3)DecorView的加载实际上加载的是预编译时期选择的不同的主题,在frameWork里面去搜过文件可以清楚其具体的布局。
(4)Activity的布局文件是通过LayoutInflater进行加载的,其最主要的功能是通过带3个参数的inflate方法实现的。

  • createviewFromTag是通过反射来生成对象,这个对象实际上是不带参数的,会帮我们造一个参数。

  • 如果根布局存在,就通过generateLayoutParams()将根布局的参数造出来,造出来之后,需要根据inflate()的第三个参数attachToRoot为false的情况,才将参数填充进去。

  • 正常的代码,系统在运行的时候,基本上值都是为true的,都是通过往root上去添加这个View.然后直接将参数填充进去。

  • 用第三个参数,实际上就是将系统使用的与用户用的将它隔离开来。

  • 我们自己在使用的时候,经常将第三个参数写为false,如果为true,就直接报出异常。

android.view.LayoutInflater#inflate(org.xmlpull.v1.XmlPullParser, android.view.ViewGroup, boolean)

是因为系统在设计View系统的时候,它的希望值是所有的View能够以树形结构来摆放。树形结构的特点就是每一个节点都只有一个父亲。

即在调用addView方法的时候,只要这个View有父亲,就抛出异常,因此childView是不能够有父亲的。

android.view.ViewGroup#addViewInner

if (child.getParent() != null) {
            throw new IllegalStateException("The specified child already has a parent. " +
                    "You must call removeView() on the child's parent first.");
        }

也就是说inflate方法的第三个参数为false的时候,没有去调用addView方法。

没有addView在实现代码中,在LinearLayout自己去写了一个属性,虽然在布局文件中看起来有一个属性,例如layout_width=“110dp”,在没有addView之前,这个值是毫无意义的,是取不到的。即在父控件上去拿这个值是拿不到的。

1.2Factory2

android.view.LayoutInflater.Factory2
android.view.LayoutInflater#tryCreateView

(1)这个factory是一个空的接口,仅仅声明了一个onCreateView方法
(2)它将createVeiw的过程交给了程序员,如果我们去设置一个工厂,在View里面只创建了一个Button,程序执行之后,就只能看到Button.
(3)具体重写Factory2创建View的方法,后续加上。
(4)android.view.LayoutInflater#createViewFromTag(android.view.View, java.lang.String, android.content.Context, android.util.AttributeSet, boolean)

这个方法是不是只执行了一次

android.view.LayoutInflater#rInflateChildren
android.view.LayoutInflater#rInflate

它会通过一个循环用pull解析,不断遍历标签,只要不到根节点,就用一个where循环去加载我们的View,还是调用的createViewFromTag(),所以整个布局里面的每一个View都会执行这一个方法。

while (((type = parser.next()) != XmlPullParser.END_TAG ||
                parser.getDepth() > depth) && type != XmlPullParser.END_DOCUMENT) {

            if (type != XmlPullParser.START_TAG) {
                continue;
            }

            final String name = parser.getName();

            if (TAG_REQUEST_FOCUS.equals(name)) {
                pendingRequestFocus = true;
                consumeChildElements(parser);
            } else if (TAG_TAG.equals(name)) {
                parseViewTag(parser, parent, attrs);
            } else if (TAG_INCLUDE.equals(name)) {
                if (parser.getDepth() == 0) {
                    throw new InflateException("<include /> cannot be the root element");
                }
                parseInclude(parser, context, parent, attrs);
            } else if (TAG_MERGE.equals(name)) {
                throw new InflateException("<merge /> must be the root element");
            } else {
                final View view = createViewFromTag(parent, name, context, attrs);
                final ViewGroup viewGroup = (ViewGroup) parent;
                final ViewGroup.LayoutParams params = viewGroup.generateLayoutParams(attrs);
                rInflateChildren(parser, view, attrs, true);
                viewGroup.addView(view, params);
            }
        }

(5)因此我们自己写工厂是为了去收集这些View用的。

2.动态换肤实现

(1)好处是进入项目之后,可以随时无屏闪的将皮肤换掉。

(2)采用插件化的方案,任何一个APP的apk都可以复制之后拿过来使用,可以在里边儿加一些自定义的属性,整个APK的包就可以直接去使用了。

2.1自定义控件实现换肤的接口

/**
 * @author XiongJie
 * @version appVer
 * @Package com.gdc.lib.interfaces
 * @file
 * @Description:
 * 1.接口用于帮助写自定义控件时能够让控件自己提供换肤的方案
 * (1)自定义控件实现换肤的接口
 * @date 2021-6-14 07:01
 * @since appVer
 */

public interface SkinViewSupport {
    void applySkin();
}

2.2换肤方案信息的存储

/**
 * @author XiongJie
 * @version appVer
 * @Package com.gdc.lib.interfaces
 * @file
 * @Description:
 * 1.换肤方案信息的存储
 * @date 2021-6-14 07:44
 * @since appVer
 */

public class SkinPreference {
    //1.目录是skins中的
    private static final String SKIN_SHARED = "skins";
    /**
     * 1.用一个键值对保存一下最后一个皮肤包的文件名,即文件名的路径
     */
    private static final String KEY_SKIN_PATH = "skin-path";
    private volatile static SkinPreference    instance;
    private final           SharedPreferences mPref;

    public static void init(Context context) {
        if (instance == null) {
            synchronized (SkinPreference.class) {
                if (instance == null) {
                    instance = new SkinPreference(context.getApplicationContext());
                }
            }
        }
    }

    public static SkinPreference getInstance() {
        return instance;
    }

    private SkinPreference(Context context) {
        mPref = context.getSharedPreferences(SKIN_SHARED, Context.MODE_PRIVATE);
    }

    /**
     * 1.设置皮肤包的路径
     * (1)如果这个皮肤包里面没有数据,那么就证明使用的是整个皮肤包里面最原始的一个皮肤。
     * (2)如果这个皮肤包里面有数据,就会找到目录里面的那一个皮肤。
     * @param skinPath
     */
    public void setSkin(String skinPath) {
        mPref.edit().putString(KEY_SKIN_PATH, skinPath).apply();
    }

    /**
     * 重新设置主题的路径
     */
    public void reset() {
        mPref.edit().remove(KEY_SKIN_PATH).apply();
    }

    /**
     * 获取主题的路径
     * @return
     */
    public String getSkin() {
        return mPref.getString(KEY_SKIN_PATH, null);
    }

}

2.3换肤资源的获取

(1)设置背景色,setBackgroundColor实际上是对应着主APK 某一个View的属性,以及某个资源的值。
案例代码:

new View().setBackgroundColor(R.color.xxxx);

(2)setColor() 改颜色就要给颜色填写一个颜色R.color.xxxx,而这个颜色是有一个真实的数据的。我们在set的时候,就是在主APP里面,setColor是一个控件属性。对于系统来说是一个颜色属性。最终想要的效果是将#223344这样的颜色值填写上去。在主app里面根据id是能够找得到这个颜色值所对应的名称的。

(3)在插件包里面, 唯一的区别是#23122442颜色的值不一样,因此只需要从主APP的id找到这一个名称,再利用这个名称对应插件中的颜色值,如果拿到这个值,再去setColor,去设置这一个值,皮肤就按照插件中的颜色改掉了。

(4)R.color.xxxx怎么去拿到?
可以通过AssetsManager去拿到。

  • AssetsManager是包了三层了的,最上层是Resources,Resources里面又有一个ResourceImpl,在ResourcesImpl里面包含了一个AssetsManager.
  • 即只要能够拿到Resources,对于资源文件就可以全部操作了。不管是用AssetsManager操作还是使用Resources操作,都是可以通用的。
public class SkinResources {

    /**
     * 1.皮肤包的包名
     * (1)用来保存皮肤包的包名
     */
    private String mSkinPkgName;

    /**
     * 1.是否使用默认的皮肤。
     * (1)正常情况下,一打开APP,就是一个默认的皮肤,使用的是原生的参数
     */
    private boolean isDefaultSkin = true;

    /**
     * 1.app原始的resource
     * (1)主APP使用的资源
     * (2)根据主APP的名字,然后将名字传到另外一个APP,再去找那个值。
     */
    private Resources mAppResources;

    /**
     * 1.皮肤包的resource
     */
    private Resources mSkinResources;


    /**
     * 双重松测单例
     */
    private volatile static SkinResources instance;

    private SkinResources(Context context){
        mAppResources = context.getResources();
    }

    public static void init(Context context){
        if(null == instance){
            synchronized (SkinResources.class){
                if(null == instance){
                    instance = new SkinResources(context);
                }
            }
        }
    }

    public static SkinResources getInstance(){
        return instance;
    }


    /**
     * 1.复位
     * (1)将皮肤包,皮肤包名,是否为默认值将其置空。
     * (2)假如不去加载皮肤包了,只需要将这几个属性值置空就可以了。
     */
    public void reset(){
        mSkinResources = null;
        mSkinPkgName = null;
        isDefaultSkin = true;
    }

    /**
     * 使用皮肤
     * @param resources
     * @param pkgName
     */
    public void applySkin(Resources resources, String pkgName){
        mSkinResources = resources;
        mSkinPkgName = pkgName;

        isDefaultSkin = TextUtils.isEmpty(pkgName) || resources == null;
    }

    /**
     * 1.通过原始app中的resId(R.color.XX)获取到自己的名字
     * 2.根据名字和类型获取皮肤包中的ID号
     * 3.mAppResources就是apk中的resources.arsc文件中的一些信息,也就是ID,Name,值。
     * 4.能够拿到插件包中的id号,将来需要数据值的时候,比如需要填充颜色,就可以调用这个方法去拿值。
     * new View().setBackgroundColor(getIdentifier(resId));
     * 即自己APK中的颜色值就可以被插件中的颜色值替换掉。
     * 5.插件就是一个单独的APK,在市场上下载的任何一个APK包都能用,或者自己新建的一个APK都可以用,
     * 也就是市场上任何一个APK的皮肤都可以拿来用的。
     * 6.正常情况是无论放在手机的哪一目录都可以,一般是从服务器下载之后,放在手机的任一目录,但一定是
     * 可以访问的目录,一般处在data/data....的某个地方。
     */
    public int getIdentifier(int resId){
        //(1)默认皮肤的,就返回当前这个资源的id值
        if(isDefaultSkin){
            return resId;
        }
        //(2)拿自己APP里面对应的id的名称,id的类型,以及
        String resName = mAppResources.getResourceEntryName(resId);
        String restType = mAppResources.getResourceTypeName(resId);
        //(3)从皮肤包中根据名称及资源类型以及包名获取插件包中的ID号
        int skinId = mSkinResources.getIdentifier(resName,restType,mSkinPkgName);
        return skinId;
    }

    /**
     * 1.输入主APP的ID,到皮肤包APK文件中去找到对应的ID的颜色值
     * (1)即实现动态的获取
     * (2)APP
     * (3)皮肤.apk
     * (4)调用一下getColor,对应在皮肤.apk中的值就可以拿到,拿到之后,在APP中就可以通过setColor
     * 进行设置,屏幕上的效果就可以动态改变了。
     * @param resId
     * @return
     */
    public int getColor(int resId){
        //(1)如果是默认的情况,返回的是自己的主APP里面的颜色
        if(isDefaultSkin){
            return mAppResources.getColor(resId);
        }

        //(2)否则返回的是皮肤包中的资源的id
        int skinId = getIdentifier(resId);

        //(3)如果没有相同的值,就还是返回自己的资源
        if(skinId == 0){
            return mAppResources.getColor(resId);
        }

        //(4)如果有相同的值,就根据得到的插件中的资源id,获取其资源值。
        return  mSkinResources.getColor(skinId);
    }

    /**
     * 1.输入主APP的ID,到皮肤包APK文件中去找到对应的ID的颜色状态列表
     * @param resId
     * @return
     */
    public ColorStateList getColorStateList(int resId) {
        if (isDefaultSkin) {
            return mAppResources.getColorStateList(resId);
        }
        int skinId = getIdentifier(resId);
        if (skinId == 0) {
            return mAppResources.getColorStateList(resId);
        }
        return mSkinResources.getColorStateList(skinId);
    }

    /**
     * 1.输入主APP的ID,到皮肤包APK文件中去找到对应的ID的图片
     * @param resId
     * @return
     */
    public Drawable getDrawable(int resId) {
        if (isDefaultSkin) {
            return mAppResources.getDrawable(resId);
        }
        //通过 app的resource 获取id 对应的 资源名 与 资源类型
        //找到 皮肤包 匹配 的 资源名资源类型 的 皮肤包的 资源 ID
        int skinId = getIdentifier(resId);
        if (skinId == 0) {
            return mAppResources.getDrawable(resId);
        }
        return mSkinResources.getDrawable(skinId);
    }

    /**
     * 1.输入主APP的ID,到皮肤包APK文件中去找到对应的ID的背景
     * (1)可能是Color
     * (2)也可能是drawable
     * @param resId
     * @return
     */
    public Object getBackground(int resId) {
        String resourceTypeName = mAppResources.getResourceTypeName(resId);

        if ("color".equals(resourceTypeName)) {
            return getColor(resId);
        } else {
            // drawable
            return getDrawable(resId);
        }
    }

}

(1)如果在项目中还有其他的属性,都按以上逻辑进行编写。找到自己的API,将自己的逻辑加进去。

2.4变更Theme主题中的样式

public class SkinThemeUtils {

    private static int[] APPCOMPAT_COLOR_PRIMARY_DARK_ATTRS = {
            androidx.appcompat.R.attr.colorPrimaryDark
    };

    private static int[] STATUSBAR_COLOR_ATTRS = {
            android.R.attr.statusBarColor,android.R.attr.navigationBarColor
    };

    /**
     * 1.获得theme属性中定义的资源id
     * (1)obtainStyledAttributes从theme中寻找attrIdArray的值
     * (2)参考地址:https://blog.csdn.net/qq_34224268/article/details/102900281
     * @param context
     * @param attrs
     * @return
     */
    public static int[] getResId(Context context,int[] attrs){
        int [] resIds = new int[attrs.length];
        TypedArray a = context.obtainStyledAttributes(attrs);
        for(int i = 0 ; i < attrs.length; i++){
            resIds[i] = a.getResourceId(i,0);
        }
        a.recycle();
        return resIds;
    }

    public static void updateStatusBarColor(Activity activity){

        //(1)要求Android5.0以上系统
        if(Build.VERSION.SDK_INT<Build.VERSION_CODES.LOLLIPOP){
            return;
        }

        //(2)获得theme属性中定义的资源id
        int [] resIds = getResId(activity,STATUSBAR_COLOR_ATTRS);

        int statusBarColorResId = resIds[0];
        int navigationBarColor = resIds[1];

        /**
         * (3)设置状态栏颜色
         * - 如果直接在style中写入固定颜色值(而不是 @color.xx )获得0
         * - 获得 colorPrimaryDark
         */
        if(0 != statusBarColorResId){
            int color = SkinResources.getInstance().getColor(statusBarColorResId);
            activity.getWindow().setStatusBarColor(color);
        }else{
            int colorPrimaryDarkResId = getResId(activity,
                    APPCOMPAT_COLOR_PRIMARY_DARK_ATTRS)[0];
            if(0 != colorPrimaryDarkResId){
                int color =
                        SkinResources.getInstance().getColor(colorPrimaryDarkResId);
                activity.getWindow().setStatusBarColor(color);
            }
        }

        /**
         * (4)设置导航条颜色
         */
        if(0 != navigationBarColor){
            int color = SkinResources.getInstance().getColor(navigationBarColor);
            activity.getWindow().setNavigationBarColor(color);
        }
    }
}

2.5存放需要换肤的View及View所对应的属性

public class SkinAttribute {

    //记录换肤需要操作的View与属性信息
    private List<SkinView> skinViews = new ArrayList<>();

    static class SkinView{
        /**
         * 一个View
         */
        View view;

        /**
         * 这个View能被换肤的属性与它对应id的集合
         */
        List<SkinPair> skinPairs;
    }


    static class SkinPair{
        /**
         * 属性名
         */
        String attributeName;
        /**
         * 对应的资源id
         */
        int resId;

        public SkinPair(String attributeName, int resId) {
            this.attributeName = attributeName;
            this.resId = resId;
        }
    }
}

2.6寻找并记录需要换肤的属性

    /**
     * 1.查找需要换肤的属性是否出现在自己定义的需要换肤的属性列表中
     * (1)记录一个View的哪几个属性需要换肤textColor/src
     * (2)带?的属性,都是系统私有的属性,属于主题包中的属性。就到主题中寻找。
     * (3)@是在app的xml文件中能够找到的属性,到xml文件中去找。
     * (4)#开始的属性值,换肤没有任何意义,因为它已经写死了。
     */
    public void look(View view, AttributeSet attrs){
        List<SkinPair> skinPairs = new ArrayList<>();

        for(int i = 0 ; i < attrs.getAttributeCount();i++){
            //1.1获得属性名 textColor/background
            String attributeName = attrs.getAttributeName(i);
            if(mAttributes.contains(attributeName)){
                /**
                 * 能换肤的包含:
          

以上是关于04插件化换肤技术实战的主要内容,如果未能解决你的问题,请参考以下文章

04插件化换肤技术实战

04插件化换肤技术实战

插件化换肤方案

Android 手写实现插件化换肤 兼容Android10 Android11

Android 手写实现插件化换肤框架 兼容Android10 Android11

Activity布局流程+资源加载过程+插件化换肤思路