侵入性低擴(kuò)展性強(qiáng)的Android換膚框架XSkinLoader的用法及原理

更好的閱讀體驗(yàn),請(qǐng)轉(zhuǎn)到我的個(gè)人博客:Windy'Journal

前言

Android發(fā)展到現(xiàn)在,很多成熟的應(yīng)用上已經(jīng)集成了插件式換膚的功能,比如網(wǎng)易云音樂(lè),手機(jī)QQ,QQ音樂(lè)等等。但是,成熟穩(wěn)定易用的開(kāi)源換膚框架并沒(méi)有出現(xiàn)。

國(guó)內(nèi)最早的插件式換膚框架是Android-Skin-Loader。后面也出現(xiàn)了一些在此基礎(chǔ)上的改進(jìn)版,比如:hongyang的ChangeSkin,andSkin,Android-skin-supportinjor,QSkinLoader等等。大家都對(duì)Android-Skin-Loader做了一些改進(jìn),以使換膚過(guò)程侵入性更低,擴(kuò)展性更強(qiáng),使用更簡(jiǎn)單。但是還是會(huì)有一些不足之處,因此,XSkinLoader就誕生了。

XSkinLoader是在Android-Skin-Loader和QSkinLoader的基礎(chǔ)上又進(jìn)行了一次重大改進(jìn),主要的改進(jìn)點(diǎn)有如下:

  1. 侵入性更低,換膚Activity并不用實(shí)現(xiàn)某個(gè)接口或者繼承某個(gè)BaseActivity
  1. 支持布局里style中定義的屬性換膚,默認(rèn)支持了TextView的textColor和ProgressBar的indeterminateDrawable,并支持?jǐn)U展;
  2. 更好地支持了AppCompatActivity中的控件換膚,由于AppCompatActivity中的TextView,ImageView等控件會(huì)被轉(zhuǎn)為AppCompatTextView,AppCompatImageView,XSkinLoader換膚時(shí)并不會(huì)覆蓋此轉(zhuǎn)換,其他換膚框架會(huì)覆蓋;
  3. 支持狀態(tài)欄顏色換膚,并可以通過(guò)相似方法擴(kuò)展支持標(biāo)題欄和虛擬導(dǎo)航欄的換膚;
  4. 支持xml中指定的屬性換膚

XSkinLoader項(xiàng)目源碼地址為:https://github.com/WindySha/XSkinLoader

下面,先簡(jiǎn)單介紹XSkinLoader的基本用法,再通過(guò)分析源碼來(lái)解析這些改進(jìn)點(diǎn)的實(shí)現(xiàn)原理。

XSkinLoader的使用方法

XSkinLoader的使用方式特別簡(jiǎn)單,對(duì)代碼的侵入性很低,需要換膚的Activity中只用在調(diào)用一行代碼即可:

    SkinInflaterFactory.setFactory(this);

用法跟其他換膚框架基本相同,先在Application中初始化,然后在相關(guān)xml中加上skin:enable="true"即可, 詳細(xì)用法如下:

初始化

首先在ApplicationonCreate中進(jìn)行初始化:

        SkinManager.get().init(this);

如果代碼中需要經(jīng)常使用Application Context的LayoutInflater加載View,最好同時(shí)加上這樣一行代碼:

        SkinInflaterFactory.setFactory(LayoutInflater.from(this));  // for skin change
        SkinManager.get().init(this);

如此,使用LayoutInflater.from(context.getApplicationContext()).inflate()加載的view也是可以換膚的

XML換膚

xml布局中的View需要換膚的,只需要在布局文件中相關(guān)View標(biāo)簽下添加skin:enable="true"即可,例如:

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:skin="http://schemas.android.com/android/skin"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <TextView
        android:id="@+id/status_bar"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        skin:enable="true"
        android:background="@color/title_color">
    </TextView>
<RelativeLayout/>

