Android可更換佈局的換膚方案

換膚,顧名思義,就是對應用中的視覺元素進行更新,呈現新的顯示效果。通常來講,換膚的時候只是更新UI上使用的資源,如顏色,圖片,字體等等。本文介紹一種筆者本身使用的基於佈局的Android換膚方案,不只能夠更換全部的UI資源,並且能夠更換主題樣式(style)和佈局樣式。代碼已託管到github:SkinFrameworkandroid

換膚固然得有相應的皮膚包,不論是內置在應用內,仍是作成可安裝的皮膚應用包。可是這兩種都有弊端:git

1.內置在應用內會增長應用包的體積。github

2.皮膚安裝包須要安裝過程,會佔用更多的設備內置存儲,用戶會介意安裝過多應用。並且爲了是應用可以訪問安裝包內的資源,必須與應用使用相同的shareUserId。api

鑑於此,本文推薦使用無需安裝的外置皮膚包,優勢在於:app

1.無需安裝,也無關乎shareUserId,不會引發用戶反感。框架

2.按需下載使用,用戶須要使用時自行下載,下載便可使用。ide

3.可放置於任何可訪問的位置,SD卡或內置存儲,可隨時刪除和添加,不會增長應用體積。佈局

先來看一下效果圖:測試

能夠看到,圖中有三種皮膚,默認皮膚,plain皮膚和vivid皮膚,都是更換了佈局和資源的,其中還使用了AdapterView和Fragment做測試。能夠看到,不一樣的皮膚有不一樣的佈局樣式,佈局樣式的不一樣也帶來了不少可能,如隱藏或移動了功能入口。字體

因此說這是一個有不少可能的換膚框架,下面介紹一下核心 實現。

1、皮膚包

皮膚包就是一個不包含代碼文件的Apk包,無需安裝,能夠新建工程,刪除掉代碼文件,複製應用裏面須要修改的資源到新工程中修改,打成新包便可做爲皮膚包使用,皮膚包後綴名能夠改成任意。示例中使用了.skin做爲後綴名。

 

2、皮膚包加載

皮膚包中包含的資源文件,須要加載到AssetManager中並建立Resources才能提供使用,關於Android的資源管理機制書上或網上已經有不少介紹,能夠參考:Android中資源管理機制詳細分析。因此咱們的第一件事也是來加載皮膚包:

 AssetManager assetManager = AssetManager.class.newInstance();
        Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
        //Return 0 on failure.
        Object ret = addAssetPath.invoke(assetManager, skinPath);
        if (Integer.parseInt(ret.toString()) == 0) {
            throw new IllegalStateException("Add asset fail");
        }
        Resources localRes = context.getResources();
        return new SkinResource(context, assetManager, localRes.getDisplayMetrics(), localRes.getConfiguration(), packageName);

 

3、資源管理器

加載了皮膚包,咱們就有了兩套可共使用的皮膚資源,應用默認資源和皮膚包資源,什麼時候使用默認,什麼時候使用皮膚,須要有一個管理器來決定,因此咱們實現一個名爲ComposedResources的類來扮演ResourcesManager:

/**
 * Created by ARES on 2016/5/20
 * This is a resources class consists of App default skin and external skin resources if exists. We will find resource in external skin resources first,then the default.
 * Assume all resources ids are original  so that we should find corresponding resources ids in skin .
 */

public class ComposedResources extends BaseResources {
    static int LAYOUT_TAG_ID = -1;
    private Context mContext;
    private BaseSkinResources mSkinResources;

    public ComposedResources(Context context) {
        this(context, null);
    }

    public ComposedResources(Context context, BaseSkinResources skinResources) {
        super(context.getResources());
        mContext = context;
        mSkinResources = skinResources;
    }


    public ComposedResources setSkinResources(BaseSkinResources resources) {
        mSkinResources = resources;
        return this;
    }

    public BaseSkinResources getSkinResources() {
        return mSkinResources;
    }

    @NonNull
    @Override
    public CharSequence getText(@StringRes int id) throws NotFoundException {
        int realId = getCorrespondResId(id);
        if (realId > 0) {
            try {
                return mSkinResources.getText(realId);
            } catch (Exception e) {
            }
        }
        return super.getText(id);
    }

