【靶點(diǎn)突破】網(wǎng)易云換膚方案探討
- 老方案
- 網(wǎng)易云音樂(lè)換膚方案原理
- 動(dòng)手實(shí)現(xiàn)一個(gè)網(wǎng)易云換膚方案的demo
- 動(dòng)手打造換膚方案的輪子
- 黑白夜模式切換
??Hello,大家好旱易,我是Ellen,這是Android靶點(diǎn)突破系列文章阀坏,旨在幫助你更加了解Android技術(shù)開(kāi)發(fā)的同時(shí),把業(yè)務(wù)做到精致浸船。思考自己的職業(yè)生涯箫老,想成為怎樣的技術(shù)人耍鬓,想追求怎么樣的生活。
至尊寶腳踏七彩祥云娶了紫霞涣达,希望你也能成為她的自尊寶。
| from Ellen緣言
1.老方案
??App皮膚切換老方案分為2點(diǎn):
- 1.設(shè)置不同的Style鸦概,結(jié)合Activity的recreate & setTheme方法
- 2.通過(guò)全局Setting進(jìn)行修改,回調(diào)通知所有存活的Activity & Fragment & Dialog等
??如果是老的項(xiàng)目突然需要添加換膚功能,那么這將是一個(gè)極大的勞動(dòng)工程扎拣,費(fèi)時(shí)又費(fèi)力,而且隨著皮膚的增多刊愚,你的資源文件會(huì)越來(lái)越大,這首先很不方便管理牡借,而且還會(huì)讓apk的體積越來(lái)越大,開(kāi)發(fā)起來(lái)吃力沈矿,用戶體驗(yàn)也不好。
??對(duì)于老方案的實(shí)現(xiàn)代碼我這里就不講解了陵像,我會(huì)貼一個(gè)Github項(xiàng)目代碼,讀者可以自行去看看瞧瞧,代碼注釋寫(xiě)的很清晰疏日,注意的是這里筆者只實(shí)現(xiàn)了Style & Setting兩種方式睬辐,Style方式是切換Theme的方式,需要配置不同的style和自定義屬性丰刊,Setting方式則更為靈活隘谣,它是通過(guò)屬性對(duì)界面的皮膚進(jìn)行控制,每個(gè)界面收到回調(diào)然后進(jìn)行切換啄巧,還有其它很多實(shí)現(xiàn)方式寻歧,但核心缺點(diǎn)都是一樣的,包體積越來(lái)越臃腫秩仆,管理性越來(lái)越差码泛,我們重點(diǎn)要實(shí)現(xiàn)網(wǎng)易云音樂(lè)的換膚方案铅搓,這才是換膚的王道。當(dāng)然你可以通過(guò)后端配置方式將資源都放在接口里蜀踏,比較占apk的圖片資源用url的方式舰罚,但是無(wú)疑增加皮膚切換的業(yè)務(wù)邏輯復(fù)雜度候醒,隨著項(xiàng)目業(yè)務(wù)越來(lái)越多,負(fù)責(zé)皮膚的bean對(duì)象也許會(huì)越來(lái)越多的屬性悠夯。
2.網(wǎng)易云音樂(lè)換膚方案原理
??網(wǎng)易云音樂(lè)相信你使用過(guò)匣摘,它的換膚可以算是秒切嘴办,那么它是怎樣做到的呢?我們先來(lái)看看它的原理,然后追求精致,我們也要實(shí)現(xiàn)這種秒切皮膚的效果古戴。
??我們來(lái)看看,它的原理需要了解的如下:
- 1.LayoutInflater mFactory & mFactory2 反射替代成自定義的
- 2.解析空殼apk獲取Resource替代原有的App Resource
步驟1:LayoutInflater mFactory & mFactory2 反射替代成自定義的
??LayoutInflater通常我們用來(lái)解析布局文件的头谜,將布局文件映射成一個(gè)一個(gè)的控件對(duì)象妹笆,下列代碼就是將布局item_skin_manager映射為一個(gè)View對(duì)象:
LayoutInflater.from(parent.getContext()).inflate(R.layout.item_skin_manager, parent, false);
??那么它是如何將布局文件映射為View對(duì)象的呢婿奔,我們來(lái)看看Android SDK版本31下inflate方法的源碼:
public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
//**********注意點(diǎn)1
final Resources res = getContext().getResources();
if (DEBUG) {
Log.d(TAG, "INFLATING from resource: \"" + res.getResourceName(resource) + "\" ("
+ Integer.toHexString(resource) + ")");
}
View view = tryInflatePrecompiled(resource, res, root, attachToRoot);
if (view != null) {
return view;
}
//**********注意點(diǎn)2
XmlResourceParser parser = res.getLayout(resource);
try {
//**********注意點(diǎn)3
return inflate(parser, root, attachToRoot);
} finally {
parser.close();
}
}
??請(qǐng)注意上方代碼筆者標(biāo)注的"注意點(diǎn)1"和"注意點(diǎn)2"以及"注意點(diǎn)3",后面我直接簡(jiǎn)稱為點(diǎn)1和點(diǎn)2以及點(diǎn)3项乒,從點(diǎn)1中我們可以看到它是獲取了一個(gè)Resource res,再?gòu)狞c(diǎn)2看到啃憎,它獲取了一個(gè)XML解析負(fù)責(zé)相關(guān)的類(lèi)XmlResourceParser parser,這個(gè)parser應(yīng)該提供了XML解析相關(guān)的,那么我們接下來(lái)看看點(diǎn)3標(biāo)注的inflate方法:
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
synchronized (mConstructorArgs) {
Trace.traceBegin(Trace.TRACE_TAG_VIEW, "inflate");
final Context inflaterContext = mContext;
final AttributeSet attrs = Xml.asAttributeSet(parser);
Context lastContext = (Context) mConstructorArgs[0];
mConstructorArgs[0] = inflaterContext;
View result = root;
try {
advanceToRootNode(parser);
final String name = parser.getName();
if (DEBUG) {
System.out.println("**************************");
System.out.println("Creating root view: "
+ name);
System.out.println("**************************");
}
if (TAG_MERGE.equals(name)) {
if (root == null || !attachToRoot) {
throw new InflateException("<merge /> can be used only with a valid "
+ "ViewGroup root and attachToRoot=true");
}
rInflate(parser, root, inflaterContext, attrs, false);
} else {
//**********注意點(diǎn)4
// Temp is the root view that was found in the xml
final View temp = createViewFromTag(root, name, inflaterContext, attrs);
ViewGroup.LayoutParams params = null;
if (root != null) {
if (DEBUG) {
System.out.println("Creating params from root: " +
root);
}
// Create layout params that match root, if supplied
params = root.generateLayoutParams(attrs);
if (!attachToRoot) {
// Set the layout params for temp if we are not
// attaching. (If we are, we use addView, below)
temp.setLayoutParams(params);
}
}
if (DEBUG) {
System.out.println("-----> start inflating children");
}
// Inflate all children under temp against its context.
rInflateChildren(parser, temp, attrs, true);
if (DEBUG) {
System.out.println("-----> done inflating children");
}
// We are supposed to attach all the views we found (int temp)
// to root. Do that now.
if (root != null && attachToRoot) {
root.addView(temp, params);
}
// Decide whether to return the root that was passed in or the
// top view found in xml.
if (root == null || !attachToRoot) {
result = temp;
}
}
} catch (XmlPullParserException e) {
final InflateException ie = new InflateException(e.getMessage(), e);
ie.setStackTrace(EMPTY_STACK_TRACE);
throw ie;
} catch (Exception e) {
final InflateException ie = new InflateException(
getParserStateDescription(inflaterContext, attrs)
+ ": " + e.getMessage(), e);
ie.setStackTrace(EMPTY_STACK_TRACE);
throw ie;
} finally {
// Don't retain static reference on context.
mConstructorArgs[0] = lastContext;
mConstructorArgs[1] = null;
Trace.traceEnd(Trace.TRACE_TAG_VIEW);
}
return result;
}
}
??看點(diǎn)4老外注釋的捏肢, Temp is the root view that was found in the xml藕赞,大概意思就是說(shuō)Temp 是在 xml 中找到的根視圖,原來(lái)我們的xml布局是這樣的解析的哦卖局,我們?cè)賮?lái)看看createViewFromTag方法:
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
boolean ignoreThemeAttr) {
if (name.equals("view")) {
name = attrs.getAttributeValue(null, "class");
}
// Apply a theme wrapper, if allowed and one is specified.
if (!ignoreThemeAttr) {
final TypedArray ta = context.obtainStyledAttributes(attrs, ATTRS_THEME);
final int themeResId = ta.getResourceId(0, 0);
if (themeResId != 0) {
context = new ContextThemeWrapper(context, themeResId);
}
ta.recycle();
}
try {
//***********注意點(diǎn)5
View view = tryCreateView(parent, name, context, attrs);
if (view == null) {
final Object lastContext = mConstructorArgs[0];
mConstructorArgs[0] = context;
try {
if (-1 == name.indexOf('.')) {
view = onCreateView(context, parent, name, attrs);
} else {
view = createView(context, name, null, attrs);
}
} finally {
mConstructorArgs[0] = lastContext;
}
}
return view;
} catch (InflateException e) {
throw e;
} catch (ClassNotFoundException e) {
final InflateException ie = new InflateException(
getParserStateDescription(context, attrs)
+ ": Error inflating class " + name, e);
ie.setStackTrace(EMPTY_STACK_TRACE);
throw ie;
} catch (Exception e) {
final InflateException ie = new InflateException(
getParserStateDescription(context, attrs)
+ ": Error inflating class " + name, e);
ie.setStackTrace(EMPTY_STACK_TRACE);
throw ie;
}
}
??我們?cè)倏袋c(diǎn)5斧蜕,它通過(guò)tryCreateView方法獲取到一個(gè)View,這個(gè)View就是Temp了,也就是解析布局獲取到的View對(duì)象砚偶,我們?cè)趤?lái)看看tryCreateView方法:
public final View tryCreateView(@Nullable View parent, @NonNull String name,
@NonNull Context context,
@NonNull AttributeSet attrs) {
if (name.equals(TAG_1995)) {
// Let's party like it's 1995!
return new BlinkLayout(context, attrs);
}
View view;
if (mFactory2 != null) {
//*******注意點(diǎn)6
view = mFactory2.onCreateView(parent, name, context, attrs);
} else if (mFactory != null) {
//*******注意點(diǎn)7
view = mFactory.onCreateView(name, context, attrs);
} else {
view = null;
}
if (view == null && mPrivateFactory != null) {
view = mPrivateFactory.onCreateView(parent, name, context, attrs);
}
return view;
}
??我們看到點(diǎn)6和點(diǎn)7批销,原來(lái)我們的View都是通過(guò)mFactory2或 mFactory創(chuàng)建出來(lái)的,我們看看下面代碼:
public interface Factory2 extends Factory {
/**
* Version of {@link #onCreateView(String, Context, AttributeSet)}
* that also supplies the parent that the view created view will be
* placed in.
*
* @param parent The parent that the created view will be placed
* in; <em>note that this may be null</em>.
* @param name Tag name to be inflated.
* @param context The context the view is being created in.
* @param attrs Inflation attributes as specified in XML file.
*
* @return View Newly created view. Return null for the default
* behavior.
*/
@Nullable
View onCreateView(@Nullable View parent, @NonNull String name,
@NonNull Context context, @NonNull AttributeSet attrs);
}
public interface Factory {
/**
* Hook you can supply that is called when inflating from a LayoutInflater.
* You can use this to customize the tag names available in your XML
* layout files.
*
* <p>
* Note that it is good practice to prefix these custom names with your
* package (i.e., com.coolcompany.apps) to avoid conflicts with system
* names.
*
* @param name Tag name to be inflated.
* @param context The context the view is being created in.
* @param attrs Inflation attributes as specified in XML file.
*
* @return View Newly created view. Return null for the default
* behavior.
*/
@Nullable
View onCreateView(@NonNull String name, @NonNull Context context,
@NonNull AttributeSet attrs);
}
??可以看到Factory和Factory2都是接口染坯,那么mFactory2或 mFactory是啥呢均芽?
@UnsupportedAppUsage
private Factory mFactory;
@UnsupportedAppUsage
private Factory2 mFactory2;
??它是 LayoutInflater內(nèi)私有屬性成員,那么我們是否可以通過(guò)反射攔截XML解析成具體控件對(duì)象的過(guò)程呢单鹿?只要攔截了掀宋,那么我們是否可以拿到控件對(duì)象任性設(shè)置自己要的皮膚屬呢?如果是通過(guò)設(shè)置屬性的方式進(jìn)行切換仲锄,那么我們估計(jì)也還是會(huì)像老方案那樣劲妙,只會(huì)越來(lái)越復(fù)雜,那么怎么辦呢儒喊?我們拿到控件對(duì)象啦镣奋,還記得前面提到的Resource,它是負(fù)責(zé)整個(gè)控件體系的資源設(shè)置的類(lèi),同樣的原理澄惊,我們是否可以通過(guò)我們的Resource來(lái)進(jìn)行設(shè)置呢唆途,我們?cè)賮?lái)看看Resource是如何來(lái)的:
步驟2:解析空殼apk獲取Resource替代原有的App Resource
??通過(guò)上圖我們可以確定Resource通過(guò)AssetManager來(lái)加載的,Asset是不是很熟悉掸驱,它是asset目錄啊肛搬,怎么會(huì)加載項(xiàng)目的資源呢?難道它還可以解析目錄下資源嗎毕贼?
我們接著看看這個(gè)方法:
//這里的path就是apk所在目錄
public int addAssetPath(String path) {
return addAssetPathInternal(path, false /*overlay*/, false /*appAsLib*/);
}
??雖然這個(gè)方法是public的温赔,但是被隱藏掉了,我們只能通過(guò)反射進(jìn)行調(diào)用鬼癣,也就是方案已經(jīng)很明了陶贼,就是我們將每個(gè)皮膚的資源打進(jìn)空殼apk內(nèi)啤贩,然后通過(guò)AssetManager的addAssetPath方法解析空殼apk的資源,獲取到一個(gè)Resource,然后我們通過(guò)反射LayoutInflater賦值自定義的mFactory&mFactory2來(lái)攔截控件創(chuàng)建過(guò)程拜秧,進(jìn)行屬性的替換痹屹,眼下我們還存在一個(gè)問(wèn)題,那么如何new一個(gè)Resource對(duì)象枉氮,并且將空殼apk的資源打進(jìn)去呢志衍?我們看看Resource的構(gòu)造器:
public Resources(AssetManager assets, DisplayMetrics metrics, Configuration config) {
this(null);
mResourcesImpl = new ResourcesImpl(assets, metrics, config, new DisplayAdjustments());
}
??驚喜且意外的發(fā)現(xiàn)Resources(AssetManager assets, DisplayMetrics metrics, Configuration config)這個(gè)構(gòu)造器完全滿足我們的需求,但是metrics和config是啥呢聊替,沒(méi)關(guān)系楼肪,我們通過(guò)獲取當(dāng)前的Resource,將當(dāng)前的Resource的metrics和config傳進(jìn)去即可,我們只需要設(shè)置我們重要的解析空客apk的assets即可惹悄,然后通過(guò)Resource為我們提供的解析資源的api給攔截的控件對(duì)象設(shè)置對(duì)應(yīng)的皮膚屬性即可春叫。
3.動(dòng)手實(shí)現(xiàn)一個(gè)網(wǎng)易云換膚方案的demo
??經(jīng)過(guò)網(wǎng)易云音樂(lè)換膚方案原理分析,我們要實(shí)現(xiàn)換膚的步驟如下:
- 0.準(zhǔn)備好換膚對(duì)應(yīng)的界面
- 1.反射賦值LayoutInflater mFactory & mFactory2,攔截控件對(duì)象創(chuàng)建過(guò)程
- 2.過(guò)濾出我們需要換膚的控件的屬性
- 3.下載服務(wù)器空殼apk資源泣港,加載空殼apk獲取到一個(gè)當(dāng)前皮膚的Resource skinResource
- 4.通過(guò)解析換膚屬性的資源id在skinResource中尋找對(duì)應(yīng)的值暂殖,并設(shè)置給控件對(duì)象
步驟0:準(zhǔn)備好換膚對(duì)應(yīng)的界面
??由于只是例子講解,筆者就不搞的太復(fù)雜当纱,就弄一個(gè)Activity & 3個(gè)Fragment進(jìn)行實(shí)現(xiàn)央星,通過(guò)res資源color.xml文件中"main_color屬性進(jìn)行更換",代碼請(qǐng)到SwitchSkinDemo查看,這里不在啰嗦惫东。
??demo 演示gif如下所示: 待上傳
步驟1:反射賦值LayoutInflater mFactory & mFactory2,攔截控件對(duì)象創(chuàng)建過(guò)程
??要想反射賦值到mFactory & mFactory2,我們首先要先獲取Activity對(duì)應(yīng)的LayoutInflater,因?yàn)樾枰總€(gè)存活的Activity都需要進(jìn)行反射賦值毙石,很容易聯(lián)想到廉沮,我們可以通過(guò)Application的registerActivityLifecycleCallbacks方法做到,話不多說(shuō)我們上代碼:
//皮膚管理類(lèi)
public class SkinManager {
//單例對(duì)象
private volatile static SkinManager INSTANCE;
//Application對(duì)象
private Application application;
//皮膚名字集合
private List<String> skinNames = new ArrayList<>();
//記錄當(dāng)前應(yīng)用的皮膚名
private String currentSkin = "skin_default.apk";
//記錄默認(rèn)的皮膚名
private static final String DEFAULT_SKIN_NAME = "skin_default.apk";
//應(yīng)用Activity生命周期監(jiān)聽(tīng)
private SkinActivityLifecycle skinActivityLifecycle;
private SkinManager(){
//初始化皮膚數(shù)據(jù)徐矩,當(dāng)然這里可以網(wǎng)絡(luò)下載即可滞时,但是為了方便
//筆者就用assets目錄copy到本地目錄的方式模擬網(wǎng)絡(luò)加載皮膚過(guò)程
skinNames.add("skin_blue.apk");
skinNames.add("skin_red.apk");
skinNames.add("skin_black.apk");
skinNames.add("skin_green.apk");
skinNames.add("skin_default.apk");
}
public List<String> getSkinData(){
return skinNames;
}
/**
* 切換皮膚
* @param skinName
*/
public void switchSkin(String skinName){
this.currentSkin = skinName;
skinActivityLifecycle.switchSkin();
}
/**
* 是否是默認(rèn)皮膚
* @return
*/
public boolean isDefaultSkin(){
return currentSkin.equals(DEFAULT_SKIN_NAME);
}
/**
* 獲取到當(dāng)前的皮膚名
* @return
*/
public String getCurrentSkin(){
return currentSkin;
}
public static SkinManager getInstance(){
if(INSTANCE == null){
synchronized (SkinManager.class){
if(INSTANCE == null){
INSTANCE = new SkinManager();
}
}
}
return INSTANCE;
}
public Application getApplication(){
return application;
}
/**
* 皮膚管理初始化
* @param app
*/
public void initApp(Application app){
this.application = app;
//對(duì)所有Activity的聲明周期進(jìn)行監(jiān)聽(tīng)
app.registerActivityLifecycleCallbacks(skinActivityLifecycle = new SkinActivityLifecycle());
}
}
??因?yàn)榉?wù)器下載空殼apk的接口沒(méi)有做,這里筆者用asset目錄copy到本地目錄的方式去模擬從服務(wù)器下載空殼apk的過(guò)程滤灯,請(qǐng)讀者仔細(xì)閱讀以上代碼坪稽,筆者的皮膚切換機(jī)制里帶有5種皮膚,分別是:
- skin_default.apk【黃色】
- skin_blue.apk【藍(lán)色】
- skin_red.apk【紅色】
- skin_black.apk【黑色】
- skin_green.apk【綠色】
??筆者皮膚的屬性只把包含color.xml下"main_color"這個(gè)資源字段鳞骤,如下所示:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
//皮膚主色
<color name="main_color">#FFA500</color>
</resources>
??并且筆者先將這個(gè)"main_color"修改為對(duì)應(yīng)皮膚的顏色值窒百,然后進(jìn)行空殼打包,打完的包放進(jìn)了項(xiàng)目目錄下的assets目錄下豫尽,然后我們把皮膚空殼apk準(zhǔn)備好了篙梢,接下來(lái)我們就看看如何拿到每個(gè)Activity的LayoutInflater,然后反射賦值mFactory & mFactory2那兩個(gè)屬性美旧,請(qǐng)看筆者上述SkinManager類(lèi)中的initApp方法:
/**
* 皮膚管理初始化
* @param app
*/
public void initApp(Application app){
this.application = app;
//對(duì)所有Activity的聲明周期進(jìn)行監(jiān)聽(tīng)
app.registerActivityLifecycleCallbacks(skinActivityLifecycle = new SkinActivityLifecycle());
}
??我們可以看到筆者是通過(guò)SkinActivityLifecycle對(duì)所有的Activity進(jìn)行生命周期監(jiān)聽(tīng)的,其代碼如下:
public class SkinActivityLifecycle implements Application.ActivityLifecycleCallbacks {
private List<Activity> activeActivityList = new ArrayList<>();
@Override
@SuppressLint("SoonBlockedPrivateApi")
public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle bundle) {
activeActivityList.add(activity);
LayoutInflater layoutInflater = LayoutInflater.from(activity);
//反射setFactory2,Android Q及以上已經(jīng)失效-> 報(bào)not field 異常
//Android Q以上setFactory2問(wèn)題
//http://www.javashuo.com/article/p-sheppkca-ds.html
forceSetFactory2(layoutInflater);
}
/**
* 最新的方式渤滞,適配Android Q
* @param inflater
*/
private static void forceSetFactory2(LayoutInflater inflater) {
Class<LayoutInflaterCompat> compatClass = LayoutInflaterCompat.class;
Class<LayoutInflater> inflaterClass = LayoutInflater.class;
try {
Field sCheckedField = compatClass.getDeclaredField("sCheckedField");
sCheckedField.setAccessible(true);
sCheckedField.setBoolean(inflater, false);
Field mFactory = inflaterClass.getDeclaredField("mFactory");
mFactory.setAccessible(true);
Field mFactory2 = inflaterClass.getDeclaredField("mFactory2");
mFactory2.setAccessible(true);
//自定義的Factory2
SkinLayoutFactory skinLayoutFactory = new SkinLayoutFactory();
mFactory2.set(inflater, skinLayoutFactory);
mFactory.set(inflater, skinLayoutFactory);
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (NoSuchFieldException e) {
e.printStackTrace();
}
}
@Override
public void onActivityStarted(@NonNull Activity activity) {
}
@Override
public void onActivityResumed(@NonNull Activity activity) {
}
@Override
public void onActivityPaused(@NonNull Activity activity) {
}
@Override
public void onActivityStopped(@NonNull Activity activity) {
}
@Override
public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle bundle) {
}
@Override
public void onActivityDestroyed(@NonNull Activity activity) {
activeActivityList.remove(activity);
}
public void switchSkin(){
for(Activity activity:activeActivityList){
//重新使用資源
if(!(activity instanceof SkinManagerActivity)) {
activity.recreate();
}
}
}
}
??在上述代碼中我們完成了mFactory & mFactory2的反射賦值贬墩,我們看到forceSetFactory2方法中,我們將SkinLayoutFactory對(duì)象通過(guò)反射賦值給了mFactory & mFactory2妄呕,那么SkinLayoutFactory我們應(yīng)該在它里面寫(xiě)哪些邏輯呢陶舞,聰明的你應(yīng)該知道m(xù)Factory2 & mFactory不過(guò)只是負(fù)責(zé)將XML中的控件標(biāo)簽映射為具體內(nèi)存中的控件對(duì)象,我們不僅要實(shí)現(xiàn)這個(gè)绪励,還要實(shí)現(xiàn)攔截并設(shè)置我們需要更換皮膚的屬性肿孵,接下來(lái)我們就來(lái)看看如何實(shí)現(xiàn)。
步驟2:過(guò)濾出我們需要換膚的控件的屬性
public class SkinLayoutFactory implements LayoutInflater.Factory2 {
//具體攔截邏輯都在該類(lèi)里
private SkinAttribute skinAttribute;
public SkinLayoutFactory(){
skinAttribute = new SkinAttribute();
}
//系統(tǒng)自帶的控件名包名路徑
//因?yàn)椴季种袝?huì)直接使用<TextView沒(méi)帶全路徑的优炬,所以我們?cè)撌謩?dòng)加上
private static final String[] systemViewPackage = {
"androidx.widget.",
"androidx.view.",
"androidx.webkit.",
"android.widget.",
"android.view.",
"android.webkit."
};
//反射控件對(duì)應(yīng)的構(gòu)造器而使用
private static final Class[] mConstructorSignature = new Class[]{Context.class,AttributeSet.class};
//存儲(chǔ)控件的構(gòu)造器颁井,避免重復(fù)創(chuàng)建
private static final HashMap<String, Constructor<? extends View>> mConstructor = new HashMap<>();
@Nullable
@Override
public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attributeSet) {
View view = onCreateViewFromTag(name,context,attributeSet);
if(view == null){
view = onCreateView(name, context, attributeSet);
}
//篩選符合屬性的View
skinAttribute.loadView(view,attributeSet);
return view;
}
/**
* 通過(guò)反射構(gòu)建控件對(duì)象
* @param name
* @param context
* @param attributeSet
* @return
*/
@Nullable
@Override
public View onCreateView(@NonNull String name, @NonNull Context context, @NonNull AttributeSet attributeSet) {
Constructor<? extends View> constructor = mConstructor.get(name);
View view = null;
if(constructor == null){
try {
Class<? extends View> viewClass = context.getClassLoader().loadClass(name).asSubclass(View.class);
constructor = viewClass.getConstructor(mConstructorSignature);
mConstructor.put(name,constructor);
} catch (ClassNotFoundException | NoSuchMethodException e) {
e.printStackTrace();
}
}
if(constructor != null){
try {
view = constructor.newInstance(context,attributeSet);
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InstantiationException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
}
return view;
}
private View onCreateViewFromTag(@NonNull String name, @NonNull Context context, @NonNull AttributeSet attributeSet){
if(name.indexOf(".") > 0){
//說(shuō)明XML中該控件帶有包名全路徑
}
View view = null;
for(String packageName:systemViewPackage){
view = onCreateView(packageName+name,context,attributeSet);
if(view != null){
break;
}
}
return view;
}
}
??這個(gè)類(lèi)的作用不用筆者多說(shuō)了,仔細(xì)看下代碼就會(huì)一目了然蠢护,它存在以下作用:
- 1.將XML對(duì)應(yīng)的控件標(biāo)簽映射為對(duì)應(yīng)的具體控件對(duì)象雅宾,有具體包名則直接進(jìn)行反射構(gòu)建,無(wú)包名則需要先拼接對(duì)應(yīng)的全路徑包名然后再反射葵硕,例如TextView->android.widget.TextView
- 2.攔截構(gòu)建出的控件對(duì)象眉抬,設(shè)置對(duì)應(yīng)的皮膚屬性
??看以上代碼,如下所示:
@Nullable
@Override
public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attributeSet) {
View view = onCreateViewFromTag(name,context,attributeSet);
if(view == null){
view = onCreateView(name, context, attributeSet);
}
//篩選符合屬性的View
skinAttribute.loadView(view,attributeSet);
return view;
}
??SkinAttribute類(lèi)具體負(fù)責(zé)攔截邏輯,具體代碼如下所示:
public class SkinAttribute {
//過(guò)濾出皮膚需要的屬性
private static final List<String> ATTRIBUTE = new ArrayList<>();
static {
ATTRIBUTE.add("background");
ATTRIBUTE.add("src");
ATTRIBUTE.add("textColor");
ATTRIBUTE.add("SkinTypeface");
//TabLayout
ATTRIBUTE.add("tabIndicatorColor");
ATTRIBUTE.add("tabSelectedTextColor");
}
public void loadView(View view, AttributeSet attributeSet) {
for (int i = 0; i < attributeSet.getAttributeCount(); i++) {
String attributeName = attributeSet.getAttributeName(i);
if (ATTRIBUTE.contains(attributeName)) {
String attributeValue = attributeSet.getAttributeValue(i);
if (attributeValue.startsWith("#")) {
//固定的Color值懈凹,無(wú)需修改
} else {
int resId = 0;
//判斷前綴是否為蜀变?
int attrId = Integer.parseInt(attributeValue.substring(1));
if (attributeValue.startsWith("?")) {
int[] array = {attrId};
resId = SkinThemeUtils.getResId(view.getContext(), array)[0];
} else {
resId = attrId;
}
if (resId != 0) {
String skinName = SkinManager.getInstance().getCurrentSkin();
File skinFile = new File(view.getContext().getCacheDir(), skinName);
//拿到空殼App資源
if (!SkinManager.getInstance().isDefaultSkin()) {
//如果皮膚包不存在,那么先從asset里進(jìn)行拷貝到SD卡【模擬從服務(wù)器下載過(guò)程】
if (!skinFile.exists()) {
//復(fù)制文件
FileUtils.copyFileFromAssets(view.getContext(), skinName,
view.getContext().getCacheDir().getAbsolutePath(), skinName);
}
}
SkinLoadApkPath skinLoadApkPath = new SkinLoadApkPath();
skinLoadApkPath.loadEmptyApkPath(skinFile.getAbsolutePath());
Resources skinResource = skinLoadApkPath.getSkinResource();
if (attributeName.equals("textColor")) {
TextView textView = (TextView) view;
textView.setTextColor(skinResource.getColorStateList(resId));
}
if (attributeName.equals("background")) {
view.setBackgroundColor(skinResource.getColor(resId));
}
if (attributeName.equals("tabIndicatorColor")) {
//TabLayout下劃線顏色
TabLayout tabLayout = (TabLayout) view;
tabLayout.setSelectedTabIndicatorColor(skinResource.getColor(resId));
}
if (attributeName.equals("tabSelectedTextColor")) {
//TabLayout選中文本顏色
TabLayout tabLayout = (TabLayout) view;
tabLayout.setTabTextColors(Color.BLACK, skinResource.getColor(resId));
}
}
}
}
}
}
}
??主要攔截設(shè)置皮膚屬性的邏輯都在loadView方法里介评,先遍歷控件對(duì)象對(duì)應(yīng)的AttributeSet库北,然后過(guò)濾出自己需要的皮膚屬性,負(fù)責(zé)過(guò)濾的集合是ATTRIBUTE们陆,拿到我們需要更改的控件對(duì)象以及需要修改的皮膚屬性寒瓦,我們思考一個(gè)問(wèn)題,如果想設(shè)置對(duì)應(yīng)的皮膚屬性坪仇,首先我們是不是要確定這個(gè)屬性使用哪個(gè)資源id?,如果你XML用了"?"方式使用了Style的資源杂腰,那么這時(shí)又該如何正確獲取該屬性使用的資源id呢?其具體代碼邏輯如下:
int attrId = Integer.parseInt(attributeValue.substring(1));
if (attributeValue.startsWith("?")) {
int[] array = {attrId};
resId = SkinThemeUtils.getResId(view.getContext(), array)[0];
} else {
resId = attrId;
}
??如果你的XML使用了椅文?訪問(wèn)XML資源喂很,那么就需要使用SkinThemeUtils工具將其映射為具體的資源id,其代碼如下:
public class SkinThemeUtils {
public static int[] getResId(Context context, int[] attrs){
int[] ints = new int[attrs.length];
TypedArray typedArray = context.obtainStyledAttributes(attrs);
for (int i = 0; i < typedArray.length(); i++) {
ints[i] = typedArray.getResourceId(i, 0);
}
typedArray.recycle();
return ints;
}
}
??接下來(lái)我們是不是該解析空殼apk,然后再拿到對(duì)應(yīng)的Resource,然后通過(guò)對(duì)應(yīng)的Resource api已經(jīng)對(duì)應(yīng)的皮膚屬性名和資源id,這樣我們就能更改皮膚控件對(duì)應(yīng)的皮膚屬性值啦皆刺,從loadView方法看以下代碼:
if (resId != 0) {
String skinName = SkinManager.getInstance().getCurrentSkin();
File skinFile = new File(view.getContext().getCacheDir(), skinName);
//拿到空殼App資源
if (!SkinManager.getInstance().isDefaultSkin()) {
//如果皮膚包不存在少辣,那么先從asset里進(jìn)行拷貝到SD卡【模擬從服務(wù)器下載過(guò)程】
if (!skinFile.exists()) {
//復(fù)制文件
FileUtils.copyFileFromAssets(view.getContext(), skinName,
view.getContext().getCacheDir().getAbsolutePath(), skinName);
}
}
SkinLoadApkPath skinLoadApkPath = new SkinLoadApkPath();
skinLoadApkPath.loadEmptyApkPath(skinFile.getAbsolutePath());
Resources skinResource = skinLoadApkPath.getSkinResource();
if (attributeName.equals("textColor")) {
TextView textView = (TextView) view;
textView.setTextColor(skinResource.getColorStateList(resId));
}
......
??從以上代碼看出SkinLoadApkPath類(lèi)就是我們負(fù)責(zé)加載空殼apk的類(lèi),接下來(lái)我們看看如何解析空殼apk獲取一個(gè)Resource對(duì)象:
3.下載服務(wù)器空殼apk資源芹橡,加載空殼apk獲取到一個(gè)當(dāng)前皮膚的Resource skinResource
public class SkinLoadApkPath {
private Resources skinResources;
public Resources getSkinResource(){
return skinResources;
}
/**
* 加載空殼Apk資源
*
* @param apkPath
*/
public void loadEmptyApkPath(String apkPath) {
try {
Resources appResources = SkinManager.getInstance().getApplication().getResources();
if(SkinManager.getInstance().isDefaultSkin()){
//使用默認(rèn)資源毒坛,當(dāng)前應(yīng)用的Resource就是皮膚Resource
skinResources = appResources;
}else {
//反射addAssetPath方法進(jìn)行解析空殼apk
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager, apkPath);
//使用空殼Apk資源,并傳入當(dāng)前App Resource的Metrics,Configuration獲取Resource
skinResources = new Resources(assetManager,
appResources.getDisplayMetrics(), appResources.getConfiguration());
}
} catch (Exception e) {
Log.d("Skin","發(fā)生異常");
}
}
}
步驟4:通過(guò)解析換膚屬性的資源id在skinResource中尋找對(duì)應(yīng)的值,并設(shè)置給控件對(duì)象
??空殼apk的Resource賦值到skinResources中了,SkinAttribute的loadView方法只需要傳入空殼apk的路徑即可獲取到皮膚對(duì)應(yīng)的Resource,接下來(lái)通過(guò)Resource的api,控件對(duì)象,資源id設(shè)置對(duì)應(yīng)的屬性值:
String skinName = SkinManager.getInstance().getCurrentSkin();
File skinFile = new File(view.getContext().getCacheDir(), skinName);
//拿到空殼App資源
if (!SkinManager.getInstance().isDefaultSkin()) {
//如果皮膚包不存在煎殷,那么先從asset里進(jìn)行拷貝到SD卡【模擬從服務(wù)器下載過(guò)程】
if (!skinFile.exists()) {
//復(fù)制文件
FileUtils.copyFileFromAssets(view.getContext(), skinName,
view.getContext().getCacheDir().getAbsolutePath(), skinName);
}
}
SkinLoadApkPath skinLoadApkPath = new SkinLoadApkPath();
skinLoadApkPath.loadEmptyApkPath(skinFile.getAbsolutePath());
Resources skinResource = skinLoadApkPath.getSkinResource();
if (attributeName.equals("textColor")) {
TextView textView = (TextView) view;
textView.setTextColor(skinResource.getColorStateList(resId));
}
if (attributeName.equals("background")) {
view.setBackgroundColor(skinResource.getColor(resId));
}
if (attributeName.equals("tabIndicatorColor")) {
//TabLayout下劃線顏色
TabLayout tabLayout = (TabLayout) view;
tabLayout.setSelectedTabIndicatorColor(skinResource.getColor(resId));
}
if (attributeName.equals("tabSelectedTextColor")) {
//TabLayout選中文本顏色
TabLayout tabLayout = (TabLayout) view;
tabLayout.setTabTextColors(Color.BLACK, skinResource.getColor(resId));
}
??這里還要說(shuō)明一點(diǎn)屯伞,demo中皮膚管理界面切換相應(yīng)的皮膚時(shí),會(huì)出現(xiàn)短暫的黑屏閃爍現(xiàn)象豪直,其原因是調(diào)用了該界面的recreate方法導(dǎo)致的劣摇,為了更好的用戶體驗(yàn),此界面需要手動(dòng)在Activity添加邏輯進(jìn)行皮膚改變弓乙,這樣用戶在此界面切換皮膚時(shí)不會(huì)出現(xiàn)閃屏末融,并完成了皮膚切換效果,也就達(dá)到了網(wǎng)易云那種秒切效果暇韧。整體代碼如下:
Github整體代碼demo:SwitchSkinDemo
4.動(dòng)手打造換膚的輪子
??目前換膚筆者已經(jīng)封裝完畢勾习,只是文檔沒(méi)有寫(xiě),沒(méi)有發(fā)布到Jitpack上懈玻,等文檔寫(xiě)了巧婶,發(fā)布到Jitpack后,你就可以用到自己項(xiàng)目中啦涂乌,GitHub地址如下所示:
基于網(wǎng)易云換膚方案打造的輪子:LmySkinSwitcher
5.黑白夜模式切換
??以上已經(jīng)講解完了網(wǎng)易云換膚方案的原理艺栈,而且還實(shí)踐了代碼,最后造成一個(gè)可以換膚的輪子湾盒,那么黑白夜模式切換自然也是一個(gè)水到渠成的事情湿右,用上面的輪子去實(shí)踐一把吧,打兩個(gè)空殼apk,一個(gè)負(fù)責(zé)黑夜模式罚勾,一個(gè)負(fù)責(zé)白天模式毅人,還有個(gè)問(wèn)題是否跟隨系統(tǒng)的黑白夜模式?在Application中提供了一個(gè)方法onConfigurationChanged用來(lái)判斷當(dāng)前系統(tǒng)處于黑夜還是白天模式尖殃,代碼如下:
@Override
public void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
if ((newConfig.uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO) {
//白天模式
} else if ((newConfig.uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES) {
//黑夜模式
}
}
??詳細(xì)的代碼筆者這里就不演示了堰塌,請(qǐng)讀者自行實(shí)踐哦!