插件化換膚原理View創(chuàng)建流程偏形、LayoutInflater源碼的分析

相信大家也發(fā)現(xiàn)了,我們常用的APP中觉鼻,每到節(jié)假日都會換上不一樣的主題背景俊扭,換成對應(yīng)節(jié)日的皮膚,像這種換膚肯定不是為了某一個節(jié)日單獨發(fā)一個版本坠陈,這樣的話也太麻煩了萨惑,很多大廠都有自己的換膚技術(shù),不需要通過發(fā)版就可以實時換膚仇矾,活動結(jié)束之后自動恢復(fù)庸蔼,所以有哪些資源可以通過換膚來進(jìn)行切換的呢?

其實在Android的res目錄下所有資源都可以進(jìn)行換膚若未,像圖片朱嘴、文字顏色、字體粗合、背景等都可以通過換膚來進(jìn)行無卡頓切換萍嬉,那么究竟如何才能高效穩(wěn)定地實現(xiàn)換膚,我們需要對于View的生命周期以及加載流程有一定的認(rèn)識隙疚。

1 XML布局的解析流程

如果沒有使用Compose壤追,我們現(xiàn)階段的Android開發(fā)布局依然是在XML文件中,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical">

    <TextView
        android:layout_width="match_parent"
        android:layout_height="80dp"
        android:background="#2196F3"
        android:text="這是頂部TextView"
        android:gravity="center"
        android:textColor="#FFFFFF"
        app:layout_behavior=".behavior.ScrollBehavior"/>
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rv_child"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="80dp"
        app:layout_behavior=".behavior.RecyclerViewBehavior"/>

</androidx.coordinatorlayout.widget.CoordinatorLayout>

所以如果想要改變字體顏色供屉,就需要動態(tài)修改textColor屬性行冰;如果想改變背景,就需要修改background屬性伶丐;當(dāng)一個Activity想要加載某個布局文件的時候悼做,就需要調(diào)用setContentView方法,實例化View哗魂;

setContentView(R.layout.activity_main)

那么我們是否能夠改變系統(tǒng)加載布局文件的邏輯肛走,讓其加載我們自己的皮膚包,那這樣是不是就能夠?qū)崿F(xiàn)動態(tài)換膚录别?

1.1 setContentView源碼分析

我這邊看的是Android 11的源碼朽色,算是比較新的了吧,伙伴們可以跟著看一下组题。

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

一般情況下葫男,我們傳入的就是一個布局id,內(nèi)部實現(xiàn)是調(diào)用了AppCompatDelegate實現(xiàn)類的setContentView方法崔列,AppCompatDelegate是一個抽象類梢褐,它的實現(xiàn)類為AppCompatDelegateImpl。

@NonNull
public AppCompatDelegate getDelegate() {
    if (mDelegate == null) {
        mDelegate = AppCompatDelegate.create(this, this);
    }
    return mDelegate;
}

所以我們看下AppCompatDelegateImpl的setContentView方法。

@Override
public void setContentView(int resId) {
    ensureSubDecor();
    ViewGroup contentParent = mSubDecor.findViewById(android.R.id.content);
    contentParent.removeAllViews();
    LayoutInflater.from(mContext).inflate(resId, contentParent);
    mAppCompatWindowCallback.bypassOnContentChanged(mWindow.getCallback());
}

首先調(diào)用了ensureSubDecor方法利职,這里我就不細(xì)說了趣效,這個方法的目的就是保證DecorView創(chuàng)建成功,我們看下這個圖猪贪,布局的層級是這樣的跷敬。

我們所有的自定義布局,都是加載在DecorView這個容器上热押,我們看下面這個布局:

<androidx.appcompat.widget.ActionBarOverlayLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:id="@+id/decor_content_parent"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:fitsSystemWindows="true">

    <!--布局id為 action_bar_activity_content---->
    <include layout="@layout/abc_screen_content_include"/>

    <androidx.appcompat.widget.ActionBarContainer
            android:id="@+id/action_bar_container"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_alignParentTop="true"
            style="?attr/actionBarStyle"
            android:touchscreenBlocksFocus="true"
            android:gravity="top">

        <androidx.appcompat.widget.Toolbar
                android:id="@+id/action_bar"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                app:navigationContentDescription="@string/abc_action_bar_up_description"
                style="?attr/toolbarStyle"/>

        <androidx.appcompat.widget.ActionBarContextView
                android:id="@+id/action_context_bar"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:visibility="gone"
                android:theme="?attr/actionModeTheme"
                style="?attr/actionModeStyle"/>

    </androidx.appcompat.widget.ActionBarContainer>