    @NonNull
    @Override
    public CharSequence getQuantityText(@PluralsRes int id, int quantity) throws NotFoundException {
        int realId = getCorrespondResId(id);
        if (realId > 0) {
            return mSkinResources.getQuantityText(realId, quantity);
        }
        return super.getQuantityText(id, quantity);
    }

    @NonNull
    @Override
    public String getQuantityString(@PluralsRes int id, int quantity, Object... formatArgs) throws NotFoundException {
        int realId = getCorrespondResId(id);
        if (realId > 0) {
            return mSkinResources.getQuantityString(realId, quantity, formatArgs);
        }
        return super.getQuantityString(id, quantity, formatArgs);
    }

    @NonNull
    @Override
    public String getQuantityString(@PluralsRes int id, int quantity) throws NotFoundException {
        int realId = getCorrespondResId(id);
        if (realId > 0) {
            return mSkinResources.getQuantityString(realId, quantity);
        }
        return super.getQuantityString(id, quantity);
    }

    @Override
    public CharSequence getText(@StringRes int id, CharSequence def) {
        int realId = getCorrespondResId(id);
        if (realId > 0) {
            return mSkinResources.getText(realId, def);
        }
        return super.getText(id, def);
    }

    @NonNull
    @Override
    public CharSequence[] getTextArray(@ArrayRes int id) throws NotFoundException {
        int realId = getCorrespondResId(id);
        if (realId > 0) {
            return mSkinResources.getTextArray(id);
        }
        return super.getTextArray(id);
    }

    @NonNull
    @Override
    public String[] getStringArray(@ArrayRes int id) throws NotFoundException {
        int realId = getCorrespondResId(id);
        if (realId > 0) {
            return mSkinResources.getStringArray(realId);
        }
        return super.getStringArray(id);
    }

    @NonNull
    @Override
    public int[] getIntArray(@ArrayRes int id) throws NotFoundException {
        int realId = getCorrespondResId(id);
        if (realId > 0) {
            return mSkinResources.getIntArray(realId);
        }
        return super.getIntArray(id);
    }

    @NonNull
    @Override
    public TypedArray obtainTypedArray(@ArrayRes int id) throws NotFoundException {
        int realId = getCorrespondResId(id);
        if (realId > 0) {
            return mSkinResources.obtainTypedArray(realId);
        }
        return super.obtainTypedArray(id);
    }

    @Override
    public float getDimension(@DimenRes int id) throws NotFoundException {
        int realId = getCorrespondResId(id);
        if (realId > 0) {
            return mSkinResources.getDimension(realId);
        }
        return super.getDimension(id);
    }

    @Override
    public int getDimensionPixelOffset(@DimenRes int id) throws NotFoundException {
        int realId = getCorrespondResId(id);
        if (realId > 0) {
            return mSkinResources.getDimensionPixelOffset(realId);
        }
        return super.getDimensionPixelOffset(id);
    }

    @Override
    public int getDimensionPixelSize(@DimenRes int id) throws NotFoundException {
        int realId = getCorrespondResId(id);
        if (realId > 0) {
            return mSkinResources.getDimensionPixelSize(realId);
        }
        return super.getDimensionPixelSize(id);
    }

    @Override
    public float getFraction(@FractionRes int id, int base, int pbase) {
        int realId = getCorrespondResId(id);
        if (realId > 0) {
            return mSkinResources.getFraction(id, base, pbase);
        }
        return super.getFraction(id, base, pbase);
    }

    @Override
    public Drawable getDrawable(@DrawableRes int id) throws NotFoundException {
        int realId = getCorrespondResId(id);
        if (realId > 0) {
            return mSkinResources.getDrawable(realId);
        }
        return super.getDrawable(id);
    }

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    @Override
    public Drawable getDrawable(@DrawableRes int id, @Nullable Theme theme) throws NotFoundException {
        int realId = getCorrespondResId(id);
        if (realId > 0) {
            return mSkinResources.getDrawable(realId, theme);
        }
        return super.getDrawable(id, theme);
    }

