zl程序教程

您现在的位置是:首页 >  APP

当前栏目

腾讯Android自动化测试实战3.2.1 Robotium支持Native原理

2023-03-09 22:24:31 时间

3.2.1 Robotium支持Native原理

1. 获取控件原理

我们知道Android会为res目录下的所有资源分配ID,例如在布局xml文件中使用了 android:id="@+id/example_id",那么在Android工程编译时就会在R.java中相应地为该布局控件分配一个int型的ID,在Android工程中就可以通过Activity、Context或View等对象调用findViewById(int id)方法引用相应布局中的控件。因此,在测试工程中,如果是在源码的情况下,测试工程可以引用被测工程的代码,也即可以直接获得被测工程中R.java中的ID,因此可以通过这种方式直接根据ID获取控件。Robotium中根据ID获取控件的实现即包含该方式,如代码清单3-5所示。

代码清单3-5 Getter.getView

public View getView(int id, int index, int timeout){

    final Activity activity = activityUtils.getCurrentActivity(false);

    View viewToReturn = null;

    //如果index小于1,则直接通过Activity的findViewById查找

    if(index < 1){

        index = 0;

        viewToReturn = activity.findViewById(id);

    }

 

    if (viewToReturn != null) {

        return viewToReturn;

    }

 

    return waiter.waitForView(id, index, timeout);

}

在getView(int id, int index, int timeout)方法中,先获取当前所在的Activity,然后直接通过findViewById(id)方法尝试获取控件,如果该方法能够正确获取,则直接返回;否则,使用waitForView(id, index, timeout)方法进一步等待控件的出现。

对于测试工程没有关联被测工程的情况,是无法直接通过R.id.example_id的形式获取控件的,此时一般调用getView(String id)方法,即通过String型ID获取。之所以可以通过String型ID获取控件,是因为Robotium中该方法使用了Resources.getIdentifier(String name, String defType, String defPackage)方法动态地将String型ID转换成了int型ID,如代码清单3-6所示。

代码清单3-6 Getter.getView(String id,int index)

public View getView(String id, int index){

    View viewToReturn = null;

    Context targetContext = instrumentation.getTargetContext();

    String packageName = targetContext.getPackageName();

    //先将String类型的ID转换成int型的ID

    int viewId = targetContext.getResources().getIdentifier(id, "id", packageName);

 

    if(viewId != 0){

        viewToReturn = getView(viewId, index, TIMEOUT);

    }

    //如果还未找到,则传入的ID可能是Android系统中的ID

    if(viewToReturn == null){

        int androidViewId = targetContext.getResources().getIdentifier(id, "id", "android");

        if(androidViewId != 0){

            viewToReturn = getView(androidViewId, index, TIMEOUT);

        }

    }

 

    if(viewToReturn != null){

        return viewToReturn;

    }

    return getView(viewId, index);

}

因此,为了简化操作,我们完全可以统一使用getView(String id)方法来获取控件。

以上为根据ID获取控件的一种方式,另一种方式则是通过WindowManager获取所有View后再进行各种过滤封装。如代码清单3-7所示,在ViewFetcher中通过getAllViews方法获取所有的View,其中分别处理DecorView与nonDecorView。

代码清单3-7 ViewFetcher.getAllViews

public ArrayList<View> getAllViews(boolean onlySufficientlyVisible) {

    //获取所有的DocorViews

    final View[] views = getWindowDecorViews();

    final ArrayList<View> allViews = new ArrayList<View>();

    final View[] nonDecorViews = getNonDecorViews(views);

    View view = null;

 

    if(nonDecorViews != null){

        for(int i = 0; i < nonDecorViews.length; i++){

            view = nonDecorViews[i];

            try {

                addChildren(allViews, (ViewGroup)view, onlySufficientlyVisible);

            } catch (Exception ignored) {}

            if(view != null) allViews.add(view);

        }

    }

 

    if (views != null && views.length > 0) {

        view = getRecentDecorView(views);

        try {

            addChildren(allViews, (ViewGroup)view, onlySufficientlyVisible);

        } catch (Exception ignored) {}

 

        if(view != null) allViews.add(view);

    }

 

    return allViews;

}

