Android動(dòng)態(tài)換膚實(shí)現(xiàn)原理解析

換膚分為動(dòng)態(tài)換膚和靜態(tài)換膚

靜態(tài)換膚

這種換膚的方式,也就是我們所說(shuō)的內(nèi)置換膚,就是在APP內(nèi)部放置多套相同的資源。進(jìn)行資源的切換。
這種換膚的方式有很多缺點(diǎn),比如, 靈活性差,只能更換內(nèi)置的資源、apk體積太大,在我們的應(yīng)用Apk中等一般圖片文件能占到apk大小的一半左右。
當(dāng)然了,這種方式也并不是一無(wú)是處, 比如我們的應(yīng)用內(nèi),只是普通的 日夜間模式 的切換,并不需要圖片等的更換,只是更換顏色,那這樣的方式就很實(shí)用。

動(dòng)態(tài)換膚

適用于大量皮膚,用戶選擇下載,像QQ、網(wǎng)易云音樂(lè)這種。它是將皮膚包下載到本地,皮膚包其實(shí)是個(gè)APK。

換膚包括替換圖片資源、布局顏色、字體、文字顏色、狀態(tài)欄和導(dǎo)航欄顏色。

動(dòng)態(tài)換膚步驟包括:

  • 采集需要換膚的控件
  • 加載皮膚包
  • 替換資源

采集換膚控件

android解析xml創(chuàng)建view的步驟:

  • setContentView -> window.setContentView()(實(shí)現(xiàn)類是PhoneWindow)->mLayoutInflater.inflate() -> inflate .. ->createViewFromTag().

所以我們復(fù)寫了Factory的onCreateView之后,就可以不通過(guò)系統(tǒng)層而是自己截獲從xml映射的View進(jìn)行相關(guān)View創(chuàng)建的操作,包括對(duì)View的屬性進(jìn)行設(shè)置(比如背景色,字體大小,顏色等)以實(shí)現(xiàn)換膚的效果。如果onCreateView返回null的話,會(huì)將創(chuàng)建View的操作交給Activity默認(rèn)實(shí)現(xiàn)的Factory的onCreateView處理。

1.使用ActivityLifecycleCallbacks,盡可能少的去侵入代碼,在onActivityCreated中監(jiān)聽(tīng)每個(gè)activity的創(chuàng)建。

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

       //添加自定義創(chuàng)建View 工廠
       SkinLayoutFactory factory = new SkinLayoutFactory(activity, skinTypeface);
       layoutInflater.setFactory2(factory);
}

2.在 SkinLayoutFactory中將每個(gè)創(chuàng)建的view進(jìn)行篩選采集

  //根據(jù)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封裝成對(duì)象

    //view的參數(shù)對(duì)象
    static class SkinPain {
        String attributeName;
        int resId;

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

    //view對(duì)象
     static class SkinView {
        View view;
        List<SkinPain> skinPains;

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

將屬性符合的view保存起來(lái)

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)) {
                //獲取屬性對(duì)應(yīng)的值
                String attributeValue = attrs.getAttributeValue(i);
                if (attributeValue.startsWith("#")) {
                    continue;
                }
                int resId;
                //判斷前綴字符串 是否是"?"
                //attributeValue  = "?2130903043"
                if (attributeValue.startsWith("?")) {  //系統(tǒng)屬性值
                    //字符串的子字符串  從下標(biāo) 1 位置開(kāi)始
                    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實(shí)現(xiàn)的接口,用來(lái)區(qū)分是否需要換膚
        if (!skinPains.isEmpty() || view instanceof TextView || view instanceof SkinViewSupport) {
            SkinView skinView = new SkinView(view, skinPains);
            skinView.applySkin(mTypeface);
            skinViews.add(skinView);
        }
    }

    ...

    }

加載皮膚包

加載皮膚包需要我們動(dòng)態(tài)獲取網(wǎng)絡(luò)下載的皮膚包資源,問(wèn)題是我們?nèi)绾渭虞d皮膚包中的資源