    @Override
    public Drawable getDrawableForDensity(@DrawableRes int id, int density) throws NotFoundException {
        int realId = getCorrespondResId(id);
        if (realId > 0) {
            return mSkinResources.getDrawableForDensity(realId, density);
        }
        return super.getDrawableForDensity(id, density);
    }

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    @Override
    public Drawable getDrawableForDensity(@DrawableRes int id, int density, @Nullable Theme theme) {
        int realId = getCorrespondResId(id);
        if (realId > 0) {
            return mSkinResources.getDrawableForDensity(realId, density, theme);
        }
        return super.getDrawableForDensity(id, density, theme);
    }

    @Override
    public Movie getMovie(@RawRes int id) throws NotFoundException {
        int realId = getCorrespondResId(id);
        if (realId > 0) {
            return mSkinResources.getMovie(realId);
        }
        return super.getMovie(id);
    }

    @Override
    public int getColor(@ColorRes int id) throws NotFoundException {
        int realId = getCorrespondResId(id);
        if (realId > 0) {
            return mSkinResources.getColor(realId);
        }
        return super.getColor(id);
    }

    @RequiresApi(api = Build.VERSION_CODES.M)
    @Override
    public int getColor(@ColorRes int id, @Nullable Theme theme) throws NotFoundException {
        int realId = getCorrespondResId(id);
        if (realId > 0) {
            return mSkinResources.getColor(realId, theme);
        }
        return super.getColor(id, theme);
    }

    @Nullable
    @Override
    public ColorStateList getColorStateList(@ColorRes int id) throws NotFoundException {
        int realId = getCorrespondResId(id);
        if (realId > 0) {
            return mSkinResources.getColorStateList(realId);
        }
        return super.getColorStateList(id);
    }

    @RequiresApi(api = Build.VERSION_CODES.M)
    @Nullable
    @Override
    public ColorStateList getColorStateList(@ColorRes int id, @Nullable Theme theme) throws NotFoundException {
        int realId = getCorrespondResId(id);
        if (realId > 0) {
            return mSkinResources.getColorStateList(realId, theme);
        }
        return super.getColorStateList(id, theme);
    }

    @Override
    public boolean getBoolean(@BoolRes int id) throws NotFoundException {
        int realId = getCorrespondResId(id);
        if (realId > 0) {
            return mSkinResources.getBoolean(realId);
        }
        return super.getBoolean(id);
    }

    @Override
    public int getInteger(@IntegerRes int id) throws NotFoundException {
        int realId = getCorrespondResId(id);
        if (realId > 0) {
            return mSkinResources.getInteger(realId);
        }
        return super.getInteger(id);
    }

    @Override
    public XmlResourceParser getLayout(@LayoutRes int id) throws NotFoundException {
        int realId = getCorrespondResIdStrictly(id);
        if (realId > 0) {
            return mSkinResources.getLayout(realId);
        }
        return super.getLayout(id);
    }

    @Override
    public XmlResourceParser getAnimation(@AnimRes int id) throws NotFoundException {
        int realId = getCorrespondResIdStrictly(id);
        if (realId > 0) {
            return mSkinResources.getAnimation(realId);
        }
        return super.getAnimation(id);
    }

    @Override
    public XmlResourceParser getXml(@XmlRes int id) throws NotFoundException {
        int realId = getCorrespondResIdStrictly(id);
        if (realId > 0) {
            return mSkinResources.getXml(realId);
        }
        return super.getXml(id);
    }

    @Override
    public InputStream openRawResource(@RawRes int id) throws NotFoundException {
        int realId = getCorrespondResId(id);
        if (realId > 0) {
            return mSkinResources.openRawResource(realId);
        }
        return super.openRawResource(id);
    }

    @Override
    public InputStream openRawResource(@RawRes int id, TypedValue value) throws NotFoundException {
        int realId = getCorrespondResId(id);
        if (realId > 0) {
            return mSkinResources.openRawResource(realId, value);
        }
        return super.openRawResource(id, value);
    }

    @Override
    public AssetFileDescriptor openRawResourceFd(@RawRes int id) throws NotFoundException {
        int realId = getCorrespondResId(id);
        if (realId > 0) {
            return mSkinResources.openRawResourceFd(realId);
        }
        return super.openRawResourceFd(id);
    }

