Android 动态换肤原理与实现

概述

本文主要分享类似于酷狗音乐动态换肤效果的实现。

动态换肤的思路:

  • 收集换肤控件以及对应的换肤属性
  • 加载插件皮肤包
  • 替换资源实现换肤效果
  • 制作插件皮肤包

收集换肤控件以及对应的换肤属性

换肤实际上进行资源替换,如替换字体、颜色、背景、图片等,对应控件属性有src、textColor、background、drawableLeft等。需要先收集页面控件是否包含换肤属性,那如何收集页面的控件呢?
跟踪LayoutInflater中的createViewFromTag与tryCreateView方法:

View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
            boolean ignoreThemeAttr) {
        ...
        try {
            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) {
                ...
        } catch (ClassNotFoundException e) {
                ...
        } catch (Exception e) {
                ...
        }
    }
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) {
            view = mFactory2.onCreateView(parent, name, context, attrs);
        } else if (mFactory != null) {
            view = mFactory.onCreateView(name, context, attrs);
        } else {
            view = null;
        }

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

        return view;
    }

通过源码可知创建控件会先调用Factory2的onCreateView方法,如果返回的View为空才会调用LayoutInflater中的onCreateView与createView,那我们自定一个Factory2就可以用于创建控件并判断是否包含换肤属性了。核心代码如下:

public class SkinLayoutInflateFactory implements LayoutInflater.Factory2, Observer {

    static final String mPrefix[] = {
            "android.view.",
            "android.widget.",
            "android.webkit.",
            "android.app."
    };

    //xml中控件的初始化都是调用带Context和AttributeSet这个构造方法进行反射创建的
    static final Class<?>[] mConstructorSignature = new Class[]{
            Context.class, AttributeSet.class};

    //减少相同控件反射的次数
    private static final HashMap<String, Constructor<? extends View>> sConstructorMap =
            new HashMap<>();
    
    //记录每一个页面需要换肤的控件
    private SkinAttribute mSkinAttribute;

    /*
     * 关系:Activity对应一个LayoutInflate、
     *     LayoutInflate对一个SkinLayoutInflateFactory
     *     SkinLayoutInflateFactory对应一个SkinAttribute
     */
    private Activity mActivity;

    public SkinLayoutInflateFactory(Activity activity) {
        this.mActivity = activity;
        this.mSkinAttribute = new SkinAttribute();
    }

    @Nullable
    @Override
    public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
        View view;
        if (-1 == name.indexOf('.')) {//ImageView、TextView等
            view = createSdkView(context, name, attrs);
        } else {//自定义View、support、AndroidX、第三方控件等
            view = createView(context, name, attrs);
        }