能換膚的前提是解析這個(gè)xml的LayoutInflater設(shè)置Factory接口:SkinInflaterFactory
因此,在相關(guān)activity的onCreate()setContentView()方法之前添加:

    //干涉xml中view的創(chuàng)建,實(shí)現(xiàn)xml中資源換膚
    SkinInflaterFactory.setFactory(this);  //for skin change in XML

PS: 對(duì)于AppCompatActivity,務(wù)必要在onCreate()super.onCreate()之前添加,否則不會(huì)使用AppComt包裝的控件,比如:AppCompatTextView等。

某些view的資源是在代碼中動(dòng)態(tài)設(shè)置的,使用以下方式來(lái)設(shè)置資源,才能實(shí)現(xiàn)換膚效果:

    //設(shè)置imageView的src資源
    SkinManager.get().setImageDrawable(imageView, R.drawable.ic_action);
    //設(shè)置imageView的backgroud資源
    SkinManager.get().setViewBackground(imageView, R.drawable.ic_action);
    //設(shè)置textVie的color資源
    SkinManager.get().setTextViewColor(textView, R.color.title_color);
    //設(shè)置Activity的statusBarColor
    SkinManager.get().setWindowStatusBarColor(MainActivity.this.getWindow(), R.color.title_color);
    ...

xml中指定換膚屬性

xml中假如出現(xiàn)了多個(gè)可換膚屬性,但只需要換其中部分屬性,而不是全部屬性,比如:

<Button
        android:id="@+id/use_sdcard_skin"
        android:layout_width="180dp"
        android:layout_height="40dp"
        skin:enable="true"
        android:background="@drawable/confirm_skin_btn_border"
        android:textColor="@color/music_skin_change_button_color" />

這個(gè)布局中,包含兩個(gè)換膚屬性:background,textColor,假如只想換textColor,那該怎么辦?
此處,借鑒了andSkin中的一個(gè)辦法,增加一個(gè)屬性attrs,在此屬性中聲明需要換膚的屬性。
具體到上面的例子,只需要增加這樣一行代碼skin:attrs="textColor"就行:

<Button
        android:id="@+id/use_sdcard_skin"
        android:layout_width="180dp"
        android:layout_height="40dp"
        skin:enable="true"
        skin:attrs="textColor"
        android:background="@drawable/confirm_skin_btn_border"
        android:textColor="@color/music_skin_change_button_color" />

如果支持多個(gè)屬性,使用|分割就行:

        skin:attrs="textColor|background"

其實(shí),大多數(shù)情況下并不用在Xml中加此屬性來(lái)控制,如若不想此屬性換膚,也可以在相應(yīng)的皮膚apk中去掉此屬性指定的資源。

新增換膚屬性

對(duì)已經(jīng)成型的大型項(xiàng)目來(lái)說(shuō),XSkinLoader中提供的換膚屬性是不夠用的,需要額外增加的換膚屬性該怎么辦?
在sample中寫好了相應(yīng)的模板,具體參考ExtraAttrRegister.java

public static final String CUSTIOM_VIEW_TEXT_COLOR = "titleTextColor";

    static {
        //增加自定義控件的自定義屬性的換膚支持
        SkinResDeployerFactory.registerDeployer(CUSTIOM_VIEW_TEXT_COLOR, new CustomViewTextColorResDeployer());

    }

新增style中的換膚屬性

假如style中的換膚屬性不夠用,需要新增,該怎么辦?
sample中也寫了一個(gè)模板,在ExtraAttrRegister.java中:

static {
        //增加xml里的style中指定的View background屬性換膚
        StyleParserFactory.addStyleParser(new ViewBackgroundStyleParser());
    }

XSKinLoader的實(shí)現(xiàn)原理分析

換膚框架核心的技術(shù)原理和Android-skim-loader以及由此衍生出來(lái)的那些框架都差不多。主要就是實(shí)現(xiàn)LayoutInflater.Factory接口干涉xml中view解析的過(guò)程,并將解析出來(lái)的熟悉和view保存到list(map)中,換膚的時(shí)候,遍歷此list(map),重新設(shè)置此view的換膚屬性對(duì)應(yīng)的資源(用皮膚包對(duì)應(yīng)的Resources來(lái)設(shè)置)。

