UE Tool 分析及改进

对 UE Tool 原理进行分析,以及进行改进,贴合项目需要。

一、 思考

  在日常研发中如何快速定位一个控件布局位置或点击事件处理逻辑?

1.1 、ADB 命令行

  通过 adb shell dumpsys activity | grep top 快速查看布局结构

adb.png

  通过快速查看布局结构,拿到可视化的 Fragment 实例,去找对应的处理逻辑。

  缺点 : Fragment 细分力度不够 , 找到对应的 Fragment ,还需要再次去寻找对应的代码处理逻辑以及布局文件,不能一步到位。

  对应方案快速实现 :

  1.1.1. 在基类创建时,去遍历该 activity 所包含的 fragment , 将 fragment 以 window 的形式呈现出来
  1.1.2. 反射 activityThread , 拿到对应当前可视化的 activity , 再去遍历该 activity 包含的 fragment, 对应代码如下:
public static Activity getCurrentActivity() {
        try {
             Class activityThreadClass = Class.forName("android.app.ActivityThread");
            Method currentActivityThreadMethod = activityThreadClass.getMethod("currentActivityThread");
            Object currentActivityThread = currentActivityThreadMethod.invoke(null);
            Field mActivitiesField = activityThreadClass.getDeclaredField("mActivities");
            mActivitiesField.setAccessible(true);
            Map activities = (Map) mActivitiesField.get(currentActivityThread);
            for (Object record : activities.values()) {
                Class recordClass = record.getClass();
                Field pausedField = recordClass.getDeclaredField("paused");
                pausedField.setAccessible(true);
                if (!(boolean) pausedField.get(record)) {
                    Field activityField = recordClass.getDeclaredField("activity");
                    activityField.setAccessible(true);
                    return (Activity) activityField.get(record);
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
1.1 、Layout Inspector

  通过 LayoutInspector 去快速定位控件位置 ,截图如下:


device-2020-09-28-135107.png

  缺点 : 当 Activity 中有嵌套 多个Fragment 时,会导致控件选择不够清楚,无法快速定位到对应的控件位置。如果想找到对应的代码处理逻辑,如点击事件等,LayoutInspector 无法提供相关的功能

二、 UE Tool

  UE Tool 是一个各方人员(设计师、程序员、测试)都可以使用的调试工具。它可以作用于任何显示在屏幕上的 view,比如Activity/Fragment/Dialog/PopupWindow 等等。

2.1 、UE Tool 功能
  • 屏幕上的任意 view,如果重复选中一个 view,将会选中其父 view
  • 查看/修改常用控件的属性,比如修改 TextView 的文本内容、文本大小、文本颜色等等
  • 如果你的项目里正在使用 Fresco 的 DraweeView 来呈现图片,那么 UETool 将会提供更多的属性比如图片 URI、默认占位图、圆角大小等等
  • 你可以很轻松的定制任何 view 的属性,比如你想查看一些额外的业务参数
  • 有的时候 UETool 为你选中的 view 并不是你想要的,你可以选择打开 ValidView,然后选中你需要的 View
  • 显示两个 view 的相对位置关系
  • 显示网格栅栏,方便查看控件是否对齐
  • 支持 Android P
  • 支持显示当前控件所在的 Fragment
  • 显示 Activity 的 Fragment 树
2.2 、实现分析
  2.2.1 获取当前正在展示的 Activity
    public static Activity getCurrentActivity() {
        try {
            Class activityThreadClass = Class.forName("android.app.ActivityThread");
            // 通过反射拿到当前进程的 activityThread 实例
            Method currentActivityThreadMethod = activityThreadClass.getMethod("currentActivityThread");
            Object currentActivityThread = currentActivityThreadMethod.invoke(null);
            // 通过反射拿到当前 Activity 保存的 activity 信息
            Field mActivitiesField = activityThreadClass.getDeclaredField("mActivities");
            mActivitiesField.setAccessible(true);
            Map activities = (Map) mActivitiesField.get(currentActivityThread);
            // 遍历 activity record , 拿到当前正在展示的 activity 信息
            for (Object record : activities.values()) {
                Class recordClass = record.getClass();
                Field pausedField = recordClass.getDeclaredField("paused");
                pausedField.setAccessible(true);
                if (!(boolean) pausedField.get(record)) {
                    Field activityField = recordClass.getDeclaredField("activity");
                    activityField.setAccessible(true);
                    return (Activity) activityField.get(record);
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
  2.2.2 从 DecorView 收集该 activity 所有的控件信息
 @Override
    protected void onAttachedToWindow() {
        super.onAttachedToWindow();
        try {
            final Activity targetActivity = UETool.getInstance().getTargetActivity();
            final WindowManager windowManager = targetActivity.getWindowManager();

          /**
           *
           * 每一个 activity 的启动都必须经过 WindowManagerGlobal
           *
           * WindowManagerImpl 持有 WindowManagerGlobal 引用
           *
           * WindowManagerGlobal 会保存每一个当前正在展示的 window 信息 , 通过 viewList 方式将每一个window 的  decorView 保存起来, 通过
           * 反射可以拿到当前所有的 View 信息
           *
           * 注 : 在 Android P 上,绝大部分的反射调用会报错, 黑名单如下:
           *
           * @see  {https://developer.android.com/distribute/best-practices/develop/restrictions-non-sdk-interfaces?hl=zh-cn}
           *
           * 解决方案 :
           *
           *    {http://weishu.me/2018/06/07/free-reflection-above-android-p/}
           *
           */
          if (Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN) {
                final Field mGlobalField = Class.forName("android.view.WindowManagerImpl").getDeclaredField("mGlobal");
                mGlobalField.setAccessible(true);

                if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) {
                    Field mViewsField = Class.forName("android.view.WindowManagerGlobal").getDeclaredField("mViews");
                    mViewsField.setAccessible(true);
                    List views;
                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
                        views = (List) mViewsField.get(mGlobalField.get(windowManager));
                    } else {
                        views = Arrays.asList((View[]) mViewsField.get(mGlobalField.get(windowManager)));
                    }

                  /**
                   * 对比 Activity Context 和 decorView context , 拿到对应 activity 的布局信息
                   *
                   * 拿到该 Activity 的布局信息后,递归遍历,将 布局信息保存在 elements 中
                   *
                   */
                  for (int i = views.size() - 1; i >= 0; i--) {
                        View targetView = getTargetDecorView(targetActivity, views.get(i));
                        if (targetView != null) {
                            createElements(targetView);
                            break;
                        }
                    }
                } else {
                    ReflectionP.breakAndroidP(new ReflectionP.Func() {
                        @Override
                        public Void call() {
                            try {
                                Field mRootsField = Class.forName("android.view.WindowManagerGlobal").getDeclaredField("mRoots");
                                mRootsField.setAccessible(true);
                                List viewRootImpls;
                                viewRootImpls = (List) mRootsField.get(mGlobalField.get(windowManager));
                                for (int i = viewRootImpls.size() - 1; i >= 0; i--) {
                                    Class clazz = Class.forName("android.view.ViewRootImpl");
                                    Object object = viewRootImpls.get(i);
                                    Field mWindowAttributesField = clazz.getDeclaredField("mWindowAttributes");
                                    mWindowAttributesField.setAccessible(true);
                                    Field mViewField = clazz.getDeclaredField("mView");
                                    mViewField.setAccessible(true);
                                    View decorView = (View) mViewField.get(object);
                                    WindowManager.LayoutParams layoutParams = (WindowManager.LayoutParams) mWindowAttributesField.get(object);
                                    if (layoutParams.getTitle().toString().contains(targetActivity.getClass().getName())
                                            || getTargetDecorView(targetActivity, decorView) != null) {
                                        createElements(decorView);
                                        break;
                                    }
                                }
                            } catch (Exception e) {
                                e.printStackTrace();
                            }
                            return null;
                        }
                    });
                }
            } else {
                // http://androidxref.com/4.1.1/xref/frameworks/base/core/java/android/view/WindowManagerImpl.java
                Field mWindowManagerField = Class.forName("android.view.WindowManagerImpl$CompatModeWrapper").getDeclaredField("mWindowManager");
                mWindowManagerField.setAccessible(true);
                Field mViewsField = Class.forName("android.view.WindowManagerImpl").getDeclaredField("mViews");
                mViewsField.setAccessible(true);
                List views = Arrays.asList((View[]) mViewsField.get(mWindowManagerField.get(windowManager)));
                for (int i = views.size() - 1; i >= 0; i--) {
                    View targetView = getTargetDecorView(targetActivity, views.get(i));
                    if (targetView != null) {
                        createElements(targetView);
                        break;
                    }
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
  2.2.3 处理点击事件

  /**
   * elements 创建的时候,需要根据面积进行排序
   * @param view
   */
  private void createElements(View view) {

        List elements = new ArrayList<>();
        traverse(view, elements);

        //  面积从大到小排序
        Collections.sort(elements, new Comparator() {
            @Override
            public int compare(Element o1, Element o2) {
                return o2.getArea() - o1.getArea();
            }
        });

        this.elements.addAll(elements);

    }

  /**
   * 根据坐标查找当前正在展示的 View
   * @param x
   * @param y
   * @return
   */
    protected Element getTargetElement(float x, float y) {
        Element target = null;
        for (int i = elements.size() - 1; i >= 0; i--) {
            final Element element = elements.get(i);
            // 拿到 点击时候的 X , Y , 和 View 的布局边界对比
            // 优先拿 ChildView ,取面积最小的 View
            if (element.getRect().contains((int) x, (int) y)) {
                if (isParentNotVisible(element.getParentElement())) {
                    continue;
                }
                if (element != childElement) {
                    childElement = element;
                    parentElement = element;
                } else if (parentElement != null) {
                    parentElement = parentElement.getParentElement();
                }
                target = parentElement == null ? element : parentElement;
                break;
            }
        }
        if (target == null) {
            Toast.makeText(getContext(), getResources().getString(R.string.uet_target_element_not_found, x, y), Toast.LENGTH_SHORT).show();
        }
        return target;
    }
  2.2.4 收集目标 View 属性

    @Override
    public List getAttrs(Element element) {
        List items = new ArrayList<>();

        View view = element.getView();

        items.add(new TextItem("Fragment", Util.getCurrentFragmentName(element.getView()), new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Activity activity = Util.getCurrentActivity();
                if (activity instanceof TransparentActivity) {
                    ((TransparentActivity) activity).dismissAttrsDialog();
                }
                new FragmentListTreeDialog(v.getContext()).show();
            }
        }));
        items.add(new TextItem("ViewHolder", Util.getViewHolderName(element.getView())));
        items.add(new SwitchItem("Move", element, SwitchItem.Type.TYPE_MOVE));
        items.add(new SwitchItem("ValidViews", element, SwitchItem.Type.TYPE_SHOW_VALID_VIEWS));

        IAttrs iAttrs = AttrsManager.createAttrs(view);
        if (iAttrs != null) {
            items.addAll(iAttrs.getAttrs(element));
        }

        items.add(new TitleItem("COMMON"));
        items.add(new TextItem("Class", view.getClass().getName()));
        items.add(new TextItem("Id", Util.getResId(view)));
        items.add(new TextItem("ResName", Util.getResourceName(view.getId())));
        items.add(new TextItem("Tag", Util.getViewTag(view)));
        items.add(new TextItem("layout name",Util.getXmlName(view)));
        items.add(new TextItem("Clickable", Boolean.toString(view.isClickable()).toUpperCase()));
        items.add(new TextItem("OnClickListener", Util.getViewClickListener(view)));
        items.add(new TextItem("Focused", Boolean.toString(view.isFocused()).toUpperCase()));
        items.add(new AddMinusEditItem("Width(dp)", element, EditTextItem.Type.TYPE_WIDTH, px2dip(view.getWidth())));
        items.add(new AddMinusEditItem("Height(dp)", element, EditTextItem.Type.TYPE_HEIGHT, px2dip(view.getHeight())));
        items.add(new TextItem("Alpha", String.valueOf(view.getAlpha())));
        Object background = Util.getBackground(view);
        if (background instanceof String) {
            items.add(new TextItem("Background", (String) background));
        } else if (background instanceof Bitmap) {
            items.add(new BitmapItem("Background", (Bitmap) background));
        }
        items.add(new AddMinusEditItem("PaddingLeft(dp)", element, EditTextItem.Type.TYPE_PADDING_LEFT, px2dip(view.getPaddingLeft())));
        items.add(new AddMinusEditItem("PaddingRight(dp)", element, EditTextItem.Type.TYPE_PADDING_RIGHT, px2dip(view.getPaddingRight())));
        items.add(new AddMinusEditItem("PaddingTop(dp)", element, EditTextItem.Type.TYPE_PADDING_TOP, px2dip(view.getPaddingTop())));
        items.add(new AddMinusEditItem("PaddingBottom(dp)", element, EditTextItem.Type.TYPE_PADDING_BOTTOM, px2dip(view.getPaddingBottom())));

        return items;
    }

/**
   * 通过反射拿到 clickListener 注册信息 ,由于 clickListener 一般都为匿名内部类,所以可以很快定位
   *
   * 到 click 事件具体是在 哪个 presenter 中注册的,很方便定位问题
   *
   * @param view
   * @return
   */
    public static String getViewClickListener(final View view) {
        return ReflectionP.breakAndroidP(new ReflectionP.Func() {
            @Override public String call() {
                try {
                    final Field mListenerInfoField = View.class.getDeclaredField("mListenerInfo");
                    mListenerInfoField.setAccessible(true);
                    final Field mClickListenerField = Class.forName("android.view.View$ListenerInfo").getDeclaredField("mOnClickListener");
                    mClickListenerField.setAccessible(true);
                    OnClickListener listener = (OnClickListener) mClickListenerField.get(mListenerInfoField.get(view));
                    return listener.getClass().getName();
                } catch (Exception e) {
                    return null;
                }
            }
        });
    }
  2.2.5 UE Tool 总结

  可以快速定位 View ,展示 View 的相关属性,并且可以修改 View 的属性,达到实时调试View 的效果。
  改进的点 : 不提供通过此 View 直接定位到对应 XML 的功能,无法立即知道此 View 隶属于哪个 XML 文件。

三、 UE Tool 改进

   此改进主要是为展示属性增加 Layout 字段,作为 View 展示属性的一部分,方便用户直观查看,截图如下:

device-2020-09-28-135107.png
3.1 、实现分析

   Android P 以及以上,系统已经提供了对应的实现,代码如下:

public static String getXmlName(View view) {
    try {
      if (Build.VERSION.SDK_INT >= 29) {

        int layoutId = view.getSourceLayoutResId();

        return view.getResources().getResourceEntryName(layoutId);
      } else {
        return view.getContentDescription() != null ? view.getContentDescription().toString() : "";
      }

    } catch (Exception e) {
      e.printStackTrace();
    }
    return "";
  }

   在 Android P 以下,系统 inflater 阶段并没有保存对应的 layout id ,无从获取。最后尝试在布局加载阶段为每一个 view 设置对应的 contentDescription 字段,将自己所属的 xml 文件保存在自身 contentDescription 中或许。 为什么设置 view 本身 contentDescription 而不是 tag ,是基于以下考虑:


image.png
3.1.1 、创建 LayoutInflater 代理
package com.gifshow.uetools.me.ele.uetool.Hook;

import android.content.Context;
import android.content.res.Resources;
import android.os.Build;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewStub;

/**
   * create by zd
   * 

* Date : 2020-09-07 *

* Time : 19:04 */ public class LayoutInflaterHack extends LayoutInflater { private LayoutInflater mOriginalInflater; private String mAppPackageName; public LayoutInflaterHack(LayoutInflater original, Context newContext) { super(original, newContext); mOriginalInflater = original; mAppPackageName = getContext().getPackageName(); } @Override public LayoutInflater cloneInContext(Context newContext) { return new LayoutInflaterHack(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(); // 此处 resourceId 即为 xml id ,将 xmlId 保存 // 通过 xmlId 拿到对应的 xmlName ,并赋值给每一个 view 的 contentDescription 属性 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); traverseViewGroup(targetView , resName); return view; } /** * 递归遍历,为每一个 View 设置对应的 contentDescription * @param view * @param resName */ public void traverseViewGroup(View view , String resName) { if (null == view) { return; } if (view instanceof ViewGroup) { // changeContentDescription(view , resName); //遍历ViewGroup,是子view加1,是ViewGroup递归调用 for (int i = 0; i < ((ViewGroup) view).getChildCount(); i++) { View child = ((ViewGroup) view).getChildAt(i); if (child instanceof ViewGroup) { traverseViewGroup(((ViewGroup) view).getChildAt(i) , resName); } else { changeContentDescription(child , resName); } } } else { changeContentDescription(view , resName); } } public void changeContentDescription(View view , String resName){ if (view instanceof ViewStub){ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { ((ViewStub) view).setLayoutInflater(this); } } String originDes = view.getContentDescription() != null ? view.getContentDescription().toString() : ""; originDes = originDes + ", layoutName : " + resName; view.setContentDescription(originDes); } }

3.1.2 、Hook LayoutInflater
  //  怎么用代理LayoutInflater替换Activity本身的LayoutInflater.
  //  要解决这个问题需要先弄明白Activity本身的LayoutInflater从何而来。一般而言加载xml有以下几种方法:
  //
  //      Activity.setContentView(...)
  //      LayoutInflater.from(context).inflate(...)
  //      Activity.getLayoutInflater().inflate(...)
  //
  //  先看第一种情况。Activity.setContentView(...)会调用PhoneWindow.setContentView(...)
  //  ,最后会调用PhoneWindow中的成员mLayoutInflater的inflate方法。
  //  对于第二种情况,假定参数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.
  //
  public static void init(Application application) {
    application.registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() {
      @Override
      public void onActivityCreated(final 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 LayoutInflaterHack(inflater, activity);
            inflaterField.set(activity, proxyInflater);
          }

          final LayoutInflater finalProxyInflater = proxyInflater;
          ReflectionP.breakAndroidP(new ReflectionP.Func() {
            @Override
            public Void call() {

              try {
                // Replace the LayoutInflater of Activity's Window
                Class phoneWindowClass = Class.forName("com.android.internal.policy.PhoneWindow");
                Field phoneWindowInflater = phoneWindowClass.getDeclaredField("mLayoutInflater");
                phoneWindowInflater.setAccessible(true);
                LayoutInflater inflater =
                    (LayoutInflater) phoneWindowInflater.get(activity.getWindow());
                if (inflater != null && finalProxyInflater != null) {
                  phoneWindowInflater.set(activity.getWindow(), finalProxyInflater);
                }

              } catch (Exception e) {

              }

              return null;
            }
          });
        } catch (Exception e) {
          e.printStackTrace();
        }
      }
    });
  }


四、 使用接入

   目前代码已经上传至 maven ,接入方式如下 :

    // 1 . 添加 maven 仓库 如下:
   repositories {
        maven {
            url  "https://dl.bintray.com/danzhang/AdvancedUeTool"
        }
    }
   // 2 . 在 module 下添加依赖:
   debugimplementation 'com.gifshow.uetool:advancedUeTool:1.0.0'
   // 3. 初始化:
   //3.1 如果需要 layoutName 功能,需要在 ApplicationUtil 中调用初始化:
   ApplicationUtil.init(this);
   // 3.2 真正使用 UE Tool, 在 activity onCreate 中调用
   UETool.showUETMenu();

你可能感兴趣的:(UE Tool 分析及改进)