对 UE Tool 原理进行分析,以及进行改进,贴合项目需要。
一、 思考
在日常研发中如何快速定位一个控件布局位置或点击事件处理逻辑?
1.1 、ADB 命令行
通过 adb shell dumpsys activity | grep top 快速查看布局结构
通过快速查看布局结构,拿到可视化的 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 去快速定位控件位置 ,截图如下:
缺点 : 当 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<View> views;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
views = (List<View>) 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<Void>() {
@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<View> 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<Element> elements = new ArrayList<>();
traverse(view, elements);
// 面积从大到小排序
Collections.sort(elements, new Comparator<Element>() {
@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<Item> getAttrs(Element element) {
List<Item> 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<String>() {
@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 展示属性的一部分,方便用户直观查看,截图如下:
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 ,是基于以下考虑:
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
* <p>
* Date : 2020-09-07
* <p>
* 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<Void>() {
@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();