無需Root,無需反編譯,用VirtualUETool檢視修改任意App的佈局引數
UETool 是餓了麼推出一款開源庫,已經出來一段時間了,用來幫助設計師,程式設計師,測試人員來在APP上修改View的各項引數。使用起來也很方便,但它只能 在自己專案裡引入依賴 來使用,也就是說用它只能檢視自己APP的佈局位置資訊。如果可以用它來檢視手機上安裝的任意APP,那是不是很酷呢?我們今天的目標就是: 擴充套件UETool讓它成為一個SuperUETool 。先說下我們超級工具 VirtualUETool ,無需修改其他應用apk,無需反編譯apk,無需手機Root,即拿即用,在Github已開源,歡迎star、fork哈~說了這麼多,我們先看下效果吧:

VirtualUETool

VirtualUETool
接下來,我們來聊聊實現思路以及實現過程中遇到的問題,重點在於思路和想法的擴充套件,希望給你也有新的啟發。
先說下本文的行文思路:
一、UETool工作原理梳理
二、VirtualUETool框架的實現思路梳理
我們這裡的介紹重點在於UETool以及對其的改造,對VirtualApp實現外掛化功能就不做過多闡述了哈
好了,那我們開始吧。
一、UETool工作原理梳理
UETool
的基本使用就不說了,看下官方文件就很清楚了,基本使用在當前頁面呼叫下 UETool.showUETMenu
這個方法就可以了。既然我們要開始改造 UETool
,
那我們接下來的重點就聊聊這個東西它的內部實現是什麼樣的,也方便我們後續的修改嘛。
首先從 UETool.showUETMenu
往下看
UETool.showMenu
private boolean showMenu(int y) { //檢查開啟懸浮窗許可權 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; }
這裡主要是申請懸浮窗許可權,就不說了。後面下看 UETMenu
的構造方法,這個 UETMenu
是一個繼承了 LinearLayout
的普通佈局控制元件,
構造方法中主要是初始化UI相關,看下關鍵部分:
UETMenu
構造方法中
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) { //顯示網格柵欄,方便檢視控制元件是否對齊 open(TransparentActivity.Type.TYPE_SHOW_GRIDDING); } })); ...
這裡新增進懸浮窗點選展開的三部分,分別是檢視view屬性、檢視view佈局位置、顯示網格柵欄這三個部分。OK,繼續往下,就到了 uetMenu.show()
這裡,
public void show() { try { windowManager.addView(this, getWindowLayoutParams()); } catch (Exception e) { e.printStackTrace(); } }
就是往 WindowManager
中添加了 UETMenu
這個 ViewGroup
。接下來我們關注的重點來了,當點選各個功能按鈕後統一都呼叫了
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
,用於顯示我們顯示繪製佈局資訊和響應我們的手指點選,看重點
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
。
繼續看 EditAttrLayout
,這個控制元件用於顯示當前View屬性內容,主要看下這裡:
//當點選某個控制元件位置時 會呼叫 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); } } } //當移動某個控制元件位置時會呼叫 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(); } } } }
這裡抽象出公共的行為,不同行為操作單獨處理實現,程式碼很簡潔。從上面可以看到,在點選控制元件的時候,有一個 AttrsDialog
彈窗顯示,也就是我們看到的顯示控制元件實現的dialog,瞅瞅瞅瞅~
重點看下列表的adapter實現:
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
方法執行時,會從 UETool.getInstance().getAttrsProvider()
這裡來拿我們希望支援的屬性,框架預設支援了一部分基礎屬性,我們也可以通過
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
判斷具體的控制元件後取出相應控制元件屬性顯示,後面的處理就比較簡單了。
再看 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框架的實現思路梳理
我們的目標是在任何已安裝的app中可以像 UETool
一樣檢視佈局屬性來使用。從正常思路來想的話,這基本是不可能的,除非我們反編譯apk,將UETool的程式碼編譯後插入重打包,或者使用Xposed的框架來hook。理論上講我們也只能從這裡想辦法了,但有個很致命的問題就是,前者我們必須要反編譯程式碼,後者又必須要手機root。而且一個apk反編譯一次,我們僅僅是想看下佈局屬性, 能不能簡單點?操作的方式簡單點?
基於這些情況,在這裡我們用 VirtualApp
來做底層框架,用於免root載入apk,在其載入apk執行後進行hook插入 UETool
程式碼。關於 VirtualApp
,這是一個開源的外掛化方案。
VirtualApp在你的App內建立一個虛擬空間,你可以在虛擬空間內任意的安裝、啟動和解除安裝APK,這一切都與外部隔離,如同一個沙盒。
執行在VA中的APK無需在外部安裝,即VA支援免安裝執行APK。
注意:作者明確指出,如果專案需要投入商業使用,請購買「商業版」。我們這裡僅做技術學習使用哈~
我們在 VirtualApp
啟動apk之後的回撥 MyComponentDelegate
,它會回撥一系列生命週期方法。
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程序中,而我們需要真正執行操作時是在每個apk程序中,如果在兩個程序中進行訊息傳遞?
程序間通訊最簡單的是通過廣播 BroadCastReceiver
來做,但由於 Virtual
機制的原因,我們在apk程序內部回撥中動態註冊的廣播無法收到
FileObserver
來監聽檔案的變化來實現訊息的傳遞,在apk程序內我們開啟
FileObserver
監聽指定資料夾中檔案變化,來執行對應的操作。
2.由於三方apk並沒有載入UETool的資源res,也就是說通過R.layout、R.id、R.xx都會產生無法找到資源異常
這裡操作是替換掉所有R檔案相關操作,通過手動建立控制元件的方式處理。
3.由於三方apk並沒有 TransparentActivity
在 AndroidManifest.xml
中註冊,啟動 Activity
會報異常
這裡我移除了 TransparentActivity
,不啟動新 Activity
,通過在當前佈局中新增新佈局的方式處理,規避 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
啦,至於用來做什麼就取決於你的想象力了,
比如設計師可以拿來參考優秀app的佈局設計,前端工程師可以拿來參考其他app頁面效果的實現方式,當然你也可以修改下賬戶顯示餘額吹吹牛...
感興趣的小夥伴可以下載體驗下哈,Github地址在這裡: VirtualUETool 。