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

    xiaoxiao2024-01-08  149

    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自动化的支持。

    相关资源:敏捷开发V1.0.pptx
    最新回复(0)