Android通過Hook技術實現一鍵換膚

目錄

1.什麼是一鍵換膚

2.界面上那些東西可以換膚 

3.利用Hook實現一鍵換膚

4.Android創建視圖源碼分析

4.1.自定義Activity設置要顯示的佈局文件xml

4.2.調用兼容AppCompatActivity代理類AppCompatDelegate實現xml佈局到View視圖的轉換 

4.3.AppCompatActivity定義實現不同版本AppCompatDelegate實現類

4.4.setContentView()方法最終調用代理類AppCompatDelegateImplV7中的方法

4.5.在LayoutInflater中實現基於XmlPullParser解析xml佈局文件

4.6.xml解析調用createViewFromTag()通過控件名稱和屬性集合創建視圖

4.7.createViewFromTag()真正執行解析xml佈局文件以後創建View視圖

4.8.mFactory2來源(調試發現最終調用的是AppCompatActivity代理類的onCreateView()方法)

4.9.AppCompatViewInflater的createView()方法真正創建視圖(AppCompatDelegateImplV9.onCreateView()調用AppCompatViewInflater.createView())

5.Resources/AssetManager

6.實現關鍵類SkinFactory,SkinEngine

6.1SkinFactory實現Factory2接口,完成視圖的創建,視圖的緩存,全部視圖的換膚操作;

6.2SkinEngine加載插件apk的對應的Resources,(Resources用插件apk的資源)實現更換皮膚的各種方法(例如:getColor等)

6.3實例使用

6.4主app和插件app設置需要換膚資源用相同的名稱

7.顯示效果

8.注意事項


實現換膚的方案:

a.靜態修改theme主題方式

設置多套皮膚的theme;

styles.xml
<resources>
    <!-- Base application theme. -->
    <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
        <!-- Customize your theme here. -->
        <item name="defaultcolor">@color/defalultcolor</item>                                      
    </style>

    <style name="NewTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
        <item name="defaultcolor">@color/newcolor</item>
    </style>
</resources>

聲明屬性需要動態替換的樣式屬性

attrs.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <attr name="defaultcolor" format="reference|color" />
</resources>

爲控件設置屬性樣式?attr/defaultcolor

<TextView
        android:layout_marginTop="20dp"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="這是第一個fragment"
        android:textColor="?attr/defaultcolor"
        android:textSize="36dp" />

動態設置主題

@Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        if( 1 ==  1){
            setTheme(R.style.AppTheme);
        }else {
            setTheme(R.style.NewTheme);
        }
        setContentView(R.layout.activity_main);
}

缺點是設置主題在setContentView之前,設置以後需要重啓Activity;

b.動態修改樣式(應用內/插件化-皮膚apk),不需要重啓Activity

應用內

採用通過相同名稱+不同皮膚後綴來區分不同皮膚,如實現黑白皮膚,文本顏色item_text_color有一套默認皮膚,一套黑色皮膚定義資源item_text_color,item_text_color_black;

String resName = mOutResource.getResourceEntryName(resId);
        int outResId = mOutResource.getIdentifier(resName, "drawable", mOutPkgName);

resName+"_"+不同皮膚後綴

缺點:應用內多套皮膚時可能導致安裝包過大;

插件化-皮膚apk

皮膚apk和主apk有相同皮膚資源名稱,獲取皮膚apk下的資源名稱,需要和主apk下資源名稱一致,設置在皮膚apk下皮膚資源 ;

String resName = mOutResource.getResourceEntryName(resId);
        int outResId = mOutResource.getIdentifier(resName, "drawable", mOutPkgName);

mOutResource皮膚apk資源Resources;

動態修改視圖皮膚需要解決兩個問題:

1).緩存獲取全部需要換膚的視圖,換膚(資源)時執行換膚操作;

2).獲取Resources資源(插件apk資源Resources),動態設置皮膚;

1.什麼是一鍵換膚

所謂”一鍵換膚“就是通過一個接口調用,實現app範圍內所有資源文件替換,包括文本,顏色,圖片,動畫等;

2.界面上那些東西可以換膚 

例如TextView文字顏色,字體大小,ImageView的background等等;

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繼承自兼容AppCompatActivity

public class MainActivity extends AppCompatActivity implements View.OnClickListener {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }
}

創建Activity經常在onCreate()方法中調用setContentView(R.layout.xxx);設置要顯示視圖,那麼如何將我們創建的xml佈局文件轉換爲要顯示View視圖呢?

源碼執行流程:

