Android換膚框架Debug 7.1.1源碼一步步寫

大家好,我是徐愛卿。博客地址:flutterall.com

這個SkinAPPDemo是很早的時候就寫好的,今天才來總結,實在慚愧。--

成果

其實,Android換膚這個功能呢從v7包中谷歌就跟我們做了一個很好的示范。同時呢,谷歌也給我們提供了一個針對View去做自定義操作的接口。說了這么多,不如來點實際的。

本篇博客的的demo中的build.gradle配置是:

compileSdkVersion 25
buildToolsVersion "25.0.0"

然后進行debug源碼時,也是基于Android7.1.1的源碼進行的。后面一大波debug來襲,請留神。

這篇文章我們會從最簡單的XML文件開始聊起。個人覺得知識點有以下:

  • 不斷的debug到Android源碼的內部能夠加深我們對知識的了解
  • 不斷的debug,可以知道那些報錯的error信息生成的原因,以便于我們更好的解決問題
  • 學習Google的編碼方式
  • 加深對LayoutInflater的理解
  • 看源碼實現換膚

思考什么是換膚?

說白了,就是改變控件的背景以及顏色或者其本身的顏色。比如:更換TextView的字體顏色、背景顏色等等;在比如:更換LinearLayout的的背景以及顏色,等等。這些都屬于換膚的范圍。

思考如何換膚?

兩個切入點:

  • 方法一:view初始完成后,通過findViewById等方法拿到當前的控件,然后根據主題樣式,設置其顏色、背景等。
  • 方法二:在view的構建初期,直接更改其顏色、背景等。

孰好孰壞,不言而喻。我呢,就要使用方法二。這個方法聽起來不錯,如何實現呢?其實,Google已經告訴我們了。天下文章一大抄,看你會抄不會抄。我們直接分析Google的實現邏輯,然后再寫我們需要的邏輯。

有人問了,Android源碼在哪里實現了?哥們別急,開講了。

引入

我們寫一個簡單的頁面,里面就一個TextView,如下:

創建一個TextView

然后我們打印java Log.d(TAG, "tv instanceof AppCompatTextView ? -> "+(tv instanceof AppCompatTextView) +"");這句話,如下:(注意紅色框中的,就可以了)

tv instanceof AppCompatTextView

看到結果不知道大家有沒有些許疑問?在Android 7.1.1上運行的TextView竟然是AppCompatTextView的實例。我明明在XML中寫的是TextView,在這里怎么就是AppCompatTextView的實例了呢?很明顯,是在解析XML之后構建View對象的初期,看到是TextView標簽直接使用AppCompatTextView構建這個對象。輪廓流程如下:


xml轉化成View

我們關鍵看最后一步,看下如何“創建AppCompatTextView”,當我們知道了如何偶從一個XML文件變身為一個View對象后,我們就可以比葫蘆畫瓢,創建我們的自己的屬性的View了。

分析LayoutInflater

從這里開始,我們全部使用debug結果一部部分析并且來驗證了,免得空口說大話了。

我這里,創建一個SelectThemeActivity,繼承自AppCompatActivity 我們先從最簡單的 setContentView(R.layout.activity_select_theme);開始。

起初,執行的是startActivity(new Intent(this, SelectThemeActivity.class));],然后這個東東再向后調用ActivityManagerProxy#startActivity, ActivityManagerProxy是ActivityManager的一個遠程代理,不用管它。然后通過ActivityThread的內部Handler類執行performLaunchActivity,最后調用Instrumentation#callActivityOnCreate(Activity activity, Bundle icicle)

這一塊的流程如下:

從startActivity到init Factory

一定要記得下面這張圖,這是一個關鍵點。
系統的Factory

這里注意一點layoutInflater.getFactory(),返回的是LayoutInflater的一個內部接口Factory

layoutInflater.getFactory()高能注意