Android訪問(wèn)資源使用的是Resources這個(gè)類,但是程序里面通過(guò)getContext獲取到的Resources實(shí)例實(shí)際上是對(duì)應(yīng)程序本來(lái)的資源的實(shí)例,也就是說(shuō)這個(gè)實(shí)例只能加載app里面的資源,想要加載皮膚包里面的就不行了

自己構(gòu)造一個(gè)Resources(這個(gè)Resources指向的資源就是我們的皮膚包)
看看Resources的構(gòu)造方法,可以看到主要是需要一個(gè)AssetManager

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

構(gòu)造一個(gè)指向皮膚包的AssetManager,但是這個(gè)AssetManager是不能直接new出來(lái)的,這里就使用反射來(lái)實(shí)例化了

AssetManager assetManager = AssetManager.class.newInstance();

AssetManager有一個(gè)addAssetPath方法可以指定資源的位置,可惜這個(gè)也只能用反射來(lái)調(diào)用

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

再來(lái)看看Resources的其他兩個(gè)參數(shù),一個(gè)是DisplayMetrics,一個(gè)是Configuration,這兩的就可以直接使用app原來(lái)的Resources里面的就可以。

具體代碼如下:

    public void loadSkin(String path) {
        if(TextUtils.isEmpty(path)){
            // 記錄使用默認(rèn)皮膚
            SkinPreference.getInstance().setSkin("");
            //清空資源管理器, 皮膚資源屬性等
            SkinResources.getInstance().reset();
        } else {
            try {
                //反射創(chuàng)建AssetManager
                AssetManager manager = AssetManager.class.newInstance();
                // 資料路徑設(shè)置
                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());

                //記錄當(dāng)前皮膚包
                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();
        //通知觀者者,進(jìn)行替換資源
        notifyObservers();
    }

替換資源

換膚的核心操作就是替換資源,這里采用觀察者模式,被觀察者是我們的換膚管理類SkinManager,觀察者是我們之前緩存的每個(gè)頁(yè)面的LayoutInflater.Factory2

    @Override
    public void update(Observable o, Object arg) {
        //狀態(tài)欄
        SkinThemeUtils.updataStatusBarColor(activity);
        //字體
        Typeface skinTypeface = SkinThemeUtils.getSkinTypeface(activity);
        skinAttribute.setTypeface(skinTypeface);
        //更換皮膚
        skinAttribute.applySkin();
    }

applySkin()在去遍歷每個(gè)factory緩存的需要換膚的view,調(diào)用他們的換膚方法

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

這里能看到換膚的實(shí)現(xiàn)方式就是根據(jù)原始資源Id來(lái)獲取皮膚包的資源Id,從而加載資源。因此我們要保證app和皮膚包的資源名稱一致

    public Drawable getDrawable(int resId) {
        //如果有皮膚  isDefaultSkin false 沒(méi)有就是true
        if (isDefaultSkin) {
            return mAppResources.getDrawable(resId);
        }
        int skinId = getIdentifier(resId);//查找對(duì)應(yīng)的資源id
        if (skinId == 0) {
            return mAppResources.getDrawable(resId);
        }
        return mSkinResources.getDrawable(skinId);
    }


    //獲取皮膚包中對(duì)應(yīng)資源的id
    public int getIdentifier(int resId) {
        if (isDefaultSkin) {
            return resId;
        }
        //在皮膚包中的資源id不一定就是 當(dāng)前程序的 id
        //獲取對(duì)應(yīng)id 在當(dāng)前的名稱 例如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;
    }

皮膚包的生成

其實(shí)很簡(jiǎn)單,就是我們重新建立一個(gè)項(xiàng)目(這個(gè)項(xiàng)目里面的資源名字和需要換膚的項(xiàng)目的資源名字是對(duì)應(yīng)的就可以),記住我們是通過(guò)名字去獲取資源,不是id

  1. 新建工程project
  2. 將換膚的資源文件添加到res文件下,無(wú)java文件
  3. 直接運(yùn)行build.gradle,生成apk文件(注意,運(yùn)行時(shí)Run/Redebug configurations 中Launch Options選擇launch nothing),否則build 會(huì)報(bào) no default Activty的錯(cuò)誤。
  4. 將apk文件重命名,如black.apk重命名為black.skin防止用戶點(diǎn)擊安裝
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

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