4.1.自定義Activity設置要顯示的佈局文件xml

setContentView(R.layout.activity_main);

4.2.調用兼容AppCompatActivity代理類AppCompatDelegate實現xml佈局到View視圖的轉換 

由於Android版本比較分散,需要兼容各個版本Android系統,AppCompatActivity定義實現不同版本AppCompatDelegate實現類;

@Override
public void setContentView(@LayoutRes int layoutResID) {
    getDelegate().setContentView(layoutResID);
}

4.3.AppCompatActivity定義實現不同版本AppCompatDelegate實現類

private static AppCompatDelegate create(Context context, Window window,
            AppCompatCallback callback) {
        final int sdk = Build.VERSION.SDK_INT;
        if (sdk >= 23) {
            return new AppCompatDelegateImplV23(context, window, callback);
        } else if (sdk >= 14) {
            return new AppCompatDelegateImplV14(context, window, callback);
        } else if (sdk >= 11) {
            return new AppCompatDelegateImplV11(context, window, callback);
        } else {
            return new AppCompatDelegateImplV7(context, window, callback);
        }
    }

4.4.setContentView()方法最終調用代理類AppCompatDelegateImplV7中的方法

通過如下實現將layout的xml佈局文件轉換爲View視圖添加到父視圖上contentParent;

LayoutInflater.from(mContext).inflate(resId, contentParent);

4.5.在LayoutInflater中實現基於XmlPullParser解析xml佈局文件

4.6.xml解析調用createViewFromTag()通過控件名稱和屬性集合創建視圖

public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
            final AttributeSet attrs = Xml.asAttributeSet(parser);
            View result = root;
                //找到跟節點
                int type;
                while ((type = parser.next()) != XmlPullParser.START_TAG &&
                        type != XmlPullParser.END_DOCUMENT) {
                    // Empty
                }

                if (type != XmlPullParser.START_TAG) {
                    throw new InflateException(parser.getPositionDescription()
                            + ": No start tag found!");
                }
                //獲取跟節點名字
                final String name = parser.getName();
                //創建xml的layout佈局文件跟視圖
                final View temp = createViewFromTag(root, name, inflaterContext, attrs);
                //遞歸調用創建子視圖
                rInflateChildren(parser, temp, attrs, true);
                    // 添加到xml佈局文件視圖到父視圖
                    if (root != null && attachToRoot) {
                        root.addView(temp, params);
                    }
                    //將xml佈局的跟視圖添加做爲父視圖
                     if (root == null || !attachToRoot) {
                        result = temp;
                    }
            ...部分源碼

            return result;
        }
    }

//創建xml佈局文件的跟視圖

final View temp = createViewFromTag(root, name, inflaterContext, attrs);

//遞歸創建xml佈局文件的子視圖,最後調用createViewFromTag()方法創建視圖

rInflateChildren(parser, temp, attrs, true);

createViewFromTag(parent, name, context, attrs);

4.7.createViewFromTag()真正執行解析xml佈局文件以後創建View視圖

最終調用mFactory2實現視圖創建

view = mFactory2.onCreateView(parent, name, context, attrs);

if(view==null)

通過反射創建View(例如自定義View)

View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
            boolean ignoreThemeAttr) {}

* @param parent:創建視圖的父View
* @param name:xml標籤使用的視圖名稱(例如:<TextView/>)
* @param context:上下文
* @param attrs:需要創建的xml標籤的屬性集;(例如:android:textColor="")
* @param ignoreThemeAttr:true忽略{android:theme}屬性爲被解析的視圖,false相反;

4.8.mFactory2來源(調試發現最終調用的是AppCompatActivity代理類的onCreateView()方法)

AppCompatActivity設置LayoutInflater工廠

 @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        final AppCompatDelegate delegate = getDelegate();
        delegate.installViewFactory();
    }

代理類AppCompatDelegateImplV7實現installViewFactory()方法

AppCompatDelegate(Activity代理類)
@Override
    public void installViewFactory() {
        LayoutInflater layoutInflater = LayoutInflater.from(mContext);
        if (layoutInflater.getFactory() == null) {
            LayoutInflaterCompat.setFactory(layoutInflater, this);
        } else {
            if (!(LayoutInflaterCompat.getFactory(layoutInflater)
                    instanceof AppCompatDelegateImplV7)) {
                Log.i(TAG, "The Activity's LayoutInflater already has a Factory installed"
                        + " so we can not install AppCompat's");
            }
        }
    }