在這里默認沒有代碼干預的情況下,我們不設置Factory的情況下,layoutInflater.getFactory()等于null,系統會自己創建一個Factory去處理XML到View的轉換。但是!!!他這個。反之,如果我們設置了自己的Factory,那么系統就會走我們Factory的onCreateView,他會返回一個我們定制化的View。下面詳細講解。

Factory定義如下:

public interface Factory {
        /**
         * Hook you can supply that is called when inflating from a LayoutInflater.
         * You can use this to customize the tag names available in your XML
         * layout files.
         * 
         * <p>
         * Note that it is good practice to prefix these custom names with your
         * package (i.e., com.coolcompany.apps) to avoid conflicts with system
         * names.
         * 
         * @param name Tag name to be inflated.
         * @param context The context the view is being created in.
         * @param attrs Inflation attributes as specified in XML file.
         * 
         * @return View Newly created view. Return null for the default
         *         behavior.
         */
        public View onCreateView(String name, Context context, AttributeSet attrs);
    }

Factory

Factory是一個很強大的接口。當我們使用inflating一個XML布局時,可以使用這個類進行攔截解析到的XML中的標簽屬性-AttributeSet和上下文-Context,以及標簽名稱-name(例如:TextView)。然后我們根據這些屬性可以創建對應的View,設置一些對應的屬性。

比如:我讀取到XML中的TextView標簽,這時,我就創建一個AppCompatTextView對象,它的構造方法中就是我讀取到的XML屬性。然后,將構造好的View返回即可。

默認情況下,從context.getSystemService(Context.LAYOUT_INFLATER_SERVICE)得到LayoutInflater,然后通過layoutInflater.getFactory()剛開始是null,然后執行LayoutInflaterCompat.setFactory(layoutInflater, this);方法。
看下這個方法。

  * Attach a custom Factory interface for creating views while using
     * this LayoutInflater. This must not be null, and can only be set once;
     * after setting, you can not change the factory.
     *
     * @see LayoutInflater#setFactory(android.view.LayoutInflater.Factory)
     */
    public static void setFactory(LayoutInflater inflater, LayoutInflaterFactory factory) {
        IMPL.setFactory(inflater, factory);
    }

大致意識是:將一個自定義的Factory接口綁定到創建View的LayoutInflatr。這個接口的實現不能為空,同時只能設置一次(在代碼中會有mFactorySet的boolean值(默認是false)標記是否已經設置過,如果重復設置,會拋異常)

在這里我們關注傳入的LayoutInflaterFactory的實例,最終這個設置的LayoutInflaterFactory傳入到哪里了呢?,我們向下debug,進入LayoutInflater中的下面:

LayoutInflater#setFactory2

給mFactory = mFactory2 = factory執行了,進行mFactory和mFactory2的賦值。

到這里走的路程,初始化好了LayoutInflater和LayoutInflaterFactory。

這里,我們就走完了SelectThemeActivity#onCreate中的super.onCreate(savedInstanceState);下面開始走setContentView(R.layout.activity_select_theme);

setContentView(int resId)

setContentView會走到LayoutInflate的下面這里:

public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
        final Resources res = getContext().getResources();
        if (DEBUG) {
            Log.d(TAG, "INFLATING from resource: \"" + res.getResourceName(resource) + "\" ("
                    + Integer.toHexString(resource) + ")");
        }
            //在這里將Resource得到layout的XmlResourceParser對象
        final XmlResourceParser parser = res.getLayout(resource);
        try {
            return inflate(parser, root, attachToRoot);
        } finally {
            parser.close();
        }
    }

再向下就到了LayoutInflate重點:

public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
        synchronized (mConstructorArgs) {
            .....
            //將上面給我的XmlPullParser轉換為對應的View的屬性AttributeSet供View的構造方法或其他方法使用
            final AttributeSet attrs = Xml.asAttributeSet(parser);
            ....
          try {
          if{
                ....
             } else {
             //默認布局會走到這里,Temp是XML文件的根布局
                    // Temp is the root view that was found in the xml
                    final View temp = createViewFromTag(root, name, inflaterContext, attrs);
                        ...

                    // Inflate all children under temp against its context.
                    rInflateChildren(parser, temp, attrs, true);
                        ....
                        //添加解析到的根View
                    // to root. Do that now.
                    if (root != null && attachToRoot) {
                        root.addView(temp, params);
                    }
                        ....
                    }

            } catch (XmlPullParserException e) {
               ....
            return result;
        }
    }