</androidx.appcompat.widget.ActionBarOverlayLayout>

看布局你可能會覺得西傀,這個是啥?這個是系統(tǒng)appcompat包中的一個布局文件桶癣,名字為adb_screen_toolbar.xml拥褂,當(dāng)我們新建一個app項目的時候,見到的第一個頁面牙寞,如下圖所示

紅框展示的布局就是上面這個XML饺鹃,也就是DecorView加載的布局文件R.layout.adb_screen_toolbar.xml

final ContentFrameLayout contentView = (ContentFrameLayout) subDecor.findViewById(
        R.id.action_bar_activity_content);

final ViewGroup windowContentView = (ViewGroup) mWindow.findViewById(android.R.id.content);
if (windowContentView != null) {
    // There might be Views already added to the Window's content view so we need to
    // migrate them to our content view
    while (windowContentView.getChildCount() > 0) {
        final View child = windowContentView.getChildAt(0);
        windowContentView.removeViewAt(0);
        contentView.addView(child);
    }

    // Change our content FrameLayout to use the android.R.id.content id.
    // Useful for fragments.
    windowContentView.setId(View.NO_ID);
    contentView.setId(android.R.id.content);

    // The decorContent may have a foreground drawable set (windowContentOverlay).
    // Remove this as we handle it ourselves
    if (windowContentView instanceof FrameLayout) {
        ((FrameLayout) windowContentView).setForeground(null);
    }
}

對于DecorView的加載间雀,因為設(shè)置不同主題就會加載不同的XML悔详,這里我不做過多的講解,因為主要目標(biāo)是換膚惹挟,但是上面這段代碼需要關(guān)注一下茄螃,就是DecorView布局加載出來之后,獲取了include中的id為action_bar_activity_content的容器连锯,將其id替換成了content归苍。

我們再回到setContentView方法中,我們看又是通過mSubDecor獲取到了content這個id對應(yīng)的容器运怖,通過Inflate的形式將我們的布局加載到這個容器當(dāng)中拼弃,所以核心點就是Inflate是如何加載并實例化View的

1.2 LayoutInflater源碼分析

我們換膚的重點就是對于LayoutInflater源碼的分析摇展,尤其是inflate方法吻氧,直接返回了一個View。

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) + ")");
    }

    View view = tryInflatePrecompiled(resource, res, root, attachToRoot);
    if (view != null) {
        return view;
    }
    // 這里是進(jìn)行XML布局解析
    XmlResourceParser parser = res.getLayout(resource);
    try {
        return inflate(parser, root, attachToRoot);
    } finally {
        parser.close();
    }
}

首先是通過XmlParser工具進(jìn)行布局解析吗购,這部分就不講了沒有意義医男,重點看下面的代碼實現(xiàn):

public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
    synchronized (mConstructorArgs) {
        Trace.traceBegin(Trace.TRACE_TAG_VIEW, "inflate");

        final Context inflaterContext = mContext;
        final AttributeSet attrs = Xml.asAttributeSet(parser);
        Context lastContext = (Context) mConstructorArgs[0];
        mConstructorArgs[0] = inflaterContext;
        View result = root;

        try {
            advanceToRootNode(parser);
            // 代碼 - 1
            final String name = parser.getName();
            // ...... 省略部分代碼

            if (TAG_MERGE.equals(name)) {
                if (root == null || !attachToRoot) {
                    throw new InflateException("<merge /> can be used only with a valid "
                            + "ViewGroup root and attachToRoot=true");
                }

                rInflate(parser, root, inflaterContext, attrs, false);
            } else {
                // Temp is the root view that was found in the xml
                // 代碼 - 2
                final View temp = createViewFromTag(root, name, inflaterContext, attrs);

                ViewGroup.LayoutParams params = null;

                if (root != null) {
                    if (DEBUG) {
                        System.out.println("Creating params from root: " +
                                root);
                    }
                    // Create layout params that match root, if supplied
                    params = root.generateLayoutParams(attrs);
                    if (!attachToRoot) {
                        // Set the layout params for temp if we are not
                        // attaching. (If we are, we use addView, below)
                        temp.setLayoutParams(params);
                    }
                }

                if (DEBUG) {
                    System.out.println("-----> start inflating children");
                }

                // Inflate all children under temp against its context.
                rInflateChildren(parser, temp, attrs, true);

                if (DEBUG) {
                    System.out.println("-----> done inflating children");
                }

                // We are supposed to attach all the views we found (int temp)
                // to root. Do that now.
                if (root != null && attachToRoot) {
                    root.addView(temp, params);
                }

                // Decide whether to return the root that was passed in or the
                // top view found in xml.
                if (root == null || !attachToRoot) {
                    result = temp;
                }
            }

        }

        return result;
    }
}

