手把手講解 Android hook技術實現一鍵換膚

前言

手把手講解系列文章,是我寫給各位看官,也是寫給我自己的。
文章可能過分詳細,但是這是為了幫助到盡量多的人,畢竟工作5,6年,不能老吸血,也到了回饋開源的時候.
這個系列的文章:
1、用通俗易懂的講解方式,講解一門技術的實用價值
2、詳細書寫源碼的追蹤,源碼截圖,繪制類的結構圖,盡量詳細地解釋原理的探索過程
3、提供Github 的 可運行的Demo工程,但是我所提供代碼,更多是提供思路,拋磚引玉,請酌情cv
4、集合整理原理探索過程中的一些坑,或者demo的運行過程中的注意事項
5、用gif圖,最直觀地展示demo運行效果

如果覺得細節(jié)太細,直接跳過看結論即可。
本人能力有限,如若發(fā)現描述不當之處,歡迎留言批評指正。

學到老活到老,路漫漫其修遠兮。與眾君共勉 !


引子

產品大佬又提需求啦,要求app里面的圖表要實現白天黑夜模式的切換,以滿足不同光線下都能保證足夠的圖表清晰度. 怎么辦?可能解決的辦法很多,你可以給圖表view增加一個toggle方法,參數Stringday/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 這樣浪費資源呢?請看下圖:

換膚.gif

這個動態(tài)圖中,首先看到的是Activity1,點擊換膚,可直接更換界面上的background,圖片的src,還有textViewtextColor,跳轉Activity2之后的textView顏色,在我換膚之前,和換膚之后,是不同的。換膚的過程我并沒有啟動另外的Activity,界面也沒有閃爍。我在Activity1里面換膚,直接影響了Activity2textView字體顏色。

既然給出了效果,那么肯定要給出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思路

  1. "劫持"系統創(chuàng)建View的過程,我們自己來創(chuàng)建View
    系統原本自己存在創(chuàng)建View的邏輯,我們要了解這部分代碼,以便為我所用.
  2. 收集我們需要換膚的View(用自定義view屬性來標記一個view是否支持一鍵換膚),保存到變量中
    劫持了 系統創(chuàng)建view的邏輯之后,我們要把支持換膚的這些view保存起來
  3. 加載外部資源包,調用接口進行換膚
    外部資源包,是.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控件。
那它的AppCompatDelegatesetContentView方法又做了什么?

插曲:關于如何閱讀源碼?在我的上一篇文章 中詳細說明了。
但是漏了一個細節(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 name xml標簽名
  • Context context 上下文
  • AttributeSet attrs view屬性
  • 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!

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。
禁止轉載,如需轉載請通過簡信或評論聯系作者。

相關閱讀更多精彩內容

友情鏈接更多精彩內容