    @Override
    public void getValue(@AnyRes int id, TypedValue outValue, boolean resolveRefs) throws NotFoundException {
        int realId = getCorrespondResId(id);
        if (realId > 0) {
            mSkinResources.getValue(realId, outValue, resolveRefs);
            return;
        }
        super.getValue(id, outValue, resolveRefs);
    }

    @Override
    public void getValueForDensity(@AnyRes int id, int density, TypedValue outValue, boolean resolveRefs) throws NotFoundException {
        int realId = getCorrespondResId(id);
        if (realId > 0) {
            mSkinResources.getValueForDensity(realId, density, outValue, resolveRefs);
            return;
        }
        super.getValueForDensity(id, density, outValue, resolveRefs);
    }

    @Override
    public void getValue(String name, TypedValue outValue, boolean resolveRefs) throws NotFoundException {
        if (mSkinResources != null) {
            try {
                mSkinResources.getValue(name, outValue, resolveRefs);
                return;
            } catch (Exception e) {
            }
        }
        super.getValue(name, outValue, resolveRefs);
    }

    @Override
    public void updateConfiguration(Configuration config, DisplayMetrics metrics) {
        if (mSkinResources != null) {
            mSkinResources.updateConfiguration(config, metrics);
        }
        super.updateConfiguration(config, metrics);
    }

    /**
     * Get correspond resources id with  app package. See also {@link #getCorrespondResId(int)}
     *
     * @param resId
     * @return 0 if not exist
     */
    public int getCorrespondResIdStrictly(int resId) {
        if (mSkinResources == null) {
            return 0;
        }
        String resName = getResourceName(resId);
        return mSkinResources.getIdentifier(resName, null, null);
    }

    /**
     * Get correspond resources id with skin package. See also {@link #getCorrespondResId(int)}
     *
     * @param resId
     * @return
     */
    public int getCorrespondResId(int resId) {
        if (mSkinResources == null) {
            return 0;
        }
        return mSkinResources.getCorrespondResId(resId);
    }


    @Override
    public View getView(Context context, @LayoutRes int resId) {
        //Take a resource id as the tag key.
        if (LAYOUT_TAG_ID < 1) {
            LAYOUT_TAG_ID = resId;
        }
        View view;
        if (mSkinResources != null) {
            int realId = getCorrespondResId(resId);
            if (realId > 0) {
                view = mSkinResources.getView(context, realId);
                if (view != null) {
                    view.setTag(LAYOUT_TAG_ID, mSkinResources.getPackageName());
                    SkinUtils.showIds(view);
                    return view;
                }
            }
        }
        view = LayoutInflater.from(context).inflate(resId, null);
        view.setTag(LAYOUT_TAG_ID, getPackageName());
        SkinUtils.showIds(view);
        return view;
    }

    @Override
    public String getPackageName() {
        return mContext.getPackageName();
    }
}

 

能夠看到這個ResourceManager自己也是一個Resources,它繼承自BaseResources,BaseResources繼承自android.content.res.Resources。因此它能夠直接做爲應用的Resources來使用。其中有幾點須要注意:

1.查找資源時,資源管理器應優先查找皮膚包中的資源,若皮膚包中沒有相應資源,才使用應用默認資源。

2.每一個應用包中的資源id是不一樣的,查找資源時,咱們傳入Resources的id都是應用中的id,而非皮膚包中的id,因此咱們須要轉換爲皮膚包中相應的資源id,再獲取具體的資源(此代碼實如今SkinResources中,ComposedResources調用了此方法):

   /**
     * Get correspond resource id in skin archive.
     * @param resId Resource id in app.
     * @return 0 if not exist
     */
    public int getCorrespondResId(int resId) {
        Resources appResources = getAppResources();
        String resName = appResources.getResourceName(resId);
        if (!TextUtils.isEmpty(resName)) {
            String skinName = resName.replace(mAppPackageName, getPackageName());
            int id = getIdentifier(skinName, null, null);
            return id;
        }
        return 0;
    }

 

3.在獲取XmlResourceParser時,須要使用應用對於資源的描述,而非皮膚包中的資源描述,因此有了getCorrespondResIdStrictly:

  /**
     * Get correspond resources id with  app package. See also {@link #getCorrespondResId(int)}
     *
     * @param resId
     * @return 0 if not exist
     */
    public int getCorrespondResIdStrictly(int resId) {
        if (mSkinResources == null) {
            return 0;
        }
        String resName = getResourceName(resId);
        return mSkinResources.getIdentifier(resName, null, null);
    }

 

