換膚,顧名思義,就是對應用中的視覺元素進行更新,呈現新的顯示效果。一般來說,換膚的時候只是更新UI上使用的資源,如顏色,圖片,字體等等。本文介紹一種筆者自己使用的基於布局的Android換膚方案,不僅可以更換所有的UI資源,而且可以更換主題樣式(style)和布局樣式。代碼已托管到github:SkinFramework
換膚當然得有相應的皮膚包,不管是內置在應用內,還是做成可安裝的皮膚應用包。但是這兩種都有弊端:
1.內置在應用內會增加應用包的體積。
2.皮膚安裝包需要安裝過程,會占用更多的設備內置存儲,用戶會介意安裝過多應用。而且為了是應用能夠訪問安裝包內的資源,必須與應用使用相同的shareUserId。
鑒於此,本文推薦使用無需安裝的外置皮膚包,優點在於:
1.無需安裝,也無關乎shareUserId,不會引起用戶反感。
2.按需下載使用,用戶需要使用時自行下載,下載即可使用。
3.可放置於任何可訪問的位置,SD卡或內置存儲,可隨時刪除和添加,不會增加應用體積。
先來看一下效果圖:
可以看到,圖中有三種皮膚,默認皮膚,plain皮膚和vivid皮膚,都是更換了布局和資源的,其中還使用了AdapterView和Fragment作測試。可以看到,不同的皮膚有不同的布局樣式,布局樣式的不同也帶來了很多可能,如隱藏或移動了功能入口。
所以說這是一個有很多可能的換膚框架,下面介紹一下核心 實現。
一、皮膚包
皮膚包就是一個不包含代碼文件的Apk包,無需安裝,可以新建工程,刪除掉代碼文件,復制應用里面需要修改的資源到新工程中修改,打成新包即可作為皮膚包使用,皮膚包后綴名可以改為任意。示例中使用了.skin作為后綴名。
二、皮膚包加載
皮膚包中包含的資源文件,需要加載到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);
三、資源管理器
加載了皮膚包,我們就有了兩套可共使用的皮膚資源,應用默認資源和皮膚包資源,何時使用默認,何時使用皮膚,需要有一個管理器來決定,所以我們實現一個名為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; }
四、布局更換處理
通過上述的代碼,我們就已經能夠完成常見資源的換膚了。但是對於布局資源,我們還需要做額外的處理。
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; } } } }
五、使用
下載源碼,集成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的最新動態,若有任何建議和意見,歡迎指出!