LayoutInflaterCompat是LayoutInflater兼容類
    //LayoutInflater不同版本的實現
    static final LayoutInflaterCompatImpl IMPL;
    static {
        final int version = Build.VERSION.SDK_INT;
        if (version >= 21) {
            IMPL = new LayoutInflaterCompatImplV21();
        } else if (version >= 11) {
            IMPL = new LayoutInflaterCompatImplV11();
        } else {
            IMPL = new LayoutInflaterCompatImplBase();
        }
    }

public static void setFactory(LayoutInflater inflater, LayoutInflaterFactory factory) {
        IMPL.setFactory(inflater, factory);
    }

static class LayoutInflaterCompatImplV11 extends LayoutInflaterCompatImplBase {
        @Override
        public void setFactory(LayoutInflater layoutInflater, LayoutInflaterFactory factory) {
            LayoutInflaterCompatHC.setFactory(layoutInflater, factory);
        }
    }

FactoryWrapperHC實現了Factory2接口,包裹LayoutInflaterFactory接口,當調用Factroy2. 
onCreateView方法時最後調用LayoutInflaterFactory(AppCompatDelegateImplV9).onCreateView()方法(class 
AppCompatDelegateImplV9 extends AppCompatDelegateImplBase
        implements MenuBuilder.Callback, LayoutInflaterFactory),
AppCompatDelegateImplV9實現LayoutInflaterFactory接口
static void setFactory(LayoutInflater inflater, LayoutInflaterFactory factory) {
        final LayoutInflater.Factory2 factory2 = factory != null
                ? new FactoryWrapperHC(factory) : null;
        //設置包裹LayoutInflaterFactory類做爲factory2
        inflater.setFactory2(factory2);

        final LayoutInflater.Factory f = inflater.getFactory();
        if (f instanceof LayoutInflater.Factory2) {
            // The merged factory is now set to getFactory(), but not getFactory2() (pre-v21).
            // We will now try and force set the merged factory to mFactory2
            forceSetFactory2(inflater, (LayoutInflater.Factory2) f);
        } else {
            // Else, we will force set the original wrapped Factory2
            forceSetFactory2(inflater, factory2);
        }
    }

static class FactoryWrapperHC extends LayoutInflaterCompatBase.FactoryWrapper
            implements LayoutInflater.Factory2 {

        FactoryWrapperHC(LayoutInflaterFactory delegateFactory) {
            super(delegateFactory);
        }

        @Override
        public View onCreateView(View parent, String name, Context context,
                AttributeSet attributeSet) {
            return mDelegateFactory.onCreateView(parent, name, context, attributeSet);
        }
    }

LayoutInfalter設置factory2
   /**
     * Like {@link #setFactory}, but allows you to set a {@link Factory2}
     * interface.
     */
    public void setFactory2(Factory2 factory) {
        if (mFactorySet) {
            throw new IllegalStateException("A factory has already been set on this LayoutInflater");
        }
        if (factory == null) {
            throw new NullPointerException("Given factory can not be null");
        }
        mFactorySet = true;
        if (mFactory == null) {
            mFactory = mFactory2 = factory;
        } else {
            mFactory = mFactory2 = new FactoryMerger(factory, factory, mFactory, mFactory2);
        }
    }





LayoutInflaterCompat.setFactory(layoutInflater, this);

將代理類AppCompatDelegateImplV7的this設置爲mFactory2;

AppCompatDelegateImplV7實現了LayoutInflaterFactory接口,mFactory2調用onCreateView()方法最終調用實現Factory2接口FactoryWrapperHC類,FactoryWrapperHC包裹類LayoutInflaterFactory,最後調用LayoutInflaterFactory(AppCompatDelegateImplV7).onCreateView()方法創建視圖;

4.9.AppCompatViewInflater的createView()方法真正創建視圖(AppCompatDelegateImplV9.onCreateView()調用AppCompatViewInflater.createView())

 @Override
    public View createView(View parent, final String name, @NonNull Context context,
            @NonNull AttributeSet attrs) {
        final boolean isPre21 = Build.VERSION.SDK_INT < 21;

        if (mAppCompatViewInflater == null) {
            mAppCompatViewInflater = new AppCompatViewInflater();
        }

        // We only want the View to inherit its context if we're running pre-v21
        final boolean inheritContext = isPre21 && shouldInheritContext((ViewParent) parent);

        return mAppCompatViewInflater.createView(parent, name, context, attrs, inheritContext,
                isPre21, /* Only read android:theme pre-L (L+ handles this anyway) */
                true, /* Read read app:theme as a fallback at all times for legacy reasons */
                VectorEnabledTintResources.shouldBeUsed() /* Only tint wrap the context if enabled */
        );
    }

