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

換膚.gif
這個動態圖中,首先看到的是 Activity1
,點選換膚,可直接更換介面上的 background
,圖片的 src
,還有 textView
的 textColor
,跳轉 Activity2
之後的 textView
顏色,在我換膚之前,和換膚之後,是不同的。換膚的過程我並沒有啟動另外的 Activity
,介面也沒有閃爍。我在 Activity1
裡面換膚,直接影響了 Activity2
的 textView
字型顏色。
既然給出了效果,那麼肯定要給出Demo,不然太沒誠意,嘿嘿嘿
github地址奉上: https://github.com/18598925736/HookSkinDemoFromHank
2. 介面上哪些東西是可以換膚的
上面的換膚動態圖,我換了ImageView,換了background,換了TextView的字型顏色,那麼到底哪些東西可以換?
我們專案程式碼裡面 res目錄下的所有東西,幾乎都可以被替換。
(為什麼說幾乎?因為一些犄角旮旯的東西我沒有時間一個一個去試驗....囧)
具體而言就是如下這些
- 動畫
- 背景圖片
- 字型
- 字型顏色
- 字型大小
- 音訊
- 視訊
3. 利用HOOK技術實現優雅的“一鍵換膚"
-
什麼是hook
如題,我是用hook實現一鍵換膚。那麼什麼是hook?
hook,鉤子. 安卓中的hook技術,其實是一個抽象概念:對系統原始碼的程式碼邏輯進行"劫持",插入自己的邏輯,然後放行。注意:hook可能頻繁使用java反射機制···
"一鍵換膚"中的hook思路
- "劫持"系統建立View的過程,我們自己來建立View
系統原本自己存在建立View的邏輯,我們要了解這部分程式碼,以便為我所用. - 收集我們需要換膚的View(用自定義view屬性來標記一個view是否支援一鍵換膚),儲存到變數中
劫持了 系統建立view的邏輯之後,我們要把支援換膚的這些view儲存起來 - 載入外部資源包,呼叫介面進行換膚
外部資源包,是.apk
字尾的一個檔案,是通過gradle
打包形成的。裡面包含需要換膚的資原始檔,但是必須保證,要換的資原始檔,和原工程裡面的檔名完全相同
.
4. 相關android原始碼一覽
-
Activity 的 setContentView(R.layout.XXX) 到底在做什麼?
回顧我們寫
app
的習慣,建立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
方法又做了什麼?
插曲:關於如何閱讀原始碼?在我的上一篇文章 中詳細說明了。
但是漏了一個細節:那就是,當你在原始碼中看到一個 介面
或者 抽象類
,你想知道介面的 實現類
在哪?很簡單...如果你沒有更改 androidStudio
的快捷鍵設定的話, Ctrl+T
可以幫你直接定位 介面和抽象類的實現類
.
用上面的方法,找到setContentView的具體過程

原始碼.png
那麼就進入下一個環節: LayoutInflater
又做了什麼?
-
LayoutInflater
這個類是怎麼把layout.xml
的<TextView>
變成TextView
物件的?我們知道,我們傳入的是
int
,是xxx.xml
這個佈局檔案,在R檔案裡面的對應int值。LayoutInflater
拿到了這個int
之後,又幹了什麼事呢?
一路索引進去:會發現這個方法:

image.png

image.png
發現一個關鍵方法: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 name
xml標籤名 -
Context context
上下文 -
AttributeSet attrs
view屬性 -
boolean ignoreThemeAttr
是否忽略theme屬性
並且在這裡,發現一段關鍵程式碼:
if (mFactory2 != null) { view = mFactory2.onCreateView(parent, name, context, attrs); } else if (mFactory != null) { view = mFactory.onCreateView(name, context, attrs); } else { view = null; }
實際上,可能有人要問了,你怎麼知道這邊是走的哪一個if分支呢?
方法:新建立一個 Project
,跟蹤 MainActivity onCreate
裡面 setContentView()
一路找到這段程式碼 debug
:你會發現:

image.png
答案很明確了,系統在預設情況下就會走Factory2的onCreateView(),
答案如下:

image.png
如果細心Debug,就會發現 《標記標記,因為後面有一段程式碼會跳回到這裡,這裡非常重要...》

image.png

