Android進(jìn)階Hook攔截系統(tǒng)實(shí)例化View過程實(shí)現(xiàn)App換膚功能
正文
對(duì)于換膚技術(shù),相信伙伴們都見過一些大型app,到了某些節(jié)日或者活動(dòng),e.g. 雙十一、雙十二、春節(jié)等等,app的ICON還有內(nèi)部的頁面主題背景都被換成對(duì)應(yīng)的皮膚,像這種換膚肯定不是為了某個(gè)活動(dòng)單獨(dú)發(fā)一個(gè)版本,這樣的話就太雞肋了,很多大廠都有自己的換膚技術(shù),不需要通過發(fā)版就可以實(shí)時(shí)換膚,活動(dòng)結(jié)束之后自動(dòng)下掉,所以有哪些資源可以通過換膚來進(jìn)行切換的呢?
其實(shí)在Android的res目錄下所有資源都可以進(jìn)行換膚,像圖片、文字顏色、字體、背景等都可以通過換膚來進(jìn)行無卡頓切換,那么究竟如何才能高效穩(wěn)定地實(shí)現(xiàn)換膚,我們需要對(duì)于View的生命周期以及加載流程有一定的認(rèn)識(shí)。
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>
所以如果想要改變字體顏色,就需要?jiǎng)討B(tài)修改textColor屬性;如果想改變背景,就需要修改background屬性;當(dāng)一個(gè)Activity想要加載某個(gè)布局文件的時(shí)候,就需要調(diào)用setContentView方法,實(shí)例化View;
setContentView(R.layout.activity_main)
那么我們是否能夠改變系統(tǒng)加載布局文件的邏輯,讓其加載我們自己的皮膚包,那這樣是不是就能夠?qū)崿F(xiàn)動(dòng)態(tài)換膚?
<>1.1 setContentView源碼分析
我這邊看的是Android 11的源碼,算是比較新的了吧,伙伴們可以跟著看一下。
@Override
public void setContentView(@LayoutRes int layoutResID) {
initViewTreeOwners();
getDelegate().setContentView(layoutResID);
}
一般情況下,我們傳入的就是一個(gè)布局id,內(nèi)部實(shí)現(xiàn)是調(diào)用了AppCompatDelegate實(shí)現(xiàn)類的setContentView方法,AppCompatDelegate是一個(gè)抽象類,它的實(shí)現(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ì)說了,這個(gè)方法的目的就是保證DecorView創(chuàng)建成功,我們看下這個(gè)圖,布局的層級(jí)是這樣的。

我們所有的自定義布局,都是加載在DecorView這個(gè)容器上,我們看下面這個(gè)布局:
<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>
看布局你可能會(huì)覺得,這個(gè)是啥?這個(gè)是系統(tǒng)appcompat包中的一個(gè)布局文件,名字為adb_screen_toolbar.xml,當(dāng)我們新建一個(gè)app項(xiàng)目的時(shí)候,見到的第一個(gè)頁面,如下圖所示