AppCompatViewInflater

public 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 = new AppCompatTextView(context, attrs);
                break;
            case "ImageView":
                view = new AppCompatImageView(context, attrs);
                break;
            case "Button":
                view = new AppCompatButton(context, attrs);
                break;
            case "EditText":
                view = new AppCompatEditText(context, attrs);
                break;
            case "Spinner":
                view = new AppCompatSpinner(context, attrs);
                break;
            case "ImageButton":
                view = new AppCompatImageButton(context, attrs);
                break;
            case "CheckBox":
                view = new AppCompatCheckBox(context, attrs);
                break;
            case "RadioButton":
                view = new AppCompatRadioButton(context, attrs);
                break;
            case "CheckedTextView":
                view = new AppCompatCheckedTextView(context, attrs);
                break;
            case "AutoCompleteTextView":
                view = new AppCompatAutoCompleteTextView(context, attrs);
                break;
            case "MultiAutoCompleteTextView":
                view = new AppCompatMultiAutoCompleteTextView(context, attrs);
                break;
            case "RatingBar":
                view = new AppCompatRatingBar(context, attrs);
                break;
            case "SeekBar":
                view = new AppCompatSeekBar(context, attrs);
                break;
        }

        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 it's android:onClick
            checkOnClickListener(view, attrs);
        }

        return view;
    }

創建Android的原生控件都是以AppCompat開頭的兼容類例如:AppCompatSeekBar;

整個流程分析完成,我們發現最終調用mFactory2.onCreateView()和反射的方式創建視圖;

就可以實現Factory2接口 ,給LayoutInflater重新設置實現自己邏輯Factory2(創建View視圖,緩存視圖和視圖屬性便於動態調用修改視圖屬性-文字顏色,字體大小,背景等)

5.Resources/AssetManager

調用AssetManager.addAssetPath("插件apk路徑")指定要加載的皮膚apk,創建執行插件apk的Resources以便獲取資源(圖片,顏色,尺寸等);

需要動態設置皮膚資源就需要獲取相關資源類(顏色,字體,背景等等),AssetManager.addAssetPath(String path)可以實現調用指定資源包;

Android中與資源相關的類主要有Resources、ResourcesImpl、ResourcesManager、Java層的AssetManager和Native層的AssetManager;

Resources:提供了大多數與應用開發直接相關的加載資源的方法,比如getColor(int resId)等。實現上是通過AssetManager來加載資源並進行解析。

AssetManager:Java層的AssetManager是對Native層AssetManager的封裝,爲上層提供了加載資源的方法

加載資源包:一般而言,一個App至少會引用兩個資源包:系統資源包和App的資源包。一個AssetManager對象可加載多個資源包。App在創建AssetManager的時候,會先加載系統資源包,再加載App資源包。這樣,通過一個AssetManager對象就可以訪問系統資源包和App資源包的資源了。

AssetManager.addAssetPath(String path)是一個@hide方法,調用這個方法可以加載指定的資源包。

訪問資源:由於資源id包含了package id的信息,AssetManager通過解析資源id,即可知道從哪個資源包來加載資源。

 

6.實現關鍵類SkinFactory,SkinEngine

6.1SkinFactory實現Factory2接口,完成視圖的創建,視圖的緩存,全部視圖的換膚操作;

/**
 * 自定義Factory2,目的是拿到需要換膚的View和View樣式
 */
public class SkinFactory implements LayoutInflater.Factory2 {

    //預定義一個委託類,他負責按照系統原有邏輯來創建View
    private AppCompatDelegate mDelegate;
    //緩存要換膚的View
    private List<SkinView> lisCacheSkinViews = new ArrayList<>();

    /**
     * 外部提供一個Set方法
     * @param mDelegate
     */
    public void setDelegate(AppCompatDelegate mDelegate) {
        this.mDelegate = mDelegate;
    }