如代码清单3-8所示,在getWindowDecorViews方法中通过使用反射获取Window-Manager中的mViews对象来获取所有DecorView,其中也可以看到对于Android系统版本大于19的处理是不同的。

代码清单3-8 ViewFetcher.getWindowDecorViews

@SuppressWarnings("unchecked")

public View[] getWindowDecorViews()

{

    Field viewsField;

    Field instanceField;

    try {

//通过反射获取WindowManagerGlobal或WindowManagerImpl中的mViews变量

        viewsField = windowManager.getDeclaredField("mViews");

//通过反射获取WindowManagerGlobal或WindowManagerImpl中的WindowManager实例的变量

        instanceField = windowManager.getDeclaredField(windowManagerString);

        viewsField.setAccessible(true);

        instanceField.setAccessible(true);

        Object instance = instanceField.get(null);

        View[] result;

        if (android.os.Build.VERSION.SDK_INT >= 19) {

            result = ((ArrayList<View>) viewsField.get(instance)).toArray(new View[0]);

        } else {

            result = (View[]) viewsField.get(instance);

        }

        return result;

    } catch (Exception e) {

        e.printStackTrace();

    }

    return null;

}

再看代码清单3-9中的WindowManagerString变量的来源,如代码清单3-9所示,WindowManagerString也同样地需要根据Android系统版本的不同而分别处理。

代码清单3-9 ViewFetcher.setWindowManagerString

private void setWindowManagerString(){

    //不同的系统版本,WindowManager的变量名不同

    if (android.os.Build.VERSION.SDK_INT >= 17) {

        windowManagerString = "sDefaultWindowManager";

    } else if(android.os.Build.VERSION.SDK_INT >= 13) {

        windowManagerString = "sWindowManager";

    } else {

        windowManagerString = "mWindowManager";

    }

}

至此我们知道了Robotium中获取所有Views是通过反射机制实现的,而源码中的变量很可能根据版本的不同而改变,因此通过反射则往往需根据系统版本的不同而分别处理。所以,使用Robotium时最好使用开源项目中的最新版本,因为当有新的Android系统版本发布时,很可能Robotium也需要与时俱进地完善获取控件方式。

2. 控件操作原理

Robotium获取控件后,调用clickOnView(View view)方法就可以完成点击操作,这个方法可以实现两大功能:

根据View获取了控件在屏幕中的坐标。

根据坐标发送了模拟的点击操作。

如代码清单3-10所示,由于View本身可以获取到在屏幕中的起始坐标与控件长宽,因此通过getLocationOnScreen获取起始坐标后,再加上1/2的长与宽,即可计算出控件的中心点在屏幕中的位置。

代码清单3-10 Clicker.getClickCoordinates

private float[] getClickCoordinates(View view){

    sleeper.sleep(200);

    int[] xyLocation = new int[2];

    float[] xyToClick = new float[2];

    //获取view的坐标,xyLocation[0]为x坐标的值,xyLocation[1]为y坐标的值

    view.getLocationOnScreen(xyLocation);

 

    final int viewWidth = view.getWidth();

    final int viewHeight = view.getHeight();

    //xyLocation中的值为控件左上角的坐标,因此xyLocation[0]+宽长除2即为该控件在x轴的中心点,同样地计算在y轴的中心点

    final float x = xyLocation[0] + (viewWidth / 2.0f);

    float y = xyLocation[1] + (viewHeight / 2.0f);

 

    xyToClick[0] = x;

    xyToClick[1] = y;

    return xyToClick;

}

知道了需要点击的位置后,那么接下来发送模拟点击就可以了。Android中的模拟操作可以通过MotionEvent来实现,而MotionEvent主要有以下三种形式:

