(注意:本文基于UI Automator测试框架版本为2.2.0)   

前言

    Ui自动化程序的5个步骤

1、查找控件

2、操作控件

3、检查预期/状态(可省略)

4、收集日志

5、出具报告(人类可读)

    将前4个步骤不断重复,即会形成测试用例,而多个测试用例又会形成测试用例集,多个测试用例集又会形成测试场景,如果加上第5个步骤,则就形成一套完整的自动化项目程序。第2个步骤中的操作控件,最常见的动作则是模拟人类使用手指去"点击"控件的动作,本文将着重分析UiObject2中"点击"控件是如何实现的?即UiObject2的click()方法是如何做到点击到屏幕上的控件……?

    代码分析前,再次介绍一下UiObject2,UiObject2位于androidx.test.uiautomator包中,UiObject2类产生的每个UiObject2对象表示Android设备屏幕中的1个可见控件(View)或者1个控件组(ViewGroup)。如果需要获取UiObject2对象,使用UiDevice或者UiObject2本身提供的API,在UiDevice的findObject()方法中,要求指定1个表示控件属性信息的BySelector对象,每个BySelector对象负责持有控件的属性,只有控件属性匹配时,才可以在屏幕(Window)的View树中找到对应的控件,此时UiDevice的findObject()方法会返回1个UiObject2对象,如果在屏幕中没有找到相匹配的控件,你只能得到一个null……

    接下来一起学习UiObject2的click()方法是如何做到"点击"控件的?

UiObject2的click()方法分析

public void click() {
        mGestureController.performGesture(mGestures.click(getVisibleCenter()));
    }

    click()方法是UiObject2中用于点击控件的API,每个UiObject2对象表示Window中一个可见的控件(必须对用户可见),UiObject2对象代表的控件可以是一个View(单个控件)、也可以是一个ViewGroup(控件组),取决于你使用UiDevice的findObject()方法传入的BySelector对象持有的控件属性信息。当我们使用手指在屏幕中点击一个控件时,通过硬件,以及硬件驱动程序,再到软件层的系统服务,Android的App框架层可以处理由MotionEvent对象组成的表示事件的对象,在日常开发中,一个View如果注册了OnClickListener对象(监听器对象),那么在OnClickListener子类中重写的onClick()方法即会被回调。而在UI Automator测试框架中,在已获得UiObject2对象的情况下,想在程序中点击控件,只需要直接调用UiObject2的click()方法即可,此时View中注册的OnClickListener对象的onClick()方法也会被回调!

    在UiObject2的click()方法中,使用UiObject2对象持有的2个对象的功能,这2个对象是

1、mGestureController

mGestureController是UiObject2对象持有的一个GestureController对象,它是在UiObject2的构造方法中创建的,表示手势控制的对象

2、mGestrues

mGrestures是UiObject2持有的一个Gestures对象,也是在UiObject2的构造方法中创建的

GestureController对象与Gestures对象提供的方法具体实现了点击控件,稍后我们将详细分析GestureController对象的performGesture()方法,以及Gestures对象的click()方法

    继续从UiObject2的click()方法的代码分析,click()方法主要执行了3个步骤

1、获取控件的中心点

调用getVisibleCenter()方法,该方法会返回一个Point对象,表示控件的中心点坐标(相对Window),这个Point对象会传给Gestures对象的click()方法中

2、为控件的中心点,附加动作,封装出一个新的对象

mGestures(Gestures对象)的click()方法被调用,会向其传入第1步中创建的Point对象,mGestures的click()方法又会返回一个PointerGesture对象

3、完成点击事件的触发

使用mGestureController(GestureController对象)的performGesture()方法,并向其传入在第2个步骤中获取到的PointerGesture对象,此时控件的点击事件实际进行触发操作

    上文提到的getVisibleCenter()方法会返回一个Point对象,这个Point对象表示可见控件(矩形)的中心点,这个中心点是相对屏幕的x坐标和y坐标(注意:屏幕左上角坐标为0:0),再将Point对象传入到mGestures的click()方法中,mGestures的click()方法又会返回一个封装后的PointerGesture对象,接着再将PointerGesture对象传入到mGestrueConroller的performGesture()方法中,接下来先详细分析getVisibleCenter()方法,学习这个方法是如何构造出一个Ponit对象的!