具體細(xì)節(jié),如若不清楚可以參考QSkinLoader的源碼解析:
Android換膚功能實(shí)現(xiàn)與換膚框架QSkinLoader使用方式介紹
或者 andSkin的源碼解析:
Android 換膚原理分析和總結(jié)
核心原理都差不多,都來(lái)自于Android-skin-loader,此處就不再啰嗦。

這里,主要講XSkinLoader的改進(jìn)點(diǎn)。

使用WeakHashMap

將View和對(duì)應(yīng)的換膚屬性保存在全局的WeakHashMap中,這樣activity退出后,WeakHashMap中的view會(huì)被GC回收掉,因此不會(huì)出現(xiàn)內(nèi)存泄漏的問(wèn)題。

    //使用這個(gè)map保存所有需要換膚的view和其對(duì)應(yīng)的換膚屬性及資源
    //使用WeakHashMap兩個(gè)作用,1.避免內(nèi)存泄漏,2.避免重復(fù)的view被添加
    //使用HashMap存SkinAttr,為了避免同一個(gè)屬性值存了兩次
    private WeakHashMap<View, HashMap<String, SkinAttr>> mSkinAttrMap = new WeakHashMap<>();

WeakHashMap中鍵值對(duì)的值使用HashMap<String, SkinAttr>,是為了避免view的屬性重復(fù)添加,比如,在xml中設(shè)置了TextView的textColor換膚資源,在代碼中又設(shè)置了textColor換膚資源
SkinManager.get().setTextViewColor(textView, R.color.title_color);
這樣代碼中設(shè)置的換膚資源會(huì)覆蓋掉xml中設(shè)置的。(xml中設(shè)置的屬性資源也會(huì)覆蓋style中設(shè)置的屬性資源)

支持AppCompatActivity換膚

由于AppCompatActivity會(huì)設(shè)置LayoutInflater.Factory,干涉view的創(chuàng)建過(guò)程,并將TextView,ImageView等替換為AppCompatTextView,AppCompatImageView。假如不做特殊處理,會(huì)覆蓋掉AppCompatActivity中設(shè)置的Factory,因此,沒(méi)有兼容到AppCompatActivity的一些屬性。

查閱AppCompatActivity,可知,為了兼容不同的android版本,它是通過(guò)AppCompatDelegate來(lái)設(shè)置LayoutInflater的Factory,代碼細(xì)節(jié)如下:

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

delegate.installViewFactory();最終調(diào)到了AppCompatDelegateImplV9.java中的installViewFactory()。

    @Override
    public void installViewFactory() {
        LayoutInflater layoutInflater = LayoutInflater.from(mContext);
        if (layoutInflater.getFactory() == null) {
            LayoutInflaterCompat.setFactory2(layoutInflater, this);
        } else {
            if (!(layoutInflater.getFactory2() instanceof AppCompatDelegateImplV9)) {
                Log.i(TAG, "The Activity's LayoutInflater already has a Factory installed"
                        + " so we can not install AppCompat's");
            }
        }
    }

AppCompatDelegateImplV9.java中的Factory2的接口實(shí)現(xiàn)為:

    /**
     * From {@link LayoutInflater.Factory2}.
     */
    @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);
    }

createView中使用AppCompatViewInflater來(lái)創(chuàng)建View,并將TextView,ImageView等替換為AppCompatTextView,AppCompatImageView:

    public final View createView(View parent, final String name, @NonNull Context context,
            @NonNull AttributeSet attrs, boolean inheritContext,
            boolean readAndroidTheme, boolean readAppTheme, boolean wrapContext) {
        ...
        ...
        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;
            ...
            ...

        return view;
    }