進入到createViewFromTag方法之中,會進入到LayoutInflate的View createViewFromTag(View parent, String name, Context context, AttributeSet attrs, boolean ignoreThemeAttr)中。
到這里開始,我們開始學習源碼中是如何使用Factory的。會走到下面這里:

LayoutInflate#createViewFromTag

這里的name傳入的就是就是解析到的標簽值LinearLayout。

@Override
    public final View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
        // First let the Activity's Factory try and inflate the view
        先試著進行解析布局
        final View view = callActivityOnCreateView(parent, name, context, attrs);
        if (view != null) {
            return view;
        }

        // If the Factory didn't handle it, let our createView() method try
        return createView(parent, name, context, attrs);
    }

很遺憾, callActivityOnCreateView返回的總是null:

@Override
    View callActivityOnCreateView(View parent, String name, Context context, AttributeSet attrs) {
        // On Honeycomb+, Activity's private inflater factory will handle calling its
        // onCreateView(...)
        return null;
    }

然后進入到下面的,createView(parent, name, context, attrs);中。高潮來了》》》》》,我期盼已久的看看Google源碼是如何創建View的。

從XML到View的華麗轉身

根據標簽+屬性創建對象
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;
    }

看到木有,它是拿標簽名稱進行switch的比較,是哪一個就進入到哪一個中進行創建View。
有人說啦,這里沒有LinearLayout對應的switch啊。的確。最終返回null。

AppCompatViewInflater#createView并沒有對布局進行創建對象

這里回到最初,由于Line769返回null,同時name值LinearLayout不包含".",進入到Line785onCreateView(parent, name, attrs)

二次解析布局標簽

到這里,我們知道這個標簽是LinearLayout了,那么開始創建這個對象了。問題來了,我們知道這個對象名稱了,但是它屬于哪個包名?如何創建呢?

根據標簽名稱創建對象

我們知道Android控件中的包名總共就那么幾個:android.widget.]android.webkit.]android.app.],既然就這么幾種,那么我干脆挨個用這些字符串進行如下拼接:
android.widget.LinearLayout]android.webkit.LinearLayout]android.app.LinearLayout],然后挨個創建對象,一旦創建成功即說明這個標簽所在的包名是對的,返回這個對象即可。那么,從上面debug會進入到如下源碼:

進入二次解析布局標簽

sClassPrefixList的定義如下:

private static final String[] sClassPrefixList = {
        "android.widget.",
        "android.webkit.",
        "android.app."
    };

注意:是final的

創建Android布局標簽對象

繼續向下debug,進入到真正的創建Android布局標簽對象的實現。在這個方法中,才是“android.widget.”包下的,LinearLayout、RelativeLayout等等的具體實現

創建LinearLayout

name="LinearLayout"
prefix="android.widget."

下面分析下這段代碼(下面的方法中去掉了一些無用代碼):

public final View createView(String name, String prefix, AttributeSet attrs)
            throws ClassNotFoundException, InflateException {
//step1 :sConstructorMap是<標簽名稱:標簽對象>的map,用來緩存對象的。第一次進入時,這個map中是空的。
        Constructor<? extends View> constructor = sConstructorMap.get(name);
        if (constructor != null && !verifyClassLoader(constructor)) {
            constructor = null;
            sConstructorMap.remove(name);
        }
        Class<? extends View> clazz = null;

        try {
//step2:在map緩存中沒有找到對應的LinearLayout為key的對象,則創建。
            if (constructor == null) {
                // Class not found in the cache, see if it's real, and try to add it
  
//step3:【關鍵點,反射創建LinearLayout對象】,根據"prefix + name"值是"android.widget.LinearLayout"加載對應的字節碼文件對象。
              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);
                    }
                }