getVisibleCenter()方法分析

public Point getVisibleCenter() {
        Rect bounds = getVisibleBounds();
        return new Point(bounds.centerX(), bounds.centerY());
    }

位于UiObjet2中的getVisibleCenter()方法,用于返回表示控件(矩形)相对Window的中心点坐标(每个控件在屏幕中都是一个矩形,只不过你看不到边框而已,好像所有的前端都是这样表示的)

这个方法的执行过程为两个步骤

1、获取可见控件相对屏幕的Rect对象,并保存在本地局部变量中

通过调用getVisibleBounds()方法获取一个Rect对象,每个Rect对象表示控件的矩形信息,Rect对象持有的left、top、right、bottom属性表示控件相对屏幕的坐标,left表示矩形左上角x轴,top表示左上角y轴,right表示矩形右下角x轴,bottom表示右下角y轴。Rect对象会赋值给局部变量bounds(备注:left与top表示矩形的左上角坐标、right与bottom表示矩形的右下角坐标)

2、将Rect对象持有的表示控件相对屏幕的中心点x坐标、y坐标传入到创建的Point对象中

通过Rect对象的centerX()方法即可以获得控件距离屏幕的中心点x坐标、通过Rect对象的centerY()方法可以获得控件距离屏幕的中心点y坐标,两个坐标值会再被传入到一个新创建的Point对象中,由Point对象持有矩形中心点的x坐标、y坐标

3、向调用者返回新创建的Point对象(此时的Pointer对象持有矩形相对Window的中心点的x坐标与y坐标)

接下来继续学习每个控件对应的Rect对象又是如何获得的,即getVisibleBounds()方法的分析

getVisibleBounds()方法分析

public Rect getVisibleBounds() {
        return getVisibleBounds(getAccessibilityNodeInfo());
    }

位于UiObject2中的getVisibleBounds()方法,它用于获取可见控件的边界信息,主要做了3件事

1、获取表示控件详细信息的AccessibilityNode对象

首先调用getAccessibilityNodeInfo()方法获得一个AccessibilityNode对象

2、将AccessibilityNode对象传入到一个重载的getVisibleBounds()方法中,重载方法会返回一个Rect对象

3、向调用者返回Rect对象

getAccessibilityNodeInfo()方法里面有什么?我们再看看,一会然后再去看看重载的getVisibleBounds()方法又做了什么?

getAccessibilityNodeInfo()方法分析

private AccessibilityNodeInfo getAccessibilityNodeInfo() {
        if (mCachedNode == null) {
            throw new IllegalStateException("This object has already been recycled");
        }

        getDevice().waitForIdle();
        if (!mCachedNode.refresh()) {
            getDevice().runWatchers();

            if (!mCachedNode.refresh()) {
                throw new StaleObjectException();
            }
        }
        return mCachedNode;
    }

位于UiObject2中的getAccessibilityNodeInfo()方法,用于获取一个UiObject2对象持有的,表示在内存中缓存下来的,表示控件信息的AccessibilityNodeInfo对象,一起学习该方法的实现

1、首先检查UiObject2对象持有(缓存)的表示控件的AccessibilityNodeInfo对象是否已经被GC回收掉
mCachedNode是UiObject2对象持有的一个AccessibilityNodeInfo对象,当我们在View树中查找控件成功后,当前UiObject2对象会持有查找到的AccessibilityNodeInfo对象,并赋值给mCachedNode保存,这表示在内存中缓存该对象(防止对象被GC回收),我们知道每个AccessibilityNodeInfo对象表示1个控件(它持有着表示控件的属性信息),但是这个AccessibilityNodeInfo对象可能会因为整个View树中的所有控件全部不可见时而被回收(一般是因为控件不可见了),当UiObject2持有的同一个AccessibilityNodeInfo对象不存在时(已经被GC干掉),此时的mCachedNode会指向一个null,通过检查mCachedNode的值是否为null,如果mCachedNode为null,则说明UiObject2持有(缓存)的AccessibilityNodeInfo对象所在的整个View树已被回收(还是单个View呢?),当这种null的情况出现时,作者采用抛出一个IllegalStateException异常对象来告知调用者,View树已经被回收(此时记得一定处理这种异常情况)(这里不确定能否单个View回收)

