這是我入職新公司以來第一個相對來說比較成型的工具,雖然功能是那么的弱智,但是基本上我是抱着認真的態度來看待這個工具的開發
廢話不多說,首先闡明一下這個工具的意圖:
意圖:起因是當時需要測試公司APK的穩定性,開發建議使用Monkey,但是Monkey是有很多弊病的,比如加-p參數即使加了指定包名,也還是會有時跳出被測程序,跑到OS里去執行;還比如測試中經常會有需要模擬按鍵的操作,比如音量,HOME之類的,這些是我所不需要的,而恰恰公司4個APK中都有的左滑右滑貌似沒有支持,所以萌生出了一個自己用robotium寫一個類似於Monkey操作的腳本
解釋一下為什么我會選擇使用坐標點擊,而不是使用控件集來進行隨機點擊,我公司有一個業務邏輯很復雜,界面很亂的手機助手APK,起初我使用getCurrentViews()方法嘗試過對控件進行篩選,然后隨機點擊,但是由於各種空指針,而且由於界面布局上控件過於繁雜,在獲取上的效率非常之慢;但是這個方法在我公司中另一個界面比較規范簡潔的APK上測試,確實會比坐標點擊的有效率高很多,綜合考慮通用性以及穩定性,操作性各個方面,最終我還是敲定使用坐標隨機這種方式進行實現
這篇博文我會持續更新,按照我當時開發工具的順序進行講解,其中涉及到一些android開發相關的東西,所以我會一點點把整個工具的開發思路,代碼都順序寫下來,也讓大伙方便理解和思考
一、讓Monkey跑起來
原理:要實現Monkey操作其實特別簡單,但是這里有一個可以擴展的地方,就是,我們怎么讓腳本,可以適配各種屏幕尺寸呢,所以具體思路就是:我們要在點擊之前,使用一個方法去獲取到當前屏幕的寬和高,然后分別使用這個寬和高利用隨機數函數生成隨機值,然后進行隨機坐標點擊;還有一個問題,取得屏幕的寬高,是會將上方狀態欄,也就是信號欄那一條的坐標算進去,點擊那里可是會彈出通知中心的,那樣我們的腳本不就掛了嗎,所以我們還需要一個方法去計算狀態欄的寬度,然后去計算,代碼如下:
public class BaihMonkey extends ActivityInstrumentationTestCase2 { public static String LAUNCHER_ACTIVITY_FULL_CLASSNAME="com.android.haoyouduo.StartupActivity" ; private static Class launcherActivityClass; DisplayMetrics ty=new DisplayMetrics(); //靜態加載將獲取到的點擊的MainActivity字符串讀出來 // static{ // File file=new File("/mnt/sdcard", "activityName.txt"); // // try { // BufferedReader fileReader = new BufferedReader (new FileReader(file)); // String activityName = fileReader.readLine(); // System.out.println(activityName); // LAUNCHER_ACTIVITY_FULL_CLASSNAME=activityName; // fileReader.close(); // } catch (FileNotFoundException e) { // // TODO Auto-generated catch block // e.printStackTrace(); // } catch (IOException e) { // // TODO Auto-generated catch block // e.printStackTrace(); // } // // } public BaihMonkey() throws ClassNotFoundException { super(Class.forName(LAUNCHER_ACTIVITY_FULL_CLASSNAME)); } private Solo solo; String logtag="LikeMonkey_log"; @Override protected void setUp() throws Exception { solo = new Solo(getInstrumentation(), getActivity()); } public void testMonkey() throws InterruptedException{ Thread.sleep(6000); while(true) { Thread.sleep(2000); //特殊操作隨機觸發機制 Random setindex=new Random(); int setId=setindex.nextInt(20); Log.e(logtag, "特殊操作值:"+setId); switch (setId) { case 2: Log.e(logtag, "操作左滑動"); solo.scrollToSide(solo.LEFT, (float) 0.8); //左滑動 break; case 5: Log.e(logtag, "操作右滑動"); solo.scrollToSide(solo.RIGHT, (float) 0.8); //右滑動 break; case 10: Log.e(logtag, "操作返回"); solo.goBack(); //返回 break; } int ClickX=createX(); int ClickY=createY(); Log.e("baih", "x="+ClickX); Log.e("baih", "y="+ClickY); //隨機屏幕坐標點擊機制(去除信號欄高度) if(ClickX>=ty.widthPixels || ClickY>=ty.heightPixels) { continue; } else { Log.e(logtag, "點擊坐標為:x="+ClickX+" y="+ClickY); solo.clickOnScreen(ClickX, ClickY); } } } //獲取屏幕X軸長度並計算X軸隨機點 public int createX(){ solo.getCurrentActivity().getWindowManager().getDefaultDisplay().getMetrics(ty); int x1=ty.widthPixels; Random x=new Random(); int Rxindex=x.nextInt(x1); int xIndex=Rxindex+10; return xIndex; } //獲取屏幕Y軸長度(去除信號欄高度)並計算Y軸隨機點 public int createY(){ Rect frame=new Rect(); solo.getCurrentActivity().getWindow().getDecorView().getWindowVisibleDisplayFrame(frame); int statusBarHeight=frame.top;//計算頂部信號欄高度 solo.getCurrentActivity().getWindowManager().getDefaultDisplay().getMetrics(ty); int y1=ty.heightPixels; Random y=new Random(); int Ryindex=y.nextInt(y1); int yIndex=Ryindex+statusBarHeight+5; return yIndex; } @Override public void tearDown() throws Exception { solo.finishOpenedActivities(); } }
注釋已經將各功能的實現寫的很明白了,通過使用DisplayMetrics對象的widthPixels和heightPixels方法,我們可以得到當前設備的寬高(設備分辨率還需要考慮DPI的值,此處我沒有考慮進去,因為還不知道分辨率和顆粒密度之間如何計算,這個后期准備研究下)
二、讓腳本封裝成APK
這個在我前一篇隨筆里面有比較詳細的記錄,這里不再多說,各位可以自行去研究,或者在基礎上改良
三、Activity跳轉了怎么辦?
在實際測試中發現,我們公司的一款手機應用市場APK,在下載完成一個應用后,會自動彈出系統的程序安裝界面,在點擊一個已安裝的應用時,也會自動彈出系統的程序卸載界面,這樣的Acticity切換會導致我的腳本因為活動進程不在被測程序中而掛掉,也就又回歸到了2個月前我用appium寫LikeMonkey的問題:怎么可以啟動一個線程去實時監聽Activity的切換,並且還不影響主線程(即操作線程)的執行,這個時候,我想到了android四大基本組件里的Service,關於service的概念,各位可以自行百度
我在測試工具啟動時,在界面onCreate中啟動一個service,這個Service的onCreate中去另啟一個線程循環去監聽當前的Activity棧的最頂部Activity,如果檢測到當前最頂部的Activity是系統的安裝界面或者卸載界面,就startActivity喚醒我的被測程序,代碼如下:
//該類繼承Service,實現實時監聽
public class StartService extends Service { public static String activityName; public boolean setWhile=true; @Override public IBinder onBind(Intent intent) { // TODO Auto-generated method stub return null; } public void onCreate(){ Log.e("baih", "進入了onCreate里面"); IntentFilter intent =new IntentFilter("android.intent.action.VIEW"); //intent.addAction(Intent.ACTION_VIEW); intent.setPriority(Integer.MAX_VALUE); Toast.makeText(getApplicationContext(), "service已啟動", 3000).show(); Log.e("baih", "===================service已啟動"); //從Activity棧中獲取當前系統Activity列表 final ActivityManager ActivityList=(ActivityManager)getApplicationContext().getSystemService(ACTIVITY_SERVICE); //另啟線程,完成監聽安裝界面彈出工作 new Thread(){ public void run(){ while(setWhile) { //從Activity列表中讀取一個RunningTaskInfo List<RunningTaskInfo> acList=ActivityList.getRunningTasks(1); //得到第一個RunningTaskInfo RunningTaskInfo mTaskInfo; mTaskInfo=acList.get(0); //獲取該RunningTaskInfo的ActivityName String name=mTaskInfo.topActivity.getClassName(); String setup="com.android.packageinstaller.PackageInstallerActivity"; String uninstall="com.android.packageinstaller.UninstallerActivity"; //判斷獲取到的ActivityName是否為系統的安裝界面或者卸載界面 if(name.equals(setup) || name.equals(uninstall) ) { Log.e("baih", "已經跳轉到安裝/卸載界面,准備操作返回隨樂游"); //從Acitivity列表中讀取兩個RunningTaskInfo List<RunningTaskInfo> ac1=ActivityList.getRunningTasks(2); //得到第二個RunningTaskInfo RunningTaskInfo ra1; ra1=ac1.get(1); //獲取該RunningTaskInfo的ActivityName String name1=ra1.topActivity.getClassName(); ComponentName componentName = ra1.topActivity; //啟動新Activity指向到被測程序 Intent intent = new Intent(); //intent.setComponent(componentName); intent.setClassName("com.stnts.suileyoo.gamecenter", "com.android.haoyouduo.StartupActivity"); intent.setAction(Intent.ACTION_MAIN); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); startActivity(intent); Log.e("baih", "===========操作返回"); Log.e("baih", "==========="+name1); } try { Thread.sleep(3000); } catch (InterruptedException e1) { // TODO Auto-generated catch block e1.printStackTrace(); } } } }.start(); } public void onStart(){ Log.e("baih", "進入了onStart里面"); } public void onDestroy(){ setWhile=false; } }
//這個類實現測試工具啟動的Activity package test.Monkey; import java.io.IOException; import test.Monkey.R; import test.Monkey.*; import android.content.Intent; import android.os.Bundle; import android.util.Log; import android.view.View; import android.view.View.OnClickListener; import android.widget.Button; import android.widget.Toast; public class Start extends android.app.Activity{ @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.main); Button btn1=(Button) findViewById(R.id.startTest); btn1.setOnClickListener(my); //啟動Service Intent serviceIntent =new Intent(this,StartService.class); startService(serviceIntent); LogOutput log=LogOutput.getInstance(); log.startLog(); } public void onDestroy(){ //在關閉測試工具的時候關閉Service super.onDestroy(); LogOutput log=LogOutput.getInstance(); log.stopLog(); Intent intent1=new Intent(this,StartService.class); Toast.makeText(getApplicationContext(), "service已關閉", 3000).show(); stopService(intent1); } private OnClickListener my=new OnClickListener() { @Override public void onClick(View v) { // TODO Auto-generated method stub Log.e("baih", "===================================="); //使用命令行啟動測試腳本 Runtime run=Runtime.getRuntime(); try { run.exec("am instrument -w test.Monkey/test.Monkey.InstrumentationTestRunner"); } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } } }; }
四、怎么輸出Log,怎么讓開發人員debug
monkey測試這類工作,基本都是不需要人員看護,自己進行腳本執行的,所以我們就面對一個問題,出了問題,沒人看見怎么辦,所以我們需要一個功能,可以在腳本運行的過程中,把程序執行的logcat輸出到本地,這個位置的功能不多說,直接上代碼:
//這個類的作用是輸出Log到手機存儲空間根目錄下 public class LogOutput { private static final String TAG="Log"; private String LOG_PATH; private SimpleDateFormat time=new SimpleDateFormat("yyyy-mm-dd-HH-mm-ss"); private Process pro; private static LogOutput Logfile=null; private LogOutput(){ init(); } public static LogOutput getInstance(){ if(Logfile==null) { Logfile=new LogOutput(); } return Logfile; } public void startLog(){ createLog(); } public void stopLog(){ if(pro!=null) { pro.destroy(); } } private void init(){ LOG_PATH=Environment.getExternalStorageDirectory().getAbsolutePath(); createLogDir(); Log.e(TAG, "Log onCreate"); } public void createLog(){ List<String> commandlist=new ArrayList<String>(); commandlist.add("logcat"); commandlist.add("-f"); commandlist.add(getLogPath()); commandlist.add("-s"); commandlist.add("*:E"); commandlist.add("-v"); commandlist.add("time"); try { pro=Runtime.getRuntime().exec(commandlist.toArray(new String[commandlist.size()])); } catch (Exception e) { // TODO: handle exception Log.e(TAG,e.getMessage(), e); } } public String getLogPath(){ createLogDir(); String logFileName=time.format(new Date())+"_suileyoo_LikeMonkey.log"; return LOG_PATH+File.separator+logFileName; } private void createLogDir(){ File file; boolean OK; if(Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) { file=new File(LOG_PATH); if(!file.isDirectory()){ OK=file.mkdirs(); if(!OK) { return; } } } } }
編寫好Log輸出類時,我們只需要在程序啟動時調用開始打印log的函數
LogOutput log=LogOutput.getInstance();
log.startLog();
在程序關閉時調用停止打印log的函數
LogOutput log=LogOutput.getInstance();
log.stopLog();
並且在工程的manifest文件中添加讀取系統log的權限
<uses-permission android:name="android.permission.READ_LOGS"/>
如此,一個可以適配各種屏幕尺寸,可以輸出log到本地的Monkey腳本,就基本成型了