編輯:關於Android編程
如下是解讀demo的鏈接,自行下載
Android-Skin-Loader" target="_blank">https://github.com/fengjundev/Android-Skin-Loader
由於是開源的,而且對於想了解換膚功能的童鞋這個demo實在是通俗易懂,原理也很清晰,所以忍不住想要記錄一下,
題外話:附上一篇換膚技術總結的博客,這是一篇動態換膚與本地換膚(傳統的theme)換膚優劣勢的詳細比較,需要的童鞋,可以詳細拜讀,換膚功能用於公司的運營是常有的需求,畢竟皮膚對於app來說還是比較重要的;這個對於開發者來說並不關心, 我們只關心其技術原理。
一、換膚功能:
解讀的是一篇動態加載資源從而達到換膚的效果,這也是換膚的一種潮流,行業上得換膚跟這個demo基本都大同小異,比較性能來說這個方案也是比較值得推薦
既然是動態的,那就支持網絡加載的資源、本地資源應該都要支持,而且是無縫隙,高性能的,廢話不多說,先來看看設計者的思路
二、思路:
前提:這demo只是用本地講解所以將要換膚的資源搞成apk,重新命名後綴為.skin,並保存在指定的sd目錄中,方便加載,開發者也可通過網上下載保存到指定的sd目,方便支持線上換膚
1、當點擊換膚時,使用AsyncTask加載已保存在sd目錄中的apk,並通過AssetManager反射添加資源路徑的方法將apk的資源加載進去,然後通過new Resource將Assetmanager管理器注冊,得到一個全新的資源管理者AssetManager,通過這個管理者與app原生的資源管理者作為區分,當加載資源的時候,就可以通過資源管理者的資源作為換膚,所以需要護膚時將這個新對象resource將其賦值給全局的resource,通過Resource對象實現換膚;若切換回默認app的皮膚時,就將默認app生成的resource賦值給resource,在其獲取資源時,通過resource來控制是取得哪一套資源,從而實現換膚。
2、當點擊換膚,獲取到resource之後,這裡通過觀察者模式去通知當前活動的頁面進行換膚,而不是放在onResume實時監測,使用觀察者就需要activity實現接口,這裡通過在BaseActivity實現統一接口ISkinUpdate,統一進行注冊,達到方便管理,方便換膚
3、需要換膚就得知道哪些view需要換膚,通過設置inflate中的一個工廠Factory,這個工廠是用來創建一個view,有點類似hook,只要這個Factory返回一個view就不會再進行解析我們xml設置的view,每創建一個view之前factory都會執行一次,所以在這裡通過設置自己自定義的實現接口Factory的SkinInflateFactory,就可以在其讀取layout的xml文件生成view之前會執行onCreateView,通過hook這個點,即生成xml文件的view又可以滿足我們所要讀取需要換膚的view,並且判斷當前view是否需要換膚,需要則直接設置相應的color或drawable。到此基本就這個思路
三、代碼走讀
接下來一起看看代碼走讀:我的閱讀習慣是從點擊切換皮膚開始,然後一層層剝皮,需要用到的屬性在哪裡初始化,就跳轉到哪裡看看初始化的地方;讀者在讀demo的時候根據自己的習慣吧,這裡就姑且按我的思維方式走。
換膚嘛,當然是找到點擊換膚的事件咯;
找到如下類及其點擊換膚響應事件的方法
public class SettingActivity extends BaseActivity { /** * Put this skin file on the root of sdcard * eg: * /mnt/sdcard/BlackFantacy.skin */ private static final String SKIN_NAME = "BlackFantacy.skin"; private static final String SKIN_DIR = Environment .getExternalStorageDirectory() + File.separator + SKIN_NAME; private void initView() { setNightSkinBtn.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { onSkinSetClick(); } }); setOfficalSkinBtn.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { onSkinResetClick(); } }); } private void onSkinSetClick() { if(!isOfficalSelected) return; File skin = new File(SKIN_DIR); if(skin == null || !skin.exists()){ Toast.makeText(getApplicationContext(), "請檢查" + SKIN_DIR + "是否存在", Toast.LENGTH_SHORT).show(); return; } SkinManager.getInstance().load(skin.getAbsolutePath(), new ILoaderListener() { @Override public void onStart() { L.e("startloadSkin"); } @Override public void onSuccess() { L.e("loadSkinSuccess"); Toast.makeText(getApplicationContext(), "切換成功", Toast.LENGTH_SHORT).show(); setNightSkinBtn.setText("黑色幻想(當前)"); setOfficalSkinBtn.setText("官方默認"); isOfficalSelected = false; } @Override public void onFailed() { L.e("loadSkinFail"); Toast.makeText(getApplicationContext(), "切換失敗", Toast.LENGTH_SHORT).show(); } }); } }
然後在這個設置類裡邊,根據響應事件,我們看到了加載皮膚的調用處:
SkinManager.getInstance().load(skin.getAbsolutePath(),當然路徑是如下:先不管,將demo的BlackFanTancy.skin放到sd卡就行
private static final String SKIN_NAME = "BlackFantacy.skin"; private static final String SKIN_DIR = Environment .getExternalStorageDirectory() + File.separator + SKIN_NAME;
接下來看看SkinManager這個單例皮膚管理類:其中load的方法,進入如下:
public void load(String skinPackagePath, final ILoaderListener callback) { new AsyncTask() { protected void onPreExecute() { if (callback != null) { callback.onStart(); } }; @Override protected Resources doInBackground(String... params) { try { if (params.length == 1) {//加載皮膚包,並且將包的路徑加到資源管理器中AssetManager String skinPkgPath = params[0]; File file = new File(skinPkgPath); if (file == null || !file.exists()) {// Log.d(TAG, "!file.exists() skinPkgPath= " + skinPkgPath); Log.d(TAG, "!file.exists() = " + file.exists()); return null; } Log.d(TAG,"skinPkgPath = "+skinPkgPath); PackageManager mPm = context.getPackageManager(); PackageInfo mInfo = mPm.getPackageArchiveInfo(skinPkgPath, PackageManager.GET_ACTIVITIES); skinPackageName = mInfo.packageName; AssetManager assetManager = AssetManager.class.newInstance(); Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class); addAssetPath.invoke(assetManager, skinPkgPath); Resources superRes = context.getResources(); Resources skinResource = new Resources(assetManager,superRes.getDisplayMetrics(),superRes.getConfiguration()); SkinConfig.saveSkinPath(context, skinPkgPath); skinPath = skinPkgPath; isDefaultSkin = false; return skinResource; } return null; } catch (Exception e) { e.printStackTrace(); return null; } }; protected void onPostExecute(Resources result) { mResources = result; if (mResources != null) { if (callback != null) callback.onSuccess(); notifySkinUpdate(); }else{ isDefaultSkin = true; if (callback != null) callback.onFailed(); } }; }.execute(skinPackagePath); }
接下來看看其中三個方法
首先、onStart():沒做什麼工作,就是在加載之前判斷callBack是否為空做一些初始化,我們這裡沒做什麼初始化,只是打印而已
再次:doInBackground:重點的都在這個方法裡邊了:這個方法異步加載了資源,通過新創建Assetmanager與Resource建立對新加載的資源skin的管理,完成後通過SkinConfig.saveSkinPath();保存當前皮膚路徑,以備下次再次打開app時默認加載皮膚還是上一次選中的。
最後:onPostExecut回到主線程處理更新皮膚;這裡將新創建的resource對象保存到全局,由於callBack不為null,然後通過回調接口callBack.onSuccess()修改ui,以及調用notifySkinUpdate();猜測這個方法就是進行皮膚更新的方法。
到了這裡這個線路基本完事兒;
細心好奇的你疑問肯定有兩點:
1)、context是在哪兒賦值的
2)、notifySkinUpdate()到底做了什麼工作
先來看看context到底在哪兒賦值的,當然是在當前類找了:你會發現下邊這個方法
public void init(Context ctx){ context = ctx.getApplicationContext(); }這個很自然的看看其在哪兒調用的,快捷鍵ctrl+alt+H,只有一處方法調用,那就是Application,這是應用啟動就初始化的如下
public class SkinApplication extends Application { public void onCreate() { super.onCreate(); initSkinLoader(); } /** * Must call init first */ private void initSkinLoader() { SkinManager.getInstance().init(this); SkinManager.getInstance().load(); } }
看到這裡,你會意外發現,咦,這裡也有個load()那我們就會疑問這個load()是干啥用的,初始化的時候為何要調用它,這個load是在我們進入app時調用的,那就有理由猜想如下:
1)、 上次登錄app時我們還沒切換過皮膚,還是默認皮膚,這個load是怎麼工作的
2)、上次登錄app時我們切換皮膚了,那麼這個load又是怎麼工作的
帶著這兩個疑問,我們進入load()方法一探究竟呗
public void load(){ String skin = SkinConfig.getCustomSkinPath(context); Log.d(TAG, "skin = " + skin); load(skin, null); }首先從sharePreference獲取皮膚路徑:分兩步走
<一>、沒切換過皮膚,則skin得到的是默認
public static final String DEFALT_SKIN = "cn_feng_skin_default";
<二>、切換了皮膚,則獲取的是切換皮膚的路徑:
然後往下走,神奇的發現調用了load(skin,null); 這個方法不就是前邊我們分析過的嗎,接下來我們再次看看這個方法,畢竟參數不一樣了嘛:
/** * Load resources from apk in asyc task * @param skinPackagePath path of skin apk * @param callback callback to notify user */ public void load(String skinPackagePath, final ILoaderListener callback) { new AsyncTask() { protected void onPreExecute() { if (callback != null) { callback.onStart(); } }; @Override protected Resources doInBackground(String... params) { try { if (params.length == 1) {//加載皮膚包,並且將包的路徑加到資源管理器中AssetManager String skinPkgPath = params[0]; File file = new File(skinPkgPath); if (file == null || !file.exists()) {// Log.d(TAG, "!file.exists() skinPkgPath= " + skinPkgPath); Log.d(TAG, "!file.exists() = " + file.exists()); return null; } Log.d(TAG,"skinPkgPath = "+skinPkgPath); PackageManager mPm = context.getPackageManager(); PackageInfo mInfo = mPm.getPackageArchiveInfo(skinPkgPath, PackageManager.GET_ACTIVITIES); skinPackageName = mInfo.packageName; AssetManager assetManager = AssetManager.class.newInstance(); Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class); addAssetPath.invoke(assetManager, skinPkgPath); Resources superRes = context.getResources(); Resources skinResource = new Resources(assetManager,superRes.getDisplayMetrics(),superRes.getConfiguration()); SkinConfig.saveSkinPath(context, skinPkgPath); skinPath = skinPkgPath; isDefaultSkin = false; return skinResource; } return null; } catch (Exception e) { e.printStackTrace(); return null; } }; protected void onPostExecute(Resources result) { mResources = result; if (mResources != null) { if (callback != null) callback.onSuccess(); notifySkinUpdate(); }else{ isDefaultSkin = true; if (callback != null) callback.onFailed(); } }; }.execute(skinPackagePath); }
1)默認皮膚時,skin為默認的
public static final String DEFALT_SKIN = "cn_feng_skin_default";
由於這個是不存在的,所以當執行到doInBackground():
File file = new File(skinPkgPath); if (file == null || !file.exists()) {// Log.d(TAG, "!file.exists() skinPkgPath= " + skinPkgPath); Log.d(TAG, "!file.exists() = " + file.exists()); return null; }在這裡會直接返回,不在加載皮膚;用的就是app中layout默認的顏色背景;到這裡這個默認的分析完畢
2)切換了皮膚:前邊也分析保存了皮膚在sd中的絕對路徑:所以這裡獲取的skin路徑上一次皮膚的路徑
再往下走,除了callback不調用外,最後還是調用notifySkinUpdate()方法;
那麼這個方法到底做了什麼,肯定就是我們的換膚方法了;好奇的你肯定會進入notifySkinUpdate()方法一探究竟,那我們就一起看看:
@Override public void notifySkinUpdate() { if(skinObservers == null) return; for(ISkinUpdate observer : skinObservers){ observer.onThemeUpdate(); } }
當打開app的時候,不管曾經是否換膚,由於skinObservers為null,所以直接返回
那麼我們就要看看這個觀察者skinObservers在哪兒初始化,哪兒訂閱的了;
我們會發現初始化的方法、訂閱的地方以及取消訂閱的地方如下:
@Override public void attach(ISkinUpdate observer) { if(skinObservers == null){ skinObservers = new ArrayList(); } if(!skinObservers.contains(skinObservers)){ skinObservers.add(observer); } } @Override public void detach(ISkinUpdate observer) { if(skinObservers == null) return; if(skinObservers.contains(observer)){ skinObservers.remove(observer); } }
public class SkinManager implements ISkinLoader{然後再看看attach(ISkinUpdate observer)和detach(ISkinUpdate observer)在哪兒調用,在查看之前我們有理由猜想,BaseActivity肯定是作為觀察者實現了ISkinUpdate,已實時監測換膚功能;
接下來查看attach(ISkinUpdate observer)和detach(ISkinUpdate observer)調用:發現確實是BaseActivity和BaseFragementActivity兩個類中調用:如下
@Override protected void onResume() { super.onResume(); SkinManager.getInstance().attach(this); } @Override protected void onDestroy() { super.onDestroy(); SkinManager.getInstance().detach(this); mSkinInflaterFactory.clean(); }
傳的是this,那麼她們肯定實現了接口
public interface ISkinUpdate { void onThemeUpdate(); }
自然而然,我們就來看看onThemeUdapte做了什麼,它就能更換皮膚了?
@Override public void onThemeUpdate() { if(!isResponseOnSkinChanging){ return; } mSkinInflaterFactory.applySkin(); }
isResponseOnSkinChanging這個默認是true,也沒地方改變它的默認值,我們先不管,直接跳到下面那行
mSkinInflaterFactory.applySkin();還是看看mSkinInflaterFactory到底是什麼鬼,在哪兒初始化的,查看知道在oncreate方法中:
private SkinInflaterFactory mSkinInflaterFactory; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); mSkinInflaterFactory = new SkinInflaterFactory(); getLayoutInflater().setFactory(mSkinInflaterFactory); }這到底是什麼鬼,每次進入activity都要設置這個Factory,這裡我們部隊Factory開講,下次再進行對它的源碼深究,我們只要知道它是一個生產view的工廠類,在inflate的時候,通過每遍歷一個layout的每個組件view之前都會檢測Factory是否為null,若不為null,則會調用onCreaterView();
擴展:Factory是否要生成view,如果生成view,則不會在創建layout遍歷的那個組件,所以通過這個Factory也可以更改返回顯示的view:比如layout布局其中一個組件是Imageview,而通過Factory可以生成TextView代替ImageView;
接下來看看SkinInFlaterFactory這個類的onCreateView,這個類肯定實現了Factory接口,否則setFactory()的,所以每次在inFlate時由於factory不為空,肯定都會檢測是否要調用onCreateView;所以setFactory必須設置在setContentView()方法之前;因為setContentView實際上也是調用inflate;
那就看看其oncreateView()咯:
@Override public View onCreateView(String name, Context context, AttributeSet attrs) { // if this is NOT enable to be skined , simplly skip it //在xml的節點中設置,設置為true表示是需要換膚的view,否則跳過這個view,因為這個view不需要換膚 boolean isSkinEnable = attrs.getAttributeBooleanValue(SkinConfig.NAMESPACE, SkinConfig.ATTR_SKIN_ENABLE, false); if (!isSkinEnable){ return null; } View view = createView(context, name, attrs); if (view == null){ return null; } parseSkinAttr(context, attrs, view); return view; }
其次:假設在組件中設置屬性skin:enable=true,則就會往下執行 ,執行createView()以及parseSkinAttr(),以下分析這連個個方法:
private View createView(Context context, String name, AttributeSet attrs) { View view = null; try { Log.d("SkinInflaterFactory","name = "+name); if (-1 == name.indexOf('.')){//-1則不是自定義的view if ("View".equals(name)) { view = LayoutInflater.from(context).createView(name, "android.view.", attrs); } if (view == null) { view = LayoutInflater.from(context).createView(name, "android.widget.", attrs); } if (view == null) { view = LayoutInflater.from(context).createView(name, "android.webkit.", attrs); } }else { view = LayoutInflater.from(context).createView(name, null, attrs); } L.i("about to create " + name); } catch (Exception e) { L.e("error while create 【" + name + "】 : " + e.getMessage()); view = null; } return view; }這裡返回一個創建view,主要是判斷:
這個組件view是否是用原生的還是自定義的:name是組件的名字:如TextView、ImageView,所示自定義的,則得到的是全名(包名+類名)
parseSkinAttr():
private void parseSkinAttr(Context context, AttributeSet attrs, View view) { ListviewAttrs = new ArrayList (); for (int i = 0; i < attrs.getAttributeCount(); i++){ String attrName = attrs.getAttributeName(i); String attrValue = attrs.getAttributeValue(i); Log.d(TAG,"attrName = "+attrName);//屬性name比如layout_width或者自定義屬性name比如本次用的enable 值是true Log.d(TAG,"attrValue = "+attrValue);//屬性的值 if(!AttrFactory.isSupportedAttr(attrName)){ continue; } if(attrValue.startsWith("@")){ try { int id = Integer.parseInt(attrValue.substring(1)); String entryName = context.getResources().getResourceEntryName(id); String typeName = context.getResources().getResourceTypeName(id); SkinAttr mSkinAttr = AttrFactory.get(attrName, id, entryName, typeName); Log.d(TAG,"id = "+id); Log.d(TAG,"getResourceEntryName = "+entryName);//color的name或drawable的name Log.d(TAG,"getResourceTypeName = "+typeName);//比如:color,drawable if (mSkinAttr != null) { viewAttrs.add(mSkinAttr); } } catch (NumberFormatException e) { e.printStackTrace(); } catch (NotFoundException e) { e.printStackTrace(); } } } if(!ListUtils.isEmpty(viewAttrs)){ SkinItem skinItem = new SkinItem(); skinItem.view = view; skinItem.attrs = viewAttrs; mSkinItems.add(skinItem); if (SkinManager.getInstance().isExternalSkin()) { //這裡是每次進入activity後fragment的時候都要判斷是否需要換膚 skinItem.apply(); } } }
public void apply(){ if(ListUtils.isEmpty(attrs)){ return; } for(SkinAttr at : attrs){ at.apply(view); } }由於parseSKinAttr解析式已經將attrs設置,所不會為空,所以會執行for循環
SkinAttr mSkinAttr = AttrFactory.get(attrName, id, entryName, typeName);用的竟讓是個工廠方式,進去一瞧咯:
public static SkinAttr get(String attrName, int attrValueRefId, String attrValueRefName, String typeName){ SkinAttr mSkinAttr = null; if(BACKGROUND.equals(attrName)){ mSkinAttr = new BackgroundAttr(); }else if(TEXT_COLOR.equals(attrName)){ mSkinAttr = new TextColorAttr(); }else if(LIST_SELECTOR.equals(attrName)){ mSkinAttr = new ListSelectorAttr(); }else if(DIVIDER.equals(attrName)){ mSkinAttr = new DividerAttr(); }else{ return null; } mSkinAttr.attrName = attrName; mSkinAttr.attrValueRefId = attrValueRefId; mSkinAttr.attrValueRefName = attrValueRefName; mSkinAttr.attrValueTypeName = typeName; return mSkinAttr; }很容易就知道,這是生成什麼屬性,支持哪些換膚,主要有四個,所以只拿第一個BackgroundAttr類作為分析,其它原理一樣:
public class BackgroundAttr extends SkinAttr { @Override public void apply(View view) { if(RES_TYPE_NAME_COLOR.equals(attrValueTypeName)){ view.setBackgroundColor(SkinManager.getInstance().getColor(attrValueRefId)); Log.i("attr", "_________________________________________________________"); Log.i("attr", "apply as color"); }else if(RES_TYPE_NAME_DRAWABLE.equals(attrValueTypeName)){ Drawable bg = SkinManager.getInstance().getDrawable(attrValueRefId); view.setBackground(bg); Log.i("attr", "_________________________________________________________"); Log.i("attr", "apply as drawable"); Log.i("attr", "bg.toString() " + bg.toString()); Log.i("attr", this.attrValueRefName + " 是否可變換狀態? : " + bg.isStateful()); } } }原來換膚最終的真相在這裡:
view.setBackgroundColor(SkinManager.getInstance().getColor(attrValueRefId))以及
Drawable bg = SkinManager.getInstance().getDrawable(attrValueRefId); view.setBackground(bg);這裡邊的
SkinManager.getInstance().getColor(attrValueRefId)和
SkinManager.getInstance().getDrawable(attrValueRefId)
做了什麼:進去瞧瞧就知道了:這裡選擇一個來分析吧,其它都一樣的
public int getColor(int resId){ int originColor = context.getResources().getColor(resId); if(mResources == null || isDefaultSkin){ return originColor; } //通過默認的resId獲取默認顏色的資源名,通過名字查找皮膚包一致的名字再獲取生成的dstId String resName = context.getResources().getResourceEntryName(resId); int trueResId = mResources.getIdentifier(resName, "color", skinPackageName); int trueColor = 0; try{ trueColor = mResources.getColor(trueResId); }catch(NotFoundException e){ e.printStackTrace(); trueColor = originColor; } return trueColor; }
首先:通過app的context獲取originColor,app的默認的顏色,若mResoutces=null(沒切換皮膚)或isDefaultSkin=true(顯示的是默認的),則直接返回顯示默認color
否則:通過默認的resId獲取默認顏色的資源名,通過名字查找皮膚包一致的名字再獲取生成的dstId,得到dstId這裡就是trueResId,這個id就是從新的Resource資源管理者獲取的,就是換膚的皮膚顏色id,這樣就能獲得了皮膚,直接返回設置顏色就可以換膚成功了;
到這裡終於結束了!!!,理解有誤的地方,敬請指正!!!
最近在公司,項目不是很忙了,偶爾看見一個兄台在CSDN求助,幫忙要一個自定義的漸變色進度條,我當時看了一下進度條,感覺挺漂亮的,就嘗試的去自定義view實現了一個,廢話不
最近開發App,美工設計了一個有鋸齒邊沿效果的背景圖,只給了我一個鋸齒,然後需要平鋪展示鋸齒效果: android中實現平鋪圖片有兩種方式:(1)在drawable中的d
1. 樣式資源解析(1) 樣式簡介樣式解析: 樣式是設置給 View 組件的多個屬性的集合;--樣式的好處: 給一個 TextView 設置 文字大小, 顏色, 對齊方式
當我們在app的不同頁面間穿梭翱翔的時候,app中的Activity也在他們各自的生命周期中轉換著不同的狀態。當用戶執行進入或者是離開某個Activity的操作時,And