4.咱們使用了LAYOUT_TAG_ID來記錄了Layout所屬的皮膚包,以即可以動態的判斷是否須要更換佈局(此方法能夠用在動態換膚的時候,詳情參考Demo):

 @Override
    public View getView(Context context, @LayoutRes int resId) {
        //Take a resource id as the tag key.
        if (LAYOUT_TAG_ID < 1) {
            LAYOUT_TAG_ID = resId;
        }
        View view;
        if (mSkinResources != null) {
            int realId = getCorrespondResId(resId);
            if (realId > 0) {
                view = mSkinResources.getView(context, realId);
                if (view != null) {
                    view.setTag(LAYOUT_TAG_ID, mSkinResources.getPackageName());
                    return view;
                }
            }
        }
        view = LayoutInflater.from(context).inflate(resId, null);
        view.setTag(LAYOUT_TAG_ID, getPackageName());
        return view;
    }

 

4、佈局更換處理

經過上述的代碼,咱們就已經可以完成常見資源的換膚了。可是對於佈局資源,咱們還須要作額外的處理。

1.Context與LayoutInflater

渲染View時,咱們須要使用皮膚對應的Context和LayoutInflater,這樣才能在View中使用正確的資源,因此咱們爲外置皮膚包建立相應的Context:

  /**
     * Context implementation for skin package.
     */
    private class SkinThemeContext extends ContextThemeWrapper {
        private WeakReference<Context> mContextRef;

        public SkinThemeContext(Context base) {
            super();
            if (base instanceof ContextThemeWrapper) {
                attachBaseContext(((ContextThemeWrapper) base).getBaseContext());
                mContextRef = new WeakReference<Context>(base);
            } else {
                attachBaseContext(base);
            }
            int themeRes = getThemeRes();
            if (themeRes <= 0) {
                themeRes = android.R.style.Theme_Light;
            }
            setTheme(themeRes);
        }

        /**
         * This implementation will support <code>onClick</code> attribute of view in xml.
         * @param v
         */
        public void onClick(View v) {
            Context context = mContextRef == null ? null : mContextRef.get();
            if (context == null) {
                return;
            }
            if (context instanceof View.OnClickListener) {
                ((View.OnClickListener) context).onClick(v);
            } else {
                Class cls = context.getClass();
                try {
                    Method m = cls.getDeclaredMethod("onClick", View.class);
                    if (m != null) {
                        m.invoke(context, v);
                    }
                } catch (NoSuchMethodException e) {
                    e.printStackTrace();
                } catch (InvocationTargetException e) {
                    e.printStackTrace();
                } catch (IllegalAccessException e) {
                    e.printStackTrace();
                }
            }
        }

        @Override
        public AssetManager getAssets() {
            return getAssets();
        }

        @Override
        public Resources getResources() {
            return SkinResource.this;
        }

        private int getThemeRes() {
            try {
                Method m = Context.class.getMethod("getThemeResId");
                return (int) m.invoke(getBaseContext());
            } catch (Exception e) {
                e.printStackTrace();
            }
            return -1;
        }

    }

 

其中咱們對xml佈局文件中的onClick屬性做了支持,同時經過提供了皮膚包對應的資源,而後咱們使用這個Context的實例建立LayoutInflater並渲染View:

  @Override
    public View getView(Context context, @LayoutRes int resId) {
        try {
            Context skinContext = new SkinThemeContext(context);
            View v = LayoutInflater.from(skinContext).inflate(resId, null);
            handleView(skinContext, v);
            return v;
        } catch (Exception e) {

        }
        return null;
    }

 

