Android 動態(tài)換膚原理與實現(xiàn)

概述

本文主要分享類似于酷狗音樂動態(tài)換膚效果的實現(xiàn)。

動態(tài)換膚的思路:

  • 收集換膚控件以及對應的換膚屬性
  • 加載插件皮膚包
  • 替換資源實現(xiàn)換膚效果
  • 制作插件皮膚包

收集換膚控件以及對應的換膚屬性

換膚實際上進行資源替換,如替換字體、顏色、背景、圖片等,對應控件屬性有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;
    }

通過源碼可知創(chuàng)建控件會先調(diào)用Factory2的onCreateView方法,如果返回的View為空才會調(diào)用LayoutInflater中的onCreateView與createView,那我們自定一個Factory2就可以用于創(chuàng)建控件并判斷是否包含換膚屬性了。核心代碼如下:

public class SkinLayoutInflateFactory implements LayoutInflater.Factory2, Observer {

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

    //xml中控件的初始化都是調(diào)用帶Context和AttributeSet這個構(gòu)造方法進行反射創(chuàng)建的
    static final Class<?>[] mConstructorSignature = new Class[]{
            Context.class, AttributeSet.class};

    //減少相同控件反射的次數(shù)
    private static final HashMap<String, Constructor<? extends View>> sConstructorMap =
            new HashMap<>();
    
    //記錄每一個頁面需要換膚的控件
    private SkinAttribute mSkinAttribute;

    /*
     * 關(guān)系: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);
        }

        //關(guān)鍵代碼:采集需要換膚的控件
        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的主要工作是:

  • 創(chuàng)建xml中的控件
  • 收集需要換膚的控件

創(chuàng)建控件主要是參考系統(tǒng)源碼實現(xiàn)的,重點在于收集換膚控件,通過SkinAttribute記錄每一個頁面需要換膚的控件,核心代碼如下:

public class SkinAttribute {

    //需要換膚的屬性集合,如背景、顏色、字體等
    private static final List<String> mAttributes = new ArrayList<>();

    static {
        //后續(xù)的換膚屬性可在此處添加
        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;
                //判斷是否使用系統(tǒng)資源
                if (attributeValue.startsWith("?")) {// ? 系統(tǒng)資源
                    int attrId = Integer.parseInt(attributeValue.substring(1));
                    //獲取獲得Theme中屬性中定義的資源id
                    resId = SkinThemeUtils.getThemeResId(view.getContext(), new int[]{attrId})[0];
                } else {//@ 開發(fā)者自定義資源
                    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);
            //如果已經(jīng)加載過換膚了,此時需要主動調(diào)用一次換膚方法
            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;
        }

        //關(guān)鍵方法:換膚方法(提供給Sdk自帶控件)
        public void applySkin() {
            applySkinSupport();
            /*
             * 關(guān)鍵思路:1.獲取原始App中resId對應的類型、名稱
             *     2.根據(jù)類型、名稱、插件皮膚包名獲取插件皮膚包中對應的resId
             *     3.獲取插件插件皮膚包中resId對應的資源(如:顏色、背景、圖片)再設置給原始App中的控件實現(xiàn)換膚功能
             */
            for (SkinPair skinPair : skinPairList) {
                Drawable left = null, top = null, right = null, bottom = null;
                switch (skinPair.attributeName) {
                    //后續(xù)的換膚屬性可在此處添加
                    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需要實現(xiàn)SkinViewSupport接口,自己實現(xiàn)換膚功能,代碼如下:

public interface SkinViewSupport {
    void applySkin();
}

/**
 * 注意:如果自定義View需要自己實現(xiàn)換膚,先通過屬性獲取ResourceId,再通過代碼方式實現(xiàn)換膚
 */
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之前設置才能生效,這里有兩種實現(xiàn)方式:

  • 封裝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了,第二次調(diào)用會報錯,所以此處使用反射手動修改成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);
    }
}

加載插件皮膚包

通過創(chuàng)建AssetManager加載插件皮膚包,核心代碼如下:

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

替換資源實現(xiàn)換膚效果

替換資源的流程:通過原始App的resId獲取對應的名稱、類型,再根據(jù)名稱、類型、插件包名去皮膚包中查找出對應的resId,獲取插件resId對應的資源再設置給原始App的控件,從而實現(xiàn)換膚。

資源替換工具類:

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.根據(jù)名字和類型獲取皮膚包中的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();

                //創(chuàng)建AssetManager對象用于加載換膚插件
                AssetManager assetManager = AssetManager.class.newInstance();
                Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
                addAssetPath.invoke(assetManager,skinPath);

                //創(chuàng)建Resources用于加載換膚插件的資源
                Resources skinResources = new Resources(assetManager, appResources.getDisplayMetrics(), appResources.getConfiguration());

                //根據(jù)皮膚插件路徑獲取加載換膚插件的包名
                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();
            }
        }

        /**
         * 關(guān)鍵要點:
         *      上面設置完皮膚后,要通知頁面進行換膚,此處采用觀察者模式進行通知,通知的對象為SkinLayoutInflateFactory,
         *      SkinLayoutInflateFactor在調(diào)用SkinAttribute的applySkin方法進行換膚
         */
        setChanged();
        notifyObservers();
    }
}

這里采用了觀察者模式通知多頁面換膚,SkinManager對應Observable,SkinLayoutInflateFactory對應Observer,當SkinManager調(diào)用loadSkin進行換膚后,會通知SkinLayoutInflateFactory回調(diào)update方法,而SkinLayoutInflateFactory包含了SkinAttribute,在update方法中調(diào)用SkinAttribute的applySkin方法便可以通知到頁面控件進行資源替換,從而實現(xiàn)換膚效果。

制作插件皮膚包

皮膚包只需要包含資源文件并且資源的名稱要與原始App保持一致,制作完成后上傳到服務的,客戶端按需下載皮膚包,進行加載以及換膚操作

完整代碼實現(xiàn)

百度鏈接
密碼:wmay

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

友情鏈接更多精彩內(nèi)容