        //关键代码:采集需要换肤的控件
        if (view != null) {
            mSkinAttribute.look(view, attrs);
        }
        return view;
    }

    //以下代码为控件初始化
    private View createSdkView(Context context, String name, AttributeSet attrs) {
        for (String prefix : mPrefix) {
            View view = createView(context, prefix + name, attrs);
            if (view != null) {
                return view;
            }
        }
        return null;
    }

    private View createView(Context context, String name, AttributeSet attrs) {
        Constructor<? extends View> constructor = findConstructor(context, name);
        try {
            return constructor.newInstance(context, attrs);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

    private Constructor<? extends View> findConstructor(Context context, String name) {
        Constructor<? extends View> constructor = sConstructorMap.get(name);
        if (constructor == null) {
            try {
                Class<? extends View> clazz = Class.forName(name, false,
                        context.getClassLoader()).asSubclass(View.class);
                constructor = clazz.getConstructor(mConstructorSignature);
                constructor.setAccessible(true);
                sConstructorMap.put(name, constructor);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return constructor;
    }

    @Nullable
    @Override
    public View onCreateView(@NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
        return null;
    }

    @Override
    public void update(Observable o, Object arg) {
         //此处进行换肤
        mSkinAttribute.applySkin();
    }
}

SkinLayoutInflateFactory的主要工作是:

  • 创建xml中的控件
  • 收集需要换肤的控件

创建控件主要是参考系统源码实现的,重点在于收集换肤控件,通过SkinAttribute记录每一个页面需要换肤的控件,核心代码如下:

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");
    }

    //记录每一个页面需要换肤的控件集合
    private List<SkinView> mSkinViewList = new ArrayList<>();

    //查找需要换肤的控件以及对应的换肤属性
    public void look(View view, AttributeSet attrs) {
        List<SkinPair> skinPairList = 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;
                //判断是否使用系统资源
                if (attributeValue.startsWith("?")) {// ? 系统资源
                    int attrId = Integer.parseInt(attributeValue.substring(1));
                    //获取获得Theme中属性中定义的资源id
                    resId = SkinThemeUtils.getThemeResId(view.getContext(), new int[]{attrId})[0];
                } else {//@ 开发者自定义资源
                    resId = Integer.parseInt(attributeValue.substring(1));
                }

                SkinPair skinPair = new SkinPair(attributeName, resId);
                skinPairList.add(skinPair);
            }
        }
        //如果skinPairList长度不为0,即有换肤属性,此时记录换肤控件
        if (!skinPairList.isEmpty() || view instanceof SkinViewSupport) {
            SkinView skinView = new SkinView(view, skinPairList);
            //如果已经加载过换肤了,此时需要主动调用一次换肤方法
            skinView.applySkin();
            mSkinViewList.add(skinView);
        }
    }

    //提供页面换肤功能
    public void applySkin() {
        for (SkinView skinView : mSkinViewList) {
            skinView.applySkin();
        }
    }

    //对应每一个换肤控件
    static class SkinView {
        View view;//换肤控件
        List<SkinPair> skinPairList;//换肤属性集合

        SkinView(View view, List<SkinPair> skinPairList) {
            this.view = view;
            this.skinPairList = skinPairList;
        }

        //关键方法:换肤方法(提供给Sdk自带控件)
        public void applySkin() {
            applySkinSupport();
            /*
             * 关键思路:1.获取原始App中resId对应的类型、名称
             *     2.根据类型、名称、插件皮肤包名获取插件皮肤包中对应的resId
             *     3.获取插件插件皮肤包中resId对应的资源(如:颜色、背景、图片)再设置给原始App中的控件实现换肤功能
             */
            for (SkinPair skinPair : skinPairList) {
                Drawable left = null, top = null, right = null, bottom = null;
                switch (skinPair.attributeName) {
                    //后续的换肤属性可在此处添加
                    case "background":
                        Object background = SkinResources.getInstance().getBackground(skinPair
                                .resId);
                        //背景可能是 @color 也可能是 @drawable
                        if (background instanceof Integer) {
                            view.setBackgroundColor((int) 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;
                    default:
                        break;
                }
                if (null != left || null != right || null != top || null != bottom) {
                    ((TextView) view).setCompoundDrawablesWithIntrinsicBounds(left, top, right,
                            bottom);
                }
            }
        }

        //提供给自定义控件进行换肤
        public void applySkinSupport() {
            if (view instanceof SkinViewSupport) {
                ((SkinViewSupport) view).applySkin();
            }
        }
    }

    //对应每一个换肤属性
    static class SkinPair {
        //换肤属性
        String attributeName;
        //资源Id
        int resId;

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

这里要注意如果是自定义View需要实现SkinViewSupport接口,自己实现换肤功能,代码如下:

public interface SkinViewSupport {
    void applySkin();
}

/**
 * 注意:如果自定义View需要自己实现换肤,先通过属性获取ResourceId,再通过代码方式实现换肤
 */
public class MyTabLayout extends TabLayout implements SkinViewSupport {

    int mTabIndicatorColorResId;

    public MyTabLayout(@NonNull Context context) {
        this(context, null);
    }

    public MyTabLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public MyTabLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TabLayout);
        mTabIndicatorColorResId = a.getResourceId(R.styleable.TabLayout_tabIndicatorColor, 0);
        a.recycle();
    }

    @Override
    public void applySkin() {
        if (mTabIndicatorColorResId != 0) {
            int tabIndicatorColor = SkinResources.getInstance().getColor(mTabIndicatorColorResId);
            setSelectedTabIndicatorColor(tabIndicatorColor);
        }
    }
}


由源码可知SkinLayoutInflateFactory必须在setContentView之前设置才能生效,这里有两种实现方式:

  • 封装BaseActivity中,但侵入性比较强
  • 在ActivityLifecycleCallbacks的onActivityCreated方法中添加,AOP思想(推荐)

核心代码如下:

public class ApplicationActivityLifecycle implements Application.ActivityLifecycleCallbacks {

    private Observable mObservable;
    private ArrayMap<Activity, SkinLayoutInflateFactory> mSkinLayoutInflateFactory = new ArrayMap<>();

    public ApplicationActivityLifecycle(Observable observable) {
        this.mObservable = observable;
    }

    @Override
    public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) {

        //Activity -->LayoutInflate -->SkinLayoutInflateFactory
        //为每一个Activity对应的LayoutInflate添加SkinLayoutInflateFactory

        LayoutInflater layoutInflater = activity.getLayoutInflater();

        try {
            //注意:LayoutInflate的setFactory2方法中将mFactorySet设置成true了,第二次调用会报错,所以此处使用反射手动修改成false
            Field field = LayoutInflater.class.getDeclaredField("mFactorySet");
            field.setAccessible(true);
            field.setBoolean(layoutInflater, false);
        } catch (Exception e) {
            e.printStackTrace();
        }

        SkinLayoutInflateFactory factory = new SkinLayoutInflateFactory(activity);
        LayoutInflaterCompat.setFactory2(layoutInflater, factory);

        //添加换肤观察者
        mObservable.addObserver(factory);
        mSkinLayoutInflateFactory.put(activity, factory);
    }
    
    @Override
    public void onActivityDestroyed(@NonNull Activity activity) {
        SkinLayoutInflateFactory factory = mSkinLayoutInflateFactory.get(activity);
        mObservable.deleteObserver(factory);
    }
}