//step4:獲取LinearLayout的Constructor對象
                constructor = clazz.getConstructor(mConstructorSignature);
                constructor.setAccessible(true);
//step5:緩存LinearLayout的Constructor對象
                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[] args = mConstructorArgs;
            args[1] = attrs;
//step6:args的兩個值分別是SelectThemeActivity,XmlBlock$Parser。到這里就調用了LinearLayout的兩個參數的構造方法去實例化對象。至此,LinearLayout的實現也就是Android中的布局文件的實現全部完成。最后把創建的View給return即可。
            final View view = constructor.newInstance(args);
            if (view instanceof ViewStub) {
                // Use the same context when inflating ViewStub later.
                final ViewStub viewStub = (ViewStub) view;
                viewStub.setLayoutInflater(cloneInContext((Context) args[0]));
            }
            return view;

        } 
                                      ......
    }

在這個方法中關鍵的步驟就是如何去實例化布局標簽對象。這也是我們下面換膚的前提知識。

總結下根據標簽+屬性創建View的思路:

根據標簽+屬性創建View

兩個關鍵點:

  • 是否設置了Factory
  • Factory的onCreateView是否返回null

再讓我們回到最初的地方:

23
        
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);
            }
//請注意下面??這個判斷,系統肯定會走上面的mFactory2.onCreateView,
//默認系統的Factory返回的是null,
//所以系統會走下面自己的創建View的實現邏輯。

//如果我們在上面的流程圖的第一步中設置了自己的Factory,那么系統
//會調用我們自己的Factory的createView的方法,這個時候,如果我們
//自己的Factory#onCreateView != 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;
                }
            }

 return view;

換膚開始

換膚代碼思路:

換膚代碼思路

我們通過我們view的屬性的值white,拿到skin-apk中的white屬性的skinResId,然后根據skinRes.getColor(skinResId)返回color,然后設置到我們的TextView上面。

step1 實現LayoutInflaterFactory接口,創建自己的Factory

public  class SkinActivity extends AppCompatActivity {

    protected LayoutInflaterFactoryImpl layoutInflaterFactory;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        layoutInflaterFactory = new LayoutInflaterFactoryImpl();
        LayoutInflaterCompat.setFactory(getLayoutInflater(), layoutInflaterFactory);
        super.onCreate(savedInstanceState);
    }

}

看下運行結果:

創建LinearLayout
創建TextView

OK!沒問題,每一個View的實現我們都可以攔截到,下一步開始拿取View的background、或者TextColor進行相應的更改。

step2 獲取view需要換膚的屬性

部分屬性值

保存view的相關屬性

public class ViewAttrs {


    public String attributeName, resourceEntryName, resourceTypeName;
    public int resId;


    public ViewAttrs(String attributeName, int resId, String resourceEntryName, String resourceTypeName) {
        this.attributeName = attributeName;
        this.resId = resId;
        this.resourceEntryName = resourceEntryName;
        this.resourceTypeName = resourceTypeName;
    }


}

View換膚時的操作

public class SkinView {

    private View view;
 
    private ArrayList<ViewAttrs> viewAttrses;

    public SkinView(View view, ArrayList<ViewAttrs> viewAttrses) {
        this.view = view;
        this.viewAttrses = viewAttrses;
    }

    //android:textColor = "@color/red_color"
    //android:background = "@mipmap/pic1"
    //android:background = "@drawable/selector"
    //android:background = "@color/blue_color"
    public void changeTheme() {
         //TODO 待實現的換膚代碼
    }
}