2、检查通过后,首先告知插桩线程停顿一下(不要执行),这是为了确保插桩测试线程不能影响被测应用主线程的运行(注意:这里是同一个进程的情况,一个进程两个应用执行流的情况)

通过UiDevice对象的waitForIdle()方法可以做到插桩线程的停顿,插桩线程会等待被测应用的主线程处于空闲状态时(MessageQueue为空时,表示主线程空闲),插桩线程才会继续运行,绝对不能影响App框架自身的执行(当我们是单独的工具App时,与被测App完全无关,相当于是两个独立的进程)

3、检查UiObject2对象持有的AccessibilityNodeInfo对象是否刷新

如果发现AccessibilityNodeInfo对象没有刷新,则先调用UiDevice的runWatcher()方法,runWatcher()方法会引起所有在UiDevice中注册的UiWatcher对象得到执行(被称作UI监听器对象),所有注册的UiWatcher对象执行完毕后,接着会再次检查AccessibilityNodeInfo对象的刷新状态,如果控件在View树中长时间未见(还是未刷新状态)则会抛出StaleObjectException对象,告知用户UiObject2已经过期,需要重新获取一次UiObject2对象!

这里缺一个代码分析:AccessibilityNodeInfo的refresh()方法是如何判断刷新状态的,即AccessibilityNodeInfo对象持有的刷新状态是什么时候被改变的?

代码好多……我们再继续学习重载的getVisibleBounds(AccessibilityNodeInfo)方法,看看它是如何返回一个Rect对象的……

getVisibleBounds(AccessibilityNodeInfo) 方法分析

private Rect getVisibleBounds(AccessibilityNodeInfo node) {
        // Get the object bounds in screen coordinates
        Rect ret = new Rect();
        node.getBoundsInScreen(ret);

        // Trim any portion of the bounds that are not on the screen
        Rect screen = new Rect(0, 0, getDevice().getDisplayWidth(), getDevice().getDisplayHeight());
        ret.intersect(screen);

        // On platforms that give us access to the node's window
        if (UiDevice.API_LEVEL_ACTUAL >= Build.VERSION_CODES.LOLLIPOP) {
            // Trim any portion of the bounds that are outside the window
            Rect window = new Rect();
            if (node.getWindow() != null) {
                node.getWindow().getBoundsInScreen(window);
                ret.intersect(window);
            }
        }

        // Find the visible bounds of our first scrollable ancestor
        AccessibilityNodeInfo ancestor = null;
        for (ancestor = node.getParent(); ancestor != null; ancestor = ancestor.getParent()) {
            // If this ancestor is scrollable
            if (ancestor.isScrollable()) {
                // Trim any portion of the bounds that are hidden by the non-visible portion of our
                // ancestor
                Rect ancestorRect = getVisibleBounds(ancestor);
                ret.intersect(ancestorRect);
                break;
            }
        }

        return ret;
    }

位于UiObject2中的getVisibleBounds()方法,它是一个重载方法,用于获取表示可见控件相对于Window坐标的Rect对象(Rect对象保存着相对Window的坐标信息)

1、首先创建一个空的Rect对象

这个空的Rect对象用于存储控件矩形边界的坐标信息

2、将控件相对屏幕的左上角坐标、右下角坐标信息存储到一个Rect对象中

将创建好的Rect对象rect传入到AccessibilityNodeInfo对象的getBoundsInScreen()方法中,这个方法会将控件相对屏幕的左上角坐标、右下角坐标全部存储到传入的Rect对象中

3、处理控件在屏幕中显示不全的情况,并更新可见部分的坐标

控件在全屏幕(Window与屏幕大小一致)中可能会出现显示不全的情况

先创建一个表示控件相对屏幕左上角坐标、右下角坐标的Rect对象

再调用表示当前控件坐标信息的Rect对象的intersect()方法,intersect()方法接受的参数表示相对屏幕坐标的Rect对象,在该方法中会更新控件对于用户实际的可见坐标!

(这个处理非常好,这种情况下,显示不全的控件也会被点击到,牛逼!)

