UETool是餓了么推出一款開源庫,已經(jīng)出來一段時間了惩系,用來幫助設(shè)計師位岔,程序員如筛,測試人員來在APP上修改View的各項參數(shù)。使用起來也很方便抒抬,但它只能在自己項目里引入依賴來使用杨刨,也就是說用它只能查看自己APP的布局位置信息。如果可以用它來查看手機上安裝的任意APP擦剑,那是不是很酷呢妖胀?我們今天的目標就是:擴展UETool讓它成為一個SuperUETool。先說下我們超級工具VirtualUETool惠勒,無需修改其他應(yīng)用apk赚抡,無需反編譯apk,無需手機Root纠屋,即拿即用涂臣,在Github已開源,歡迎star售担、fork哈~說了這么多赁遗,我們先看下效果吧:
接下來,我們來聊聊實現(xiàn)思路以及實現(xiàn)過程中遇到的問題族铆,重點在于思路和想法的擴展岩四,希望給你也有新的啟發(fā)。
先說下本文的行文思路:
一哥攘、UETool工作原理梳理
二剖煌、VirtualUETool框架的實現(xiàn)思路梳理
我們這里的介紹重點在于UETool以及對其的改造,對VirtualApp實現(xiàn)插件化功能就不做過多闡述了哈
好了逝淹,那我們開始吧耕姊。
一、UETool工作原理梳理
UETool
的基本使用就不說了栅葡,看下官方文檔就很清楚了箩做,基本使用在當前頁面調(diào)用下UETool.showUETMenu
這個方法就可以了。既然我們要開始改造UETool
,
那我們接下來的重點就聊聊這個東西它的內(nèi)部實現(xiàn)是什么樣的妥畏,也方便我們后續(xù)的修改嘛。
首先從UETool.showUETMenu
往下看
UETool.showMenu
private boolean showMenu(int y) {
//檢查開啟懸浮窗權(quán)限
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (!Settings.canDrawOverlays(Application.getApplicationContext())) {
requestPermission(Application.getApplicationContext());
Toast.makeText(Application.getApplicationContext(), "After grant this permission, re-enable UETool", Toast.LENGTH_LONG).show();
return false;
}
}
//啟動UETool懸浮窗
if (uetMenu == null) {
uetMenu = new UETMenu(Application.getApplicationContext(), y);
}
uetMenu.show();
return true;
}
這里主要是申請懸浮窗權(quán)限安吁,就不說了醉蚁。后面下看UETMenu
的構(gòu)造方法,這個UETMenu
是一個繼承了LinearLayout
的普通布局控件鬼店,構(gòu)造方法中主要是初始化UI相關(guān)网棍,看下關(guān)鍵部分:
UETMenu
構(gòu)造方法中
public class UETMenu extends LinearLayout
...
subMenus.add(new UETSubMenu.SubMenu(resources.getString(R.string.uet_catch_view), R.drawable.uet_edit_attr, new OnClickListener() {
@Override
public void onClick(View v) {
//查看view屬性
open(TransparentActivity.Type.TYPE_EDIT_ATTR);
}
}));
subMenus.add(new UETSubMenu.SubMenu(resources.getString(R.string.uet_relative_location), R.drawable.uet_relative_position,
new OnClickListener() {
@Override
public void onClick(View v) {
//查看view布局位置
open(TransparentActivity.Type.TYPE_RELATIVE_POSITION);
}
}));
subMenus.add(new UETSubMenu.SubMenu(resources.getString(R.string.uet_grid), R.drawable.uet_show_gridding,
new OnClickListener() {
@Override
public void onClick(View v) {
//顯示網(wǎng)格柵欄,方便查看控件是否對齊
open(TransparentActivity.Type.TYPE_SHOW_GRIDDING);
}
}));
...
這里添加進懸浮窗點擊展開的三部分妇智,分別是查看view屬性滥玷、查看view布局位置氏身、顯示網(wǎng)格柵欄這三個部分。OK,繼續(xù)往下惑畴,就到了uetMenu.show()
這里蛋欣,
public void show() {
try {
windowManager.addView(this, getWindowLayoutParams());
} catch (Exception e) {
e.printStackTrace();
}
}
就是往WindowManager
中添加了UETMenu
這個ViewGroup
。接下來我們關(guān)注的重點來了如贷,當點擊各個功能按鈕后統(tǒng)一都調(diào)用了open
方法陷虎,往下走。
private void open(@TransparentActivity.Type int type) {
Activity currentTopActivity = Util.getCurrentActivity();
if (currentTopActivity == null) {
return;
} else if (currentTopActivity.getClass() == TransparentActivity.class) {
currentTopActivity.finish();
return;
}
//啟動透明activity
Intent intent = new Intent(currentTopActivity, TransparentActivity.class);
intent.putExtra(TransparentActivity.EXTRA_TYPE, type);
currentTopActivity.startActivity(intent);
currentTopActivity.overridePendingTransition(0, 0);
UETool.getInstance().setTargetActivity(currentTopActivity);
}
這里啟動了一個透明的Activity
,用于顯示我們顯示繪制布局信息和響應(yīng)我們的手指點擊杠袱,看重點
TransparentActivity.java
switch (type) {
case TYPE_EDIT_ATTR:
EditAttrLayout editAttrLayout = new EditAttrLayout(this);
editAttrLayout.setOnDragListener(new EditAttrLayout.OnDragListener() {
@Override
public void showOffset(String offsetContent) {
board.updateInfo(offsetContent);
}
});
vContainer.addView(editAttrLayout);
break;
case TYPE_RELATIVE_POSITION:
vContainer.addView(new RelativePositionLayout(this));
break;
case TYPE_SHOW_GRIDDING:
vContainer.addView(new GriddingLayout(this));
board.updateInfo("LINE_INTERVAL: " + DimenUtil.px2dip(GriddingLayout.LINE_INTERVAL, true));
break;
default:
Toast.makeText(this, getString(R.string.uet_coming_soon), Toast.LENGTH_SHORT).show();
finish();
break;
}
這里我們看到不同的功能在界面添加了不同的Layout
,那接下來就分別分析下咯尚猿。
EditAttrLayout
和RelativePositionLayout
都繼承自CollectViewsLayout
,先來看下它們的爸爸~
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
try {
Activity targetActivity = UETool.getInstance().getTargetActivity();
WindowManager windowManager = targetActivity.getWindowManager();
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 = (List<View>) mViewsField.get(mGlobalField.get(windowManager));
for (int i = views.size() - 1; i >= 0; i--) {
View targetView = getTargetDecorView(targetActivity, views.get(i));
if (targetView != null) {
//獲取當前顯示的view
traverse(targetView);
break;
}
}
} else {
Field mRootsField = Class.forName("android.view.WindowManagerGlobal").getDeclaredField("mRoots");
mRootsField.setAccessible(true);
List viewRootImpls;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
viewRootImpls = (List) mRootsField.get(mGlobalField.get(windowManager));
} else {
viewRootImpls = Arrays.asList((Object[]) 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) {
traverse(decorView);
break;
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
//遞歸遍歷界面所有view并添加進elements集合中
private void traverse(View view) {
//如果在過濾的列表中,忽略
if (UETool.getInstance().getFilterClasses().contains(view.getClass().getName())) return;
//如果View不顯示 忽略
if (view.getAlpha() == 0 || view.getVisibility() != View.VISIBLE) return;
//如果view tag == DESABLE_UETOOL 忽略
if (getResources().getString(R.string.uet_disable).equals(view.getTag())) return;
elements.add(new Element(view));
if (view instanceof ViewGroup) {
ViewGroup parent = (ViewGroup) view;
for (int i = 0; i < parent.getChildCount(); i++) {
traverse(parent.getChildAt(i));
}
}
}
在onAttachedToWindow
方法中查找到當前界面顯示的View并且遞歸遍歷子View楣富,添加至elements
集合中凿掂,每個Element
中保存由當前View的位置信息和其父級Element
。
繼續(xù)看EditAttrLayout
,這個控件用于顯示當前View屬性內(nèi)容纹蝴,主要看下這里:
//當點擊某個控件位置時 會調(diào)用 triggerActionUp
class ShowMode implements IMode {
@Override
public void triggerActionUp(final MotionEvent event) {
final Element element = getTargetElement(event.getX(), event.getY());
if (element != null) {
targetElement = element;
invalidate();
if (dialog == null) {
dialog = new AttrsDialog(getContext());
dialog.setAttrDialogCallback(new AttrsDialog.AttrDialogCallback() {
@Override
public void enableMove() {
mode = new MoveMode();
dialog.dismiss();
}
@Override
public void showValidViews(int position, boolean isChecked) {
int positionStart = position + 1;
if (isChecked) {
dialog.notifyValidViewItemInserted(positionStart, getTargetElements(lastX, lastY), targetElement);
} else {
dialog.notifyItemRangeRemoved(positionStart);
}
}
@Override
public void selectView(Element element) {
targetElement = element;
dialog.dismiss();
dialog.show(targetElement);
}
});
dialog.setOnDismissListener(new DialogInterface.OnDismissListener() {
@Override
public void onDismiss(DialogInterface dialog) {
if (targetElement != null) {
targetElement.reset();
invalidate();
}
}
});
}
dialog.show(targetElement);
}
}
}
//當移動某個控件位置時 會調(diào)用 triggerActionMove 方法
class MoveMode implements IMode {
@Override
public void triggerActionMove(MotionEvent event) {
if (targetElement != null) {
boolean changed = false;
View view = targetElement.getView();
float diffX = event.getX() - lastX;
if (Math.abs(diffX) >= moveUnit) {
view.setTranslationX(view.getTranslationX() + diffX);
lastX = event.getX();
changed = true;
}
float diffY = event.getY() - lastY;
if (Math.abs(diffY) >= moveUnit) {
view.setTranslationY(view.getTranslationY() + diffY);
lastY = event.getY();
changed = true;
}
if (changed) {
targetElement.reset();
invalidate();
}
}
}
}
這里抽象出公共的行為庄萎,不同行為操作單獨處理實現(xiàn),代碼很簡潔骗灶。從上面可以看到惨恭,在點擊控件的時候,有一個AttrsDialog
彈窗顯示耙旦,也就是我們看到的顯示控件實現(xiàn)的dialog,瞅瞅瞅瞅~
重點看下列表的adapter實現(xiàn):
public void notifyDataSetChanged(Element element) {
items.clear();
for (String attrsProvider : UETool.getInstance().getAttrsProvider()) {
try {
IAttrs attrs = (IAttrs) Class.forName(attrsProvider).newInstance();
items.addAll(attrs.getAttrs(element));
} catch (Exception e) {
e.printStackTrace();
}
}
notifyDataSetChanged();
}
當adapter的notifyDataSetChanged
方法執(zhí)行時脱羡,會從UETool.getInstance().getAttrsProvider()
這里來拿我們希望支持的屬性,框架默認支持了一部分基礎(chǔ)屬性免都,我們也可以通過
UETool.putAttrsProviderClass(String customizeClassName)
來添加自定義支持的屬性锉罐。先看下默認支持的怎么處理的:
private Set<String> attrsProviderSet = new LinkedHashSet<String>() {
{
add(UETCore.class.getName());
}
};
UETCore.java
@Override
public List<Item> getAttrs(Element element) {
List<Item> items = new ArrayList<>();
View view = 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("Clickable", Boolean.toString(view.isClickable()).toUpperCase()));
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;
}
static class AttrsManager {
public static IAttrs createAttrs(View view) {
if (view instanceof TextView) {
return new UETTextView();
} else if (view instanceof ImageView) {
return new UETImageView();
}
return null;
}
}
到這里基本就清楚了,將我們支持的控件屬性逐一添加進來绕娘,用instanceof
判斷具體的控件后取出相應(yīng)控件屬性顯示脓规,后面的處理就比較簡單了。
再看RelativePositionLayout
,主要就是再手指點擊后查找當前位置View,并在當前View的Canvas上繪制標記線:
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
break;
case MotionEvent.ACTION_UP:
final Element element = getTargetElement(event.getX(), event.getY());
if (element != null) {
relativeElements[searchCount % elementsNum] = element;
searchCount++;
invalidate();
}
break;
}
return true;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
boolean doubleNotNull = true;
for (Element element : relativeElements) {
if (element != null) {
Rect rect = element.getRect();
canvas.drawLine(0, rect.top, screenWidth, rect.top, dashLinePaint);
canvas.drawLine(0, rect.bottom, screenWidth, rect.bottom, dashLinePaint);
canvas.drawLine(rect.left, 0, rect.left, screenHeight, dashLinePaint);
canvas.drawLine(rect.right, 0, rect.right, screenHeight, dashLinePaint);
canvas.drawRect(rect, areaPaint);
} else {
doubleNotNull = false;
}
}
if (doubleNotNull) {
Rect firstRect = relativeElements[searchCount % elementsNum].getRect();
Rect secondRect = relativeElements[(searchCount - 1) % elementsNum].getRect();
if (secondRect.top > firstRect.bottom) {
int x = secondRect.left + secondRect.width() / 2;
drawLineWithText(canvas, x, firstRect.bottom, x, secondRect.top);
}
if (firstRect.top > secondRect.bottom) {
int x = secondRect.left + secondRect.width() / 2;
drawLineWithText(canvas, x, secondRect.bottom, x, firstRect.top);
}
if (secondRect.left > firstRect.right) {
int y = secondRect.top + secondRect.height() / 2;
drawLineWithText(canvas, secondRect.left, y, firstRect.right, y);
}
if (firstRect.left > secondRect.right) {
int y = secondRect.top + secondRect.height() / 2;
drawLineWithText(canvas, secondRect.right, y, firstRect.left, y);
}
drawNestedAreaLine(canvas, firstRect, secondRect);
drawNestedAreaLine(canvas, secondRect, firstRect);
}
}
重點在于getTargetElement
方法查找到當前點擊的子View:
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);
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;
break;
}
}
if (target == null) {
Toast.makeText(getContext(), String.format("could not found view in (%1$.0f , %2$.0f), please select view again", x, y), Toast.LENGTH_SHORT).show();
}
return target;
}
最后的GriddingLayout
是用來展示柵格化布局的险领,方便查看控件是否對齊侨舆,這個就很簡單了,看下:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int startX = 0;
while (startX < screenWidth) {
//畫豎線
canvas.drawLine(startX, 0, startX, screenHeight, paint);
startX = startX + LINE_INTERVAL;
}
int startY = 0;
while (startY < screenHeight) {
//畫橫線
canvas.drawLine(0, startY, screenWidth, startY, paint);
startY = startY + LINE_INTERVAL;
}
}
呼~~終于把整個流程梳理完了,UETool
的原理流程梳理完了绢陌,那我們要開始改造了挨下。
二、UETool框架的實現(xiàn)思路梳理
我們的目標是在任何已安裝的app中可以像UETool
一樣查看布局屬性來使用脐湾。從正常思路來想的話臭笆,這基本是不可能的,除非我們反編譯apk,將UETool的代碼編譯后插入重打包愁铺,或者使用Xposed的框架來hook鹰霍。理論上講我們也只能從這里想辦法了,但有個很致命的問題就是茵乱,前者我們必須要反編譯代碼茂洒,后者又必須要手機root。而且一個apk反編譯一次似将,我們僅僅是想看下布局屬性获黔,能不能簡單點?操作的方式簡單點在验?
基于這些情況玷氏,在這里我們用VirtualApp
來做底層框架,用于免root加載apk腋舌,在其加載apk運行后進行hook插入UETool
代碼盏触。關(guān)于VirtualApp
,這是一個開源的插件化方案。
VirtualApp在你的App內(nèi)創(chuàng)建一個虛擬空間块饺,你可以在虛擬空間內(nèi)任意的安裝赞辩、啟動和卸載APK蛾派,這一切都與外部隔離读第,如同一個沙盒。運行在VA中的APK無需在外部安裝魏铅,即VA支持免安裝運行APK淮腾。
注意:作者明確指出糟需,如果項目需要投入商業(yè)使用,請購買「商業(yè)版」谷朝。我們這里僅做技術(shù)學(xué)習(xí)使用哈~
我們在VirtualApp
啟動apk之后的回調(diào)MyComponentDelegate
,它會回調(diào)一系列生命周期方法洲押。
void beforeApplicationCreate(Application application);
void afterApplicationCreate(Application application);
void beforeActivityCreate(Activity activity);
void beforeActivityResume(Activity activity);
void beforeActivityPause(Activity activity);
void beforeActivityDestroy(Activity activity);
void afterActivityCreate(Activity activity);
void afterActivityResume(Activity activity);
void afterActivityPause(Activity activity);
void afterActivityDestroy(Activity activity);
void onSendBroadcast(Intent intent);
1.由于我們的UETool Menu是在Virtual進程中,而我們需要真正執(zhí)行操作時是在每個apk進程中圆凰,如果在兩個進程中進行消息傳遞杈帐?
進程間通信最簡單的是通過廣播BroadCastReceiver
來做,但由于Virtual
機制的原因专钉,我們在apk進程內(nèi)部回調(diào)中動態(tài)注冊的廣播無法收到
在外部進程的廣播消息挑童。這里切換了一下思路,通過使用FileObserver
來監(jiān)聽文件的變化來實現(xiàn)消息的傳遞跃须,在apk進程內(nèi)我們開啟FileObserver
監(jiān)聽指定文件夾中文件變化炮沐,來執(zhí)行對應(yīng)的操作。
2.由于三方apk并沒有加載UETool的資源res,也就是說通過R.layout回怜、R.id、R.xx都會產(chǎn)生無法找到資源異常
這里操作是替換掉所有R文件相關(guān)操作,通過手動創(chuàng)建控件的方式處理玉雾。
3.由于三方apk并沒有
TransparentActivity
在AndroidManifest.xml
中注冊,啟動Activity
會報異常
這里我移除了TransparentActivity
,不啟動新Activity
,通過在當前布局中添加新布局的方式處理,規(guī)避Activity
注冊問題翔试。
View view = Util.getCurrentView(activity);
ViewGroup viewGroup = null;
if (view instanceof ViewGroup){
viewGroup = (ViewGroup) view;
}
if (viewGroup != null){
View viewWithTag = viewGroup.findViewWithTag(EXTRA_TYPE);
if (viewWithTag != null){
viewGroup.removeView(viewWithTag);
}
vContainer.setTag(EXTRA_TYPE);
vContainer.setFocusable(false);
vContainer.setFocusableInTouchMode(false);
viewGroup.addView(vContainer,new ViewGroup.LayoutParams(viewGroup.getWidth(),viewGroup.getHeight()));
}
至此,修改后的UETool
集成進VirtualApp
中,在我們拖入app啟動后,就可在三方app中正常使用UETool
啦,至于用來做什么就取決于你的想象力了,比如設(shè)計師可以拿來參考優(yōu)秀app的布局設(shè)計,前端工程師可以拿來參考其他app頁面效果的實現(xiàn)方式,當然你也可以修改下賬戶顯示余額吹吹牛...
感興趣的小伙伴可以下載體驗下哈,Github地址在這里:VirtualUETool。