伙伴們從代碼中標(biāo)記的tag砸狞,自行找對應(yīng)的代碼講解

代碼 - 1

前面我們通過XML布局解析捻勉,拿到了布局文件中的信息,這個name其實就是我們在XML中寫的控件的名稱刀森,例如TextView踱启、Button、LinearLayout、include埠偿、merge......

如果是merge標(biāo)簽的話透罢,跟其他控件走的渲染方式不一樣,我們重點看 代碼-2 中的實現(xiàn)冠蒋。

代碼 - 2

這里有一個核心方法羽圃,createViewFromTag,最終返回了一個View抖剿,這里就包含系統(tǒng)創(chuàng)建并實例化View的秘密朽寞。

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();
    }

    try {
        // 代碼 - 3
        View view = tryCreateView(parent, name, context, attrs);
        // 代碼 - 4
        if (view == null) {
            final Object lastContext = mConstructorArgs[0];
            mConstructorArgs[0] = context;
            try {
                if (-1 == name.indexOf('.')) {
                    view = onCreateView(context, parent, name, attrs);
                } else {
                    view = createView(context, name, null, attrs);
                }
            } finally {
                mConstructorArgs[0] = lastContext;
            }
        }

        return view;
    }
}

代碼 - 3

其實createViewFromTag這個方法中,最終的一個方法就是tryCreateView斩郎,在這個方法中返回的View就是createViewFromTag的返回值脑融,當(dāng)然也有可能創(chuàng)建失敗,最終走到 代碼-4中缩宜,但我們先看下這個方法肘迎。

public final View tryCreateView(@Nullable View parent, @NonNull String name,
    @NonNull Context context,
    @NonNull AttributeSet attrs) {
    if (name.equals(TAG_1995)) {
        // Let's party like it's 1995!
        return new BlinkLayout(context, attrs);
    }

    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);
    }

    return view;
}

在這個方法中,我們看到創(chuàng)建View锻煌,其實是通過兩個Factory妓布,分別是:mFactory2和mFactory,通過調(diào)用它們的onCreateView方法進(jìn)行View的實例化炼幔,如果這兩個Factory都沒有設(shè)置秋茫,那么最終返回的view = null;當(dāng)然后面也有一個兜底策略乃秀,如果view = null肛著,但是mPrivateFactory(其實也是Factory2)不為空,也可以通過mPrivateFactory創(chuàng)建跺讯。

1.3 Factory接口

在前面我們提到兩個成員變量枢贿,分別是:mFactory2和mFactory,這兩個變量是LayoutInflater中的成員變量刀脏,我們看下是在setFactory和setFactory2中進(jìn)行賦值的局荚。

public void setFactory(Factory 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 = factory;
    } else {
        mFactory = new FactoryMerger(factory, null, mFactory, mFactory2);
    }
}