4、在API 21版本(Android5.0)开始及以上系统,通过AccessibilityNodeInfo对象(结点对象)即可访问所在的Window

从Android5.0开始,Android的Window可以缩放,这时候的Window可能会比屏幕小(窗口没有全屏),这个步骤就是处理这种情况的,首先获取Window在屏幕中的左上角坐标、以及右下角坐标信息,然后调用表示控件矩形的Rect对象的intersect()方法,传入Window的Rect对象信息,更新控件在实际Window中的可见坐标(所以上文中提及到最后返回的坐标,并不是相对屏幕的坐标,应该都是相对Window的坐标)

5、找到第一个可滚动父控件的可见边界,并更新当前控件的信息

这是一个一直向上查找的过程,局部变量ancestor表示当前传入控件的父容器(任意一个祖先容器),当查找到第一个可以滚动的父容器,查找工作结束,更新当前控件在第一个可滚动的控件中的坐标信息。如果一直在View树中遍历控件,没有找到可滚动的父控件,则不会做更新信息的行为

6、返回保存着控件相对Window坐标信息的Rect对象(注意Rect对象保存着的矩形坐标信息不是相对屏幕,而是相对window)

上文提及到了AccessibilityNodeInfo对象,每个AccessibilityNodeInfo对象表示一个控件,它持有着控件相关的信息,低层是AccessiblityManagerService返回的AccessibilityNodeInfo……后面会单独文章总结,这里暂且不表,只需先知道AccessibilityNodeInfo表示一个控件即可,接下来分析Gestures下的click()方法,它是如何处理传入的Point对象的,Point对象保存着控件相对Window的坐标信息,看来点击控件最后一定会转换为坐标!

Gestures的click()方法分析

public PointerGesture click(Point point) {
        // A basic click is a touch down and touch up over the same point with no delay.
        return click(point, 0);
    }

位于Gestures类中的click()方法,它用于创建一个PointerGesture对象,传入的Point对象表示某个控件矩形相对Window(注意不是屏幕)的中心点坐标

1、调用重载的click()方法,并将传入的Point对象,以及0值同时传入进去(参数0表示无需停留)

2、向调用者返回PointerGesture对象

Gestures的click()方法分析(重载方法:2个参数)

public PointerGesture click(Point point, long duration) {
        // A click is a touch down and touch up over the same point with an optional delay inbetween
        return new PointerGesture(point).pause(duration);
    }

位于Gestures中的click()方法,用于实际创建PointerGesture对象,可传入2个参数,第一个参数Point对象表示控件相对Window(注意:不是屏幕)的中心点坐标,第二个传入的参数duration则表示动作的停留时间。点击事件,即TOUCH_DOWN与TOUCH_UP之间没有时间停顿,所以点击事件时,会传入的值是0(突然发现原来长按事件,只是在TOUCH_DOWN与TOUCH_UP之间停留500ms)

1、先创建PointerGesture对象

new一个PointerGesture对象,同时将传入的Point对象,一并传给PointerGesture

2、调用PointerGesture对象的pause()方法

pause()方法仍然会返回当前对象

3、向调用者返回PointerGesture对象

下面是PointerGesture对象创建时,被调用的构造方法,可见传入Point是作为起始Point

PointerGesture构造方法分析

public PointerGesture(Point startPoint) {
        this(startPoint, 0);
    }

PointerGesture的构造方法,可传入一个参数,表示起始点,内部又调用了重载的构造方法

接下来分析PointerGesture的pause()方法是如何做的?又是如何将当前PointerGesture对象返回的?

PointerGesture的pause()方法分析

public PointerGesture pause(long time) {
        if (time < 0) {
            throw new IllegalArgumentException("time cannot be negative");
        }
        mActions.addLast(new PointerPauseAction(mActions.peekLast().end, time));
        mDuration += (mActions.peekLast().duration);
        return this;
    }

位于PointerGesture中的pause()方法,用于构造一个短按、或者长按手势的方法,返回值仍为当前的PointerGesture对象,传入参数表示停留时长(当传入参数为0时,表示短按,说明短按是长按的特例)

1、检查停留时间是否合理 

当传入的time值小于0,抛出IllegalArgumentExceptioin对象,告知调用者传入的停留时间不合理

2、创建PointerPauseAction对象