為了使我們的SkinInflaterFactory不干涉AppCompatActivity的view創(chuàng)建過(guò)程,我們可以這樣做:

    public static void setFactory(Activity activity) {
        LayoutInflater inflater = activity.getLayoutInflater();
        SkinInflaterFactory factory = new SkinInflaterFactory();
        if (activity instanceof AppCompatActivity) {
            //AppCompatActivity本身包含一個(gè)factory,將TextView等轉(zhuǎn)換為AppCompatTextView.java, 參考:AppCompatDelegateImplV9.java
            final AppCompatDelegate delegate = ((AppCompatActivity) activity).getDelegate();
            factory.setInterceptFactory(new Factory() {
                @Override
                public View onCreateView(String name, Context context, AttributeSet attrs) {
                    //創(chuàng)建view的過(guò)程還是交給AppCompatDelegate來(lái)做
                    return delegate.createView(null, name, context, attrs);
                }
            });
        }
        inflater.setFactory(factory);
    }
    
    //因?yàn)長(zhǎng)ayoutInflater的setFactory方法只能調(diào)用一次,當(dāng)框架外需要處理view的創(chuàng)建時(shí),可以調(diào)用此方法
    public void setInterceptFactory(Factory factory) {
        mViewCreateFactory = factory;
    }

    @Override
    public View onCreateView(String name, Context context, AttributeSet attrs) {
        View view = null;
        if (mViewCreateFactory != null) {
            //給框架外提供創(chuàng)建View的機(jī)會(huì)
            view = mViewCreateFactory.onCreateView(name, context, attrs);
        }
        if (isSupportSkin(attrs)) {
            if (view == null) {
                view = createView(context, name, attrs);
            }
            if (view != null) {
                parseAndSaveSkinAttr(attrs, view);
            }
        }

        return view;
    }

Activity的statusBar顏色換膚

首先將Activity對(duì)應(yīng)的Window傳過(guò)來(lái),然后獲取Window對(duì)應(yīng)的DecorView,對(duì)DecorView實(shí)施換膚:

    public void setWindowStatusBarColor(Window window, @ColorRes int resId) {
        View decorView = window.getDecorView();
        setSkinViewResource(decorView, SkinResDeployerFactory.ACTIVITY_STATUS_BAR_COLOR, resId);
    }

正真換膚的時(shí)候,又通過(guò)DecorView反射獲取其對(duì)應(yīng)的Window,然后設(shè)置window的StatusBarColor:

public class ActivityStatusBarColorResDeployer implements ISkinResDeployer {
    @Override
    public void deploy(View view, SkinAttr skinAttr, ISkinResourceManager resource) {
        //the view is the window's DecorView
        Window window = (Window) ReflectUtils.getField(view, "mWindow");
        if (window == null) {
            throw new IllegalArgumentException("view is not a DecorView, cannot get the window");
        }
        if (SkinConfig.RES_TYPE_NAME_COLOR.equals(skinAttr.attrValueTypeName)) {
            window.setStatusBarColor(resource.getColor(skinAttr.attrValueRefId));
        }
    }
}

支持style中的換膚屬性

style中的換膚屬性支持方法主要是根據(jù)傳入的AttributeSet和控件的styleable列表獲取控件中屬性對(duì)應(yīng)的資源id,并將view,屬性,資源id保存起來(lái)。以TextView的textColor為例,具體實(shí)現(xiàn)細(xì)節(jié)如下:

public class TextViewTextColorStyleParser implements ISkinStyleParser{

    private static int[] sTextViewStyleList;
    private static int sTextViewTextColorStyleIndex;

    @Override
    public void parseXmlStyle(View view, AttributeSet attrs, Map<String, SkinAttr> viewAttrs, String[] specifiedAttrList) {
        if (!TextView.class.isAssignableFrom(view.getClass())) {
            return;
        }
        Context context = view.getContext();
        int[] textViewStyleable = getTextViewStyleableList();
        int textViewStyleableTextColor = getTextViewTextColorStyleableIndex();

        TypedArray a = context.obtainStyledAttributes(attrs, textViewStyleable, 0, 0);
        if (a != null) {
            int n = a.getIndexCount();
            for (int j = 0; j < n; j++) {
                int attr = a.getIndex(j);
                if (attr == textViewStyleableTextColor &&
                        SkinConfig.isCurrentAttrSpecified(SkinResDeployerFactory.TEXT_COLOR, specifiedAttrList)) {
                    int colorResId = a.getResourceId(attr, -1);
                    SkinAttr skinAttr = SkinAttributeParser.parseSkinAttr(context, SkinResDeployerFactory.TEXT_COLOR, colorResId);
                    if (skinAttr != null) {
                        viewAttrs.put(skinAttr.attrName, skinAttr);
                    }
                }
            }
            a.recycle();
        }
    }