/**
 * 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);
    }
}

我們系統(tǒng)在進(jìn)行布局解析的時候,肯定也是設(shè)置了自己的Factory愈污,這樣的話就直接走系統(tǒng)的初始化流程耀态;

protected LayoutInflater(LayoutInflater original, Context newContext) {
    StrictMode.assertConfigurationContext(newContext, "LayoutInflater");
    mContext = newContext;
    mFactory = original.mFactory;
    mFactory2 = original.mFactory2;
    mPrivateFactory = original.mPrivateFactory;
    setFilter(original.mFilter);
    initPrecompiledViews();
}

但是如果我們想實現(xiàn)換膚,是不是也可自定義換膚的Factory來代替系統(tǒng)的Factory暂雹,以此實現(xiàn)我們想要的效果首装,e.g. 我們在XML布局中設(shè)置了一個TextView

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:id="@+id/cs_root"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <TextView
        android:id="@+id/tv_skin"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="開啟換膚"
        android:textColor="#000000"
        android:textStyle="bold"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>

我們通過自定義的Factory2,在onCreateView中創(chuàng)建一個Button替代TextView杭跪。

class MainActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
    // 代碼 - 5
    super.onCreate(savedInstanceState)
    val inflater = LayoutInflater.from(this)
    inflater.factory2 = object : LayoutInflater.Factory2 {
        override fun onCreateView(
            parent: View?,
            name: String,
            context: Context,
            attrs: AttributeSet
        ): View? {

            if (name == "TextView") {
                val button = Button(context)
                button.setText("換膚")
                return button
            }
            return null
        }

        override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? {
            return null
        }

    }
    val view = inflater.inflate(R.layout.layout_skin, findViewById(R.id.cs_root), false)
    setContentView(view)

}

但是運行之后仙逻,我們發(fā)現(xiàn)報錯了:

Caused by: java.lang.IllegalStateException: A factory has already been set on this LayoutInflater
        at android.view.LayoutInflater.setFactory2(LayoutInflater.java:314)
        at com.lay.learn.asm.MainActivity.onCreate(Unknown Source:22)

看報錯的意思是已經(jīng)設(shè)置了一個factory驰吓,不能重復(fù)設(shè)置。這行報錯信息系奉,我們在1.3開頭的代碼中就可以看到檬贰,有一個標(biāo)志位mFactorySet,如果mFactorySet = true缺亮,那么就直接報錯了翁涤,但是在LayoutInflater源碼中,只有在調(diào)用setFactory和setFactory2方法的時候萌踱,才會將其設(shè)置為true迷雪,那為什么還報錯呢?

代碼 - 5

既然只有在調(diào)用setFactory和setFactory2方法的時候虫蝶,才會設(shè)置mFactorySet為true章咧,那么原因只會有一個,就是重復(fù)調(diào)用能真。我們看下super.onCreate(saveInstanceState)做了什么赁严。

因為當(dāng)前Activity繼承了AppCompatActivity,在AppCompatActivity的構(gòu)造方法中調(diào)用了initDelegate方法粉铐。

@ContentView
public AppCompatActivity(@LayoutRes int contentLayoutId) {
    super(contentLayoutId);
    initDelegate();
}

private void initDelegate() {
    // TODO: Directly connect AppCompatDelegate to SavedStateRegistry
    getSavedStateRegistry().registerSavedStateProvider(DELEGATE_TAG,
            new SavedStateRegistry.SavedStateProvider() {
                @NonNull
                @Override
                public Bundle saveState() {
                    Bundle outState = new Bundle();
                    getDelegate().onSaveInstanceState(outState);
                    return outState;
                }
            });
    addOnContextAvailableListener(new OnContextAvailableListener() {
        @Override
        public void onContextAvailable(@NonNull Context context) {
            final AppCompatDelegate delegate = getDelegate();
            delegate.installViewFactory();
            delegate.onCreate(getSavedStateRegistry()
                    .consumeRestoredStateForKey(DELEGATE_TAG));
        }
    });
}

最終會調(diào)用AppCompatDelegateImpl的installViewFactory方法疼约。

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

在這個方法中,我們可以看到蝙泼,如果LayoutInflater獲取到factory為空程剥,那么就會調(diào)用setFactory2方法,這個時候mFactorySet = true汤踏,當(dāng)我們再次調(diào)用setContentView的時候织鲸,就直接報錯,所以我們需要在super.onCreate之前進(jìn)行換膚的操作溪胶。

當(dāng)然我們也可以通過反射的方式搂擦,在setFactory的時候?qū)FactorySet設(shè)置為false

1.4 小結(jié)

所以最終換膚的方案:通過Hook的形式哗脖,修改替代系統(tǒng)的Factory瀑踢,從而自行完成組件的實例化,達(dá)到與系統(tǒng)行為一致的效果才避。

代碼 - 4

如果有些View通過Factory沒有實例化的橱夭,此時view為空,那么會通過反射的方式來完成組件實例化桑逝,像一些帶包名的系統(tǒng)組件棘劣,或者自定義View。

2 換膚框架搭建

其實在搭建換膚框架的時候肢娘,我們肯定不可能對所有的控件都進(jìn)行換膚呈础,所以對于XML布局中的組件,我們需要進(jìn)行一次標(biāo)記橱健,那么標(biāo)記的手段有哪些呢而钞?

(1)創(chuàng)建一個接口,e.g. ISkinChange接口拘荡,然后重寫系統(tǒng)所有需要換膚的控件實現(xiàn)這個接口臼节,然后遍歷獲取XML中需要換膚的控件,進(jìn)行換膚珊皿,這個是一個方案网缝,但是成本比較高。

(2)自定義屬性蟋定,因為對于每個控件來說都有各自的屬性粉臊,如果我們通過自定義屬性的方式給每個需要換膚的控件加上這個屬性,在實例化View的時候就可以進(jìn)行區(qū)分驶兜。

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="Skinable">
        <attr name="isSupport" format="boolean"/>
    </declare-styleable>
</resources>

第一步:創(chuàng)建View并返回

這里我們創(chuàng)建了一個SkinFactory扼仲,實現(xiàn)了LayoutInflater.Factory2接口,這個類就是用于收集需要換膚的組件抄淑,并實現(xiàn)換膚的功能屠凶。

class SkinFactory : LayoutInflater.Factory2 {

    override fun onCreateView(
        parent: View?,
        name: String,
        context: Context,
        attrs: AttributeSet
    ): View? {

        //創(chuàng)建View

        //收集可以換膚的組件

        return null
    }

    override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? {
        return null
    }
}

首先在onCreateView中,需要創(chuàng)建一個View并返回肆资,我們看下系統(tǒng)是怎么完成的矗愧。

通過上面的截圖我們知道,通過AppCompatDelegate的實現(xiàn)類就能夠?qū)崿F(xiàn)view的創(chuàng)建郑原。

override fun onCreateView(
    parent: View?,
    name: String,
    context: Context,
    attrs: AttributeSet
): View? {

    //創(chuàng)建View
    val view = delegate.createView(parent, name, context, attrs)
    if (view == null) {
        //TODO 沒有創(chuàng)建成功唉韭,需要通過反射來創(chuàng)建
    }
    //收集可以換膚的組件
    if (view != null) {
        collectSkinComponent(attrs, context, view)
    }
    return view
}

/**
 * 收集能夠進(jìn)行換膚的控件
 */