MotionEvent.ACTION_DOWN:模拟对屏幕发送下按事件。

MotionEvent.ACTION_UP:模拟对屏幕发送上抬事件。

MotionEvent.ACTION_MOVE:模拟对屏幕发送移动事件。

Robotium中的点击屏幕方法即是通过MotionEvent实现的,如代码清单3-11所示,通过MotionEvent.obtain(long downTime, long eventTime, int action, float x, float y, int metaState)方法获取相应的event事件后,再通过Instrumentation的sendPointerSync(MotionEvent event)方法将event事件实际地在手机上模拟执行。

代码清单3-11 Clicker.clickOnScreen

public void clickOnScreen(float x, float y, View view) {

    boolean successfull = false;

    int retry = 0;

    SecurityException ex = null;

 

    while(!successfull && retry < 20) {

        long downTime = SystemClock.uptimeMillis();

        long eventTime = SystemClock.uptimeMillis();

        MotionEvent event = MotionEvent.obtain(downTime, eventTime,

            MotionEvent.ACTION_DOWN, x, y, 0);

        MotionEvent event2 = MotionEvent.obtain(downTime, eventTime,

            MotionEvent.ACTION_UP, x, y, 0);

        try{

            //通过Instrumentation模拟发送下按操作

            inst.sendPointerSync(event);

            //通过Instrumentation模拟发送上抬操作,与下按操作结合,模拟完成了一个点击过程

            inst.sendPointerSync(event2);

            successfull = true;

        }catch(SecurityException e){

            ex = e;

            dialogUtils.hideSoftKeyboard(null, false, true);

            sleeper.sleep(MINI_WAIT);

            retry++;

            View identicalView = viewFetcher.getIdenticalView(view);

            if(identicalView != null){

                float[] xyToClick = getClickCoordinates(identicalView);

                x = xyToClick[0];

                y = xyToClick[1];

            }

        }

    }

//如果点击失败,将抛出异常

    if(!successfull) {

        Assert.fail("Click at ("+x+", "+y+") can not be completed! ("+(ex != null ? ex.getClass().getName()+": "+ex.getMessage() : "null")+")");

    }

}

结合getClickCoordinates(View view)与clickOnScreen(float x, float y, View view)方法就完成了clickOnView(View view)方法的核心实现。通过控制不同手势操作的时间顺序还可以模拟各种手势操作,例如先发送MotionEvent.ACTION_DOWN,一段时间后,再发送MotionEvent.ACTION_UP就模拟了长按操作。先发送MotionEvent.ACTION_DOWN,然后发送MotionEvent.ACTION_MOVE,最后发送MotionEvent.ACTION_UP就是滑动操作了。因此,结合MotionEvent的各种模拟事件也可以自行实现自定义的手势操作。

3.2.2 Robotium支持WebView原理

在上一节中我们介绍了在Robotium中如何通过By.id或By.className方式获取Web-Element,那么Robotium中是如何获取到相应的HTML元素,并能知道元素坐标,从而发送点击事件的呢?

1. WebElement对象

Robotium中以WebElement对象对HTML元素进行了封装,在这个WebElement对象中包含locationX、locationY、ID、text、name、className、tagName等信息。

locationX、locationY:标识该HTML元素在屏幕中所在的X坐标和Y坐标。

ID、className:该HTML元素的属性。

tagName:该HTML元素的标签。

Robotium中封装了WebElement,提供了clickOnWebElement(WebElement webElement),ArrayList<WebElement> getCurrentWebElements()等操作Web元素的API,对于在Android客户端中展示的Web页面,Robotium是如何把里面的元素都提取出来,并封装进WebElement对象中的呢?

如图3-13所示,通过getWebElements方法的调用关系图可以看出,Robotium主要通过JS注入的方式获取Web页面所有的元素,再对这些元素进行提取并封装成WebElement对象。在Android端与JS交互则离不开WebView和WebCromeClient。

 

 

图3-13 getWebElements方法的调用关系图

