程序员

Android 动态换肤原理与实现

2020-07-10  本文已影响0人  昊空_6f4f

概述

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

动态换肤的思路:

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

换肤实际上进行资源替换,如替换字体、颜色、背景、图片等,对应控件属性有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的主要工作是:

创建控件主要是参考系统源码实现的,重点在于收集换肤控件,通过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之前设置才能生效,这里有两种实现方式:

核心代码如下:

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

上一篇下一篇

猜你喜欢

热点阅读