紅框展示的布局就是上面這個(gè)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);
}
}
對(duì)于DecorView的加載,因?yàn)樵O(shè)置不同主題就會(huì)加載不同的XML,這里我不做過多的講解,因?yàn)橹饕繕?biāo)是換膚,但是上面這段代碼需要關(guān)注一下,就是DecorView布局加載出來之后,獲取了include中的id為action_bar_activity_content的容器,將其id替換成了content。
我們?cè)倩氐絪etContentView方法中,我們看又是通過mSubDecor獲取到了content這個(gè)id對(duì)應(yīng)的容器,通過Inflate的形式將我們的布局加載到這個(gè)容器當(dāng)中,所以核心點(diǎn)就是Inflate是如何加載并實(shí)例化View的。
1.2 LayoutInflater源碼分析
我們換膚的重點(diǎn)就是對(duì)于LayoutInflater源碼的分析,尤其是inflate方法,直接返回了一個(gè)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)行布局解析,這部分就不講了沒有意義,重點(diǎn)看下面的代碼實(shí)現(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,自行找對(duì)應(yīng)的代碼講解
代碼 - 1
前面我們通過XML布局解析,拿到了布局文件中的信息,這個(gè)name其實(shí)就是我們?cè)赬ML中寫的控件的名稱,例如TextView、Button、LinearLayout、include、merge......
如果是merge標(biāo)簽的話,跟其他控件走的渲染方式不一樣,我們重點(diǎn)看 代碼-2 中的實(shí)現(xiàn)。
代碼 - 2
這里有一個(gè)核心方法,createViewFromTag,最終返回了一個(gè)View,這里就包含系統(tǒng)創(chuàng)建并實(shí)例化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
其實(shí)createViewFromTag這個(gè)方法中,最終的一個(gè)方法就是tryCreateView,在這個(gè)方法中返回的View就是createViewFromTag的返回值,當(dāng)然也有可能創(chuàng)建失敗,最終走到 代碼-4中,但我們先看下這個(gè)方法。
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;
}
在這個(gè)方法中,我們看到創(chuàng)建View,其實(shí)是通過兩個(gè)Factory,分別是:mFactory2和mFactory,通過調(diào)用它們的onCreateView方法進(jìn)行View的實(shí)例化,如果這兩個(gè)Factory都沒有設(shè)置,那么最終返回的view = null;當(dāng)然后面也有一個(gè)兜底策略,如果view = null,但是mPrivateFactory(其實(shí)也是Factory2)不為空,也可以通過mPrivateFactory創(chuàng)建。
1.3 Factory接口
在前面我們提到兩個(gè)成員變量,分別是:mFactory2和mFactory,這兩個(gè)變量是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í)候,肯定也是設(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();
}
但是如果我們想實(shí)現(xiàn)換膚,是不是也可自定義換膚的Factory來代替系統(tǒng)的Factory,以此實(shí)現(xiàn)我們想要的效果,e.g. 我們?cè)赬ML布局中設(shè)置了一個(gè)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)建一個(gè)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)
}
但是運(yùn)行之后,我們發(fā)現(xiàn)報(bào)錯(cuò)了:
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)
看報(bào)錯(cuò)的意思是已經(jīng)設(shè)置了一個(gè)factory,不能重復(fù)設(shè)置。這行報(bào)錯(cuò)信息,我們?cè)?.3開頭的代碼中就可以看到,有一個(gè)標(biāo)志位mFactorySet,如果mFactorySet = true,那么就直接報(bào)錯(cuò)了,但是在LayoutInflater源碼中,只有在調(diào)用setFactory和setFactory2方法的時(shí)候,才會(huì)將其設(shè)置為true,那為什么還報(bào)錯(cuò)呢?
代碼 - 5
既然只有在調(diào)用setFactory和setFactory2方法的時(shí)候,才會(huì)設(shè)置mFactorySet為true,那么原因只會(huì)有一個(gè),就是重復(fù)調(diào)用。我們看下super.onCreate(saveInstanceState)做了什么。
因?yàn)楫?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));
}
});
}
最終會(huì)調(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");
}
}
}
在這個(gè)方法中,我們可以看到,如果LayoutInflater獲取到factory為空,那么就會(huì)調(diào)用setFactory2方法,這個(gè)時(shí)候mFactorySet = true,當(dāng)我們?cè)俅握{(diào)用setContentView的時(shí)候,就直接報(bào)錯(cuò),所以我們需要在super.onCreate之前進(jìn)行換膚的操作。
當(dāng)然我們也可以通過反射的方式,在setFactory的時(shí)候?qū)FactorySet設(shè)置為false。
1.4 小結(jié)
所以最終換膚的方案:通過Hook的形式,修改替代系統(tǒng)的Factory,從而自行完成組件的實(shí)例化,達(dá)到與系統(tǒng)行為一致的效果。
代碼 - 4
如果有些View通過Factory沒有實(shí)例化的,此時(shí)view為空,那么會(huì)通過反射的方式來完成組件實(shí)例化,像一些帶包名的系統(tǒng)組件,或者自定義View。
2 換膚框架搭建
其實(shí)在搭建換膚框架的時(shí)候,我們肯定不可能對(duì)所有的控件都進(jìn)行換膚,所以對(duì)于XML布局中的組件,我們需要進(jìn)行一次標(biāo)記,那么標(biāo)記的手段有哪些呢?
(1)創(chuàng)建一個(gè)接口,e.g. ISkinChange接口,然后重寫系統(tǒng)所有需要換膚的控件實(shí)現(xiàn)這個(gè)接口,然后遍歷獲取XML中需要換膚的控件,進(jìn)行換膚,這個(gè)是一個(gè)方案,但是成本比較高。
(2)自定義屬性,因?yàn)閷?duì)于每個(gè)控件來說都有各自的屬性,如果我們通過自定義屬性的方式給每個(gè)需要換膚的控件加上這個(gè)屬性,在實(shí)例化View的時(shí)候就可以進(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)建了一個(gè)SkinFactory,實(shí)現(xiàn)了LayoutInflater.Factory2接口,這個(gè)類就是用于收集需要換膚的組件,并實(shí)現(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中,需要?jiǎng)?chuàng)建一個(gè)View并返回,我們看下系統(tǒng)是怎么完成的。