加载插件皮肤包

通过创建AssetManager加载插件皮肤包,核心代码如下:

     AssetManager assetManager = AssetManager.class.newInstance();
     Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
     addAssetPath.invoke(assetManager,skinPath);

替换资源实现换肤效果

替换资源的流程:通过原始App的resId获取对应的名称、类型,再根据名称、类型、插件包名去皮肤包中查找出对应的resId,获取插件resId对应的资源再设置给原始App的控件,从而实现换肤。

资源替换工具类:

public class SkinResources {

    //插件App包名
    private String mSkinPgk;

    //是否使用默认皮肤包
    private boolean mDefaultSkin = true;

    //原始App的资源
    private Resources mAppResources;
    //插件App的资源
    private Resources mSkinResources;

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

    private volatile static SkinResources instance;

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

    public static SkinResources getInstance() {
        return instance;
    }

    //设置皮肤包资源
    public void applySkin(Resources skinResources, String skinPgk) {
        mSkinResources = skinResources;
        mSkinPgk = skinPgk;
        mDefaultSkin = skinResources == null || TextUtils.isEmpty(skinPgk);
    }

    //恢复默认皮肤包
    public void reset() {
        mSkinResources = null;
        mDefaultSkin = true;
        mSkinPgk = "";
    }

    /**
     * 1.通过原始app中的resId(R.color.XX)获取到自己的名字和类型
     * 2.根据名字和类型获取皮肤包中的resId
     */
    public int getIdentifier(int resId) {
        if (mDefaultSkin) return resId;
        String name = mAppResources.getResourceEntryName(resId);
        String type = mAppResources.getResourceTypeName(resId);
        return mSkinResources.getIdentifier(name, type, mSkinPgk);
    }

    public int getColor(int resId) {
        if (mDefaultSkin) return mAppResources.getColor(resId);

        int skinId = getIdentifier(resId);
        if (skinId == 0) return mAppResources.getColor(resId);

        return mSkinResources.getColor(skinId);
    }