onCreateView創建View后,讀取view的屬性值,并且保存

 /**
     * 解析本地view的屬性,并保存該view
     * 解析:view的屬性名稱;view的屬性值;view的background;view的resId
     * @param view
     * @param context
     * @param attrs
     */
    private void saveViewAttrs(View view, Context context, AttributeSet attrs) {
        //將view的每一種屬性 以及對應的值放在list中
        ArrayList<ViewAttrs> viewAttrses = new ArrayList<>();
        for (int i = 0; i < attrs.getAttributeCount(); i++) {
            String attributeName = attrs.getAttributeName(i);//background或者textColor
            String attributeValue = attrs.getAttributeValue(i);//拿到view的id。類似于@2131361811
            if(SkinConstans.BACKGROUND.equalsIgnoreCase(attributeName) || SkinConstans.TEXT_COLOR.equalsIgnoreCase(attributeName)){//暫且這樣判斷,后面會有優化后的代碼
                int resId = Integer.parseInt(attributeValue.substring(1));//截取@2131361811 ,拿到實際的在R文件中的值
                String resourceTypeName = context.getResources().getResourceTypeName(resId);//background的mipmap或者drawable或者color等
                String resourceEntryName = context.getResources().getResourceEntryName(resId); //mipmap、drawable、color對應的值
                ViewAttrs viewAttrs = new ViewAttrs(attributeName, resId, resourceEntryName, resourceTypeName);
                viewAttrses.add(viewAttrs);
            }

        }
        if(viewAttrses.size() > 0){
            //保存需要換膚的view以及對應的屬性
            SkinView skinView = new SkinView(view, viewAttrses);
            skinViews.add(skinView);
        }
    }

執行換膚時調用:

public void changeTheme(){
        for (int i = 0; i < skinViews.size(); i++) {
            skinViews.get(i).changeTheme();
        }
    }

step3 實現加載插件apk,并且拿到插件的資源對象

public void loadSkin(String skinPath) {
        //------------拿到skinPackageName----------
        skinPackageName = context.getPackageManager().getPackageArchiveInfo(skinPath, PackageManager.GET_ACTIVITIES).packageName;
        //----------拿到skin中的Resource對象----------
        AssetManager assets = null;
        try {
            assets = AssetManager.class.newInstance();
            Method addAssetPath = AssetManager.class.getMethod("addAssetPath", String.class);
            addAssetPath.invoke(assets, skinPath);
        } catch (InstantiationException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        } catch (InvocationTargetException e) {
            e.printStackTrace();
        }
        skinRes = new Resources(assets, context.getResources().getDisplayMetrics(), context.getResources().getConfiguration());
    }

step 4 獲取插件中的resId的值


    /**
     * @param resId
     * @return
     */
    public int getColor(int resId) {
        if (skinRes == null) {
            return resId;
        }
        //通過本地APP中的resId拿到本app對應的資源名稱,然后再skin apk中找到該資源名稱, 在根據skin中的資源名稱 拿到對應的資源值
        String resourceName = context.getResources().getResourceName(resId);
        //String name, String defType, String skinPackageName  拿到skin包中的resId
        int skinResId = skinRes.getIdentifier(resourceName.substring(resourceName.indexOf("/") + 1), SkinConstans.COLOR, skinPackageName);
        if (skinResId == 0) {//說明在skin皮膚中沒有找到對應的resId,則返回原本的resId
            return context.getResources().getColor(resId);
        }
        return skinRes.getColor(skinResId);
    }

    public Drawable getDrawable(int resId) {
        Drawable drawable = context.getResources().getDrawable(resId);
        if (skinRes == null) {
            return drawable;
        }

        String resourceName = context.getResources().getResourceName(resId);
        //String name, String defType, String skinPackageName  拿到skin包中的resId
        int skinResId = skinRes.getIdentifier(resourceName.substring(resourceName.indexOf("/") + 1), SkinConstans.DRAWABLE, skinPackageName);
        if (skinResId == 0) {//說明在skin皮膚中沒有找到對應的resId,則返回原本的resId
            return drawable;
        }

        return skinRes.getDrawable(skinResId);
    }

step 5 SkinView中換膚

