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