2. WebElement元素获取

1)利用JS获取页面中的所有元素

在PC上,获取网页的元素可以通过注入javascript元素来完成,以Chrome浏览器为例,打开工具—JavaScript控制台(快捷方式:Ctrl+Shift+J键),输入javascript:prompt (document.URL)即会弹出含当前页面的URL的提示框,因此通过编写适当的JS脚本就可以在这个弹出框中显示所有的页面元素。RobotiumWeb.js就提供了获取所有HTML元素的JS脚本。以Solo中getWebElements()为例,如代码清单3-12所示,可分为两步,先通过executeJavaScriptFunction()方法执行JS脚本,然后根据执行结果通过getWebElements返回。

代码清单3-12 WebUtils.getWebElements

public ArrayList<WebElement> getWebElements(boolean onlySufficientlyVisible){

    boolean javaScriptWasExecuted = executeJavaScriptFunction("allWebElements();");

 

    return getWebElements(javaScriptWasExecuted, onlySufficientlyVisible);

}

如代码清单3-13所示,在executeJavaScriptFunction(final String function)方法中通过webView.loadUrl(String url)方法执行JS,而这里的WebView是通过getCurrentViews (Class<T> classToFilterBy, boolean includeSubclasses)过滤出来的,且是过滤的android.webkit.WebView,这也是Robotium只支持系统WebView而不支持第三方浏览内核中的WebView的原因:

代码清单3-13 WebUtils.executeJavaScriptFunction

private boolean executeJavaScriptFunction(final String function) {

    List<WebView> webViews = viewFetcher.getCurrentViews(WebView.class, true);

    //获取当前屏幕中最新的WebView,即目标要执行JS的WebView

    //注:这里获取的WebView可能不是目标WebView,那么将导致获取WebElement失败

    final WebView webView = viewFetcher.getFreshestView((ArrayList<WebView>) webViews);

   

    if(webView == null) {

        return false;

    }

    //执行JS前的准备工作,如设置WebSettings、获取JS方法等

    final String javaScript = setWebFrame(prepareForStartOfJavascriptExecution(webViews));

       

    inst.runOnMainSync(new Runnable() {

        public void run() {

            if(webView != null){

    //调用loadUrl执行JS

                webView.loadUrl("javascript:" + javaScript + function);

            }

        }

    });

    return true;

}

想返回什么样的结果,关键在于执行了什么样的JS方法,Robotium中的getWeb-Elements()执行的JS方法是allWebElements(),代码片段可以通过RobotiumWeb.js找到,如代码清单3-14所示,采用遍历DOM的形式获取所有的元素信息。

代码清单3-14 RobotiumWeb.js中的allWebElements()

function allWebElements() {

    for (var key in document.all){

        try{

            promptElement(document.all[key]);          

        }catch(ignored){}

    }

    finished();

}

如代码清单3-15所示,将代码清单3-15中遍历获取到的每一个元素分别获取ID、text、className等,然后将元素通过prompt方法以提示框形式显示。在prompt时,会在ID、text、className等字段之间加上';,'特殊字符,以便解析时区分这几个字段。

代码清单3-15 RobotiumWeb.js中的promptElement(element)

function promptElement(element) {

    var id = element.id;

    var text = element.innerText;

    if(text.trim().length == 0){

        text = element.value;

    }

    var name = element.getAttribute('name');

    var className = element.className;

    var tagName = element.tagName;

    var attributes = "";

    var htmlAttributes = element.attributes;

    for (var i = 0, htmlAttribute; htmlAttribute = htmlAttributes[i]; i++){

        attributes += htmlAttribute.name + "::" + htmlAttribute.value;

        if (i + 1 < htmlAttributes.length) {

            attributes += "#$";

        }

    }

 

    var rect = element.getBoundingClientRect();

    if(rect.width > 0 && rect.height > 0 && rect.left >= 0 && rect.top >= 0){

        prompt(id + ';,' + text + ';,' + name + ";," + className + ";," + tagName + ";," + rect.left + ';,' + rect.top + ';,' + rect.width + ';,' + rect.height + ';,' + attributes);

    }

}