public void changeTheme() {
        for (int i = 0; i < viewAttrses.size(); i++) {
            ViewAttrs viewAttrs = viewAttrses.get(i);
            if (SkinConstans.TEXT_COLOR.equalsIgnoreCase(viewAttrs.attributeName)) {
                if (view instanceof TextView) {
                    //替換textColor
                    if (SkinConstans.COLOR.equalsIgnoreCase(viewAttrs.resourceTypeName)){
                        ((TextView) view).setTextColor(SkinManager.getInstance().getColor(viewAttrs.resId));
                    }
                }
            } else if (SkinConstans.BACKGROUND.equalsIgnoreCase(viewAttrs.attributeName)) {

                if (SkinConstans.DRAWABLE.equalsIgnoreCase(viewAttrs.resourceTypeName)) {

                    view.setBackgroundDrawable(SkinManager.getInstance().getDrawable(viewAttrs.resId));
                } else if (SkinConstans.COLOR.equalsIgnoreCase(viewAttrs.resourceTypeName)) {

                    view.setBackgroundColor(SkinManager.getInstance().getColor(viewAttrs.resId));
                } else if (SkinConstans.MIPMAP.equalsIgnoreCase(viewAttrs.resourceTypeName)) {

                }
            }
        }
    }
Paste_Image.png

代碼跑起來

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/activity_main_color"
    android:orientation="vertical"
    android:paddingTop="@dimen/activity_vertical_margin"
    tools:context="xu.myapplication.MainActivity">

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@color/text_background_color"
        android:text="Hello World!"
        android:textColor="@color/text_color" />
    <Button
        android:text="換膚"
        android:onClick="changeTheme"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

</LinearLayout>

color.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="colorPrimary">#3F51B5</color>
    <color name="colorPrimaryDark">#303F9F</color>
    <color name="colorAccent">#FF4081</color>
    <color name="text_color">#aadd00</color>
    <color name="text_background_color">#3F51B5</color>
    <color name="activity_main_color">#009977</color>
</resources>

MainActivity

public class MainActivity extends SkinActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }


    public void changeTheme(View view){
        SkinManager.getInstance().initContext(this);
        ActivityCompat.requestPermissions(this,
                new String[]{Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE}, 100);
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        SkinManager.getInstance().loadSkin(Environment.getExternalStorageDirectory().getAbsolutePath()+"/skinplugin-debug.apk");
        layoutInflaterFactory.changeTheme();
    }
}

下面是skin-apk的color.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="colorPrimary">#3F51B5</color>
    <color name="colorPrimaryDark">#303F9F</color>
    <color name="colorAccent">#3F51B5</color>
    <color name="text_color">#FF4081</color>
    <color name="text_background_color">#1199aa</color>
    <color name="activity_main_color">#aadd00</color>
</resources>
APP-color
skin-color
換膚結果

Activity的background和TextView的textColor都換了

代碼優化:

優化這一塊,有很多地方可以優化。比如:

  • 在saveViewAttrs(View view, Context context, AttributeSet attrs)方法中,將換膚進行抽象化處理。
