原創(chuàng) @shhp 轉(zhuǎn)載請注明作者和出處
前記
對于一個Android開發(fā),時不時會有這樣的需求:想知道一個頁面上的某些view元素是由哪些xml布局資源文件加載而來。如果這個頁面是你開發(fā)的嘉熊,那你應(yīng)該很熟悉這其中涉及到的xml文件胧卤,你可以快速準(zhǔn)確地找到它們。如果頁面不是你開發(fā)的呢烈炭?幸運(yùn)的話榜掌,你剛好認(rèn)識相關(guān)的開發(fā)优妙,而且TA的記性比較好,你可以直接詢問TA資源文件名憎账。然而現(xiàn)實是大多數(shù)情況下套硼,你需要自己動手。尋找相關(guān)xml文件的過程并不總是簡單省時的胞皱,于是我想能不能找到方法解決這個小小的痛點(diǎn)邪意。
一開始我嘗試使用AnnotationProcessor
來做文本解析,但會遺漏很多情況(因為我只解析標(biāo)注了@Override
的函數(shù))反砌。也想過是否可以通過AOP或者Android Studio插件的方式來實現(xiàn)抄罕,但這些方法太復(fù)雜了,性價比不高于颖。
后續(xù)的調(diào)研的過程中,我在StackOverflow上搜到了這樣一個問題嚷兔。
看來這個問題對這位朋友是一個很大的痛點(diǎn)森渐。其中的一個回答給了我很大啟發(fā)做入。
該回答提到的ResourceInspector采用的方法是替換Activity
本身的LayoutInflater
,并利用了Facebook開源的調(diào)試神器Stetho來展示當(dāng)前Activity
涉及的xml布局資源文件同衣。
但是這樣一來就得引入一個新的庫竟块。有沒有更加簡便優(yōu)雅的方式?經(jīng)過探索耐齐,我找到了方法可以在Layout Inspector的截屏里直接查看view是從哪個xml加載而來的浪秘。
實現(xiàn)
實現(xiàn)這個功能的核心是要用一個代理LayoutInflater
替換Activity
本身的LayoutInflater
. 首先創(chuàng)建一個代理LayoutInflater
的類命名為LayoutIndicatorInflater
.
public class LayoutIndicatorInflater extends LayoutInflater {
private LayoutInflater mOriginalInflater;
private String mAppPackageName;
protected LayoutIndicatorInflater(LayoutInflater original, Context newContext) {
super(original, newContext);
mOriginalInflater = original;
mAppPackageName = getContext().getPackageName();
}
@Override
public LayoutInflater cloneInContext(Context newContext) {
return new LayoutIndicatorInflater(mOriginalInflater.cloneInContext(newContext), newContext);
}
@Override
public void setFactory(Factory factory) {
super.setFactory(factory);
mOriginalInflater.setFactory(factory);
}
@Override
public void setFactory2(Factory2 factory) {
super.setFactory2(factory);
mOriginalInflater.setFactory2(factory);
}
@Override
public View inflate(int resourceId, ViewGroup root, boolean attachToRoot) {
Resources res = getContext().getResources();
String packageName = "";
try {
packageName = res.getResourcePackageName(resourceId);
} catch (Exception e) {}
String resName = "";
try {
resName = res.getResourceEntryName(resourceId);
} catch (Exception e) {}
View view = mOriginalInflater.inflate(resourceId, root, attachToRoot);
if (!mAppPackageName.equals(packageName)) {
return view;
}
View targetView = view;
if (root != null && attachToRoot) {
targetView = root.getChildAt(root.getChildCount() - 1);
}
targetView.setContentDescription("資源文件名:" + resName);
if (targetView instanceof ViewGroup) {
ViewGroup viewGroup = (ViewGroup) targetView;
for (int i = 0; i < viewGroup.getChildCount(); i++) {
View child = viewGroup.getChildAt(i);
if (TextUtils.isEmpty(child.getContentDescription())) {
child.setContentDescription("資源文件名:" + resName);
}
}
}
return view;
}
}
LayoutIndicatorInflater
的構(gòu)造函數(shù)需要兩個參數(shù)LayoutInflater original, Context newContext
,其中original
就是Activity
本身的LayoutInflater
埠况,我們要用它來做實際的加載xml的工作耸携。
主要來看看關(guān)鍵的inflate
函數(shù)。
Resources res = getContext().getResources();
String packageName = "";
try {
packageName = res.getResourcePackageName(resourceId);
} catch (Exception e) {}
String resName = "";
try {
resName = res.getResourceEntryName(resourceId);
} catch (Exception e) {}
這一段做了兩件事:第一拿到參數(shù)resourceId
所在的包名辕翰,第二拿到resourceId
對應(yīng)的資源文件名夺衍。取包名是因為我只關(guān)心自己應(yīng)用的xml,后面會根據(jù)這個包名做一個過濾處理喜命。
View view = mOriginalInflater.inflate(resourceId, root, attachToRoot);
接著直接調(diào)用mOriginalInflater
的inflate
函數(shù)來加載xml沟沙。到這里就可以明白為什么LayoutIndicatorInflater
只是一個代理了。LayoutIndicatorInflater
只是攔截了頁面里的inflate
函數(shù)調(diào)用壁榕,記錄下我們關(guān)心的xml資源文件名矛紫。真正加載xml的工作還是交給Activity
本身的LayoutInflater
.
if (!mAppPackageName.equals(packageName)) {
return view;
}
這個if
語句就是前面所說用來過濾包名的。
View targetView = view;
if (root != null && attachToRoot) {
targetView = root.getChildAt(root.getChildCount() - 1);
}
targetView.setContentDescription("資源文件名:" + resName);
這里的targetView
就是xml里的根元素∨评铮現(xiàn)在面臨的問題是:把資源文件名這個信息記錄在哪里颊咬?又要如何呈現(xiàn)?當(dāng)然這里可以直接輸出一條log二庵。但是當(dāng)頁面比較復(fù)雜時贪染,log就會令人眼花繚亂。經(jīng)過嘗試我發(fā)現(xiàn)view的ContentDescription
屬性可以直接在Layout Inspector的截屏里面展示催享,而且把資源文件名設(shè)置到ContentDescription
也不會影響程序的邏輯杭隙。
if (targetView instanceof ViewGroup) {
ViewGroup viewGroup = (ViewGroup) targetView;
for (int i = 0; i < viewGroup.getChildCount(); i++) {
View child = viewGroup.getChildAt(i);
if (TextUtils.isEmpty(child.getContentDescription())) {
child.setContentDescription("資源文件名:" + resName);
}
}
}
最后如果targetView
是一個ViewGroup
,那么將資源文件名也設(shè)置到targetView
所有第一級子view的ContentDescription
上因妙。
創(chuàng)建了代理LayoutInflater
之后痰憎,還要解決另一個關(guān)鍵問題:怎么用代理LayoutInflater
替換Activity
本身的LayoutInflater
. 要解決這個問題需要先弄明白Activity
本身的LayoutInflater
從何而來。一般而言加載xml有以下幾種方法:
Activity.setContentView(...)
LayoutInflater.from(context).inflate(...)
Activity.getLayoutInflater().inflate(...)
先看第一種情況攀涵。Activity.setContentView(...)
會調(diào)用PhoneWindow.setContentView(...)
铣耘,最后會調(diào)用PhoneWindow
中的成員mLayoutInflater
的inflate
方法。
對于第二種情況以故,假定參數(shù)context
是一個Activity
. LayoutInflater.from(context)
返回的是context.getSystemService(Context.LAYOUT_INFLATER_SERVICE)
拿到的LayoutInflater
對象蜗细。當(dāng)這里的context
是一個Activity
時,getSystemService(Context.LAYOUT_INFLATER_SERVICE)
返回的是Activity
繼承自父類ContextThemeWrapper
的成員mInflater
.
最后一種情況,Activity.getLayoutInflater()
直接返回對應(yīng)PhoneWindow
中的成員mLayoutInflater
.
由此可以得出結(jié)論:接下來需要做兩件事炉媒,第一替換Activity
繼承自父類ContextThemeWrapper
的成員mInflater
诺苹;第二替換Activity
對應(yīng)PhoneWindow
中的成員mLayoutInflater
.
替換的時機(jī)當(dāng)然是越早越好厂榛,而且需要對每一個創(chuàng)建的Activity
進(jìn)行替換。這里Application
的ActivityLifecycleCallbacks
就派上了用場。這個工作交給一個工具類來做模暗。
public class LayoutIndicatorHelper {
public static void init(Application application) {
if (BuildConfig.DEBUG) {
application.registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() {
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
try {
// Replace Activity's LayoutInflater
Field inflaterField = ContextThemeWrapper.class.getDeclaredField("mInflater");
inflaterField.setAccessible(true);
LayoutInflater inflater = (LayoutInflater) inflaterField.get(activity);
LayoutInflater proxyInflater = null;
if (inflater != null) {
proxyInflater = new LayoutIndicatorInflater(inflater, activity);
inflaterField.set(activity, proxyInflater);
}
// Replace the LayoutInflater of Activity's Window
Class phoneWindowClass = Class.forName("com.android.internal.policy.PhoneWindow");
Field phoneWindowInflater = phoneWindowClass.getDeclaredField("mLayoutInflater");
phoneWindowInflater.setAccessible(true);
inflater = (LayoutInflater) phoneWindowInflater.get(activity.getWindow());
if (inflater != null && proxyInflater != null) {
phoneWindowInflater.set(activity.getWindow(), proxyInflater);
}
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
public void onActivityStarted(Activity activity) {
}
@Override
public void onActivityResumed(Activity activity) {
}
@Override
public void onActivityPaused(Activity activity) {
}
@Override
public void onActivityStopped(Activity activity) {
}
@Override
public void onActivitySaveInstanceState(Activity activity, Bundle outState) {
}
@Override
public void onActivityDestroyed(Activity activity) {
}
});
}
}
}
最后在Application
的onCreate
里調(diào)用一下LayoutIndicatorHelper.init(this);
.
大功告成担败!
實現(xiàn)原理就是這樣讯壶,有幾個注意事項需要說明一下雪情。
如果
Activity.setContentView
在super.onCreate
之前調(diào)用,那該Activity
對應(yīng)的xml文件名就拿不到了鸭巴。原因就是xml的加載發(fā)生在Activity.setContentView
里眷细,而LayoutInflater
的替換發(fā)生在super.onCreate
里。xml里面包含的
<include>
標(biāo)簽指向的資源文件名此方法是拿不到的奕扣。這是因為LayoutInflater
在加載<include>
標(biāo)簽指向的資源文件時并不會遞歸調(diào)用inflate
方法薪鹦,也就意味著我們的代理監(jiān)聽不到<include>
資源的加載。-
xml里的根元素是
<merge>
的時候惯豆,文件名只會被記錄到該xml包含的最后一個view上池磁,如下圖所示。 調(diào)用
LayoutInflater.from(context)
時傳入的context
是非Activity
對象楷兽,那么相應(yīng)的xml是拿不到的地熄。考慮到絕大多數(shù)情況下context
都是Activity
對象芯杀,這個case基本可以忽略不計了端考。
后記
有意思的是,調(diào)研過程中我在Google Groups上搜到了這么一篇帖子:
下面有一個疑似Google工程師給出了一個答復(fù):
現(xiàn)在Android Studio 3.1正式版已經(jīng)發(fā)布揭厚,然而并沒有包含該功能(看來if possible沒有成立)却特。如果能做到點(diǎn)擊view直接跳轉(zhuǎn)相關(guān)的xml,那就真的完美了筛圆。