这里新创建的PointerPasueAction很有意思,它使用双端队列mActions中持有的最后一个PointerAction对象持有的Point对象(结束点)作为新创建的PointerPauseAction对象持有的Point对象(起始点),而传入的停顿时间则作为第二个参数(备注:PointerPauseAction为PointerAction的子类)

3、将新创建的PointerPasueAction对象添加到双向队列中

使用PointerGesture对象持有的mActions(一个ArrayDeque对象,即双端队列),将一个创建好的PointerPauseAction对象添加到双端队列的尾部

4、更新总的停留时间

获得双向队列最后一个PointerAction对象的停顿时间,增加到PointerGesture对象持有的mDuration中,mDuration表示保存的总的停留时间

5、向调用者返回当前PointerGesture对象

PointerPauseAction介绍

PointerAction的直接子类,产生的对象表示一个点暂停的动作?暂停点

PointerAction介绍

private static abstract class PointerAction {
        final Point start;
        final Point end;
        final long duration;

        public PointerAction(Point startPoint, Point endPoint, long time) {
            start = startPoint;
            end = endPoint;
            duration = time;
        }

        public abstract Point interpolate(float fraction);
    }

位于PointerGesture中的静态内部类,规范了作为点动作的要求,PointerAction对象持有的start表示起始点对象(Point对象),持有的end表示结尾点对象(Point对象),持有的duration表示停留时间,方法interpolate()表示用于插入一个小数?它仍会返回一个Point对象

PointerLinearMoveAction介绍

PointerAction的另一个子类,产生的对象表示两个点之间的移动速度?线性移动动作

GestureController的performGesture()方法分析

public void performGesture(PointerGesture ... gestures) {
        // Initialize pointers
        int count = 0;
        Map<PointerGesture, Pointer> pointers = new HashMap<PointerGesture, Pointer>();
        for (PointerGesture g : gestures) {
            pointers.put(g, new Pointer(count++, g.start()));
        }

        // Initialize MotionEvent arrays
        List<PointerProperties> properties = new ArrayList<PointerProperties>();
        List<PointerCoords>     coordinates = new ArrayList<PointerCoords>();

        // Track active and pending gestures
        PriorityQueue<PointerGesture> active = new PriorityQueue<PointerGesture>(gestures.length,
                END_TIME_COMPARATOR);
        PriorityQueue<PointerGesture> pending = new PriorityQueue<PointerGesture>(gestures.length,
                START_TIME_COMPARATOR);
        pending.addAll(Arrays.asList(gestures));

        // Record the start time
        long startTime = SystemClock.uptimeMillis();

        // Loop
        MotionEvent event;
        for (long elapsedTime = 0; !pending.isEmpty() || !active.isEmpty();
                elapsedTime = SystemClock.uptimeMillis() - startTime) {

            // Touchdown any new pointers
            while (!pending.isEmpty() && elapsedTime > pending.peek().delay()) {
                PointerGesture gesture = pending.remove();
                Pointer pointer = pointers.get(gesture);

                // Add the pointer to the MotionEvent arrays
                properties.add(pointer.prop);
                coordinates.add(pointer.coords);

                // Touch down
                int action = MotionEvent.ACTION_DOWN;
                if (!active.isEmpty()) {
                    // Use ACTION_POINTER_DOWN for secondary pointers. The index is stored at
                    // ACTION_POINTER_INDEX_SHIFT.
                    action = MotionEvent.ACTION_POINTER_DOWN
                            + ((properties.size() - 1) << MotionEvent.ACTION_POINTER_INDEX_SHIFT);
                }
                event = getMotionEvent(startTime, startTime + elapsedTime, action, properties,
                        coordinates);
                getDevice().getUiAutomation().injectInputEvent(event, true);

                // Move the PointerGesture to the active list
                active.add(gesture);
            }

            // Touch up any completed pointers
            while (!active.isEmpty()
                    && elapsedTime > active.peek().delay() + active.peek().duration()) {

                PointerGesture gesture = active.remove();
                Pointer pointer = pointers.get(gesture);

                // Update pointer positions
                pointer.updatePosition(gesture.end());
                for (PointerGesture current : active) {
                    pointers.get(current).updatePosition(current.pointAt(elapsedTime));
                }

                int action = MotionEvent.ACTION_UP;
                int index = properties.indexOf(pointer.prop);
                if (!active.isEmpty()) {
                    action = MotionEvent.ACTION_POINTER_UP
                            + (index << MotionEvent.ACTION_POINTER_INDEX_SHIFT);
                }
                event = getMotionEvent(startTime, startTime + elapsedTime, action, properties,
                        coordinates);
                getDevice().getUiAutomation().injectInputEvent(event, true);

                properties.remove(index);
                coordinates.remove(index);
            }

            // Move any active pointers
            for (PointerGesture gesture : active) {
                Pointer pointer = pointers.get(gesture);
                pointer.updatePosition(gesture.pointAt(elapsedTime - gesture.delay()));

            }
            if (!active.isEmpty()) {
                event = getMotionEvent(startTime, startTime + elapsedTime, MotionEvent.ACTION_MOVE,
                        properties, coordinates);
                getDevice().getUiAutomation().injectInputEvent(event, true);
            }
        }
    }

