Hook源碼實現阿里無閃爍換膚

做者:波瀾步驚

https://www.jianshu.com/p/4c8d46f58c4fjavascript

引子

產品大佬又提需求啦,要求app裏面的圖表要實現白天黑夜模式的切換,以知足不一樣光線下都能保證足夠的圖表清晰度. 怎麼辦?可能解決的辦法不少,你能夠給圖表view增長一個toggle方法,參數Stringday/night,而後切換以後postInvalidate 刷新重繪.
OK,可行,可是這種方式切換白天黑夜,只是單個View中有效,那麼若是哪天產品又要另外一個View換膚,難道我要一個一個去寫toggle麼?未免太low了.php

那麼能不能要實現一個全app內的一鍵換膚,一勞永逸~~~java

後面會更新高級自定義view系列文章,都爲學員波瀾步驚在學習高級進階課程的過程當中所吸取和思考的知識,尤爲對於從事Android開發3-5年,處於瓶頸期須要尋找突破和進階方向的小夥伴們必定有所啓發和幫助的


高級自定義viwe系列思惟腦圖;android


正文大綱

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. 什麼是一鍵換膚

所謂"一鍵"git

,就是經過
"一個"
**接口的調用,就能實現全app範圍內的全部資源文件的替換.包括 文本,顏色,圖片等.

一些換膚實現方式的對比github

  • 方案1:自定義View中,要換膚,那如同引言中所述,toggle方法,invalidate重繪。
    弊端:換膚範圍僅限於這個View.
  • 方案2:給靜態變量賦值,而後重啓Activity. 若是一個Activity內用靜態變量定義了兩種色系,那麼確實是能夠經過關閉Activity,再啓動的方式,實現 貌似換膚的效果(實際上是從新啓動了Activity)
    弊端:太low,並且很浪費資源

也許還有其餘方案吧,View重繪,重啓Activity,都能實現,可是仍然不是最優雅的方案,那麼,有沒有一種方案,可以實現全app內的換膚效果,又不會像重啓 Activity 這樣浪費資源呢?請看下圖:web

這個動態圖中,首先看到的是Activity1,點擊換膚,可直接更換界面上的background,圖片的src,還有textViewtextColor,跳轉Activity2以後的textView顏色,在我換膚以前,和換膚以後,是不一樣的。換膚的過程我並無啓動另外的Activity,界面也沒有閃爍。我在Activity1裏面換膚,直接影響了Activity2textView字體顏色。緩存

既然給出了效果,那麼確定要給出Demo,否則太沒誠意,嘿嘿嘿
github地址奉上:github.com/18598925736…
app

2. 界面上哪些東西是能夠換膚的

上面的換膚動態圖,我換了ImageView,換了background,換了TextView的字體顏色,那麼到底哪些東西能夠換?ide

答案其實就一句話:咱們項目代碼裏面 res目錄下的全部東西,幾乎均可以被替換。
(爲何說幾乎?由於一些犄角旮旯的東西我沒有時間一個一個去試驗....囧)

具體而言就是以下這些

  • 動畫
  • 背景圖片
  • 字體
  • 字體顏色
  • 字體大小
  • 音頻
  • 視頻

3. 利用HOOK技術實現優雅的「一鍵換膚"

  • 什麼是hook
    如題,我是用hook實現一鍵換膚。那麼什麼是hook?
    hook,鉤子. 安卓中的hook技術,實際上是一個抽象概念:對系統源碼的代碼邏輯進行"劫持",插入本身的邏輯,而後放行。注意:hook可能頻繁使用java反射機制···

"一鍵換膚"中的hook思路

  1. "劫持"系統建立View的過程,咱們本身來建立View
    系統本來本身存在建立View的邏輯,咱們要了解這部分代碼,以便爲我所用.
  2. 收集咱們須要換膚的View(用自定義view屬性來標記一個view是否支持一鍵換膚),保存到變量中
    劫持了 系統建立view的邏輯以後,咱們要把支持換膚的這些view保存起來
  3. 加載外部資源包,調用接口進行換膚
    外部資源包,是.apk後綴的一個文件,是經過gradle打包造成的。裏面包含須要換膚的資源文件,可是必須保證,要換的資源文件,和原工程裏面的文件名徹底相同.