    private static int[] getTextViewStyleableList() {
        if (sTextViewStyleList == null || sTextViewStyleList.length == 0) {
            sTextViewStyleList = (int[]) ReflectUtils.getField("com.android.internal.R$styleable", "TextView");
        }
        return sTextViewStyleList;
    }

    private static int getTextViewTextColorStyleableIndex() {
        if (sTextViewTextColorStyleIndex == 0) {
            Object o = ReflectUtils.getField("com.android.internal.R$styleable", "TextView_textColor");
            if (o != null) {
                sTextViewTextColorStyleIndex = (int) o;
            }
        }
        return sTextViewTextColorStyleIndex;
    }
}

具體細(xì)節(jié)如若不明白,可以參考TextView.java第四個(gè)構(gòu)造方法中對(duì)AttributeSetstyle的處理。

總結(jié)

XSkinLoader雖然說(shuō)已經(jīng)很完美了,但是還有一些不足之處:

  1. 無(wú)法支持Theme中定義的屬性換膚,無(wú)論是Activity中的Theme還是Application還是控件中指定的Theme,都是無(wú)法支持換膚。暫時(shí)沒(méi)能找到解決方法,而且其他的換膚框架也沒(méi)有解決這個(gè)問(wèn)題,比較坑。
  2. 暫時(shí)沒(méi)能支持Glide控件設(shè)置默認(rèn)圖片的換膚,一般使用Glide設(shè)置默認(rèn)圖是這樣:
    Glide.with(context).load(url).placeholder(R.drawable.default).into(imageView);
    暫時(shí)不能支持R.drawable.default的換膚,不過(guò),此問(wèn)題應(yīng)該可解,畢竟Glide的擴(kuò)展性非常強(qiáng)。
  3. RecyclerView的緩存問(wèn)題,可能會(huì)導(dǎo)致?lián)Q膚RecyclerView的item換膚失敗,不過(guò)暫時(shí)未碰到。假如遇到此問(wèn)題,可以參考QSkinLoader的清除緩存的方法。
  4. 可能存在的性能問(wèn)題.
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 大家好,我是徐愛(ài)卿。博客地址:flutterall.com 這個(gè)SkinAPPDemo是很早的時(shí)候就寫好的,今天才...
    徐愛(ài)卿閱讀 3,664評(píng)論 3 38
  • 前言: 本文主要講述如何在項(xiàng)目中,在不重啟應(yīng)用的情況下,實(shí)現(xiàn)動(dòng)態(tài)換膚的效果。換膚這塊做的比較好的,有網(wǎng)易云音樂(lè),q...
    Yagami3zZ閱讀 13,853評(píng)論 5 51
  • Android 自定義View的各種姿勢(shì)1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 179,323評(píng)論 25 708
  • 小雪夜,天干,物燥。 北方風(fēng)很大,吱吱呀呀折了枝椏,呼啦呼啦,樓下安居的一群頑童好麻將。 夜里的麻將聲,分不清哪個(gè)...
    司聘閱讀 467評(píng)論 0 1
  • 淼淼滄浪映霞鶩,楚天岳陽(yáng)樓睇。 煙波浩瀚,葉舟難系。憶何年,攜纖手,似游鯉。 別后曾思否,去無(wú)計(jì)。 北上長(zhǎng)相望,遠(yuǎn)...
    劉小地閱讀 437評(píng)論 24 77

友情鏈接更多精彩內(nèi)容