位于GestureController中的performGesture()方法,用于执行事件手势,传入参数为可变参数,在方法体中最终会转换为数组对象,该数组对象的每个元素是PointerGesture对象。这个方法很长,并且使用了多个数据结构,非常值得学习与分析

1、先创建一个用于记录数量的局部变量

局部变量count,默认值0,用于记录数量,后面它的值会递增

2、创建一个Map集合对象,用于保存一个一个的Pointer对象

Key为PointerGesture对象,Value为Pointer对象

3、遍历作为参数传入的每一个PointerGesture对象

通过遍历数组gestures,来访问数组对象中持有的每一个PointerGesture对象

将每个PointerGesture对象作为Key,新创建的Pointer对象作为Value,而每个Value对象又负责持有一个动态的count值,以及PointerGesture对象持有的start值

4、创建两个线性表

这里使用动态数组ArrayList,一个List持有的元素为PointerProperties对象,另一个List持有的元素为PointerCoords对象

5、创建两个优先级队列

两个优先级队列是active与pending,它们的长度由传入的PointerGesture对象的数量决定的

active传入的用于比较元素的对象为END_TIME_COMPARATOR

pending传入的用于比较元素的对象为START_TIME_COMPARATOR

6、先把传入的每个PointerGesture存放到优先级队列pending中

pending.addAll(Arrays.asList(gestures));

7、记录一个起始时间戳,后面肯定会使用

long startTime = SystemClock.uptimeMillis();

8、创建一个MotionEvent局部变量

9、进入一个超大的for循环……

创建变量elapsedTime用于记录流逝的时间,只要两个优先级队列任意一个还持有着元素,循环就继续,每次循环过后更新已经流逝的时间值,而for循环的内部执行,每次都是3个循环的执行……

9-1、第一个while循环

官方注释:Touchdown any new pointers

按下任意新的位置点,当pending优先级队列中有元素且当前流逝的时间已经大于pending优先级队列中第一个元素的延迟时间,循环就会继续

先从pending优先级队列中获取并删除第一个元素,由局部变量gesture负责保存

接着从map中取出来,Key为PointerGesture对象的Pointer对象,赋值给局部变量pointer负责保存


Add the pointer to the MotionEvent arrays 然后将点信息添加两个List中 Touch down 接着处理touch down,这个里面使用到了UiAutomation对象的injectInputEvent(),而它的内部又会调用UiAutomationConnection对象中的injectInputEvent()方法,最终调用InputManager对象的injectInputEvent()方法完成工作,这个方法会执行到InputManagerService的输入事件,最终完成touch down事件


9-2、第二个while循环


Touch up any completed pointers 用于其他up事件 同样最后执行到InputManagerService进行输入事件


9-3、第三个for循环


Move any active pointers 用于处理move事件 同样最后执行到InputManagerService进行输入事件


总结

1、程序中执行点击事件,最后仍然通过UiAutomation中获取到的InputManagerService系统服务执行了输入事件,包括Down、Move、Up事件

2、在使用系统服务输入事件前,首先获取到View距离Window的坐标,注意不是屏幕,因为Android的Window支持小窗口

3、这篇文章写了太久,后面还得细化……醉了……