天天看點

Android 插件化換膚 思路及實作

文章目錄

    • 1. 換膚效果
    • 2. 換膚思路
    • 3. 代碼實作
    • 4. 生成皮膚包
    • 5. 代碼下載下傳位址

1. 換膚效果

先看效果,此demo比較簡陋,主要實作了顔色、圖檔、自定義View、字型樣式、狀态欄換膚等子產品

Android 插件化換膚 思路及實作
Android 插件化換膚 思路及實作

2. 換膚思路

先說插件化換膚主要思路:一般應用換膚主要都是更換顔色、圖檔等資源,是以我們首先需要拿到要換膚的資源ID,然後在皮膚包中設定該屬性值為想改變的顔色或圖檔資源,原應用内下載下傳皮膚包,通過代碼即可實作換膚。

例如:一個TextView的顔色需要改變,那麼我們需要得到該TextView的

textColor

屬性對應的顔色ID值,假設為

android:textColor="@color/colorAccent"

,原應用中colorAccent的值為

<color name="colorAccent">#ffffff</color>

在皮膚包中,我們将colorAccent的值修改為任意想改變的值

<color name="colorAccent">#569847</color>

,打包成APK,通過代碼即可實作TextView的顔色的改變。

一個成熟的項目一般都是批量化換膚,我們來一步步實作。

我們知道layout資源加載都是在setContentView中,在資源檔案加載之前替換資源實作換膚,閱讀源碼,重點在框起來的這行代碼

Android 插件化換膚 思路及實作

LayoutInflater也就是布局填充器,負責将xml布局加載到頁面上,繼續深入,進入inflate方法,最終定位到我們的目标方法

createViewFromTag

中,重點在框起來的部分,factory工廠

Android 插件化換膚 思路及實作

其中,Factory2繼承自Factory,比Factory多了一個parent參數。差別在于:如果需要提供将建立視圖的父級,則需要使用Factory2 。但如果應用的定位API級别為11+,則通常使用Factory2 ,否則,隻需使用Factory

public interface Factory {
        public View onCreateView(String name, Context context, AttributeSet attrs);
    }

    public interface Factory2 extends Factory {
        public View onCreateView(View parent, String name, Context context, AttributeSet attrs);
    }
           

Factory2提供了一個接口

onCreateView

,我們可以通過實作這個接口,介入到建立view的這個過程中去,記錄所有的view,同時拿到view所有需要換膚的屬性,記錄下來,然後根據屬性替換,以上就是我們換膚的大緻思路。

3. 代碼實作

建立一個library,專門用于處理換膚的SDK,先看項目目錄如下

Android 插件化換膚 思路及實作

其實SkinManager是換膚庫的管理類,單利模式實作,在項目的MyApplication 中初始化,傳遞application

public class MyApplication extends Application {
    @Override
    public void onCreate() {
        super.onCreate();
        SkinManager.init(this);
    }
}
           

通過自定義SkinActivityLifeCycle繼承自Application.ActivityLifecycleCallbacks,實作對應用中所有Activity的生命周期的管理

Android 插件化換膚 思路及實作

SkinActivityLifeCycle主要在onActivityCreated方法中建立自定義工廠SkinFactory,先隻看框起來的部分

Android 插件化換膚 思路及實作

SkinFactory繼承自LayoutInflater.Factory2,在onCreateView方法中周遊所有的View,得到可以換膚的View的集合

Android 插件化換膚 思路及實作

createViewFromTag

createView

實作如下,代碼注釋也寫得比較清楚,主要思路是用全類名,通過反射擷取其class得到該View的構造器,存儲到自定義的構造器集合裡,便于下次再遇到不用再通過反射建立,然後傳回該View的構造器(這段其實和源碼一樣,不想寫可以直接複制源碼,這裡寫出來主要是為了了解思路)

