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

原创 @shhp 转载请注明作者和出处

前记

对于一个Android开发,时不时会有这样的需求:想知道一个页面上的某些view元素是由哪些xml布局资源文件加载而来。如果这个页面是你开发的,那你应该很熟悉这其中涉及到的xml文件,你可以快速准确地找到它们。如果页面不是你开发的呢?幸运的话,你刚好认识相关的开发,而且TA的记性比较好,你可以直接询问TA资源文件名。然而现实是大多数情况下,你需要自己动手。寻找相关xml文件的过程并不总是简单省时的,于是我想能不能找到方法解决这个小小的痛点。

一开始我尝试使用AnnotationProcessor来做文本解析,但会遗漏很多情况(因为我只解析标注了@Override的函数)。也想过是否可以通过AOP或者Android Studio插件的方式来实现,但这些方法太复杂了,性价比不高。

后续的调研的过程中,我在StackOverflow上搜到了这样一个问题

看来这个问题对这位朋友是一个很大的痛点。其中的一个回答给了我很大启发。


该回答提到的ResourceInspector采用的方法是替换Activity本身的LayoutInflater,并利用了Facebook开源的调试神器Stetho来展示当前Activity涉及的xml布局资源文件。

但是这样一来就得引入一个新的库。有没有更加简便优雅的方式?经过探索,我找到了方法可以在Layout Inspector的截屏里直接查看view是从哪个xml加载而来的。


实现

实现这个功能的核心是要用一个代理LayoutInflater替换Activity本身的LayoutInflater. 首先创建一个代理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的构造函数需要两个参数LayoutInflater original, Context newContext,其中original就是Activity本身的LayoutInflater,我们要用它来做实际的加载xml的工作。

主要来看看关键的inflate函数。

Resources res = getContext().getResources();

String packageName = "";
try {
    packageName = res.getResourcePackageName(resourceId);
} catch (Exception e) {}


String resName = "";
try {
    resName = res.getResourceEntryName(resourceId);
} catch (Exception e) {}

这一段做了两件事:第一拿到参数resourceId所在的包名,第二拿到resourceId对应的资源文件名。取包名是因为我只关心自己应用的xml,后面会根据这个包名做一个过滤处理。

View view = mOriginalInflater.inflate(resourceId, root, attachToRoot);

接着直接调用mOriginalInflaterinflate函数来加载xml。到这里就可以明白为什么LayoutIndicatorInflater只是一个代理了。LayoutIndicatorInflater只是拦截了页面里的inflate函数调用,记录下我们关心的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里的根元素。现在面临的问题是:把资源文件名这个信息记录在哪里?又要如何呈现?当然这里可以直接输出一条log。但是当页面比较复杂时,log就会令人眼花缭乱。经过尝试我发现view的ContentDescription属性可以直接在Layout Inspector的截屏里面展示,而且把资源文件名设置到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,那么将资源文件名也设置到targetView所有第一级子view的ContentDescription上。

创建了代理LayoutInflater之后,还要解决另一个关键问题:怎么用代理LayoutInflater替换Activity本身的LayoutInflater. 要解决这个问题需要先弄明白Activity本身的LayoutInflater从何而来。一般而言加载xml有以下几种方法:

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

先看第一种情况。Activity.setContentView(...)会调用PhoneWindow.setContentView(...),最后会调用PhoneWindow中的成员mLayoutInflaterinflate方法。

对于第二种情况,假定参数context是一个Activity. LayoutInflater.from(context)返回的是context.getSystemService(Context.LAYOUT_INFLATER_SERVICE)拿到的LayoutInflater对象。当这里的context是一个Activity时,getSystemService(Context.LAYOUT_INFLATER_SERVICE)返回的是Activity继承自父类ContextThemeWrapper的成员mInflater.

最后一种情况,Activity.getLayoutInflater()直接返回对应PhoneWindow中的成员mLayoutInflater.

由此可以得出结论:接下来需要做两件事,第一替换Activity继承自父类ContextThemeWrapper的成员mInflater;第二替换Activity对应PhoneWindow中的成员mLayoutInflater.

替换的时机当然是越早越好,而且需要对每一个创建的Activity进行替换。这里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里调用一下LayoutIndicatorHelper.init(this);.

大功告成!

实现原理就是这样,有几个注意事项需要说明一下。

  1. 如果Activity.setContentViewsuper.onCreate之前调用,那该Activity对应的xml文件名就拿不到了。原因就是xml的加载发生在Activity.setContentView里,而LayoutInflater的替换发生在super.onCreate里。

  2. xml里面包含的<include>标签指向的资源文件名此方法是拿不到的。这是因为LayoutInflater在加载<include>标签指向的资源文件时并不会递归调用inflate方法,也就意味着我们的代理监听不到<include>资源的加载。

  3. xml里的根元素是<merge>的时候,文件名只会被记录到该xml包含的最后一个view上,如下图所示。

  4. 调用LayoutInflater.from(context)时传入的context是非Activity对象,那么相应的xml是拿不到的。考虑到绝大多数情况下context都是Activity对象,这个case基本可以忽略不计了。

后记

有意思的是,调研过程中我在Google Groups上搜到了这么一篇帖子:


下面有一个疑似Google工程师给出了一个答复:


现在Android Studio 3.1正式版已经发布,然而并没有包含该功能(看来if possible没有成立)。如果能做到点击view直接跳转相关的xml,那就真的完美了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容