其中注意到handleView方法,正如前文所說,每一個應用包生成的資源id是不同的,這裏View中生成的id是皮膚包中的id,須要轉換爲應用中的id方可以使用:

    /**
     * Handle view to support used by app.
     *
     * @param v View resource from skin package.
     */
    public void handleView(Context context, View v) {
        resetID();
        //Id map: Key as skin id and Value as local id.
        SparseIntArray array = new SparseIntArray();
        buildIdRules(context, v, array);
        int size = array.size();
        // Map ids to which app can recognize locally.
        for (int i = 0; i < size; i++) {
            //Map id defined in skin package into real id in app.
            v.findViewById(array.keyAt(i)).setId(array.valueAt(i));
        }
    }

    /**
     * Extract id from view , build id rules and inflate rules if needed.
     *
     * @param v
     * @param array
     */
    protected void buildIdRules(Context context, View v, SparseIntArray array) {
        if (v.getId() != View.NO_ID) {
            //Get mapped id by id name.
            String idName = getResourceEntryName(v.getId());
            int mappedId = getAppResources().getIdentifier(idName, "id", context.getPackageName());
            //Add custom id to avoid id conflict when mapped id not exist.
            //Key as skin id and value as mapped id.
            array.put(v.getId(), mappedId > 0 ? mappedId : generateId());
        }
        if (v instanceof ViewGroup) {
            ViewGroup vp = (ViewGroup) v;
            int childCount = vp.getChildCount();
            for (int i = 0; i < childCount; i++) {
                buildIdRules(context, vp.getChildAt(i), array);
            }
        }
        buildInflateRules(v, array);
    }

    /**
     * Build inflate rules.
     *
     * @param v
     * @param array ID map of which Key as skin id and value as mapped id.
     */
    protected void buildInflateRules(View v, SparseIntArray array) {
        ViewGroup.LayoutParams lp = v.getLayoutParams();
        if (lp == null) {
            return;
        }
        if (lp instanceof RelativeLayout.LayoutParams) {
            int[] rules = ((RelativeLayout.LayoutParams) lp).getRules();
            if (rules == null) {
                return;
            }
            int size = rules.length;
            int mapRule = -1;
            for (int i = 0; i < size; i++) {
                //Key as skin id and value as mapped id.
                if (rules[i] > 0 && (mapRule = array.get(rules[i])) > 0) {
//                    Log.i(TAG, "Rules[" + i + "]: Mapped from: " + rules[i] + "  to  " +mapRule);
                    rules[i] = mapRule;
                }
            }
        }
    }

 

5、使用

下載源碼,集成skin module到工程中,而後使用SkinManager提供的接口:

public SkinManager initialize(Context context);//初始化皮膚管理器



/**
 * Register an observer to be informed of skin changed for ui interface such as activity,fragment, dialog etc.
 * @param observer
 */
public void register(ISkinObserver observer);//註冊換膚監聽器,用於須要動態換膚的場景。

/**
 * Get resources.
 * @return
 */
public BaseResources getResources();//獲取資源

/**
 * Change skin.
 * @param skinPath Path of skin archive.
 * @param pkgName Package name of skin archive.
 * @param cb Callback to be informed of skin-changing event.
 */
public void changeSkin(String skinPath, String pkgName, ISkinCallback cb);//更換皮膚

/**
 * Restore skin to app default skin.
 *
 * @param cb
 */
public void restoreSkin(ISkinCallback cb) ;//恢復應用默認皮膚

/**
 * Resume skin.Call it on application started.
 *
 * @param cb
 */
public void resumeSkin(ISkinCallback cb) ;//恢復當前使用的皮膚,應在應用啓動界面調用。

 

 

框架支持兩種換膚方式:

1.靜態換膚(推薦)

換膚完成後,關閉掉全部的Activity,而後從新啓動主界面。簡單方便。

2.動態換膚

須要換膚的Activity、Fragment、Dialog實現ISkinObserver, 並經過register(ISkinObserver observer)註冊到SkinManager,動態更換佈局,詳情見Sample代碼。

這種方式須要從新渲染View,綁定數據,在使用Fragment時,還須要在換膚期間detach/attach fragment,使用起來比較麻煩。優勢是換膚後能夠停留在原來界面。

 

兩種方式都須要使用SkinManager提供的Resource來獲取佈局或其餘資源。推薦寫本身的BaseActivity,重寫getResources()返回SkinManager提供的Resources方便使用。小夥伴們根據本身的實際狀況來選擇具體使用何種方式。

 

好了,到這裏換膚框架就介紹完了,歡迎關注SkinFramework的最新動態,如有任何建議和意見,歡迎指出!

相關文章
相關標籤/搜索