private fun collectSkinComponent(attrs: AttributeSet, context: Context, view: View) {
    //獲取屬性
    val skinAbleAttr = context.obtainStyledAttributes(attrs, R.styleable.Skinable, 0, 0)
    val isSupportSkin = skinAbleAttr.getBoolean(R.styleable.Skinable_isSupport, false)
    if (isSupportSkin) {
        val attrsMap: MutableMap<String, String> = mutableMapOf()
        //收集起來
        for (index in 0 until attrs.attributeCount) {
            val name = attrs.getAttributeName(index)
            val value = attrs.getAttributeValue(index)
            attrsMap[name] = value
        }
        val skinView = SkinView(view, attrsMap)
        skinList.add(skinView)
    }

    skinAbleAttr.recycle()
}

所以我們在SkinFactory中傳入一個AppCompatDelegate的實現(xiàn)類,調(diào)用createView方法先創(chuàng)建一個View犯犁,如果這個view不為空纽哥,那么會收集每個View的屬性,看是否支持換膚栖秕。

收集能夠換膚的組件春塌,其實就是根據(jù)自定義屬性劃分,通過獲取View中自帶全部屬性判斷簇捍,如果支持換膚只壳,那么就存儲起來,這部分還是比較簡單的暑塑。

第二步:換膚邏輯與Activity基類抽取

如果我們想要進(jìn)行換膚吼句,例如更換背景、或者更換字體顏色等等事格,因此我們需要設(shè)置幾個換膚的類型如下:

sealed class SkinType{
    /**
     * 更換背景顏色
     * @param color 背景顏色
     */
    class BackgroundSkin(val color:Int):SkinType()

    /**
     * 更換背景圖片
     * @param drawable 背景圖片資源id
     */
    class BackgroundDrawableSkin(val drawable:Int):SkinType()

    /**
     * 更換字體顏色
     * @param color 字體顏色
     * NOTE 這個只能TextView才能是用
     */
    class TextColorSkin(val color: Int):SkinType()

    /**
     * 更換字體類型
     * @param textStyle 字體型號
     * NOTE 這個只能TextView才能是用
     */
    class TextStyleSkin(val textStyle: Typeface):SkinType()
}

當(dāng)開啟換膚之后惕艳,需要遍歷skinList中支持換膚的控件搞隐,然后根據(jù)SkinType來對對應(yīng)的控件設(shè)置屬性,例如TextStyleSkin這類換膚類型远搪,只能對TextView生效劣纲,因此需要根據(jù)view的類型來進(jìn)行屬性設(shè)置。

/**
 * 一鍵換膚
 */
fun changedSkin(vararg skinType: SkinType) {
    Log.e("TAG","skinList $skinList")
    skinList.forEach { skinView ->
        changedSkinInner(skinView, skinType)
    }
}

/**
 * 換膚的內(nèi)部實現(xiàn)類
 */
private fun changedSkinInner(skinView: SkinView, skinType: Array<out SkinType>) {
    skinType.forEach { type ->
        Log.e("TAG", "changedSkinInner $type")
        when (type) {
            is SkinType.BackgroundSkin -> {
                skinView.view.setBackgroundColor(type.color)
            }

            is SkinType.BackgroundDrawableSkin -> {
                skinView.view.setBackgroundResource(type.drawable)
            }

            is SkinType.TextStyleSkin -> {
                if (skinView.view is TextView) {
                    //只有TextView可以換
                    skinView.view.typeface = type.textStyle
                }
            }

            is SkinType.TextColorSkin -> {
                if (skinView.view is TextView) {
                    //只有TextView可以換
                    skinView.view.setTextColor(type.color)
                }
            }
        }
    }
}

所以針對換膚的需求谁鳍,我們可以抽出一個抽象的Activity基類癞季,叫做SkinActivity。

abstract class SkinActivity : AppCompatActivity() {

    private lateinit var skinFactory: SkinFactory

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Log.e("TAG", "onCreate")
        val inflate = LayoutInflater.from(this)
        //恢復(fù)標(biāo)志位
        resetmFactorySet(inflate)
        //開啟換膚模式
        skinFactory = SkinFactory(delegate)
        inflate.factory2 = skinFactory
        setContentView(inflate.inflate(getLayoutId(), getViewRoot(), false))
        initView()
    }

    open fun initView() {

    }

    protected fun changedSkin(vararg skinType: SkinType) {
        Log.e("TAG", "changedSkin")
        skinFactory.changedSkin(*skinType)
    }

    @SuppressLint("SoonBlockedPrivateApi")
    private fun resetmFactorySet(instance: LayoutInflater) {
        val mFactorySetField = LayoutInflater::class.java.getDeclaredField("mFactorySet")
        mFactorySetField.isAccessible = true
        mFactorySetField.set(instance, false)
    }

    abstract fun getLayoutId(): Int
    abstract fun getViewRoot(): ViewGroup?
}

在onCreate方法中倘潜,主要就是進(jìn)行Factory的設(shè)置绷柒,這里就是我們前面提到的SkinFactory(實現(xiàn)了Factory2接口),然后定義了一個方法changedSkin涮因,在任意子類中都可以調(diào)用废睦。

class SkinChangeActivity : SkinActivity() {

    override fun initView() {
        findViewById<Button>(R.id.btn_skin).setOnClickListener {
            Toast.makeText(this,"更換背景",Toast.LENGTH_SHORT).show()
            changedSkin(
                SkinType.BackgroundSkin(Color.parseColor("#B81A1A"))
            )
        }
        findViewById<Button>(R.id.btn_skin_textColor).setOnClickListener {
            Toast.makeText(this,"更換字體顏色",Toast.LENGTH_SHORT).show()
            changedSkin(
                SkinType.TextColorSkin(Color.parseColor("#FFEB3B")),
                SkinType.BackgroundSkin(Color.WHITE)
            )
        }
        findViewById<Button>(R.id.btn_skin_textStyle).setOnClickListener {
            Toast.makeText(this,"更換字體樣式",Toast.LENGTH_SHORT).show()
            changedSkin(
                SkinType.TextStyleSkin(Typeface.DEFAULT_BOLD),
            )
        }
    }