private void saveViewAttrs(View view, Context context, AttributeSet attrs) {
        //將view的每一種屬性 以及對應的值放在list中
        ArrayList<ViewAttrs> viewAttrses = new ArrayList<>();
        boolean skinEnable = true;
        for (int i = 0; i < attrs.getAttributeCount(); i++) {
            String attributeName = attrs.getAttributeName(i);//background或者textColor
            String attributeValue = attrs.getAttributeValue(i);//拿到view的在R文件中的id。類似于@2131361811
            /*if(SkinConstant.BACKGROUND.equalsIgnoreCase(attributeName) || SkinConstant.TEXT_COLOR.equalsIgnoreCase(attributeName)){
                int resId = Integer.parseInt(attributeValue.substring(1));//截取@2131361811 ,拿到實際的在R文件中的值
                String resourceTypeName = context.getResources().getResourceTypeName(resId);//background的mipmap或者drawable或者color等
                String resourceEntryName = context.getResources().getResourceEntryName(resId); //mipmap、drawable、color對應的值
                ViewAttrs viewAttrs = new ViewAttrs(attributeName, resId, resourceEntryName, resourceTypeName);
                viewAttrses.add(viewAttrs);
            }*/
            if("skin".equalsIgnoreCase(attributeName)){
                //默認對所有控件換膚,但是如果屬性中包含有[skin:skin=""],則表示不對該控件做換膚處理
                skinEnable = false;
                break;
            }
            if(!ViewAttrsFactory.contains(attributeName) || attributeValue.indexOf("@") < 0){
                continue;
            }

            int resId = Integer.parseInt(attributeValue.substring(1));//截取@2131361811 ,拿到實際的在R文件中的值
            String resourceTypeName = context.getResources().getResourceTypeName(resId);//background的mipmap或者drawable或者color等
            String resourceEntryName = context.getResources().getResourceEntryName(resId); //mipmap、drawable、color對應的值
            ViewAttrs viewAttrs = ViewAttrsFactory.createViewAttrs(attributeName, resId, resourceEntryName, resourceTypeName);
            if (viewAttrs != null) {
                viewAttrses.add(viewAttrs);

            }
        }
        if (skinEnable && viewAttrses.size() > 0) {
            //保存需要換膚的view以及對應的屬性
            SkinView skinView = new SkinView(view, viewAttrses);
            skinViews.add(skinView);
            if(SkinManager.getInstance().isLoadSkinSuccess()){
                skinView.changeTheme();
            }
        }
    }

ViewAttrsFactory

public class ViewAttrsFactory {

    public static Map<String, ViewAttrs> viewAttrsMap = new HashMap<>();

    static {//添加支持換膚的屬性
        viewAttrsMap.put(SkinConstant.TEXT_COLOR, new TextColorViewAttrs());
        viewAttrsMap.put(SkinConstant.BACKGROUND, new BackgroundViewAttrs());
        viewAttrsMap.put(SkinConstant.SRC, new BackgroundViewAttrs());
        viewAttrsMap.put(SkinConstant.MENU, new NavigationMenuAttrs());
    }

    public static ViewAttrs createViewAttrs(String attributeName, int resId, String resourceEntryName, String resourceTypeName) {
        if (viewAttrsMap.get(attributeName) != null) {
            ViewAttrs viewAttrs;
            if ((viewAttrs = viewAttrsMap.get(attributeName).clone()) != null) {

                viewAttrs.attributeName = attributeName;
                viewAttrs.resId = resId;
                viewAttrs.resourceEntryName = resourceEntryName;
                viewAttrs.resourceTypeName = resourceTypeName;
                return viewAttrs;
            }
        }
        return null;
    }


    public static boolean contains(String attributeName) {
        return attributeName != null && viewAttrsMap.get(attributeName) != null;
    }

}

更多優化,在我的GitHubSkinAppDemo,大家拉下來看下。這里就不在贅述了。

最終成果

總結

多多debug,多多益善!上面的我貼的debug的流程,還希望大家多多debug。反正我不記得我debug這個流程多少遍了。

感謝

謝謝大家最后堅持看到這里,期望大家多多fork,多多start。謝謝大家。

我的博客地址是 http://www.flutterall.com

謝謝
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 173,065評論 25 708
  • afinalAfinal是一個android的ioc,orm框架 https://github.com/yangf...
    passiontim閱讀 15,489評論 2 45
  • 最近在項目中添加了MLeaksFinder,用于排查內存泄露的地方。平常weakself的意識很強,因為引用sel...
    Masazumi柒閱讀 554評論 0 0
  • 我剛接手這個班的時候,他還是個問題學生,初二了記不住作業,字寫得滿紙飛起,亂到目不忍視。每次最早到教室,然后安靜地...
    夜闌仍有星閱讀 301評論 0 4
  • 清明時節雨紛紛 春天的風實在很舒服 家人在一起 就好了 爺爺 記憶力不好從去年開始 從他生日之后,有時候突然覺得很...
    寧夏的夏閱讀 304評論 0 0