美文网首页Android技术知识Android开发Android开发经验谈
无需Root,无需反编译,用VirtualUETool查看修改任

无需Root,无需反编译,用VirtualUETool查看修改任

作者: zhangke3016 | 来源:发表于2019-01-23 19:56 被阅读28次

    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,那接下来就分别分析下咯。
    EditAttrLayoutRelativePositionLayout都继承自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并没有TransparentActivityAndroidManifest.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

    相关文章

      网友评论

        本文标题:无需Root,无需反编译,用VirtualUETool查看修改任

        本文链接:https://www.haomeiwen.com/subject/kmmtdqtx.html