    override fun getLayoutId(): Int {
        return R.layout.activity_skin_change
    }

    override fun getViewRoot(): ViewGroup? {
        return findViewById(R.id.cs_root)
    }

}

具體的效果可以看下圖:


其實這里只是實現(xiàn)了一個簡單的換膚效果,其實在業(yè)務(wù)代碼中养泡,可能存在上千個View郊楣,那么通過這種方式就能夠避免給每個View都去設(shè)置一遍背景、字體顏色......關(guān)鍵還是在于原理的理解瓤荔,其實真正的換膚現(xiàn)在主流的都是插件化換膚净蚤,通過下載皮膚包自動配置到App中,后續(xù)我們就會介紹插件化換膚的核心思想输硝。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末今瀑,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子点把,更是在濱河造成了極大的恐慌橘荠,老刑警劉巖,帶你破解...
    沈念sama閱讀 218,525評論 6 507
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件郎逃,死亡現(xiàn)場離奇詭異哥童,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)褒翰,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,203評論 3 395
  • 文/潘曉璐 我一進(jìn)店門贮懈,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人优训,你說我怎么就攤上這事朵你。” “怎么了揣非?”我有些...
    開封第一講書人閱讀 164,862評論 0 354
  • 文/不壞的土叔 我叫張陵抡医,是天一觀的道長。 經(jīng)常有香客問我早敬,道長忌傻,這世上最難降的妖魔是什么大脉? 我笑而不...
    開封第一講書人閱讀 58,728評論 1 294
  • 正文 為了忘掉前任,我火速辦了婚禮水孩,結(jié)果婚禮上镰矿,老公的妹妹穿的比我還像新娘。我一直安慰自己荷愕,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 67,743評論 6 392
  • 文/花漫 我一把揭開白布棍矛。 她就那樣靜靜地躺著安疗,像睡著了一般。 火紅的嫁衣襯著肌膚如雪够委。 梳的紋絲不亂的頭發(fā)上荐类,一...
    開封第一講書人閱讀 51,590評論 1 305
  • 那天,我揣著相機(jī)與錄音茁帽,去河邊找鬼玉罐。 笑死,一個胖子當(dāng)著我的面吹牛潘拨,可吹牛的內(nèi)容都是我干的吊输。 我是一名探鬼主播,決...
    沈念sama閱讀 40,330評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼铁追,長吁一口氣:“原來是場噩夢啊……” “哼季蚂!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起琅束,我...
    開封第一講書人閱讀 39,244評論 0 276
  • 序言:老撾萬榮一對情侶失蹤扭屁,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后涩禀,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體料滥,經(jīng)...
    沈念sama閱讀 45,693評論 1 314
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,885評論 3 336
  • 正文 我和宋清朗相戀三年艾船,在試婚紗的時候發(fā)現(xiàn)自己被綠了葵腹。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,001評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡屿岂,死狀恐怖礁蔗,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情雁社,我是刑警寧澤浴井,帶...
    沈念sama閱讀 35,723評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站霉撵,受9級特大地震影響磺浙,放射性物質(zhì)發(fā)生泄漏洪囤。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,343評論 3 330
  • 文/蒙蒙 一撕氧、第九天 我趴在偏房一處隱蔽的房頂上張望瘤缩。 院中可真熱鬧,春花似錦伦泥、人聲如沸剥啤。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,919評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽府怯。三九已至,卻和暖如春防楷,著一層夾襖步出監(jiān)牢的瞬間牺丙,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,042評論 1 270
  • 我被黑心中介騙來泰國打工复局, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留冲簿,地道東北人。 一個月前我還...
    沈念sama閱讀 48,191評論 3 370
  • 正文 我出身青樓亿昏,卻偏偏與公主長得像峦剔,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子角钩,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,955評論 2 355

推薦閱讀更多精彩內(nèi)容