    public ColorStateList getColorStateList(int resId) {
        if (mDefaultSkin) return mAppResources.getColorStateList(resId);

        int skinId = getIdentifier(resId);
        if (skinId == 0) return mAppResources.getColorStateList(resId);
        return mSkinResources.getColorStateList(skinId);
    }

    public Drawable getDrawable(int resId) {
        if (mDefaultSkin) return mAppResources.getDrawable(resId);

        //通过 app的resource 获取id 对应的 资源名 与 资源类型
        //找到 皮肤包 匹配 的 资源名资源类型 的 皮肤包的 资源 ID
        int skinId = getIdentifier(resId);
        if (skinId == 0) return mAppResources.getDrawable(resId);

        return mSkinResources.getDrawable(skinId);
    }

    /**
     * 背景可能是Color 也可能是drawable
     */
    public Object getBackground(int resId) {
        String resourceTypeName = mAppResources.getResourceTypeName(resId);
        if ("color".equals(resourceTypeName)) {
            return getColor(resId);
        } else {
            return getDrawable(resId);
        }
    }
}

换肤管理类,负责App换肤功能:

public class SkinManager extends Observable {

    private Application mContext;

    private volatile static SkinManager instance;

    public static void init(Application application) {
        if (instance == null) {
            synchronized (SkinManager.class) {
                if (instance == null) {
                    instance = new SkinManager(application);
                }
            }
        }
    }

    private SkinManager(Application application) {
        mContext = application;
        application.registerActivityLifecycleCallbacks(new ApplicationActivityLifecycle(this));
        SkinResources.init(application);
        SkinPreference.init(application);
        //加载上次使用保存的皮肤
        loadSkin(SkinPreference.getInstance().getSkin());
    }

    public static SkinManager getInstance() {
        return instance;
    }

    //加载换肤插件
    public void loadSkin(String skinPath) {
        if (TextUtils.isEmpty(skinPath)) {
            SkinPreference.getInstance().reset();
            SkinResources.getInstance().reset();
        } else {
            try {
                Resources appResources = mContext.getResources();

                //创建AssetManager对象用于加载换肤插件
                AssetManager assetManager = AssetManager.class.newInstance();
                Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
                addAssetPath.invoke(assetManager,skinPath);

                //创建Resources用于加载换肤插件的资源
                Resources skinResources = new Resources(assetManager, appResources.getDisplayMetrics(), appResources.getConfiguration());

                //根据皮肤插件路径获取加载换肤插件的包名
                PackageManager packageManager = mContext.getPackageManager();
                PackageInfo packageArchiveInfo = packageManager.getPackageArchiveInfo(skinPath, PackageManager.GET_ACTIVITIES);
                String packageName = packageArchiveInfo.packageName;

                //设置皮肤
                SkinResources.getInstance().applySkin(skinResources, packageName);

                //记录当前皮肤
                SkinPreference.getInstance().setSkin(skinPath);

            } catch (Exception e) {
                e.printStackTrace();
            }
        }

        /**
         * 关键要点:
         *      上面设置完皮肤后,要通知页面进行换肤,此处采用观察者模式进行通知,通知的对象为SkinLayoutInflateFactory,
         *      SkinLayoutInflateFactor在调用SkinAttribute的applySkin方法进行换肤
         */
        setChanged();
        notifyObservers();
    }
}

这里采用了观察者模式通知多页面换肤,SkinManager对应Observable,SkinLayoutInflateFactory对应Observer,当SkinManager调用loadSkin进行换肤后,会通知SkinLayoutInflateFactory回调update方法,而SkinLayoutInflateFactory包含了SkinAttribute,在update方法中调用SkinAttribute的applySkin方法便可以通知到页面控件进行资源替换,从而实现换肤效果。

制作插件皮肤包

皮肤包只需要包含资源文件并且资源的名称要与原始App保持一致,制作完成后上传到服务的,客户端按需下载皮肤包,进行加载以及换肤操作

完整代码实现

百度链接
密码:wmay

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,837评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,551评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,417评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,448评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,524评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,554评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,569评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,316评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,766评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,077评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,240评论 1 343
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,912评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,560评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,176评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,425评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,114评论 2 366
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,114评论 2 352