/**
     * 拿到view
     *
     * @param name         布局控件的名稱,如ImageView,LinearLayout等
     * @param attributeSet view的屬性集合
     */
    private View createViewFromTag(String name, Context context, AttributeSet attributeSet) {
        // 自定義view
//        if (-1 != name.indexOf(".")) {
        if (name.contains(".")) {
            return null;
        }

        // 原生veiw
        View view = null;
        for (String aMClassPrefixlist : mClassPrefixlist) {
            // mClassPrefixlist[i] + name === android.widget.TextView
            view = createView(aMClassPrefixlist + name, context, attributeSet);
            if (view != null) {
                break;
            }
        }
        return view;
    }


    /**
     * 根據全類名建立view
     * @param name         全類名,如 android.widget.TextView
     * @param attributeSet view的屬性集合
     */
    private View createView(String name, Context context, AttributeSet attributeSet) {
        Constructor<? extends View> constructor = constructorHashMap.get(name);
        // 先從集合中取,如果集合中沒有存儲過該view的構造器,反射擷取class,然後擷取構造器,再存儲到map中
        if (constructor == null) {
            try {
                // 反射,通過全類名擷取class
                Class<? extends View> aClass = context.getClassLoader().loadClass(name).asSubclass(View.class);
                // 擷取構造參數,隻能擷取兩個參數的構造函數
                constructor = aClass.getConstructor(mConstructorSignature);
                //添加到map中
                constructorHashMap.put(name, constructor);
                // constructor.newInstance()表示根據構造器取到對應的對象
                return constructor.newInstance(context, attributeSet);

            } catch (Exception e) {
                e.printStackTrace();
            }
        } else {
            try {
                return constructor.newInstance(context, attributeSet);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return null;
    }
           

繼續回到我們實作的onCreateView方法中,既然得到了View,我們就可以通過SkinAttr的load方法,周遊該View種的屬性集合,篩選需要換膚的屬性,存儲起來。

SkinAttr實作如下,主要思路是自定義了一個mAttributes集合,集合中包含了需要換膚的屬性合集,在load方法中,通過周遊傳進來View的AttributeSet集合,與我們需要換膚的集合比較,如果有就擷取該屬性對應的屬性值,判斷屬性值開頭是

#

@

,等。

如果是

#

說明是固定顔色值,可以修改也可以不改,具體看項目需求,此處未修改。

代表是系統屬性,需要特殊處理,其他就是類似

@

開頭的值,依次得到resId後,建立自定義的SkinPain,包括屬性名和屬性id,添加到SkinPain的集合中。

周遊完成後,判斷SkinPain集合是否為空(代碼可以看到,這裡的條件還有

view instanceof TextView || view instanceof SkinViewSupport

,這兩個是更換字型和自定義view的判斷條件,後面再講),不為空則建立SkinView(屬性包含View和SkinPain),并将其添加到SkinView的集合中,至此我們得到了所有需要換膚的View的集合。

/**
 * 周遊view的屬性集合類
 */
public class SkinAttr {
    private Typeface typeface;
    private String tag = SkinAttr.class.getSimpleName();
    // 可更改的view的屬性集合
    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");
    }

    // view的name和id集合
    private List<SkinView> skinViews = new ArrayList<>();

    public SkinAttr(Typeface typeface) {
        this.typeface = typeface;
    }

    /**
     * 加載view的屬性集合,周遊得到它可以更換的屬性集合
     *
     * @param view
     * @param attributeSet
     */
    public void load(View view, AttributeSet attributeSet) {
        List<SkinPain> skinPains = new ArrayList<>();
        for (int i = 0; i < attributeSet.getAttributeCount(); i++) {
            // 擷取屬性的名字
            String attributeName = attributeSet.getAttributeName(i);//background
            // 如果目前view的屬性集中包含這些屬性
            if (mAttributes.contains(attributeName)) {
                // 擷取對應的屬性值
                String attributeValue = attributeSet.getAttributeValue(i);//取到的是R檔案裡對應的值,隻不過是string
                Log.e(tag, "attributeValue == " + attributeValue);//?2130837582
                if (attributeValue.startsWith("#")) {//#121212
                    continue;// 帶#的是寫死的,不改//當然也可以修改,看具體項目需求
                }
                int resId;
                if (attributeValue.startsWith("?")) {// ?colorAccent
                    // 提取attributeValue值,去掉第一位的?剩下colorAccent,并轉化為id值
                    int attrId = Integer.parseInt(attributeValue.substring(1));
                    Log.e(tag, "attrId == " + attrId);//2130837582
                    resId = SkinThemeUtils.getResId(view.getContext(), new int[]{attrId})[0];
                    Log.e(tag, "resId? == " + resId);
                } else {
                    // @color/colorAccent
                    resId = Integer.parseInt(attributeValue.substring(1));
                    Log.e(tag, "resId@ == " + resId);
                }
                // 儲存屬性名稱和對應id
                if (resId != 0) {
                    SkinPain skinPain = new SkinPain(attributeName, resId);
                    skinPains.add(skinPain);
                }
            }
        }
        if (!skinPains.isEmpty() || view instanceof TextView || view instanceof SkinViewSupport) {
            SkinView skinView = new SkinView(view, skinPains);
            skinView.applySkin(typeface);
            // 儲存view和他的可變屬性集合,用于後續修改
            skinViews.add(skinView);
        }
    }

    public void setTypeface(Typeface typeface) {
        this.typeface = typeface;
    }


    // view
    // view的名稱和id
    private class SkinView {
        View view;
        List<SkinPain> skinPains;

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

        // 循環周遊,應用修改皮膚屬性
        public void applySkin(Typeface typeface) {
            applyTypeFace(typeface);
            applySkinSupport();
            for (SkinPain skinPain : skinPains) {
                Drawable left = null, top = null, right = null, bottom = null;
                Log.e(tag, "skinPain == " + skinPain.attributeName);
                switch (skinPain.attributeName) {
                    case "background":
                        Object background = SkinResources.getInstance().getBackground(skinPain.resId);
                        if (background instanceof Integer) {// 顔色值
                            view.setBackgroundColor((Integer) background);
                        } else {//drawable屬性值
                            ViewCompat.setBackground(view, (Drawable) background);
                        }
                        break;
                    case "src":
                        background = SkinResources.getInstance().getBackground(skinPain.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(skinPain.resId));
                        break;
                    case "drawableLeft":
                        left = SkinResources.getInstance().getDrawable(skinPain.resId);
                        break;
                    case "drawableTop":
                        top = SkinResources.getInstance().getDrawable(skinPain.resId);
                        break;
                    case "drawableRight":
                        right = SkinResources.getInstance().getDrawable(skinPain.resId);
                        break;
                    case "drawableBottom":
                        bottom = SkinResources.getInstance().getDrawable(skinPain.resId);
                        break;
                    case "skinTypeface":
                        applyTypeFace(SkinResources.getInstance().getTypeface(skinPain.resId));
                        break;
                    default:
                        break;
                }
                if (null != left || null != right || null != top || null != bottom) {
                    ((TextView) view).setCompoundDrawablesWithIntrinsicBounds(left, top, right, bottom);
                }

            }
        }

		// 字型換膚
        private void applyTypeFace(Typeface typeface) {
            if (view instanceof TextView) {
                Log.e(tag, "typeface == " + typeface.getStyle());
                ((TextView) view).setTypeface(typeface);
            }

        }

		// 自定義View換膚
        private void applySkinSupport() {
            if (view instanceof SkinViewSupport) {
                Log.e(tag,"applySkinSupport === ");
                ((SkinViewSupport) view).applySkinView();
            }
        }
    }




    /**
     * view屬性名和在R檔案中對應id的類
     */
    private class SkinPain {
        String attributeName;
        int resId;

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

    // 更換皮膚
    public void applySkin() {
        for (SkinView skinView : skinViews) {
            skinView.applySkin(typeface);
        }
    }
}

           

通過實作Factory的onCreateView方法,我們在xml加載之前得到了所有需要換膚的View集合,那麼怎麼實作換膚呢?

SkinManager中的

loadSkin

方法,判斷傳過來的皮膚包位址是否為空,空就加載預設皮膚,否則加載給定路徑的皮膚,這裡我換膚路徑寫死了,給模拟器對應的路徑傳了自定義皮膚包的apk進去,一般線上是先下載下傳,然後換膚。

注意:這裡因為讀取了sd卡,是以需要添加讀寫權限,否則會空指針異常
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
           

通過反射得到PackageInfo,重點在

SkinResources.getInstance().applySkin(skinResource, packageName);

這行代碼,初始化皮膚包資源和包名。然後通過觀察者模式通知更新皮膚。

// 加載皮膚
    public void loadSkin(String skinPath) {
        Log.e(tag,"skinPath == " + skinPath);
        if (TextUtils.isEmpty(skinPath)) {
            // 清空資料總管,皮膚資源屬性
            SkinResources.getInstance().reset();
            // 位址為空,使用預設皮膚
            SkinPreference.getInstance().setSkin("");
        } else {
            try {
                // 反射建立AssetManager與Resource
                AssetManager assetManager = AssetManager.class.newInstance();
                // 資源路徑設定目錄或壓縮包
                Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
                addAssetPath.invoke(assetManager, skinPath);
                Resources appResources = application.getResources();
                // 根據目前的顯示與配置(橫豎屏、語言等)建立Resources
                Resources skinResource = new Resources(assetManager, appResources.getDisplayMetrics(), appResources.getConfiguration());


                // 擷取外部APK(皮膚包)的包名
                PackageInfo packageArchiveInfo = application.getPackageManager().getPackageArchiveInfo(skinPath, PackageManager.GET_ACTIVITIES);
                Log.e(tag,"packageArchiveInfo == "+packageArchiveInfo);
                if(packageArchiveInfo!=null) {
                    String packageName = packageArchiveInfo.packageName;
                    // 初始化皮膚包資料總管
                    Log.e(tag,"skinResource == " + skinResource);
                    Log.e(tag,"packageName == " + packageName);
                    SkinResources.getInstance().applySkin(skinResource, packageName);

                    // 本地記錄
                    SkinPreference.getInstance().setSkin(skinPath);
                } else {
                    Toast.makeText(application,"包資訊為null",Toast.LENGTH_SHORT).show();
                }


            } catch (IllegalAccessException e) {
                e.printStackTrace();
            } catch (InstantiationException e) {
                e.printStackTrace();
            } catch (NoSuchMethodException e) {
                e.printStackTrace();
            } catch (InvocationTargetException e) {
                e.printStackTrace();
            }
        }
        // 通知采集的view更新皮膚
        // 被觀察者改變 通知所有觀察者
        setChanged();
        notifyObservers(null);
    }
           

SkinFactory的

update

方法中收到更新消息,skinAttr.applySkin()方法實作換膚

@Override
    public void update(Observable observable, Object o) {
    	// 狀态欄換膚
        SkinThemeUtils.updateStatusBarColor(activity);
        // 字型換膚,此處是用于設定及時生效的
        Typeface typeface = SkinThemeUtils.updateTypeFace(activity);
        skinAttr.setTypeface(typeface);
        // 普通屬性換膚
        skinAttr.applySkin();
    }
           

SkinAttr的

applySkin

方法,周遊之前得到的需要換膚的skinViews集合,通過applySkin方法,周遊屬性合集

// 更換皮膚
    public void applySkin() {
        for (SkinView skinView : skinViews) {
            skinView.applySkin(typeface);
        }
    }
           

SkinAttr的

SkinView

内部類實作

// view的名稱和id
    private class SkinView {
        View view;
        List<SkinPain> skinPains;

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

        // 循環周遊,應用修改皮膚屬性
        public void applySkin(Typeface typeface) {
            applyTypeFace(typeface);
            applySkinSupport();
            for (SkinPain skinPain : skinPains) {
                Drawable left = null, top = null, right = null, bottom = null;
                Log.e(tag, "skinPain == " + skinPain.attributeName);
                switch (skinPain.attributeName) {
                    case "background":
                        Object background = SkinResources.getInstance().getBackground(skinPain.resId);
                        if (background instanceof Integer) {// 顔色值
                            view.setBackgroundColor((Integer) background);
                        } else {//drawable屬性值
                            ViewCompat.setBackground(view, (Drawable) background);
                        }
                        break;
                    case "src":
                        background = SkinResources.getInstance().getBackground(skinPain.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(skinPain.resId));
                        break;
                    case "drawableLeft":
                        left = SkinResources.getInstance().getDrawable(skinPain.resId);
                        break;
                    case "drawableTop":
                        top = SkinResources.getInstance().getDrawable(skinPain.resId);
                        break;
                    case "drawableRight":
                        right = SkinResources.getInstance().getDrawable(skinPain.resId);
                        break;
                    case "drawableBottom":
                        bottom = SkinResources.getInstance().getDrawable(skinPain.resId);
                        break;
                    case "skinTypeface":
                        applyTypeFace(SkinResources.getInstance().getTypeface(skinPain.resId));
                        break;
                    default:
                        break;
                }
                if (null != left || null != right || null != top || null != bottom) {
                    ((TextView) view).setCompoundDrawablesWithIntrinsicBounds(left, top, right, bottom);
                }

            }
        }
		// 字型換膚
        private void applyTypeFace(Typeface typeface) {
            if (view instanceof TextView) {
                Log.e(tag, "typeface == " + typeface.getStyle());
                ((TextView) view).setTypeface(typeface);
            }

        }
		// 自定義View換膚
        private void applySkinSupport() {
            if (view instanceof SkinViewSupport) {
                Log.e(tag,"applySkinSupport === ");
                ((SkinViewSupport) view).applySkinView();
            }
        }
    }
           

SkinResources是實作換膚的類,大緻思路是,如果是預設皮膚,就傳回原始包中對應的id值,如果需要換膚就傳回mSkinResources也就是通過皮膚包得到的id,然後在SkinAttr的SkinView的applySkin中設定給對應的View即可實作換膚。

public class SkinResources {

    private static SkinResources instance;

    private Resources mSkinResources;
    private String mSkinPkgName;
    private boolean isDefaultSkin = true;

    private Resources mAppResources;
    private String tag = SkinResources.class.getSimpleName();

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

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

    public static SkinResources getInstance() {
        if (instance == null) {
            throw new IllegalStateException("SkinResources 未初始化");
        }
        return instance;
    }

    public void reset() {
        mSkinResources = null;
        mSkinPkgName = "";
        isDefaultSkin = true;
    }

    public void applySkin(Resources resources, String pkgName) {
        mSkinResources = resources;
        mSkinPkgName = pkgName;
        //是否使用預設皮膚
        isDefaultSkin = TextUtils.isEmpty(pkgName) || resources == null;
    }


    // 根據原生app view參數的id,得到name,取到皮膚包中相同name的id
    public int getIdentifier(int resId) {
        if (isDefaultSkin) {
            return resId;
        }
        //在皮膚包中不一定就是 目前程式的 id
        //擷取對應id 在目前的名稱 colorPrimary
        //R.drawable.ic_launcher
        String resName = mAppResources.getResourceEntryName(resId);//ic_launcher
        Log.e(tag, " resName == " + resName);
        String resType = mAppResources.getResourceTypeName(resId);//drawable
        Log.e(tag, " resType == " + resType);

        int skinId = mSkinResources.getIdentifier(resName, resType, mSkinPkgName);
        Log.e(tag, " skinId == " + skinId);

        return skinId;
    }

    public int getColor(int resId) {
        if (isDefaultSkin) {
            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 (isDefaultSkin) {
            return mAppResources.getColorStateList(resId);
        }
        int skinId = getIdentifier(resId);
        if (skinId == 0) {
            return mAppResources.getColorStateList(resId);
        }
        return mSkinResources.getColorStateList(skinId);
    }

    public Drawable getDrawable(int resId) {
        //如果有皮膚  isDefaultSkin false 沒有就是true
        if (isDefaultSkin) {
            return mAppResources.getDrawable(resId);
        }
        int skinId = getIdentifier(resId);
        if (skinId == 0) {
            return mAppResources.getDrawable(resId);
        }
        return mSkinResources.getDrawable(skinId);
    }


    /**
     * 可能是Color 也可能是drawable
     *
     * @return
     */
    public Object getBackground(int resId) {
        String resourceTypeName = mAppResources.getResourceTypeName(resId);

        if (resourceTypeName.equals("color")) {
            return getColor(resId);
        } else {
            // drawable
            return getDrawable(resId);
        }
    }

    public String getString(int resId) {
        try {
            if (isDefaultSkin) {
                Log.e("SkinResources", "mAppResources.getString(resId) == " + mAppResources.getString(resId));
                return mAppResources.getString(resId);
            }
            int skinId = getIdentifier(resId);
            Log.e("SkinResources", "skinId == " + skinId);
            if (skinId == 0) {
                Log.e("SkinResources", "mAppResources.getString(resId) == " + mAppResources.getString(resId));
                return mAppResources.getString(resId);
            }
            Log.e("SkinResources", "mSkinResources.getString(resId) == " + mSkinResources.getString(skinId));
            return mSkinResources.getString(skinId);
        } catch (Resources.NotFoundException e) {

        }
        return null;
    }

    // 擷取字型
    public Typeface getTypeface(int resId) {
        String skinTypefacePath = getString(resId);
        Log.e("tag", "typefacepath == " + skinTypefacePath);
        if (TextUtils.isEmpty(skinTypefacePath)) {
            return Typeface.DEFAULT;
        }
        try {
            Typeface typeface;
            if (isDefaultSkin) {
                typeface = Typeface.createFromAsset(mAppResources.getAssets(), skinTypefacePath);
                return typeface;

            }
            typeface = Typeface.createFromAsset(mSkinResources.getAssets(), skinTypefacePath);
            return typeface;
        } catch (RuntimeException e) {
        }
        return Typeface.DEFAULT;
    }
}

           

最後在項目中添加點選事件實作換膚即可

Android 插件化換膚 思路及實作

寫得有點啰嗦,但是大緻思路和實作方法基本就是這些,不是很難,就是具體項目中皮膚包的實作比較繁瑣,需要細心細心再細心。

4. 生成皮膚包

說到這裡,說一下怎麼實作皮膚包吧,建立一個項目,不需要activity這些,隻保留value下的資源,設定需要換膚的屬性值,color,圖檔等,然後Build——Build Bundle(s)/APK(s)——Build APK(s),打包成一個apk就行了

Android 插件化換膚 思路及實作
Android 插件化換膚 思路及實作

5. 代碼下載下傳位址

https://download.csdn.net/download/mr_hmgo/21351930

Android 插件化換膚 思路及實作