相信大家也發(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ù)我們就會介紹插件化換膚的核心思想输硝。