    /**
     *
     * @param parent
     * @param name
     * @param context
     * @param attrs
     * @return
     */
    @Override
    public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
        //關鍵點1:執行系統代碼裏的創建View的過程,我們只是想加入自己的思想,並不是全盤接管
        //系統創建出來的時候有可能爲空,你問爲啥?請全文搜索 “標記標記,因爲” 你會找到你要的答案
        View view  = mDelegate.createView(parent, name, context, attrs);
        if(view == null){//萬一系統創建出來是空,那我們來補救
            if(-1 == name.indexOf('.')){ //不包含,說明不帶包名,那麼我門幫他加上包名
                view = createViewByPrefix(context, name, prefixs, attrs);
            }else {
                view = createViewByPrefix(context, name, null, attrs);
            }
        }

        //關鍵點2:收集需要換膚的View
        collectSkinView(context, attrs, view);

        return view;
    }

    /**
     * 收集換膚的控件
     * 收集的方式是:通過自定義isSupport,從創建出來的很多View中,找到支持換膚的那些,保存到map中
     * @param context
     * @param attrs
     * @param view
     */
    private void collectSkinView(Context context, AttributeSet attrs, View view){
        //獲取我們自己定義的屬性
        TypedArray  typedArray = context.obtainStyledAttributes(attrs, R.styleable.Skinable);
        boolean isSupport = typedArray.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;
            //將可換膚的View放到listCacheSkinView中
            lisCacheSkinViews.add(skinView);
        }
    }

    /**
     * 公開對外的換膚接口
     */
    public void changeSkin(){
        for(SkinView skinView  : lisCacheSkinViews){
            skinView.changeSkin();
        }
    }

    /**
     * Factory2是繼承Factory的,所以這次主要重寫Factory2的onCreatteView邏輯,就不必理會Factory的重寫方法
     * @param name
     * @param context
     * @param attrs
     * @return
     */
    @Override
    public View onCreateView(String name, Context context, AttributeSet attrs) {
        return null;
    }

    /**
     * 所謂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."
    };

    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 (ClassNotFoundException e) {
                e.printStackTrace();
                return null;
            } catch (NoSuchMethodException e) {
                e.printStackTrace();
                return null;
            }
            constructor.setAccessible(true);
            //然後緩存起來,下次再用,就直接從內存中去取
            sConstructorMap.put(name, constructor);
        }
       Object[] args = mConstructorArgs;
        try {
            args[0] = context;
            args[1] = attrs;
            //通過反射創建View對象
            //執行構造函數,拿到View對下
            final View view = constructor.newInstance(args);
            return view;
        } catch (InstantiationException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (InvocationTargetException e) {
            e.printStackTrace();
        }finally {
            args[0] = null;
            args[1] = null;
        }
        return null;
    }

    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 (!TextUtils.isEmpty(attrsMap.get("textSize"))) {
                    int textSizeId = Integer.valueOf(attrsMap.get("textSize").substring(1));
                    ((TextView) view).setTextSize(SkinEngine.getInstance().getTextSize(textSizeId));
                }
            }



//            //那麼如果是自定義組件呢
//            if (view instanceof ZeroView) {
//                //那麼這樣一個對象,要換膚,就要寫針對性的方法了,每一個控件需要用什麼樣的方式去換,尤其是那種,自定義的屬性,怎麼去set,
//                // 這就對開發人員要求比較高了,而且這個換膚接口還要暴露給 自定義View的開發人員,他們去定義
//                // ....
//            }
        }

    }








}

6.2SkinEngine加載插件apk的對應的Resources,(Resources用插件apk的資源)實現更換皮膚的各種方法(例如:getColor等)

/**
 *  單例
 */

public class SkinEngine {
    private final static SkinEngine instance = new SkinEngine();

    public static SkinEngine getInstance(){
        return instance;
    }

    public void init(Context context){
        mContext = context.getApplicationContext();
        //使用application的目的是,如果萬一傳進來的是Activity對象
        //那麼它被靜態對象instance所持有,這個Activity就無法釋放了
    }

    private Resources mOutResource; //資源管理器
    private Context mContext;  //上下文
    private String mOutPkgName; //外部資源包packageName

