Robotium源碼解讀-native控制項/webview元素的獲取和操作

目前比較有名的Uitest框架有Uiautomator/Robotium/Appium,由於一直對webview元素的獲取和操作比較好奇,另外Robotium代碼量也不是很大,因此打算學習一下。

一.環境準備以及初始化

用來說明的用例採用的是Robotium官網的一個tutorial用例-Notepad

@RunWith(AndroidJUnit4.class)public class NotePadTest { private static final String NOTE_1 = "Note 1"; private static final String NOTE_2 = "Note 2"; @Rule public ActivityTestRule<NotesList> activityTestRule = new ActivityTestRule<>(NotesList.class); private Solo solo; @Before public void setUp() throws Exception { //setUp() is run before a test case is started. //This is where the solo object is created. solo = new Solo(InstrumentationRegistry.getInstrumentation(), activityTestRule.getActivity()); } @After public void tearDown() throws Exception { //tearDown() is run after a test case has finished. //finishOpenedActivities() will finish all the activities that have been opened during the test execution. solo.finishOpenedActivities(); } @Test public void testAddNote() throws Exception { //Unlock the lock screen solo.unlockScreen(); //Click on action menu item add solo.clickOnView(solo.getView(R.id.menu_add)); //Assert that NoteEditor activity is opened solo.assertCurrentActivity("Expected NoteEditor Activity", NoteEditor.class); //In text field 0, enter Note 1 solo.enterText(0, NOTE_1); //Click on action menu item Save solo.clickOnView(solo.getView(R.id.menu_save)); //Click on action menu item Add solo.clickOnView(solo.getView(R.id.menu_add)); //In text field 0, type Note 2 solo.typeText(0, NOTE_2); //Click on action menu item Save solo.clickOnView(solo.getView(R.id.menu_save)); //Takes a screenshot and saves it in "/sdcard/Robotium-Screenshots/". solo.takeScreenshot(); //Search for Note 1 and Note 2 boolean notesFound = solo.searchText(NOTE_1) && solo.searchText(NOTE_2); //To clean up after the test case deleteNotes(); //Assert that Note 1 & Note 2 are found assertTrue("Note 1 and/or Note 2 are not found", notesFound); }}

在進行初始化時,Solo對象依賴Instrumentation對象以及被測應用的Activity對象,在這裡是NotesList,然後所有的UI操作都依賴這個Solo對象。

二.Native控制項解析與操作

1.Native控制項解析

看一個標準的操作:solo.clickOnView(solo.getView(R.id.menu_save));

solo點擊id為menu_save的控制項,其中clickOnView傳入參數肯定為menu_save的view對象,那這個是怎麼獲取的呢?

由於調用比較深,因此直接展示關鍵方法

public View waitForView(int id, int index, int timeout, boolean scroll) { HashSet uniqueViewsMatchingId = new HashSet(); long endTime = SystemClock.uptimeMillis() + (long)timeout; while(SystemClock.uptimeMillis() <= endTime) { this.sleeper.sleep(); Iterator i$ = this.viewFetcher.getAllViews(false).iterator(); while(i$.hasNext()) { View view = (View)i$.next(); Integer idOfView = Integer.valueOf(view.getId()); if(idOfView.equals(Integer.valueOf(id))) { uniqueViewsMatchingId.add(view); if(uniqueViewsMatchingId.size() > index) { return view; } } } if(scroll) { this.scroller.scrollDown(); } } return null; }

這個方法是先去獲取所有的View: this.viewFetcher.getAllViews(false),然後通過匹配id來獲取正確的View。

那Robotium是怎麼獲取到所有的View呢?這就要看看viewFetcher里的實現了。

public ArrayList<View> getAllViews(boolean onlySufficientlyVisible) { View[] views = this.getWindowDecorViews(); ArrayList allViews = new ArrayList(); View[] nonDecorViews = this.getNonDecorViews(views); View view = null; if(nonDecorViews != null) { for(int ignored = 0; ignored < nonDecorViews.length; ++ignored) { view = nonDecorViews[ignored]; try { this.addChildren(allViews, (ViewGroup)view, onlySufficientlyVisible); } catch (Exception var9) { ; } if(view != null) { allViews.add(view); } } } if(views != null && views.length > 0) { view = this.getRecentDecorView(views); try { this.addChildren(allViews, (ViewGroup)view, onlySufficientlyVisible); } catch (Exception var8) { ; } if(view != null) { allViews.add(view); } } return allViews; }

需要說明的是,DecorView是整個ViewTree的最頂層View,它是一個FrameLayout布局,代表了整個應用的界面。

從上面的代碼可以看到,allViews包括WindowDecorViews,nonDecorViews,RecentDecorView。所以,我對這三個封裝比較感興趣,他們是怎麼拿到WindowDecorViews,nonDecorViews,RecentDecorView的呢?

繼續看代碼,可以看到以下方法(看注釋)

// 獲取 DecorViews public View[] getWindowDecorViews() { try { Field viewsField = windowManager.getDeclaredField("mViews"); Field instanceField = windowManager.getDeclaredField(this.windowManagerString); viewsField.setAccessible(true); instanceField.setAccessible(true); Object e = instanceField.get((Object)null); View[] result; if(VERSION.SDK_INT >= 19) { result = (View[])((ArrayList)viewsField.get(e)).toArray(new View[0]); } else { result = (View[])((View[])viewsField.get(e)); } return result; } catch (Exception var5) { var5.printStackTrace(); return null; } } // 獲取NonDecorViews private final View[] getNonDecorViews(View[] views) { View[] decorViews = null; if(views != null) { decorViews = new View[views.length]; int i = 0; for(int j = 0; j < views.length; ++j) { View view = views[j]; if(!this.isDecorView(view)) { decorViews[i] = view; ++i; } } } return decorViews; } // 獲取RecentDecorView public final View getRecentDecorView(View[] views) { if(views == null) { return null; } else { View[] decorViews = new View[views.length]; int i = 0; for(int j = 0; j < views.length; ++j) { View view = views[j]; if(this.isDecorView(view)) { decorViews[i] = view; ++i; } } return this.getRecentContainer(decorViews); } }

其中DecorViews就不用多說了,通過反射拿到一個裡面的元素都是View的List,而NonDecorViews則是通過便利DectorViews進行過濾,nameOfClass 不滿足要求的,則為NonDecorViews

String nameOfClass = view.getClass().getName();return nameOfClass.equals("com.android.internal.policy.impl.PhoneWindow$DecorView") || nameOfClass.equals("com.android.internal.policy.impl.MultiPhoneWindow$MultiPhoneDecorView") || nameOfClass.equals("com.android.internal.policy.PhoneWindow$DecorView");

而recentlyView則通過以下條件進行判斷,滿足則為recentlyView

view != null && view.isShown() && view.hasWindowFocus() && view.getDrawingTime() > drawingTime

2.Native控制項解析

依舊說的是這個操作:solo.clickOnView(solo.getView(R.id.menu_save));接下來要看的是clickOnView的封裝了。

這部分實現相對簡單很多了,獲取控制項坐標的中央X,Y值後,利用instrumentation的sendPointerSync來完成點擊/長按操作

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, 0, x, y, 0); MotionEvent event2 = MotionEvent.obtain(downTime, eventTime, 1, x, y, 0); try { this.inst.sendPointerSync(event); this.inst.sendPointerSync(event2); successfull = true; } catch (SecurityException var16) { ex = var16; this.dialogUtils.hideSoftKeyboard((EditText)null, false, true); this.sleeper.sleep(300); ++retry; View identicalView = this.viewFetcher.getIdenticalView(view); if(identicalView != null) { float[] xyToClick = this.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") + ")"); } }

3.總結:

從源碼中可以看出,其實native控制項操作的思想是這樣的。

通過android.view.windowManager獲取到所有的view,然後經過過濾得到自己需要的view,最後通過計算view的 Coordinates得到中央坐標,最後依賴instrument來完成操作。

三.Webview的解析與操作

webview的解析需要利用JS注入獲取到Web頁面的元素,進行過濾後再進行操作。

webview的調試環境可以利用inspect來進行,具體參考此文章:chrome inspect 遠程調測:Chrome on Android之一 普通調試

在這裡進行說明的解析操作代碼為:

ArrayList<WebElement> webElements = solo.getCurrentWebElements(By.className("ns-video ns-icon")); solo.clickOnWebElement(webElements.get(0));

這段代碼很好理解,取出className為"btn btn-block primary"的元素,並點擊第一個。在這裡,元素的可操作對象為WebElement.

debug界面為:

在具體debug代碼前,我們有必要先了解一下解析Webview的流程應該是怎樣的(儘管我是看代碼了解的,但先把流程說一下方便解說),不然很可能會雲里霧裡。流程如下:

1. 獲取所有的view,過濾出webview

2.初始化javascript環境

3.載入本地js並注入

4.WebElment操作

接下來,自然而然,帶著目的去看代碼,就可以很清楚每一步在做什麼了。

1. 獲取所有的view,過濾出webview

(1)直接跳到關鍵代碼,首先要看的是By是用來幹嘛的。通過查看源碼,可以發現,其實By是一個Java bean,裡面封裝了Id/CssSelector/ClassName/Text等等屬性,可以理解為WebView中的目標對象封裝。

public boolean executeJavaScript(By by, boolean shouldClick) { return by instanceof Id?this.executeJavaScriptFunction("id("" + by.getValue() + "", "" + shouldClick + "");"):(by instanceof Xpath?this.executeJavaScriptFunction("xpath("" + by.getValue() + "", "" + shouldClick + "");"):(by instanceof CssSelector?this.executeJavaScriptFunction("cssSelector("" + by.getValue() + "", "" + shouldClick + "");"):(by instanceof Name?this.executeJavaScriptFunction("name("" + by.getValue() + "", "" + shouldClick + "");"):(by instanceof ClassName?this.executeJavaScriptFunction("className("" + by.getValue() + "", "" + shouldClick + "");"):(by instanceof Text?this.executeJavaScriptFunction("textContent("" + by.getValue() + "", "" + shouldClick + "");"):(by instanceof TagName?this.executeJavaScriptFunction("tagName("" + by.getValue() + "", "" + shouldClick + "");"):false)))))); } private boolean executeJavaScriptFunction(final String function) { ArrayList webViews = this.viewFetcher.getCurrentViews(WebView.class, true); final WebView webView = (WebView)this.viewFetcher.getFreshestView((ArrayList)webViews); if(webView == null) { return false; } else { final String javaScript = this.setWebFrame(this.prepareForStartOfJavascriptExecution(webViews)); this.inst.runOnMainSync(new Runnable() { public void run() { if(webView != null) { webView.loadUrl("javascript:" + javaScript + function); } } }); return true; } }

executeJavaScript獲取到的是對應的執行方法調用語句,這個根據自己定位的元素決定,在這,我的為:"className("ns-video ns-icon", "false");"

(2)getCurrentViews,獲取所有當前View

public <T extends View> ArrayList<T> getCurrentViews(Class<T> classToFilterBy, boolean includeSubclasses, View parent) { ArrayList filteredViews = new ArrayList(); ArrayList allViews = this.getViews(parent, true); Iterator i$ = allViews.iterator(); while(true) { View view; Class classOfView; do { do { if(!i$.hasNext()) { allViews = null; return filteredViews; } view = (View)i$.next(); } while(view == null); classOfView = view.getClass(); } while((!includeSubclasses || !classToFilterBy.isAssignableFrom(classOfView)) && (includeSubclasses || classToFilterBy != classOfView)); filteredViews.add(classToFilterBy.cast(view)); } }

其中classToFilterBy為android.webkit.Webview這個類,所作的操作為調用獲取所有View(跟navitive調用的方法一致),包括控制項view跟webView,如圖所示

然後逐一過濾出,條件為(!includeSubclasses || !classToFilterBy.isAssignableFrom(classOfView)) && (includeSubclasses || classToFilterBy != classOfView))。在這裡加個題外話,因為robotium默認的是android.webkit.Webview,因此如果你用robotium去解析操作第三方內核的Webview,是會失敗的,解決辦法就是改源碼?

2.初始化javascript環境

看(1)的代碼:this.setWebFrame(this.prepareForStartOfJavascriptExecution(webViews));

在這裡會初始化一個robotiumWebCLient, 並將webChromeClient設置成RobotiumWebClient.this.robotiumWebClient,由於我對這一塊也不熟悉,不太清楚這一塊的目的,因此不贅述,姑且認為是執行js注入的環境初始化。

3.載入js腳本並注入

private String getJavaScriptAsString() { InputStream fis = this.getClass().getResourceAsStream("RobotiumWeb.js"); StringBuffer javaScript = new StringBuffer(); try { BufferedReader e = new BufferedReader(new InputStreamReader(fis)); String line = null; while((line = e.readLine()) != null) { javaScript.append(line); javaScript.append("
"); } e.close(); return javaScript.toString(); } catch (IOException var5) { throw new RuntimeException(var5); } }

這一塊就沒多少要說的了,就是把本地的js腳本載入進來,方便執行,最後在非同步線程中將js注入,參考(1)中的webView.loadUrl("javascript:" + javaScript + function);

在這裡可以展示一下我這邊注入的js是怎樣的(只展示結構,具體方法內容略掉):

javascript:/** * Used by the web methods. * * @author Renas Reda, renas.reda@robotium.com * */function allWebElements() { ...}function allTexts() { ...}function clickElement(element){ var e = document.createEvent(MouseEvents); e.initMouseEvent(click, true, true, window, 1, 0, 0, 0, 0, false, false, false, false, 0, null); element.dispatchEvent(e);}function id(id, click) { ...}function xpath(xpath, click) { ...}function cssSelector(cssSelector, click) { ...}function name(name, click) { ...}function className(nameOfClass, click) { ... }function textContent(text, click) { ...}function tagName(tagName, click) { ...}function enterTextById(id, text) { ...}function enterTextByXpath(xpath, text) { ...}function enterTextByCssSelector(cssSelector, text) { ... }function enterTextByName(name, text) { ...}function enterTextByClassName(name, text) { ...}function enterTextByTextContent(textContent, text) { ...}function enterTextByTagName(tagName, text) { ...}function promptElement(element) { ...}function promptText(element, range) { ...}function finished(){ prompt(robotium-finished);}className("ns-video ns-icon", "false");

4.WebElment操作

接下來便是元素操作了,在這裡的操作對象是WebElment,獲取到下X,Y坐標進行對應操作就可以了。

總結:

這篇文章展示了robotium是如何去識別控制項跟webview元素的,這個基本上是一個框架能用與否的關鍵,有興趣造輪子或者想學習robotium源碼的可以多多參考。


推薦閱讀:

Appium Android Ui自動化環境搭建及使用實戰
如何來快速定位啟動時間中的異常方法(Android版)

TAG:android自動化測試 | 測試開發 | 軟體測試 |