Android动态换肤实现原理解析

换肤分为动态换肤和静态换肤

静态换肤

这种换肤的方式,也就是我们所说的内置换肤,就是在APP内部放置多套相同的资源。进行资源的切换。
这种换肤的方式有很多缺点,比如, 灵活性差,只能更换内置的资源、apk体积太大,在我们的应用Apk中等一般图片文件能占到apk大小的一半左右。
当然了,这种方式也并不是一无是处, 比如我们的应用内,只是普通的 日夜间模式 的切换,并不需要图片等的更换,只是更换颜色,那这样的方式就很实用。

动态换肤

适用于大量皮肤,用户选择下载,像QQ、网易云音乐这种。它是将皮肤包下载到本地,皮肤包其实是个APK。

换肤包括替换图片资源、布局颜色、字体、文字颜色、状态栏和导航栏颜色。

动态换肤步骤包括:

  • 采集需要换肤的控件
  • 加载皮肤包
  • 替换资源

采集换肤控件

android解析xml创建view的步骤:

  • setContentView -> window.setContentView()(实现类是PhoneWindow)->mLayoutInflater.inflate() -> inflate .. ->createViewFromTag().

所以我们复写了Factory的onCreateView之后,就可以不通过系统层而是自己截获从xml映射的View进行相关View创建的操作,包括对View的属性进行设置(比如背景色,字体大小,颜色等)以实现换肤的效果。如果onCreateView返回null的话,会将创建View的操作交给Activity默认实现的Factory的onCreateView处理。

1.使用ActivityLifecycleCallbacks,尽可能少的去侵入代码,在onActivityCreated中监听每个activity的创建。

@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
       LayoutInflater layoutInflater = LayoutInflater.from(activity);
       try {
           //系统默认 LayoutInflater只能设置一次factory,所以利用反射解除限制
           Field mFactorySet = LayoutInflater.class.getDeclaredField("mFactorySet");
           mFactorySet.setAccessible(true);
           mFactorySet.setBoolean(layoutInflater, false);
       } catch (Exception e) {
           e.printStackTrace();
       }

       //添加自定义创建View 工厂
       SkinLayoutFactory factory = new SkinLayoutFactory(activity, skinTypeface);
       layoutInflater.setFactory2(factory);
}

2.在 SkinLayoutFactory中将每个创建的view进行筛选采集

  //根据tag反射获取view
    @Override
    public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
        // 反射 classLoader
        View view = createViewFromTag(name, context, attrs);
        // 自定义View
        if(null ==  view){
            view = createView(name, context, attrs);
        }

        //筛选符合属性View
        skinAttribute.load(view, attrs);

        return view;
    }

3.将view封装成对象

    //view的参数对象
    static class SkinPain {
        String attributeName;
        int resId;

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

    //view对象
     static class SkinView {
        View view;
        List<SkinPain> skinPains;

        public SkinView(View view, List<SkinPain> skinPains) {
            this.view = view;
            this.skinPains = skinPains;
        }
     }

将属性符合的view保存起来

public class SkinAttribute {
    private static final List<String> mAttributes = new ArrayList<>();

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

        mAttributes.add("textColor");
        mAttributes.add("drawableLeft");
        mAttributes.add("drawableTop");
        mAttributes.add("drawableRight");
        mAttributes.add("drawableBottom");

        mAttributes.add("skinTypeface");
    }

    private List<SkinView> skinViews = new ArrayList<>();

    public void load(View view, AttributeSet attrs) {
        List<SkinPain> skinPains = new ArrayList<>();
        for (int i = 0; i < attrs.getAttributeCount(); i++) {
            //获取属性名字
            String attributeName = attrs.getAttributeName(i);
            if (mAttributes.contains(attributeName)) {
                //获取属性对应的值
                String attributeValue = attrs.getAttributeValue(i);
                if (attributeValue.startsWith("#")) {
                    continue;
                }
                int resId;
                //判断前缀字符串 是否是"?"
                //attributeValue  = "?2130903043"
                if (attributeValue.startsWith("?")) {  //系统属性值
                    //字符串的子字符串  从下标 1 位置开始
                    int attrId = Integer.parseInt(attributeValue.substring(1));
                    resId = SkinThemeUtils.getResId(view.getContext(), new int[]{attrId})[0];
                } else {
                    //@1234564
                    resId = Integer.parseInt(attributeValue.substring(1));
                }
                if (resId != 0) {
                    SkinPain skinPain = new SkinPain(attributeName, resId);
                    skinPains.add(skinPain);
                }
            }
        }
        //SkinViewSupport是自定义view实现的接口,用来区分是否需要换肤
        if (!skinPains.isEmpty() || view instanceof TextView || view instanceof SkinViewSupport) {
            SkinView skinView = new SkinView(view, skinPains);
            skinView.applySkin(mTypeface);
            skinViews.add(skinView);
        }
    }

    ...

    }