最后,执行finished()方法,调用prompt提示框,提示语为特定的'robotium-finished',用于在Robotium执行JS时,判断是否执行完毕,如代码清单3-16所示。

代码清单3-16 RobotiumWeb.js中的finished()

function finished(){

    //robotium-finished用来标识Web元素遍历结束

    prompt('robotium-finished');

}

通过JS完成了Web页面所有元素的提取,提取的所有元素是以prompt方式显示在提示框中的,那么提示框中包含的内容在Android中怎么获取呢?

2)通过onJsPrompt回调获取prompt提示框中的信息

如代码清单3-17所示,通过JS注入获取到Web页面所有的元素后,可以通过onJsPrompt回调来对这些元素进行提取。Robotium写了个继承自WebChromeClient类的RobotiumWebClient类,覆写了onJsPrompt用于回调提取元素信息,如果提示框中包含“robotium-finished”字符串,即表示这段JS脚本执行完毕了,此时通知webElementCreator可以停止等待,否则,将不断将prompt框中的信息交由webElementCreator.createWeb-ElementAndAddInList解析处理。

代码清单3-17 RobotiumWebClient中的onJsPrompt

@Override

public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult r) {

    //当message包含robotium-finished时,表示JS执行结束

    if(message != null && (message.contains(";,") || message.contains("robotium-finished"))){

  

        if(message.equals("robotium-finished")){

    //setFinished为true后,WebElementCreator将停止等待

            webElementCreator.setFinished(true);

        }

        else{

            webElementCreator.createWebElementAndAddInList(message, view);

        }

        r.confirm();

        return true;

    }

    else {

        if(originalWebChromeClient != null) {

            return originalWebChromeClient.onJsPrompt(view, url, message, defaultValue, r);

        }

        return true;

    }

 

}

3)将回调中获取的元素信息封装进WebElement对象中

获取到onJsPrompt回调中的元素信息后,接下来就可以对这些已经过处理、含特殊格式的消息进行解析了,依次得到WebElement的ID、text、name等字段。如代码清单3-18所示,将information通过特殊字符串“;,”分隔成数组对该字符串进行分段解析,将解析而得的ID、text、name及x,y坐标存储至WebElement对象中。

代码清单3-18 WebElementCreator中的createWebElementAndSetLocation

private WebElement createWebElementAndSetLocation(String information, WebView webView){

    //将information通过特殊字符串“;,”分隔成数组

    String[] data = information.split(";,");

    String[] elements = null;

    int x = 0;

    int y = 0;

    int width = 0;

    int height = 0;

    Hashtable<String, String> attributes = new Hashtable<String, String>();

    try{

        x = Math.round(Float.valueOf(data[5]));

        y = Math.round(Float.valueOf(data[6]));

        width = Math.round(Float.valueOf(data[7]));

        height = Math.round(Float.valueOf(data[8]));   

        elements = data[9].split("\\#\\$");

    }catch(Exception ignored){}

 

    if(elements != null) {

        for (int index = 0; index < elements.length; index++){

            String[] element = elements[index].split("::");

            if (element.length > 1) {

                attributes.put(element[0], element[1]);

            } else {

                attributes.put(element[0], element[0]);

            }

        }

    }

    WebElement webElement = null;

    try{

    //设置WebElement中的各个字段

        webElement = new WebElement(data[0], data[1], data[2], data[3], data[4], attributes);

        setLocation(webElement, webView, x, y, width, height);

    }catch(Exception ignored) {}

 

    return webElement;

}

这样,把JS执行时提取到的所有元素信息解析出来,并储存至WebElement对象中,在获取到相应的WebElement对象后,就包括了元素的ID、text、className等属性及其在屏幕中的坐标,完成了对Web自动化的支持。