    //加載外部資源包
    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;//資源管理器
        //關鍵技術點3 通過反射獲取AssetManager 用來加載外面的資源包
        try {
            //反射創建AssetManager對象,爲何要反射?使用反射,是因爲他這個類內部的addAssetPath方法是hide狀態
            assetManager = AssetManager.class.newInstance();
            //addAssetPath方法可以加載外部的資源包
            //爲什麼要反射執行這個方法?因爲它是hide的,不直接對外開放,只能反射調用
            Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
            addAssetPath.invoke(assetManager, path); //反射執行方法
            mOutResource = new Resources(assetManager,  //資源管理器
                    mContext.getResources().getDisplayMetrics(),    //屏幕參數
                    mContext.getResources().getConfiguration());    //資源配置
            //最終創建出一個 "外部資源包"mOutResource ,它的存在,就是要讓我們的app有能力加載外部的資源文件
        } catch (InstantiationException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        } catch (InvocationTargetException e) {
            e.printStackTrace();
        }
    }

    /**
     * 提供外部資源包裏面的顏色
     * @param resId
     * @return
     */
    public int getColor(int resId) {
        if (mOutResource == null) {
            return resId;
        }
        String resName = mContext.getResources().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 = mContext.getResources().getResourceEntryName(resId);
        int outResId = mOutResource.getIdentifier(resName, "drawable", mOutPkgName);
        if (outResId == 0) {
            return ContextCompat.getDrawable(mContext, resId);
        }
        return mOutResource.getDrawable(outResId);
    }

    /**
     * 提供外部資源包裏的字體大小
     * @param resId
     * @return
     */
    public float getTextSize(int resId) {//獲取字體大小
        if (mOutResource == null) {
            return mContext.getResources().getDimension(resId);
        }
        String resName = mContext.getResources().getResourceEntryName(resId);
        int outResId = mOutResource.getIdentifier(resName, "textSize", mOutPkgName);
        if (outResId == 0) {
            return mContext.getResources().getDimension(resId);
        }
        return mOutResource.getDimension(outResId);
    }

    //..... 這裏還可以提供外部資源包裏的String,font等等等,只不過要手動寫代碼來實現getXX方法









}

注意事項:

通過資源id找到資源名稱,mContext是當前主app的上下文;

String resName = mContext.getResources().getResourceEntryName(resId);

mOutResource指的是插件apk的Resources獲取和主app相同名稱的資源替換顯示;
int outResId = mOutResource.getIdentifier(resName, "textSize", mOutPkgName);

6.3實例使用

初始化"換膚引擎"指定當前上下文

public class MyApp extends Application {

    @Override
    public void onCreate() {
        super.onCreate();
        //初始化換膚引擎
        SkinEngine.getInstance().init(this);
    }
}

在基類Activity爲LayoutInflator注入Facotry2類,其他Activity實現基類;

/**
 * Activity積類
 */

public class BaseActivity extends AppCompatActivity {

    private boolean ifAllowChangeSkin=true;
    private SkinFactory mSkinFactory;
    private String mCurrentSkin;

    @Override
    protected void onCreate(@Nullable 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);

    }
}

在設置界面調用換膚操作, changeSkin("apk插件的名字"),具體apk插件存在哪和如何放入存儲卡加載插件apk,下面僅僅是插件apk下載完成以後實現的方法;

changeSkin("skinmodule-debug.apk");
protected void changeSkin(String path) {
        if (ifAllowChangeSkin) {
            File skinFile = new File(Environment.getExternalStorageDirectory(), path);
            SkinEngine.getInstance().load(skinFile.getAbsolutePath());//加載外部資源包
            mSkinFactory.changeSkin();//執行換膚操作
            mCurrentSkin = path;
        }
    }

6.4主app和插件app設置需要換膚資源用相同的名稱

6.5自定義isSupport屬性設置View是否支持換膚

<resources><!--TODO: 關鍵技術點2 通過自定義屬性來標識哪些view支持換膚-->
    <declare-styleable name="Skinable">
        <!--TODO: isSupport=true標識當前控件支持換膚-->
        <attr name="isSupport" format="boolean" />
    </declare-styleable>
</resources>

<TextView
            android:layout_width="0dp"
            android:layout_weight="1"
            android:layout_height="match_parent"
            android:text="第一個"
            android:id="@+id/tv_one"
            android:gravity="center"
            android:textSize="@dimen/tabsize"

            app:isSupport="true"

            />

7.顯示效果

以下效果實現動態替換字體大小,背景顏色;

已經創建的Activity界面可能需要監聽器,監聽換膚操作執行換膚,新創建的頁面將使用插件apk皮膚資源;

8.注意事項

a.skinmodule創建module只需要管理assets和res下的資源即可,需要替換資源名稱要和app一致;

b.皮膚包 skin_plugin module的gradle sdk版本最好和app module的保持完全一致,否則無法保證不會出現奇葩問題;

c.skinmodule生成的具體保存位置自己定義,只需要創建插件Resources時執行插件路徑apk即可;

 

 

參考:

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

https://github.com/18598925736/HookSkinDemoFromHank

 

 

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章