Android動態換膚框架實現
今天介紹一下Android 中的常用的換膚策略,同時動手實現一個動態換膚的框架
先上效果圖:
換膚概念
換膚: 在android中是指 對 文字、 顏色、 圖片 等的資源的更換。
人 : 對應於現實生活中,就是我們的 膚色 、 衣服 等的更換。
有什麼好處或者說 目的是什麼 ??
對應於我們android 中呢,就是 可以 滿足使用者的新鮮感,可以提高使用者的留存率,當然 面板也可以進行收費。 這就好比我們生活中 去旅行時,有普通的酒店, 也有很多主題酒店,有各種各樣的…, 你會去哪個呢?
換膚實現方式
在我們的開發中, 一般常用的有兩種換膚的方式:
第一種: 靜態換膚:
這種換膚的方式,也就是我們所說的內建換膚,這種方式我也用過很多次,就是在APP內部放置多套相同的資源。進行資源的切換(這種方式,我想你也用過,當然,我們今天並不是要說這種). 這種換膚的方式有很多缺點,比如, 靈活性差,只能更換內建的資源、apk體積太大,在我們的應用Apk中等一般圖片檔案能佔到apk大小的一半左右。
當然了,這種方式也並不是一無是處, 任何事物的存在,必然有其理由。比如我們的應用內,只是普通的 日夜間模式 的切換,並不需要圖片等的更換,只是更換顏色,那這樣的方式就很實用。
囉嗦了半天… 是時候開始正式進入我們今天的主題.
~
~
~
Ok .
第二種:動態換膚
怎麼個動態法呢? 即在執行時動態的載入面板包
這麼說可能有點不是很好理解,大白話就是,當控制元件初始化完畢,需要設定內容(圖片
、顏色
等)時,動態的去我們對應的面板包去找。
~ 我這麼說你可能覺的這跟靜態換膚沒啥不同都是 需要去 set
。
我們這裡呢,強調兩點 :
1.動態
,即靈活性比較高,可以隨意的切換 , 當然這是基於第二點上 .
2.面板包
,這個和apk
是分離開來的,我們可以去網路,去伺服器獲取到面板包,想怎麼換就怎麼換。
是時候開始表演真正的技術了, 哈哈~~~
在開始之前,我們先重申一點: 我們今天要做的是框架。 所以我們要站在框架開發者的角度, 怎麼讓使用這更加方便、更加簡潔的使用。
Ok, 開始 !
先總結一下我們的整體步驟:
1.採集控制元件 獲取需要換膚的控制元件。(要換膚,你總得拿到或者知道要換哪些控制元件吧)
2.載入面板包 載入我們的外接面板包。
3.換膚 這一步呢,我們需要控制元件與面板包資源的匹配,進行換膚.
採集控制元件
我們需要去拿到每一個頁面的 所有需要換膚的控制元件。你可能會想到在每個Activity
的OnCreate()
中去獲取,然後儲存到集合中。 stop! ~~~ ok 我們要做的是一個框架,要做到儘可能少的去 侵入使用者的程式碼。 下面這個 api, 可以拿到Activity
的回撥。
application.registerActivityLifecycleCallbacks(引數)
這個方法可以獲取到Activity
的生命週期回撥 ActivityLifecycleCallbacks。
ok,下面我們來點實際的。
我們新建一個類,用來做初始化,application
中初始化。
public class SkinChangeManager {
private static final String TAG = SkinChangeManager.class.getSimpleName();
private static SkinChangeManager _Instance = null;
private static Application mContext;
private ActivityCallbacks mActivityCallBacks;
public static void init(Application context) {
mContext = context;
synchronized (SkinChangeManager.class) {
if (_Instance == null) {
_Instance = new SkinChangeManager();
}
}
}
public SkinChangeManager() {
SkinPreference.init(mContext); // sp 儲存
SkinResources.init(mContext); // 資源的獲取
//註冊activity的回撥 此方法執行在 super.xxx 之前
mActivityCallBacks = new ActivityCallbacks();
mContext.registerActivityLifecycleCallbacks(mActivityCallBacks);
}
public static SkinChangeManager getInstance() {
return _Instance;
}
ok, 在構造方法 的初始化中,我們 建立了 ActivityLifecycleCallbacks
的例項,並進行了註冊 registerActivityLifecycleCallbacks
.
下面是 ActivityCallbacks
的程式碼:
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
}
@Override
public void onActivityStarted(Activity activity) {
}
@Override
public void onActivityResumed(Activity activity) {
}
...
你或許已經猜到了, 我們要搞點事的地方,沒錯,就是她 onActivityCreated
ok~~~,接下來我們要拿到所有的View
。這下才是重點。
需要拿到View,我們就要知道setContentView
是幹了什麼事。
簡單描述下流程:
setContentView -> window.setContentView()(實現類是PhoneWindow)->mLayoutInflater.inflate() -> inflate .. ->createViewFromTag().
下面這段是 系統 createViewFromTag()
中的部分程式碼:
View view;
if (mFactory2 != null) {
view = mFactory2.onCreateView(parent, name, context, attrs);
} else if (mFactory != null) {
view = mFactory.onCreateView(name, context, attrs);
} else {
view = null;
}
if (view == null && mPrivateFactory != null) {
view = mPrivateFactory.onCreateView(parent, name, context, attrs);
}
if (view == null) {
final Object lastContext = mConstructorArgs[0];
mConstructorArgs[0] = context;
try {
if (-1 == name.indexOf('.')) {
view = onCreateView(parent, name, attrs);
} else {
view = createView(name, null, attrs);
}
} finally {
mConstructorArgs[0] = lastContext;
}
}
return view;
ok,非常簡單,首先判斷了mFactory2
、mFactory
是否為null。然後,判斷是否是系統View if (-1 ==
那麼 拼接字首進行反射建立例項,如果是自定義的View ,則直接去建立了例項。
name.indexOf('.'))
這裡我們的思路是 View部分的建立我們自己來完成.
即給LayoutInflater 設定一個 Factory
我們模仿系統的實現來給系統返回一個View,當然我們也可以在中間做一些我們想做的操作。
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
LayoutInflater inflater = LayoutInflater.from(activity);
SkinViewFactory skinFactory = new SkinViewFactory(activity,mSkinTypeface);
inflater.setFactory2(skinFactory);
}
ok, 我們來看 SkinFactory的實現:
public class SkinViewFactory implements LayoutInflater.Factory2{
private Activity mActivity;
private SkinAttribute mSkinAttrs; // 此View 用來儲存 所有我們需要換膚的view
private static final Class<?>[] mConstructorSignature = new Class[]{
Context.class, AttributeSet.class}; // 兩個引數的簽名
private static final HashMap<String, Constructor<? extends View>> sConstructorMap =
new HashMap<String, Constructor<? extends View>>();
private static final String[] mClassPrefixList = {
"android.widget.",
"android.view.",
"android.webkit."
};
public SkinViewFactory(Activity activity, Typeface mSkinTypeface) {
this.mActivity = activity;
mSkinAttrs = new SkinAttribute(mSkinTypeface);
}
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
View view = createViewFromTag(name, context, attrs);
//自定義view的情況 這裡是全類名
if (view == null) {
view = createView(name, context, attrs);
}
//view 建立完畢了, 我們要從其中晒選出我們需要 換膚的view
mSkinAttrs.load(view,attrs);
return view;
}
private View createViewFromTag(String name, Context context, AttributeSet attrs) {
View view = null;
if (-1 != name.indexOf('.')) { // 包含了. 自定義view
return null;
}
for (int i = 0; i < mClassPrefixList.length; i++) { // 拼接 然後 生成view
view = createView(mClassPrefixList[i] + name, context, attrs);
if (view != null) {
break;
}
}
return view;
}
private View createView(String name, Context context, AttributeSet attrs) {
Constructor<? extends View> constructor = sConstructorMap.get(name);
if (constructor == null) {
try {
Class<? extends View> aClass = context.getClassLoader().loadClass(name).asSubclass(View.class);
constructor = aClass.getConstructor(mConstructorSignature);
sConstructorMap.put(name, constructor);
} catch (Exception e) {
e.printStackTrace();
}
}
if (null != constructor) {
try {
return constructor.newInstance(context, attrs);
} catch (InstantiationException e) {
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
}
return null;
}
@Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
return null;
}
}
SkinFactory
中我們做了兩件事:
- View的建立並返回,由系統來進行後續的操作
- 我們建立了一個實體
SkinAttribute
並呼叫了mSkinAttrs.load(view,attrs);
來進行View的篩選。
我們接著來看篩選的邏輯部分, 即mSkinAttrs.load(view,attrs);
/**
* 獲取view 中的屬性, 過濾出 需要換膚的View 以及 相關的屬性 儲存起來
*
* @param view
* @param set
*/
public void load(View view, AttributeSet set) {
List<SkinPair> skinPairs = new ArrayList<>();
int attributeCount = set.getAttributeCount();
for (int i = 0; i < attributeCount; i++) {
String attrName = set.getAttributeName(i);
if (mAttributes.contains(attrName)) {
int resId;
String attributeValue = set.getAttributeValue(i);
Log.e(TAG, "attribute name :" + attrName + " attribute value " + attributeValue);
if (attributeValue.startsWith("#")) {
// 如果寫固定了,比如#111111 我們則不做操作
continue;
} else if (attributeValue.startsWith("?")) {
// 主題中的資源
int attrId = Integer.parseInt(attributeValue.substring(1));
// 獲取到attrId
resId = SkinThemeUtils.getResId(view.getContext(), new int[]{attrId})[0]; } else {
//@1213432 格式
resId = Integer.parseInt(attributeValue.substring(1));
// 剪下之前 類似 @2131165298
}
if (resId != 0) {
// 儲存對應的 name - id 比如. drawable - 121321423 SkinPair skinPair = new SkinPair(attrName, resId); skinPairs.add(skinPair);
}
}
}
if (!skinPairs.isEmpty()) {
SkinView skinView = new SkinView(view, skinPairs); skinView.applySkin(mSkinTypeface);
mSkinViews.add(skinView);
}
}
在下面我們定義了一個 所要過濾View 屬性的集合。 即滿足該屬性的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");
}
以上,我們就把 採集控制元件 的步驟完成了, 把需要換膚的控制元件 都 儲存了起來,具體的程式碼我會在 文章放上鍊接。 大家可以下載下來詳細的看流程。
載入面板包
即把面板包(外接的apk或者zip檔案)載入進來。 下面是SkinChangeManager.java
中的程式碼。
/**
* 載入面板包
*
* @param path
*/
public void loadSkin(String path) {
if (TextUtils.isEmpty(path)) {
SkinPreference.getInstance().setSkin("");
SkinResources.getInstance().reset();
} else {
try {
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.setAccessible(true);
addAssetPath.invoke(assetManager, path);
Resources mAppResource = mContext.getResources();
//獲取外部Apk(面板包) 包名
PackageManager mPm = mContext.getPackageManager();
PackageInfo info = mPm.getPackageArchiveInfo(path, PackageManager
.GET_ACTIVITIES);
String packageName = info.packageName;
Resources mSkinResources = new Resources(assetManager, mAppResource.getDisplayMetrics(), mAppResource.getConfiguration());
Log.e(TAG,"package Name : "+packageName);
SkinResources.getInstance().applySkin(mSkinResources, packageName);
SkinPreference.getInstance().setSkin(path);
} catch (Exception e) {
e.printStackTrace();
}
}
}
以上的程式碼完成 載入面板包,並 得到面板包 對應的 Resources
。
然後交給了 SkinResources
來處理。就這麼簡單?
沒錯,面板包載入完了 ~~~
我們需要稍微提一下Android 載入資源的方式:
ok, 你理解了上面的圖中的圖片載入方式,就能理解SkinResources
的程式碼操作了.下面是部分 SkinResources
的程式碼:
public int getIdentifier(int resId) {
if (isDefaultSkin) {
return resId;
}
//在面板包中不一定就是 當前程式的 id
//獲取對應id 在當前的名稱 colorPrimary
//R.drawable.ic_launcher
String resName = mAppResources.getResourceEntryName(resId);//ic_launcher
String resType = mAppResources.getResourceTypeName(resId);//drawable
int skinId = mSkinResources.getIdentifier(resName, resType, mSkinPkgName); 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);
}
稍微解釋一下: 當你現在使用的是apk的預設面板,會直接 mAppResources.getColor(resId);
如果不是 預設面板,就會通過 resId
獲取到對應的資源名 和 資源型別 ,然後去面板包去取。
說了半天了,終於該到最後一步了.
換膚
在我們的 SkinAttribute
這個類中,有一個類 SkinView
用來記錄換膚的View 和 View對應的屬性 key(比如 textColor
屬性) 。
面向物件的思想,誰知道你該穿什麼衣服? 當然是你自己咯 ~ 。
所以我們的 換膚方法 就在SkinView
中 :
/**
* 應用面板, 換膚 是View的 方法 所以我們在 View包裝類 的內部進行 面板的切換
* @param mSkinTypeface
*/
public void applySkin(Typeface mSkinTypeface) {
for (SkinPair skinPair : skinPairs) {
Drawable left = null, top = null, right = null, bottom = null;
switch (skinPair.attributesName) {
case "background":
Object background = SkinResources.getInstance().getBackground(skinPair.resId);
// Color
if (background instanceof Integer) {
view.setBackgroundColor((Integer) background);
} else {
view.setBackgroundDrawable((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;
default:
break;
}
if (null != left || null != right || null != top || null != bottom) {
((TextView) view).setCompoundDrawablesWithIntrinsicBounds(left, top, right,
bottom);
}
}
}
總結:
你知道動態的換膚需要幾步麼?那你知道把大象裝進冰箱需要幾步麼?
沒錯,三步就夠了。
第一步:採集所有需要換膚的控制元件,儲存起來。
第二步:載入面板包。
第三步:換膚(讓控制元件對應上指定的資源)
到這裡,我們的換膚的整體思路與程式碼邏輯就結束,還有一些關於 字型、狀態列 、自定義View 等的換膚,整體的思想是一致的,大家可以去參看完整的原始碼。
程式碼地址
https://download.csdn.net/download/mike_cui_ls/10311537