加载皮肤包

加载皮肤包需要我们动态获取网络下载的皮肤包资源,问题是我们如何加载皮肤包中的资源

Android访问资源使用的是Resources这个类,但是程序里面通过getContext获取到的Resources实例实际上是对应程序本来的资源的实例,也就是说这个实例只能加载app里面的资源,想要加载皮肤包里面的就不行了

自己构造一个Resources(这个Resources指向的资源就是我们的皮肤包)
看看Resources的构造方法,可以看到主要是需要一个AssetManager

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

构造一个指向皮肤包的AssetManager,但是这个AssetManager是不能直接new出来的,这里就使用反射来实例化了

AssetManager assetManager = AssetManager.class.newInstance();

AssetManager有一个addAssetPath方法可以指定资源的位置,可惜这个也只能用反射来调用

Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
        addAssetPath.invoke(assetManager, filePath);

再来看看Resources的其他两个参数,一个是DisplayMetrics,一个是Configuration,这两的就可以直接使用app原来的Resources里面的就可以。

具体代码如下:

    public void loadSkin(String path) {
        if(TextUtils.isEmpty(path)){
            // 记录使用默认皮肤
            SkinPreference.getInstance().setSkin("");
            //清空资源管理器, 皮肤资源属性等
            SkinResources.getInstance().reset();
        } else {
            try {
                //反射创建AssetManager
                AssetManager manager = AssetManager.class.newInstance();
                // 资料路径设置
                Method addAssetPath = manager.getClass().getMethod("addAssetPath", String.class);
                addAssetPath.invoke(manager, path);

                Resources appResources = this.application.getResources();
                Resources skinResources = new Resources(manager,
                        appResources.getDisplayMetrics(), appResources.getConfiguration());

                //记录当前皮肤包
                SkinPreference.getInstance().setSkin(path);
                //获取外部Apk(皮肤薄) 包名
                PackageManager packageManager = this.application.getPackageManager();
                PackageInfo packageArchiveInfo = packageManager.getPackageArchiveInfo(path, PackageManager.GET_ACTIVITIES);
                String packageName = packageArchiveInfo.packageName;

                SkinResources.getInstance().applySkin(skinResources,packageName);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

        setChanged();
        //通知观者者,进行替换资源
        notifyObservers();
    }

替换资源

换肤的核心操作就是替换资源,这里采用观察者模式,被观察者是我们的换肤管理类SkinManager,观察者是我们之前缓存的每个页面的LayoutInflater.Factory2

    @Override
    public void update(Observable o, Object arg) {
        //状态栏
        SkinThemeUtils.updataStatusBarColor(activity);
        //字体
        Typeface skinTypeface = SkinThemeUtils.getSkinTypeface(activity);
        skinAttribute.setTypeface(skinTypeface);
        //更换皮肤
        skinAttribute.applySkin();
    }

applySkin()在去遍历每个factory缓存的需要换肤的view,调用他们的换肤方法

    public void applySkin() {
        for (SkinView mSkinView : skinViews) {
            mSkinView.applySkin(mTypeface);
        }
    }

applySkin方法如下:

        public void applySkin(Typeface typeface) {
            //换字体
            if(view instanceof TextView){
                ((TextView) view).setTypeface(typeface);
            }
            //自定义view换肤
            if(view instanceof SkinViewSupport){
                ((SkinViewSupport)view).applySkin();
            }

            for (SkinPain skinPair : skinPains) {
                Drawable left = null, top = null, right = null, bottom = null;
                switch (skinPair.attributeName) {
                    case "background":
                        Object background = SkinResources.getInstance().getBackground(
                                skinPair.resId);
                        //Color
                        if (background instanceof Integer) {
                            view.setBackgroundColor((Integer) background);
                        } else {
                            ViewCompat.setBackground(view, (Drawable) background);
                        }
                        break;
                    case "src":
                        background = SkinResources.getInstance().getBackground(skinPair
                                .resId);
                        if (background instanceof Integer) {
                            ((ImageView) view).setImageDrawable(new ColorDrawable((Integer)
                                    background));
                        } else {
                            ((ImageView) view).setImageDrawable((Drawable) background);
                        }
                        break;
                    case "textColor":
                        ((TextView) view).setTextColor(SkinResources.getInstance().getColorStateList
                                (skinPair.resId));
                        break;
                    case "drawableLeft":
                        left = SkinResources.getInstance().getDrawable(skinPair.resId);
                        break;
                    case "drawableTop":
                        top = SkinResources.getInstance().getDrawable(skinPair.resId);
                        break;
                    case "drawableRight":
                        right = SkinResources.getInstance().getDrawable(skinPair.resId);
                        break;
                    case "drawableBottom":
                        bottom = SkinResources.getInstance().getDrawable(skinPair.resId);
                        break;
                    case "skinTypeface" :
                        applyTypeface(SkinResources.getInstance().getTypeface(skinPair.resId));
                        break;
                    default:
                        break;
                }
                if (null != left || null != right || null != top || null != bottom) {
                    ((TextView) view).setCompoundDrawablesWithIntrinsicBounds(left, top, right,
                            bottom);
                }
            }
        }

这里能看到换肤的实现方式就是根据原始资源Id来获取皮肤包的资源Id,从而加载资源。因此我们要保证app和皮肤包的资源名称一致

    public Drawable getDrawable(int resId) {
        //如果有皮肤  isDefaultSkin false 没有就是true
        if (isDefaultSkin) {
            return mAppResources.getDrawable(resId);
        }
        int skinId = getIdentifier(resId);//查找对应的资源id
        if (skinId == 0) {
            return mAppResources.getDrawable(resId);
        }
        return mSkinResources.getDrawable(skinId);
    }


    //获取皮肤包中对应资源的id
    public int getIdentifier(int resId) {
        if (isDefaultSkin) {
            return resId;
        }
        //在皮肤包中的资源id不一定就是 当前程序的 id
        //获取对应id 在当前的名称 例如colorPrimary
        String resName = mAppResources.getResourceEntryName(resId);//ic_launcher   /colorPrimaryDark
        String resType = mAppResources.getResourceTypeName(resId);//drawable
        int skinId = mSkinResources.getIdentifier(resName, resType, mSkinPkgName);//使用皮肤包的Resource
        return skinId;
    }

皮肤包的生成

其实很简单,就是我们重新建立一个项目(这个项目里面的资源名字和需要换肤的项目的资源名字是对应的就可以),记住我们是通过名字去获取资源,不是id

  1. 新建工程project
  2. 将换肤的资源文件添加到res文件下,无java文件
  3. 直接运行build.gradle,生成apk文件(注意,运行时Run/Redebug configurations 中Launch Options选择launch nothing),否则build 会报 no default Activty的错误。
  4. 将apk文件重命名,如black.apk重命名为black.skin防止用户点击安装
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342

推荐阅读更多精彩内容