Android黑科技: 快速找到view所在的xml文件

原創(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)用mOriginalInflaterinflate函數(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有以下幾種方法:

  1. Activity.setContentView(...)
  2. LayoutInflater.from(context).inflate(...)
  3. Activity.getLayoutInflater().inflate(...)

先看第一種情況攀涵。Activity.setContentView(...)會調(diào)用PhoneWindow.setContentView(...)铣耘,最后會調(diào)用PhoneWindow中的成員mLayoutInflaterinflate方法。

對于第二種情況以故,假定參數(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)行替換。這里ApplicationActivityLifecycleCallbacks就派上了用場。這個工作交給一個工具類來做模暗。

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) {

                }
            });
        }
    }

}

最后在ApplicationonCreate里調(diào)用一下LayoutIndicatorHelper.init(this);.

大功告成担败!

實現(xiàn)原理就是這樣讯壶,有幾個注意事項需要說明一下雪情。

  1. 如果Activity.setContentViewsuper.onCreate之前調(diào)用,那該Activity對應(yīng)的xml文件名就拿不到了鸭巴。原因就是xml的加載發(fā)生在Activity.setContentView里眷细,而LayoutInflater的替換發(fā)生在super.onCreate里。

  2. xml里面包含的<include>標(biāo)簽指向的資源文件名此方法是拿不到的奕扣。這是因為LayoutInflater在加載<include>標(biāo)簽指向的資源文件時并不會遞歸調(diào)用inflate方法薪鹦,也就意味著我們的代理監(jiān)聽不到<include>資源的加載。

  3. xml里的根元素是<merge>的時候惯豆,文件名只會被記錄到該xml包含的最后一個view上池磁,如下圖所示。

  4. 調(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,那就真的完美了筛圆。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末裂明,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子太援,更是在濱河造成了極大的恐慌闽晦,老刑警劉巖,帶你破解...
    沈念sama閱讀 212,884評論 6 492
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件提岔,死亡現(xiàn)場離奇詭異仙蛉,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)碱蒙,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,755評論 3 385
  • 文/潘曉璐 我一進(jìn)店門荠瘪,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事哀墓”廾В” “怎么了?”我有些...
    開封第一講書人閱讀 158,369評論 0 348
  • 文/不壞的土叔 我叫張陵麸祷,是天一觀的道長。 經(jīng)常有香客問我褒搔,道長阶牍,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 56,799評論 1 285
  • 正文 為了忘掉前任星瘾,我火速辦了婚禮走孽,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘琳状。我一直安慰自己磕瓷,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,910評論 6 386
  • 文/花漫 我一把揭開白布念逞。 她就那樣靜靜地躺著困食,像睡著了一般。 火紅的嫁衣襯著肌膚如雪翎承。 梳的紋絲不亂的頭發(fā)上硕盹,一...
    開封第一講書人閱讀 50,096評論 1 291
  • 那天,我揣著相機(jī)與錄音叨咖,去河邊找鬼瘩例。 笑死,一個胖子當(dāng)著我的面吹牛甸各,可吹牛的內(nèi)容都是我干的垛贤。 我是一名探鬼主播,決...
    沈念sama閱讀 39,159評論 3 411
  • 文/蒼蘭香墨 我猛地睜開眼趣倾,長吁一口氣:“原來是場噩夢啊……” “哼聘惦!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起誊酌,我...
    開封第一講書人閱讀 37,917評論 0 268
  • 序言:老撾萬榮一對情侶失蹤部凑,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后碧浊,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體涂邀,經(jīng)...
    沈念sama閱讀 44,360評論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,673評論 2 327
  • 正文 我和宋清朗相戀三年箱锐,在試婚紗的時候發(fā)現(xiàn)自己被綠了比勉。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,814評論 1 341
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖浩聋,靈堂內(nèi)的尸體忽然破棺而出观蜗,到底是詐尸還是另有隱情,我是刑警寧澤衣洁,帶...
    沈念sama閱讀 34,509評論 4 334
  • 正文 年R本政府宣布墓捻,位于F島的核電站,受9級特大地震影響坊夫,放射性物質(zhì)發(fā)生泄漏砖第。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 40,156評論 3 317
  • 文/蒙蒙 一环凿、第九天 我趴在偏房一處隱蔽的房頂上張望梧兼。 院中可真熱鬧,春花似錦智听、人聲如沸羽杰。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,882評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽考赛。三九已至,卻和暖如春环肘,著一層夾襖步出監(jiān)牢的瞬間欲虚,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,123評論 1 267
  • 我被黑心中介騙來泰國打工悔雹, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留复哆,地道東北人。 一個月前我還...
    沈念sama閱讀 46,641評論 2 362
  • 正文 我出身青樓腌零,卻偏偏與公主長得像梯找,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子益涧,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,728評論 2 351

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