通過上面的截圖我們知道,通過AppCompatDelegate的實(shí)現(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()
}
所以我們?cè)赟kinFactory中傳入一個(gè)AppCompatDelegate的實(shí)現(xiàn)類,調(diào)用createView方法先創(chuàng)建一個(gè)View,如果這個(gè)view不為空,那么會(huì)收集每個(gè)View的屬性,看是否支持換膚。
收集能夠換膚的組件,其實(shí)就是根據(jù)自定義屬性劃分,通過獲取View中自帶全部屬性判斷,如果支持換膚,那么就存儲(chǔ)起來,這部分還是比較簡(jiǎn)單的。
第二步:換膚邏輯與Activity基類抽取
如果我們想要進(jìn)行換膚,例如更換背景、或者更換字體顏色等等,因此我們需要設(shè)置幾個(gè)換膚的類型如下:
sealed class SkinType{
/**
* 更換背景顏色
* @param color 背景顏色
*/
class BackgroundSkin(val color:Int):SkinType()
/**
* 更換背景圖片
* @param drawable 背景圖片資源id
*/
class BackgroundDrawableSkin(val drawable:Int):SkinType()
/**
* 更換字體顏色
* @param color 字體顏色
* NOTE 這個(gè)只能TextView才能是用
*/
class TextColorSkin(val color: Int):SkinType()
/**
* 更換字體類型
* @param textStyle 字體型號(hào)
* NOTE 這個(gè)只能TextView才能是用
*/
class TextStyleSkin(val textStyle: Typeface):SkinType()
}
當(dāng)開啟換膚之后,需要遍歷skinList中支持換膚的控件,然后根據(jù)SkinType來對(duì)對(duì)應(yīng)的控件設(shè)置屬性,例如TextStyleSkin這類換膚類型,只能對(duì)TextView生效,因此需要根據(jù)view的類型來進(jìn)行屬性設(shè)置。
/**
* 一鍵換膚
*/
fun changedSkin(vararg skinType: SkinType) {
Log.e("TAG","skinList $skinList")
skinList.forEach { skinView ->
changedSkinInner(skinView, skinType)
}
}
/**
* 換膚的內(nèi)部實(shí)現(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)
}
}
}
}
}
所以針對(duì)換膚的需求,我們可以抽出一個(gè)抽象的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(實(shí)現(xiàn)了Factory2接口),然后定義了一個(gè)方法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)
}
}
具體的效果可以看動(dòng)圖:

其實(shí)這里只是實(shí)現(xiàn)了一個(gè)簡(jiǎn)單的換膚效果,其實(shí)在業(yè)務(wù)代碼中,可能存在上千個(gè)View,那么通過這種方式就能夠避免給每個(gè)View都去設(shè)置一遍背景、字體顏色......關(guān)鍵還是在于原理的理解,其實(shí)真正的換膚現(xiàn)在主流的都是插件化換膚,通過下載皮膚包自動(dòng)配置到App中,后續(xù)我們就會(huì)介紹插件化換膚的核心思想。
以上就是Android進(jìn)階Hook攔截系統(tǒng)實(shí)例化View過程實(shí)現(xiàn)App換膚功能的詳細(xì)內(nèi)容,更多關(guān)于Android Hook攔截View換膚的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
詳解android 人臉檢測(cè)你一定會(huì)遇到的坑
這篇文章主要介紹了詳解android 人臉檢測(cè)你一定會(huì)遇到的坑,小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2017-11-11
Android在一個(gè)app中安裝并卸載另一個(gè)app的示例代碼
這篇文章主要介紹了Android在一個(gè)app中安裝并卸載另一個(gè)app的示例代碼,本文給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2021-03-03
android獲取附近藍(lán)牙設(shè)備并計(jì)算距離的實(shí)例代碼
下面小編就為大家分享一篇android獲取附近藍(lán)牙設(shè)備并計(jì)算距離的實(shí)例代碼,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來看看吧2018-01-01
Android實(shí)現(xiàn)歌曲播放時(shí)歌詞同步顯示具體思路
歌曲播放時(shí)歌詞同步顯示,我們需要讀取以上歌詞文件的每一行轉(zhuǎn)換成成一個(gè)個(gè)歌詞實(shí)體,可根據(jù)當(dāng)前播放器的播放進(jìn)度與每句歌詞的開始時(shí)間,得到當(dāng)前屏幕中央高亮顯示的那句歌詞2013-06-06
Android打造屬于自己的新聞平臺(tái)(客戶端+服務(wù)器)
這篇文章主要為大家詳細(xì)介紹了Android打造屬于自己的新聞平臺(tái)的相關(guān)資料,Android實(shí)現(xiàn)新聞客戶端服務(wù)器,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2016-06-06
Android給scrollView截圖超過屏幕大小形成長圖
這篇文章主要為大家詳細(xì)介紹了Android給scrollView截圖超過屏幕大小形成長圖,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-12-12
Android開發(fā)自學(xué)筆記(一):Hello,world!
這篇文章主要介紹了Android開發(fā)自學(xué)筆記(一):Hello,world!本文講解了創(chuàng)建HelloWorld工程、編寫代碼、啟動(dòng)模擬器等步驟,需要的朋友可以參考下2015-04-04

