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的各种模拟事件也可以自行实现自定义的手势操作。