前言
手把手講解系列文章,是我寫給各位看官,也是寫給我自己的。
文章可能過分詳細,但是這是為了幫助到盡量多的人,畢竟工作5,6年,不能老吸血,也到了回饋開源的時候.
這個系列的文章:
1、用通俗易懂的講解方式,講解一門技術的實用價值
2、詳細書寫源碼的追蹤,源碼截圖,繪制類的結構圖,盡量詳細地解釋原理的探索過程
3、提供Github 的 可運行的Demo工程,但是我所提供代碼,更多是提供思路,拋磚引玉,請酌情cv
4、集合整理原理探索過程中的一些坑,或者demo的運行過程中的注意事項
5、用gif圖,最直觀地展示demo運行效果如果覺得細節(jié)太細,直接跳過看結論即可。
本人能力有限,如若發(fā)現描述不當之處,歡迎留言批評指正。
學到老活到老,路漫漫其修遠兮。與眾君共勉 !
引子
產品大佬又提需求啦,要求
app里面的圖表要實現白天黑夜模式的切換,以滿足不同光線下都能保證足夠的圖表清晰度. 怎么辦?可能解決的辦法很多,你可以給圖表view增加一個toggle方法,參數String,day/night,然后切換之后postInvalidate刷新重繪.
OK,可行,但是這種方式切換白天黑夜,只是單個View中有效,那么如果哪天產品又要另一個View換膚,難道我要一個一個去寫toggle么?未免太low了.
那么能不能要實現一個全app內的一鍵換膚,一勞永逸~~~
鳴謝
感謝享學課堂的免費視頻課程 https://ke.qq.com/course/341933 需要視頻的兄弟可以給我留言評論
正文大綱
1. 什么是一鍵換膚
2. 界面上哪些東西是可以換膚的
3. 利用HOOK技術實現優(yōu)雅的“一鍵換膚"
4. 相關android源碼一覽
- Activity 的 setContentView(R.layout.XXX) 到底在做什么?
- LayoutInflater這個類是怎么把 layout.xml 的 <TextView> 變成TextView對象的?
- app中資源文件大管家 Resources / AssetManager 是怎么工作的
5. "全app一鍵換膚" Demo源碼詳解
- 關鍵類 SkinEngine SkinFactory
- 關鍵類的調用方式,聯系之前的android源碼,解釋hook起作用的原理
- 效果展示
- 注意事項
正文
1. 什么是一鍵換膚
所謂"一鍵",就是通過"一個"接口的調用,就能實現全app范圍內的所有資源文件的替換.包括 文本,顏色,圖片等.
一些換膚實現方式的對比
- 方案1:自定義View中,要換膚,那如同引言中所述,toggle方法,invalidate重繪。
弊端:換膚范圍僅限于這個View.- 方案2:給靜態(tài)變量賦值,然后重啟Activity. 如果一個Activity內用靜態(tài)變量定義了兩種色系,那么確實是可以通過關閉Activity,再啟動的方式,實現 貌似換膚的效果(其實是重新啟動了Activity)
弊端:太low,而且很浪費資源
也許還有其他方案吧,
View重繪,重啟Activity,都能實現,但是仍然不是最優(yōu)雅的方案,那么,有沒有一種方案,能夠實現全app內的換膚效果,又不會像重啟Activity這樣浪費資源呢?請看下圖:

這個動態(tài)圖中,首先看到的是
Activity1,點擊換膚,可直接更換界面上的background,圖片的src,還有textView的textColor,跳轉Activity2之后的textView顏色,在我換膚之前,和換膚之后,是不同的。換膚的過程我并沒有啟動另外的Activity,界面也沒有閃爍。我在Activity1里面換膚,直接影響了Activity2的textView字體顏色。
既然給出了效果,那么肯定要給出Demo,不然太沒誠意,嘿嘿嘿
github地址奉上:https://github.com/18598925736/HookSkinDemoFromHank
2. 界面上哪些東西是可以換膚的
上面的換膚動態(tài)圖,我換了ImageView,換了background,換了TextView的字體顏色,那么到底哪些東西可以換?
答案其實就一句話: 我們項目代碼里面 res目錄下的所有東西,幾乎都可以被替換。
(為什么說幾乎?因為一些犄角旮旯的東西我沒有時間一個一個去試驗....囧)
具體而言就是如下這些
- 動畫
- 背景圖片
- 字體
- 字體顏色
- 字體大小
- 音頻
- 視頻
3. 利用HOOK技術實現優(yōu)雅的“一鍵換膚"
- 什么是hook
如題,我是用hook實現一鍵換膚。那么什么是hook?
hook,鉤子. 安卓中的hook技術,其實是一個抽象概念:對系統源碼的代碼邏輯進行"劫持",插入自己的邏輯,然后放行。注意:hook可能頻繁使用java反射機制···
"一鍵換膚"中的hook思路
- "劫持"系統創(chuàng)建View的過程,我們自己來創(chuàng)建View
系統原本自己存在創(chuàng)建View的邏輯,我們要了解這部分代碼,以便為我所用.- 收集我們需要換膚的View(用自定義view屬性來標記一個view是否支持一鍵換膚),保存到變量中
劫持了 系統創(chuàng)建view的邏輯之后,我們要把支持換膚的這些view保存起來- 加載外部資源包,調用接口進行換膚
外部資源包,是.apk后綴的一個文件,是通過gradle打包形成的。里面包含需要換膚的資源文件,但是必須保證,要換的資源文件,和原工程里面的文件名完全相同.
4. 相關android源碼一覽
- Activity 的 setContentView(R.layout.XXX) 到底在做什么?
回顧我們寫app的習慣,創(chuàng)建Activity,寫xxx.xml,在Activity里面setContentView(R.layout.xxx).我們寫的是xml,最終呈現出來的是一個一個的界面上的UI控件,那么setContentView到底做了什么事,使得XML里面的內容,變成了UI控件呢?
如果不先來點干貨,估計有些人就看不下去了,各位客官請看下圖:
image.png
源碼索引:
setContentView(R.layout.activity_main);
---》
getDelegate().setContentView(layoutResID);
OK,這里暴露出了兩個方法,getDelegate()和setContentView()
先看getDelegate:
這里返回了一個AppCompatDelegate對象,跟蹤到AppCompatDelegate內部,閱讀源碼,可以得出一個結論:AppCompatDelegate 是 替Activity生成View對象的委托類,它提供了一系列setContentView方法,在Activity中加入UI控件。
那它的AppCompatDelegate的setContentView方法又做了什么?
插曲:關于如何閱讀源碼?在我的上一篇文章 中詳細說明了。
但是漏了一個細節(jié):那就是,當你在源碼中看到一個接口或者抽象類,你想知道接口的實現類在哪?很簡單...如果你沒有更改androidStudio的快捷鍵設置的話,Ctrl+T可以幫你直接定位接口和抽象類的實現類.
用上面的方法,找到setContentView的具體過程
源碼.png
那么就進入下一個環(huán)節(jié):LayoutInflater又做了什么?
LayoutInflater這個類是怎么把layout.xml的<TextView>變成TextView對象的?
我們知道,我們傳入的是int,是xxx.xml這個布局文件,在R文件里面的對應int值。LayoutInflater拿到了這個int之后,又干了什么事呢?
一路索引進去:會發(fā)現這個方法:
image.png
image.png
發(fā)現一個關鍵方法:CreateViewFromTag,tag是指的什么?其實就是 xml里面 的標簽頭:<TextView ....> 里的
TextView.
跟蹤進去:
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();
}
if (name.equals(TAG_1995)) {
// Let's party like it's 1995!
return new BlinkLayout(context, attrs);
}
try {
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;
} catch (InflateException e) {
throw e;
} catch (ClassNotFoundException e) {
final InflateException ie = new InflateException(attrs.getPositionDescription()
+ ": Error inflating class " + name, e);
ie.setStackTrace(EMPTY_STACK_TRACE);
throw ie;
} catch (Exception e) {
final InflateException ie = new InflateException(attrs.getPositionDescription()
+ ": Error inflating class " + name, e);
ie.setStackTrace(EMPTY_STACK_TRACE);
throw ie;
}
}
這個方法有4個參數,意義分別是:
-
View parent父組件 -
String namexml標簽名 -
Context context上下文 -
AttributeSet attrsview屬性 -
boolean ignoreThemeAttr是否忽略theme屬性
并且在這里,發(fā)現一段關鍵代碼:
if (mFactory2 != null) {
view = mFactory2.onCreateView(parent, name, context, attrs);
} else if (mFactory != null) {
view = mFactory.onCreateView(name, context, attrs);
} else {
view = null;
}
實際上,可能有人要問了,你怎么知道這邊是走的哪一個if分支呢?
方法:新創(chuàng)建一個Project,跟蹤MainActivity onCreate里面setContentView()一路找到這段代碼debug:你會發(fā)現:
image.png
答案很明確了,系統在默認情況下就會走Factory2的onCreateView(),
應該有人好奇:這個mFactory2對象是哪來的?是什么時候set進去的
答案如下:image.png
如果細心Debug,就會發(fā)現《標記標記,因為后面有一段代碼會跳回到這里,這里非常重要...》
image.png
image.png
當時,getDelegate()得到的對象,和 LayoutInflater里面mFactory2其實是同一個對象
那么繼續(xù)跟蹤,一直到:AppCompatViewInflater 類
final View createView(View parent, final String name, @NonNull Context context,
@NonNull AttributeSet attrs, boolean inheritContext,
boolean readAndroidTheme, boolean readAppTheme, boolean wrapContext) {
final Context originalContext = context;
// We can emulate Lollipop's android:theme attribute propagating down the view hierarchy
// by using the parent's context
if (inheritContext && parent != null) {
context = parent.getContext();
}
if (readAndroidTheme || readAppTheme) {
// We then apply the theme on the context, if specified
context = themifyContext(context, attrs, readAndroidTheme, readAppTheme);
}
if (wrapContext) {
context = TintContextWrapper.wrap(context);
}
View view = null;
// We need to 'inject' our tint aware Views in place of the standard framework versions
switch (name) {
case "TextView":
view = createTextView(context, attrs);
verifyNotNull(view, name);
break;
case "ImageView":
view = createImageView(context, attrs);
verifyNotNull(view, name);
break;
case "Button":
view = createButton(context, attrs);
verifyNotNull(view, name);
break;
case "EditText":
view = createEditText(context, attrs);
verifyNotNull(view, name);
break;
case "Spinner":
view = createSpinner(context, attrs);
verifyNotNull(view, name);
break;
case "ImageButton":
view = createImageButton(context, attrs);
verifyNotNull(view, name);
break;
case "CheckBox":
view = createCheckBox(context, attrs);
verifyNotNull(view, name);
break;
case "RadioButton":
view = createRadioButton(context, attrs);
verifyNotNull(view, name);
break;
case "CheckedTextView":
view = createCheckedTextView(context, attrs);
verifyNotNull(view, name);
break;
case "AutoCompleteTextView":
view = createAutoCompleteTextView(context, attrs);
verifyNotNull(view, name);
break;
case "MultiAutoCompleteTextView":
view = createMultiAutoCompleteTextView(context, attrs);
verifyNotNull(view, name);
break;
case "RatingBar":
view = createRatingBar(context, attrs);
verifyNotNull(view, name);
break;
case "SeekBar":
view = createSeekBar(context, attrs);
verifyNotNull(view, name);
break;
default:
// The fallback that allows extending class to take over view inflation
// for other tags. Note that we don't check that the result is not-null.
// That allows the custom inflater path to fall back on the default one
// later in this method.
view = createView(context, name, attrs);
}
if (view == null && originalContext != context) {
// If the original context does not equal our themed context, then we need to manually
// inflate it using the name so that android:theme takes effect.
view = createViewFromTag(context, name, attrs);
}
if (view != null) {
// If we have created a view, check its android:onClick
checkOnClickListener(view, attrs);
}
return view;
}
這邊利用了大量的switch case來進行系統控件的創(chuàng)建,例如:TextView
@NonNull
protected AppCompatTextView createTextView(Context context, AttributeSet attrs) {
return new AppCompatTextView(context, attrs);
}
都是new 出來一個具有兼容特性的TextView,返回出去。
但是,使用過switch 的人都知道,這種case形式的分支,無法涵蓋所有的類型怎么辦呢?這里switch之后,view仍然可能是null.
所以,switch之后,谷歌大佬加了一個if,但是很詭異,這段代碼并未進入if,因為 originalContext != context并不滿足....具體原因我也沒查出來,(;′д`)ゞ
if (view == null && originalContext != context) {
// If the original context does not equal our themed context, then we need to manually
// inflate it using the name so that android:theme takes effect.
view = createViewFromTag(context, name, attrs);
}
然而,這里的補救措施沒有執(zhí)行,那自然有地方有另外的補救措施:
回到之前的LayoutInflater的下面這段代碼:
if (mFactory2 != null) {
view = mFactory2.onCreateView(parent, name, context, attrs);
} else if (mFactory != null) {
view = mFactory.onCreateView(name, context, attrs);
} else {
view = null;
}
這段代碼的下面,如果view是空,補救措施如下:
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;
}
}
這里的兩個方法onCreateView(parent, name, attrs)和createView(name, null, attrs);都最終索引到:
public final View createView(String name, String prefix, AttributeSet attrs)
throws ClassNotFoundException, InflateException {
Constructor<? extends View> constructor = sConstructorMap.get(name);
if (constructor != null && !verifyClassLoader(constructor)) {
constructor = null;
sConstructorMap.remove(name);
}
Class<? extends View> clazz = null;
try {
Trace.traceBegin(Trace.TRACE_TAG_VIEW, name);
if (constructor == null) {
// Class not found in the cache, see if it's real, and try to add it
clazz = mContext.getClassLoader().loadClass(
prefix != null ? (prefix + name) : name).asSubclass(View.class);
if (mFilter != null && clazz != null) {
boolean allowed = mFilter.onLoadClass(clazz);
if (!allowed) {
failNotAllowed(name, prefix, attrs);
}
}
constructor = clazz.getConstructor(mConstructorSignature);
constructor.setAccessible(true);
sConstructorMap.put(name, constructor);
} else {
// If we have a filter, apply it to cached constructor
if (mFilter != null) {
// Have we seen this name before?
Boolean allowedState = mFilterMap.get(name);
if (allowedState == null) {
// New class -- remember whether it is allowed
clazz = mContext.getClassLoader().loadClass(
prefix != null ? (prefix + name) : name).asSubclass(View.class);
boolean allowed = clazz != null && mFilter.onLoadClass(clazz);
mFilterMap.put(name, allowed);
if (!allowed) {
failNotAllowed(name, prefix, attrs);
}
} else if (allowedState.equals(Boolean.FALSE)) {
failNotAllowed(name, prefix, attrs);
}
}
}
Object lastContext = mConstructorArgs[0];
if (mConstructorArgs[0] == null) {
// Fill in the context if not already within inflation.
mConstructorArgs[0] = mContext;
}
Object[] args = mConstructorArgs;
args[1] = attrs;
final View view = constructor.newInstance(args); // 真正需要關注的關鍵代碼,就是這一行,執(zhí)行了構造函數,返回了一個View對象
if (view instanceof ViewStub) {
// Use the same context when inflating ViewStub later.
final ViewStub viewStub = (ViewStub) view;
viewStub.setLayoutInflater(cloneInContext((Context) args[0]));
}
mConstructorArgs[0] = lastContext;
return view;
} catch (NoSuchMethodException e) {
·····
}
}
這么一大段好像有點讓人害怕。其實真正需要關注的,就是反射的代碼,最后的 newInstance().
OK,Activity上那些豐富多彩的View的來源,就說到這里, 如果有看不懂的,歡迎留言探討. ( ̄▽ ̄) !
- app中資源文件大管家
Resources/AssetManager是怎么工作的
從我們的終極目的出發(fā):我們要做的是“換膚”,如果我們拿到了要換膚的View,可以對他們進行setXXX屬性來改變UI,那么屬性值從哪里來?
界面元素豐富多彩,但是這些View,都是用資源文件來進行 "裝扮"出來的,資源文件大致可以分為:
圖片,文字,顏色,聲音視頻,字體等。如果我們控制了資源文件,那么是不是有能力對界面元素進行set某某屬性來進行“再裝扮”呢? 當然,這是可行的。因為,我們平時拿到一個TextView,就能對它進行setTextColor,這種操作,在view還存活的時候,都可以進行操作,并且這種操作,并不會造成Activity的重啟。
這些資源文件,有一個統一的大管家。可能有人說是R.java文件,它里面統籌了所有的資源文件int值.沒錯,但是這個R文件是如何產生作用的呢? 答案:Resources.
本來這里應該寫上源碼追蹤記錄的,但是由于 源碼無法追蹤,原因暫時還沒找到,之前追查
setContentView(R.layout.xxxx)的時候還可以debug,現在居然不行了,很詭異!
image.png
答案找到了:因為我使用的是 真機,一般手機廠商都會對原生系統進行修改,然后將系統寫到到真機里面。
而,我們debug,用的是原生SDK。 用實例來說,我本地是SDK 27的源碼,真機也是27的系統,但是真機的運行起來的系統的代碼,是被廠家修改了的,和我本地的必然有所差別,所以,有些代碼報紅,就很正常了,無法debug也很正常。
既然如此,那我就直接寫結論了,一張圖說明一切:
image.png
5. "全app一鍵換膚" Demo源碼詳解(戳這里獲得源碼)
項目工程結構:
image.png
- 關鍵類 SkinFactory
SkinFactory類, 繼承LayoutInflater.Factory2 ,它的實例,會負責創(chuàng)建View,收集 支持換膚的view
import android.content.Context;
import android.content.res.TypedArray;
import android.support.v7.app.AppCompatDelegate;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.TextView;
import com.enjoy02.skindemo.R;
import com.enjoy02.skindemo.view.ZeroView;
import java.lang.reflect.Constructor;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
public class SkinFactory implements LayoutInflater.Factory2 {
private AppCompatDelegate mDelegate;//預定義一個委托類,它負責按照系統的原有邏輯來創(chuàng)建view
private List<SkinView> listCacheSkinView = new ArrayList<>();//我自定義的list,緩存所有可以換膚的View對象
/**
* 給外部提供一個set方法
*
* @param mDelegate
*/
public void setDelegate(AppCompatDelegate mDelegate) {
this.mDelegate = mDelegate;
}
/**
* Factory2 是繼承Factory的,所以,我們這次是主要重寫Factory的onCreateView邏輯,就不必理會Factory的重寫方法了
*
* @param name
* @param context
* @param attrs
* @return
*/
@Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
return null;
}
/**
* @param parent
* @param name
* @param context
* @param attrs
* @return
*/
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
// TODO: 關鍵點1:執(zhí)行系統代碼里的創(chuàng)建View的過程,我們只是想加入自己的思想,并不是要全盤接管
View view = mDelegate.createView(parent, name, context, attrs);//系統創(chuàng)建出來的時候有可能為空,你問為啥?請全文搜索 “標記標記,因為” 你會找到你要的答案
if (view == null) {//萬一系統創(chuàng)建出來是空,那么我們來補救
try {
if (-1 == name.indexOf('.')) {//不包含. 說明不帶包名,那么我們幫他加上包名
view = createViewByPrefix(context, name, prefixs, attrs);
} else {//包含. 說明 是權限定名的view name,
view = createViewByPrefix(context, name, null, attrs);
}
} catch (Exception e) {
e.printStackTrace();
}
}
//TODO: 關鍵點2 收集需要換膚的View
collectSkinView(context, attrs, view);
return view;
}
/**
* TODO: 收集需要換膚的控件
* 收集的方式是:通過自定義屬性isSupport,從創(chuàng)建出來的很多View中,找到支持換膚的那些,保存到map中
*/
private void collectSkinView(Context context, AttributeSet attrs, View view) {
// 獲取我們自己定義的屬性
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.Skinable);
boolean isSupport = a.getBoolean(R.styleable.Skinable_isSupport, false);
if (isSupport) {//找到支持換膚的view
final int Len = attrs.getAttributeCount();
HashMap<String, String> attrMap = new HashMap<>();
for (int i = 0; i < Len; i++) {//遍歷所有屬性
String attrName = attrs.getAttributeName(i);
String attrValue = attrs.getAttributeValue(i);
attrMap.put(attrName, attrValue);//全部存起來
}
SkinView skinView = new SkinView();
skinView.view = view;
skinView.attrsMap = attrMap;
listCacheSkinView.add(skinView);//將可換膚的view,放到listCacheSkinView中
}
}
/**
* 公開給外界的換膚入口
*/
public void changeSkin() {
for (SkinView skinView : listCacheSkinView) {
skinView.changeSkin();
}
}
static class SkinView {
View view;
HashMap<String, String> attrsMap;
/**
* 真正的換膚操作
*/
public void changeSkin() {
if (!TextUtils.isEmpty(attrsMap.get("background"))) {//屬性名,例如,這個background,text,textColor....
int bgId = Integer.parseInt(attrsMap.get("background").substring(1));//屬性值,R.id.XXX ,int類型,
// 這個值,在app的一次運行中,不會發(fā)生變化
String attrType = view.getResources().getResourceTypeName(bgId); // 屬性類別:比如 drawable ,color
if (TextUtils.equals(attrType, "drawable")) {//區(qū)分drawable和color
view.setBackgroundDrawable(SkinEngine.getInstance().getDrawable(bgId));//加載外部資源管理器,拿到外部資源的drawable
} else if (TextUtils.equals(attrType, "color")) {
view.setBackgroundColor(SkinEngine.getInstance().getColor(bgId));
}
}
if (view instanceof TextView) {
if (!TextUtils.isEmpty(attrsMap.get("textColor"))) {
int textColorId = Integer.parseInt(attrsMap.get("textColor").substring(1));
((TextView) view).setTextColor(SkinEngine.getInstance().getColor(textColorId));
}
}
//那么如果是自定義組件呢
if (view instanceof ZeroView) {
//那么這樣一個對象,要換膚,就要寫針對性的方法了,每一個控件需要用什么樣的方式去換,尤其是那種,自定義的屬性,怎么去set,
// 這就對開發(fā)人員要求比較高了,而且這個換膚接口還要暴露給 自定義View的開發(fā)人員,他們去定義
// ....
}
}
}
/**
* 所謂hook,要懂源碼,懂了之后再劫持系統邏輯,加入自己的邏輯。
* 那么,既然懂了,系統的有些代碼,直接拿過來用,也無可厚非。
*/
//*******************************下面一大片,都是從源碼里面抄過來的,并不是我自主設計******************************
// 你問我抄的哪里的?到 AppCompatViewInflater類源碼里面去搜索:view = createViewFromTag(context, name, attrs);
static final Class<?>[] mConstructorSignature = new Class[]{Context.class, AttributeSet.class};//
final Object[] mConstructorArgs = new Object[2];//View的構造函數的2個"實"參對象
private static final HashMap<String, Constructor<? extends View>> sConstructorMap = new HashMap<String, Constructor<? extends View>>();//用映射,將View的反射構造函數都存起來
static final String[] prefixs = new String[]{//安卓里面控件的包名,就這么3種,這個變量是為了下面代碼里,反射創(chuàng)建類的class而預備的
"android.widget.",
"android.view.",
"android.webkit."
};
/**
* 反射創(chuàng)建View
*
* @param context
* @param name
* @param prefixs
* @param attrs
* @return
*/
private final View createViewByPrefix(Context context, String name, String[] prefixs, AttributeSet attrs) {
Constructor<? extends View> constructor = sConstructorMap.get(name);
Class<? extends View> clazz = null;
if (constructor == null) {
try {
if (prefixs != null && prefixs.length > 0) {
for (String prefix : prefixs) {
clazz = context.getClassLoader().loadClass(
prefix != null ? (prefix + name) : name).asSubclass(View.class);//控件
if (clazz != null) break;
}
} else {
if (clazz == null) {
clazz = context.getClassLoader().loadClass(name).asSubclass(View.class);
}
}
if (clazz == null) {
return null;
}
constructor = clazz.getConstructor(mConstructorSignature);//拿到 構造方法,
} catch (Exception e) {
e.printStackTrace();
return null;
}
constructor.setAccessible(true);//
sConstructorMap.put(name, constructor);//然后緩存起來,下次再用,就直接從內存中去取
}
Object[] args = mConstructorArgs;
args[1] = attrs;
try {
//通過反射創(chuàng)建View對象
final View view = constructor.newInstance(args);//執(zhí)行構造函數,拿到View對象
return view;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
//**********************************************************************************************
}
關鍵類 SkinEngine
import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.res.AssetManager;
import android.content.res.Resources;
import android.graphics.drawable.Drawable;
import android.support.v4.content.ContextCompat;
import android.util.Log;
import java.io.File;
import java.lang.reflect.Method;
public class SkinEngine {
//單例
private final static SkinEngine instance = new SkinEngine();
public static SkinEngine getInstance() {
return instance;
}
private SkinEngine() {
}
public void init(Context context) {
mContext = context.getApplicationContext();
//使用application的目的是,如果萬一傳進來的是Activity對象
//那么它被靜態(tài)對象instance所持有,這個Activity就無法釋放了
}
private Resources mOutResource;// TODO: 資源管理器
private Context mContext;//上下文
private String mOutPkgName;// TODO: 外部資源包的packageName
/**
* TODO: 加載外部資源包
*/
public void load(final String path) {//path 是外部傳入的apk文件名
File file = new File(path);
if (!file.exists()) {
return;
}
//取得PackageManager引用
PackageManager mPm = mContext.getPackageManager();
//“檢索在包歸檔文件中定義的應用程序包的總體信息”,說人話,外界傳入了一個apk的文件路徑,這個方法,拿到這個apk的包信息,這個包信息包含什么?
PackageInfo mInfo = mPm.getPackageArchiveInfo(path, PackageManager.GET_ACTIVITIES);
mOutPkgName = mInfo.packageName;//先把包名存起來
AssetManager assetManager;//資源管理器
try {
//TODO: 關鍵技術點3 通過反射獲取AssetManager 用來加載外面的資源包
assetManager = AssetManager.class.newInstance();//反射創(chuàng)建AssetManager對象,為何要反射?使用反射,是因為他這個類內部的addAssetPath方法是hide狀態(tài)
//addAssetPath方法可以加載外部的資源包
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);//為什么要反射執(zhí)行這個方法?因為它是hide的,不直接對外開放,只能反射調用
addAssetPath.invoke(assetManager, path);//反射執(zhí)行方法
mOutResource = new Resources(assetManager,//參數1,資源管理器
mContext.getResources().getDisplayMetrics(),//這個好像是屏幕參數
mContext.getResources().getConfiguration());//資源配置
//最終創(chuàng)建出一個 "外部資源包"mOutResource ,它的存在,就是要讓我們的app有能力加載外部的資源文件
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 提供外部資源包里面的顏色
* @param resId
* @return
*/
public int getColor(int resId) {
if (mOutResource == null) {
return resId;
}
String resName = mOutResource.getResourceEntryName(resId);
int outResId = mOutResource.getIdentifier(resName, "color", mOutPkgName);
if (outResId == 0) {
return resId;
}
return mOutResource.getColor(outResId);
}
/**
* 提供外部資源包里的圖片資源
* @param resId
* @return
*/
public Drawable getDrawable(int resId) {//獲取圖片
if (mOutResource == null) {
return ContextCompat.getDrawable(mContext, resId);
}
String resName = mOutResource.getResourceEntryName(resId);
int outResId = mOutResource.getIdentifier(resName, "drawable", mOutPkgName);
if (outResId == 0) {
return ContextCompat.getDrawable(mContext, resId);
}
return mOutResource.getDrawable(outResId);
}
//..... 這里還可以提供外部資源包里的String,font等等等,只不過要手動寫代碼來實現getXX方法
}
- 關鍵類的調用方式
1. 初始化"換膚引擎"
public class MyApp extends Application {
@Override
public void onCreate() {
super.onCreate();
//初始化換膚引擎
SkinEngine.getInstance().init(this);
}
}
2. 劫持 系統創(chuàng)建view的過程
public class BaseActivity extends AppCompatActivity {
...
@Override
protected void onCreate(Bundle savedInstanceState) {
// TODO: 關鍵點1:hook(劫持)系統創(chuàng)建view的過程
if (ifAllowChangeSkin) {
mSkinFactory = new SkinFactory();
mSkinFactory.setDelegate(getDelegate());
LayoutInflater layoutInflater = LayoutInflater.from(this);
layoutInflater.setFactory2(mSkinFactory);//劫持系統源碼邏輯
}
super.onCreate(savedInstanceState);
}
3. 執(zhí)行換膚操作
protected void changeSkin(String path) {
if (ifAllowChangeSkin) {
File skinFile = new File(Environment.getExternalStorageDirectory(), path);
SkinEngine.getInstance().load(skinFile.getAbsolutePath());//加載外部資源包
mSkinFactory.changeSkin();//執(zhí)行換膚操作
mCurrentSkin = path;
}
}
- 效果展示
換膚.gif
- 注意事項
1. 皮膚包skin_plugin module,里面,只提供需要換膚的資源即可,不需要換膚的資源,還有src目錄下的源碼
(只是刪掉java源碼文件,不要刪目錄結構啊....(●′?`●)),不要放在這里,無端增大皮膚包的體積.
2. 皮膚包 skin_plugin module的gradle sdk版本最好和app module的保持完全一致,否則無法保證不會出現奇葩問題.
3. 用皮膚包skin_plugin module 打包生成的apk文件,常規(guī)來說,是放在手機內存里面,然后由app module內的代碼去加載。至于是手機內存里面的哪個位置,那就見仁見智了. 我是使用的mumu模擬器,我放在了最外層的根目錄下面,然后讀取這個位置的代碼是:
File skinFile = new File(Environment.getExternalStorageDirectory(), "skin.apk");
image.png
4. 上圖中,打了兩個皮膚包,要注意:打兩個皮膚包運行demo,打之前,一定要記得替換drawable圖片資源為同名文件,以及不然切換沒有效果.image.png
結語
hook技術是安卓高級層次的技能,學起來并不簡單,demo里面的注釋我自認為寫的很清楚了,如果還有不懂的,歡迎留言評論。讀源碼也并不是這么輕松的事,可是還是那句話,太簡單的東西,不值錢,有高難度才有高回報。為了百萬年薪,fighting!