image.png
當時,getDelegate()得到的物件,和 LayoutInflater裡面mFactory2其實是同一個物件
那麼繼續跟蹤,一直到: 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來進行系統控制元件的建立,例如: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); }
然而,這裡的補救措施沒有執行,那自然有地方有另外的補救措施:
回到之前的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); // 真正需要關注的關鍵程式碼,就是這一行,執行了建構函式,返回了一個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
是怎麼工作的
從我們的終極目的出發:我們要做的是“換膚”,如果我們拿到了要換膚的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 ,它的例項,會負責建立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;//預定義一個委託類,它負責按照系統的原有邏輯來建立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:執行系統程式碼裡的建立View的過程,我們只是想加入自己的思想,並不是要全盤接管 View view = mDelegate.createView(parent, name, context, attrs);//系統創建出來的時候有可能為空,你問為啥?請全文搜尋 “標記標記,因為” 你會找到你要的答案 if (view == null) {//萬一系統創建出來是空,那麼我們來補救 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,從創建出來的很多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的一次執行中,不會發生變化 String attrType = view.getResources().getResourceTypeName(bgId); // 屬性類別:比如 drawable ,color if (TextUtils.equals(attrType, "drawable")) {//區分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, // 這就對開發人員要求比較高了,而且這個換膚介面還要暴露給 自定義View的開發人員,他們去定義 // .... } } } /** * 所謂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種,這個變數是為了下面程式碼裡,反射建立類的class而預備的 "android.widget.", "android.view.", "android.webkit." }; /** * 反射建立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 { //通過反射建立View物件 final View view = constructor.newInstance(args);//執行建構函式,拿到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物件 //那麼它被靜態物件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();//反射建立AssetManager物件,為何要反射?使用反射,是因為他這個類內部的addAssetPath方法是hide狀態 //addAssetPath方法可以載入外部的資源包 Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);//為什麼要反射執行這個方法?因為它是hide的,不直接對外開放,只能反射呼叫 addAssetPath.invoke(assetManager, path);//反射執行方法 mOutResource = new Resources(assetManager,//引數1,資源管理器 mContext.getResources().getDisplayMetrics(),//這個好像是螢幕引數 mContext.getResources().getConfiguration());//資源配置 //最終創建出一個 "外部資源包"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. 劫持 系統建立view的過程
public class BaseActivity extends AppCompatActivity { ... @Override protected void onCreate(Bundle savedInstanceState) { // TODO: 關鍵點1:hook(劫持)系統建立view的過程 if (ifAllowChangeSkin) { mSkinFactory = new SkinFactory(); mSkinFactory.setDelegate(getDelegate()); LayoutInflater layoutInflater = LayoutInflater.from(this); layoutInflater.setFactory2(mSkinFactory);//劫持系統原始碼邏輯 } super.onCreate(savedInstanceState); }
3. 執行換膚操作
protected void changeSkin(String path) { if (ifAllowChangeSkin) { File skinFile = new File(Environment.getExternalStorageDirectory(), path); SkinEngine.getInstance().load(skinFile.getAbsolutePath());//載入外部資源包 mSkinFactory.changeSkin();//執行換膚操作 mCurrentSkin = path; } }
-
效果展示
換膚.gif
- 注意事項
注意事項
1. 面板包skin_plugin module,裡面,只提供需要換膚的資源即可,不需要換膚的資源,還有src目錄下的原始碼
(
只是刪掉java原始碼檔案,不要刪目錄結構啊....(●´∀`●)
),不要放在這裡,無端增大面板包的體積.1. 面板包skin_plugin module,裡面,只提供需要換膚的資源即可,不需要換膚的資源,還有src目錄下的原始碼
只是刪掉java原始碼檔案,不要刪目錄結構啊....(●´∀`●)
2. 面板包 skin_plugin module的gradle sdk版本最好和app module的保持完全一致,否則無法保證不會出現奇葩問題.
3. 用面板包skin_plugin module 打包生成的apk檔案,常規來說,是放在手機記憶體裡面,然後由app module內的程式碼去載入。至於是手機記憶體裡面的哪個位置,那就見仁見智了. 我是使用的mumu模擬器,我放在了最外層的根目錄下面,然後讀取這個位置的程式碼是:3. 用面板包skin_plugin module 打包生成的apk檔案,常規來說,是放在手機記憶體裡面,然後由app module內的程式碼去載入。至於是手機記憶體裡面的哪個位置,那就見仁見智了. 我是使用的mumu模擬器,我放在了最外層的根目錄下面,然後讀取這個位置的程式碼是:
File skinFile = new File(Environment.getExternalStorageDirectory(), "skin.apk");
image.png
image.png
不然切換沒有效果.