4. 相關android源碼一覽

  • Activity 的 setContentView(R.layout.XXX) 到底在作什麼?
    回顧咱們寫app的習慣,建立Activity,寫xxx.xml,在Activity裏面setContentView(R.layout.xxx). 咱們寫的是xml,最終呈現出來的是一個一個的界面上的UI控件,那麼setContentView到底作了什麼事,使得XML裏面的內容,變成了UI控件呢?

若是不先來點乾貨,估計有些人就看不下去了,各位客官請看下圖:

源碼索引:
setContentView(R.layout.activity_main);
---》
getDelegate().setContentView(layoutResID);

OK,這裏暴露出了兩個方法,getDelegate()setContentView()

先看getDelegate:
這裏返回了一個AppCompatDelegate對象,跟蹤到AppCompatDelegate內部,閱讀源碼,能夠得出一個結論:AppCompatDelegate 是 替Activity生成View對象的委託類,它提供了一系列setContentView方法,在Activity中加入UI控件。
那它的AppCompatDelegatesetContentView方法又作了什麼?

插曲:關於如何閱讀源碼?這裏漏了一個細節:那就是,當你在源碼中看到一個接口或者抽象類,你想知道接口的實現類在哪?很簡單...若是你沒有更改androidStudio的快捷鍵設置的話,Ctrl+T能夠幫你直接定位 接口和抽象類的實現類.

用上面的方法,找到setContentView的具體過程

那麼就進入下一個環節:LayoutInflater又作了什麼?

  • LayoutInflater這個類是怎麼把layout.xml<TextView> 變成TextView對象的?
    咱們知道,咱們傳入的是int,是xxx.xml這個佈局文件,在R文件裏面的對應int值。LayoutInflater拿到了這個int以後,又幹了什麼事呢?

一路索引進去:會發現這個方法:

發現一個關鍵方法: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:你會發現:

答案很明確了,系統在默認狀況下就會走Factory2的onCreateView(),

應該有人好奇:這個mFactory2對象是哪來的?是何時set進去的*
答案以下:

若是細心Debug,就會發現

《標記標記,由於後面有一段代碼會跳回到這裏,這裏很是重要...》

當時,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,如今竟然不行了,很詭異!

答案找到了:由於我使用的是 真機,通常手機廠商都會對原生系統進行修改,而後將系統寫到到真機裏面。
而,咱們debug,用的是原生SDK。 用實例來講,我本地是SDK 27的源碼,真機也是27的系統,可是真機的運行起來的系統的代碼,是被廠家修改了的,和我本地的必然有所差異,因此,有些代碼報紅,就很正常了,沒法debug也很正常。

既然如此,那我就直接寫結論了,一張圖說明一切:

5. "全app一鍵換膚" Demo源碼詳解(戳這裏得到源碼)

  • 項目工程結構:
  • 關鍵類 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;
        }
    }

複製代碼
  • 效果展現

注意事項

1. 皮膚包skin_plugin module,裏面,只提供須要換膚的資源便可,不須要換膚的資源,還有src目錄下的源碼
(只是刪掉java源碼文件,不要刪目錄結構啊....(●´∀`●)),不要放在這裏,無故增大皮膚包的體積.

2. 皮膚包 skin_plugin module的gradle sdk版本最好和app module的保持徹底一致,不然沒法保證不會出現奇葩問題.

3. 用皮膚包skin_plugin module 打包生成的apk文件,常規來講,是放在手機內存裏面,而後由app module內的代碼去加載。至因而手機內存裏面的哪一個位置,那就見仁見智了. 我是使用的mumu模擬器,我放在了最外層的根目錄下面,而後讀取這個位置的代碼是:

`File skinFile = new File(Environment.getExternalStorageDirectory(), "skin.apk");`
複製代碼

4. 上圖中,打了兩個皮膚包,要注意:打兩個皮膚包運行demo,打以前,必定要記得替換drawable圖片資源爲同名文件,以及


否則切換沒有效果.

結語

hook技術是安卓高級層次的技能,學起來並不簡單,demo裏面的註釋我自認爲寫的很清楚了,若是還有不懂的,歡迎留言評論。讀源碼也並非這麼輕鬆的事,但是仍是那句話,太簡單的東西,不值錢,有高難度纔有高回報。

相關文章
相關標籤/搜索