Android進階寶典 -- 使用Hook技術攔截系統例項化View過程實現App換膚功能
對於換膚技術,相信夥伴們都見過一些大型app,到了某些節日或者活動,e.g. 雙十一、雙十二、春節等等,app的ICON還有內部的頁面主題背景都被換成對應的面板,像這種換膚肯定不是為了某個活動單獨發一個版本,這樣的話就太雞肋了,很多大廠都有自己的換膚技術,不需要通過發版就可以實時換膚,活動結束之後自動下掉,所以有哪些資源可以通過換膚來進行切換的呢?
其實在Android的res目錄下所有資源都可以進行換膚,像圖片、文字顏色、字型、背景等都可以通過換膚來進行無卡頓切換,那麼究竟如何才能高效穩定地實現換膚,我們需要對於View的生命週期以及載入流程有一定的認識。
1 XML佈局的解析流程
如果沒有使用Compose,我們現階段的Android開發佈局依然是在XML檔案中,如下所示: ```xml
<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"/>
```
所以如果想要改變字型顏色,就需要動態修改textColor屬性;如果想改變背景,就需要修改background屬性;當一個Activity想要載入某個佈局檔案的時候,就需要呼叫setContentView方法,例項化View;
kotlin
setContentView(R.layout.activity_main)
那麼我們是否能夠改變系統載入佈局檔案的邏輯,讓其載入我們自己的面板包,那這樣是不是就能夠實現動態換膚?
1.1 setContentView原始碼分析
我這邊看的是Android 11的原始碼,算是比較新的了吧,夥伴們可以跟著看一下。
java
@Override
public void setContentView(@LayoutRes int layoutResID) {
initViewTreeOwners();
getDelegate().setContentView(layoutResID);
}
一般情況下,我們傳入的就是一個佈局id,內部實現是呼叫了AppCompatDelegate實現類的setContentView方法,AppCompatDelegate是一個抽象類,它的實現類為AppCompatDelegateImpl。
java
@NonNull
public AppCompatDelegate getDelegate() {
if (mDelegate == null) {
mDelegate = AppCompatDelegate.create(this, this);
}
return mDelegate;
}
所以我們看下AppCompatDelegateImpl的setContentView方法。
java
@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());
}
首先呼叫了ensureSubDecor方法,這裡我就不細說了,這個方法的目的就是保證DecorView建立成功,我們看下這個圖,佈局的層級是這樣的。
我們所有的自定義佈局,都是載入在DecorView這個容器上,我們看下面這個佈局:
```xml
<!--佈局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>
```
看佈局你可能會覺得,這個是啥?這個是系統appcompat包中的一個佈局檔案,名字為adb_screen_toolbar.xml,當我們新建一個app專案的時候,見到的第一個頁面,如下圖所示
紅框展示的佈局就是上面這個XML,也就是DecorView載入的佈局檔案R.layout.adb_screen_toolbar.xml。 ```java 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的載入,因為設定不同主題就會載入不同的XML,這裡我不做過多的講解,因為主要目標是換膚,但是上面這段程式碼需要關注一下,就是DecorView佈局加載出來之後,獲取了include中的id為action_bar_activity_content的容器,將其id替換成了content。
我們再回到setContentView方法中,我們看又是通過mSubDecor獲取到了content這個id對應的容器,通過Inflate的形式將我們的佈局載入到這個容器當中,所以核心點就是Inflate是如何載入並例項化View的。
1.2 LayoutInflater原始碼分析
我們換膚的重點就是對於LayoutInflater原始碼的分析,尤其是inflate方法,直接返回了一個View。 ```java 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;
}
// 這裡是進行XML佈局解析
XmlResourceParser parser = res.getLayout(resource);
try {
return inflate(parser, root, attachToRoot);
} finally {
parser.close();
}
}
首先是通過XmlParser工具進行佈局解析,這部分就不講了沒有意義,重點看下面的程式碼實現:
java
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;
}
} ``` 夥伴們從程式碼中標記的tag,自行找對應的程式碼講解
程式碼 - 1
前面我們通過XML佈局解析,拿到了佈局檔案中的資訊,這個name其實就是我們在XML中寫的控制元件的名稱,例如TextView、Button、LinearLayout、include、merge......
如果是merge標籤的話,跟其他控制元件走的渲染方式不一樣,我們重點看 程式碼-2 中的實現。
程式碼 - 2
這裡有一個核心方法,createViewFromTag,最終返回了一個View,這裡就包含系統建立並例項化View的祕密。 ```java 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的返回值,當然也有可能建立失敗,最終走到 程式碼-4中,但我們先看下這個方法。
```java 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;
} ``` 在這個方法中,我們看到建立View,其實是通過兩個Factory,分別是:mFactory2和mFactory,通過呼叫它們的onCreateView方法進行View的例項化,如果這兩個Factory都沒有設定,那麼最終返回的view = null;當然後面也有一個兜底策略,如果view = null,但是mPrivateFactory(其實也是Factory2)不為空,也可以通過mPrivateFactory建立。
1.3 Factory介面
在前面我們提到兩個成員變數,分別是:mFactory2和mFactory,這兩個變數是LayoutInflater中的成員變數,我們看下是在setFactory和setFactory2中進行賦值的。
```java 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);
}
}
我們系統在進行佈局解析的時候,肯定也是設定了自己的Factory,這樣的話就直接走系統的初始化流程;
java
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();
}
但是如果我們想實現換膚,是不是也可自定義換膚的Factory來代替系統的Factory,以此實現我們想要的效果,e.g. 我們在XML佈局中設定了一個TextView
xml
<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"/>
``` 我們通過自定義的Factory2,在onCreateView中建立一個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)
} ``` 但是執行之後,我們發現報錯了:
java
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)
看報錯的意思是已經設定了一個factory,不能重複設定。這行報錯資訊,我們在1.3開頭的程式碼中就可以看到,有一個標誌位mFactorySet,如果mFactorySet = true,那麼就直接報錯了,但是在LayoutInflater原始碼中,只有在呼叫setFactory和setFactory2方法的時候,才會將其設定為true,那為什麼還報錯呢?
程式碼 - 5
既然只有在呼叫setFactory和setFactory2方法的時候,才會設定mFactorySet為true,那麼原因只會有一個,就是重複呼叫。我們看下super.onCreate(saveInstanceState)做了什麼。
因為當前Activity繼承了AppCompatActivity,在AppCompatActivity的構造方法中呼叫了initDelegate方法。
```java @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));
}
});
}
最終會呼叫AppCompatDelegateImpl的installViewFactory方法。
java
@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為空,那麼就會呼叫setFactory2方法,這個時候mFactorySet = true,當我們再次呼叫setContentView的時候,就直接報錯,所以我們需要在super.onCreate之前進行換膚的操作。
當然我們也可以通過反射的方式,在setFactory的時候將mFactorySet設定為false。
1.4 小結
所以最終換膚的方案:通過Hook的形式,修改替代系統的Factory,從而自行完成元件的例項化,達到與系統行為一致的效果。
程式碼 - 4
如果有些View通過Factory沒有例項化的,此時view為空,那麼會通過反射的方式來完成元件例項化,像一些帶包名的系統元件,或者自定義View。
2 換膚框架搭建
其實在搭建換膚框架的時候,我們肯定不可能對所有的控制元件都進行換膚,所以對於XML佈局中的元件,我們需要進行一次標記,那麼標記的手段有哪些呢?
(1)建立一個介面,e.g. ISkinChange介面,然後重寫系統所有需要換膚的控制元件實現這個介面,然後遍歷獲取XML中需要換膚的控制元件,進行換膚,這個是一個方案,但是成本比較高。
(2)自定義屬性,因為對於每個控制元件來說都有各自的屬性,如果我們通過自定義屬性的方式給每個需要換膚的控制元件加上這個屬性,在例項化View的時候就可以進行區分。
```xml
第一步:建立View並返回
這裡我們建立了一個SkinFactory,實現了LayoutInflater.Factory2介面,這個類就是用於收集需要換膚的元件,並實現換膚的功能。
```kotlin class SkinFactory : LayoutInflater.Factory2 {
override fun onCreateView(
parent: View?,
name: String,
context: Context,
attrs: AttributeSet
): View? {
//建立View
//收集可以換膚的元件
return null
}
override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? {
return null
}
} ```
首先在onCreateView中,需要建立一個View並返回,我們看下系統是怎麼完成的。
通過上面的截圖我們知道,通過AppCompatDelegate的實現類就能夠實現view的建立。
```kotlin override fun onCreateView( parent: View?, name: String, context: Context, attrs: AttributeSet ): View? {
//建立View
val view = delegate.createView(parent, name, context, attrs)
if (view == null) {
//TODO 沒有建立成功,需要通過反射來建立
}
//收集可以換膚的元件
if (view != null) {
collectSkinComponent(attrs, context, view)
}
return view
}
/*
* 收集能夠進行換膚的控制元件
/
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
skinAbleAttr.recycle()
} ``` 所以我們在SkinFactory中傳入一個AppCompatDelegate的實現類,呼叫createView方法先建立一個View,如果這個view不為空,那麼會收集每個View的屬性,看是否支援換膚。
收集能夠換膚的元件,其實就是根據自定義屬性劃分,通過獲取View中自帶全部屬性判斷,如果支援換膚,那麼就儲存起來,這部分還是比較簡單的。
第二步:換膚邏輯與Activity基類抽取
如果我們想要進行換膚,例如更換背景、或者更換字型顏色等等,因此我們需要設定幾個換膚的型別如下: ```kotlin 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()
}
當開啟換膚之後,需要**遍歷skinList中支援換膚的控制元件,然後根據SkinType來對對應的控制元件設定屬性**,例如TextStyleSkin這類換膚型別,只能對TextView生效,因此需要根據view的型別來進行屬性設定。
kotlin
/*
* 一鍵換膚
/
fun changedSkin(vararg skinType: SkinType) {
Log.e("TAG","skinList $skinList")
skinList.forEach { skinView ->
changedSkinInner(skinView, skinType)
}
}
/*
* 換膚的內部實現類
/
private fun changedSkinInner(skinView: SkinView, skinType: Array
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。
```kotlin 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)
//恢復標誌位
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方法中,主要就是進行Factory的設定,這裡就是我們前面提到的SkinFactory(實現了Factory2介面),然後定義了一個方法changedSkin,在任意子類中都可以呼叫。
```kotlin 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)
}
} ``` 具體的效果可以看動圖:
其實這裡只是實現了一個簡單的換膚效果,其實在業務程式碼中,可能存在上千個View,那麼通過這種方式就能夠避免給每個View都去設定一遍背景、字型顏色......關鍵還是在於原理的理解,其實真正的換膚現在主流的都是外掛化換膚,通過下載面板包自動配置到App中,後續我們就會介紹外掛化換膚的核心思想。