您好,登錄后才能下訂單哦!
引子
繼多版本模擬器的支持工作告一段落之后,如何利用這些技術產生更大的價值,成為了接下來需要思考的問題。當然,接下來的課題就涉及到了今天的圖像對比技術。說來有點內疚,雖然也算是科班出身,只可惜大學還沒有真正理解圖像處理的價值,現在又要為自己的過去買單,看來出來混,遲早是要換的。
大環境
先談一下圖像對比在我廠使用的大環境,調研了幾類產品,雖然不能說很全,但是也可以略見一斑。
面對海量的圖片數據,使用最多的就是使用全局特征及局部特征進行去重、分類,這個主要應用于圖片相關的部門。
還有一種需求可以歸納為測試需要,什么性能測試、競品測試及UI類測試,一切圍繞著相似度,來獲取我們需要的信息。
這次,我要做的就是第二類測試需要,主要基于下面幾個使用場景:
第一,在移動web自動化方面,對于UI的驗證還是使用selenium+webdriver去獲取WebElement,不過這種方式只能驗證這個元素是否存在,并不能驗證元素的樣式是否滿足我們的預期,同時,對于selector的維護成本還是比較大的,尤其是面對一群對于可測試性毫不care的fe。
第二,說起來遇到不靠譜的fe,對于一些開發能力比較強的測試開發來說,可以直接通過codeReview的方式確定功能的影響范圍。但是對于很多同學來說,這個要求還是比較難的。因此,也考慮到這一點,可以通過將線上線下環境對比的方式,來獲取到UI的不同,從而為測試范圍的裁剪提供依據。
第三,在代碼合并階段,經常出現某些同學把svn代碼合錯或者漏合的現象,但是由于平時版本迭代較快,很多同學也只負責自己的項目,對用例更新不及時,對最近上線的項目不了解,就可能導致回歸時的疏漏,造成事故。基于這點考慮,只要使用圖像對比技術,將線上與線下的UI進行對比,就可以在一定程度上規避一些較明顯問題。
大環境應該就是這樣,接下來就該思考一下如何實現了。
思考及調研
初步的想法是先著手去做線上線下測試,一來收益會比較明顯,二來可以作為后續工作的基礎。大體的思路就是如何去獲取頁面截圖,然后如何去對比,最后如何把對比的結果展示出來。
如何截圖,在當前情況下,并沒有認為這個是多大的難點,既然之前就已經使用了selenium的截圖功能,這個就應該可以實現,因此就著重去考慮對比的問題了。
對于圖像對比,第一件做的事情,是先去了解下有沒有比較相似的產品。在這里也感謝下老大的支持,在調研的過程中,老大給我推薦了幾個接觸過圖像對比技術的同學,在跟他們的交流過程中,也漸漸有了思路。
第一個接觸的,是一個實習生MM,正在跟一個高工在做Android底圖的性能測試,提供了3中思路:RGB對比、灰度直方圖、SIFT特征提取。RGB對比,簡單點說就是通過對每個像素點進行R、G、B三個通道的值進行對比,從而得到整張圖的相似度,這種方式較后兩種來說會比較精確。灰度直方圖和SIFT特征提取對于整體上的匹配效果較好,但在對比粒度上會相對差一些。
第二個是網搜的同學,提供了一個叫圖以類聚的平臺,提供對海量圖片的去重分類服務,也是使用特征提取的方式。
第三個是移動云的同學,之前是通過圖像對比技術解決Android客戶端自動化基于不同分辨率坐標點的匹配,最后因為某些原因被擱置了。
第四個是在內網上搜到的一個工具,是基于selenium進行截圖的工具。
實現方案
在經過充分思考之后,開始著手與接下來的開發工作,實現思路整理如下:
這塊需要說明的是,基準圖片與測試截圖的環境,需要盡可能保持一致,這樣才可以避免由于環境差異導致的問題,比如IP定位。在做這個的時候,第一個想法其實是線上跟線下環境直接比,最后發現某些頁面還是會有一定區別,因此就采用了這種同步一套線上環境最為基準的方式。
在獲取基準圖片和測試截圖的過程中,需要保證頁面已經加載完畢。在功能自動化中,為了便于項目可測試,我們在頁面中添加了monitor的標記,當這個標記出現時,我們則認為頁面已經加載完畢。其實這個對于大部分頁面來說只能說明我想要測試的元素已經加載完了,并且已經將事件綁定完畢,但是有一小部分頁面,比如違章查詢,已經不去遵守這個原則了。并且,頁面的加載完畢,并不能代碼所有的資源都已經完全呈現出來,這就導致需要一種機制來解決這個問題。因此,在截圖這個流程中,就使用了我們的圖像對比技術,sleep 2秒,然后截圖,隨后再跟上一張圖進行對比,如果相似度滿足一定要求,則認為頁面已經渲染完畢。
頁面截圖完畢后,接下來就將這兩張圖片進行對比,并記錄下來兩張圖不相似的地方,并生成對比結果圖片,方便后續對測試結果的查看。
實施——截圖
首先是獲取基準圖片和測試圖片,實現比較簡單,直接驗證頁面的monitor元素是否已經出現,時間邏輯為
while(執行耗時 < 預期最大耗時 && 沒有找到monitor){
if (monitor元素 != null)
找到monitor;
else
等一段時間;
}
等待2秒;
截圖;
雖然在找到monitor元素后等待了2秒,大部分頁面都可以完全呈現,但是還是有些頁面無法加載完成,最長的加載時間會達到10秒以上。如果再增大等待時間,勢必會對其他用例的執行時間產生影響,并且也不能保證在低網速的情況下所有頁面都完全加載完畢。因此為了避免頁面不完全加載的情況,在此使用定時截圖,定時對比的方式,來保證頁面完全加載,實現邏輯修改如下:
上一次截圖 = null
while(執行耗時 < 預期最大耗時 && 頁面沒有加載完畢){
當前截圖 = 截圖();
if (對比相似度(當前截圖 , 上一次截圖) > 一定相似度)
頁面加載完畢;
else{
上一次截圖 = 當前截圖;
等待2秒;
}
}
這樣一改,就能夠保證,如果這個頁面在2秒鐘之內沒有變化的話,就認為頁面已經完全加載完畢了。不過也會有一個問題,假如頁面消耗了2.01秒加載完畢,那么我們要在第三次截圖的時候才能判斷這個頁面已經加載完畢了,也就是說從加載完畢到程序反饋有4秒鐘的時間浪費,這樣整體執行下來,整個用例的執行時間會有所提升,如果以一個case每次對比多3秒來計算,生成基準圖和當前圖共需要浪費6秒的時間,如果是執行100條用例,那么將會是10分鐘的浪費。從時間上來看,其實并不是很長,但是最后還是想到了一種優化策略:
定義截圖數組 ;
while(執行耗時 < 預期最大耗時 && 頁面沒有加載完畢 && 截圖數組.length > 3){
當前截圖 = 截圖();
if (對比相似度(當前截圖 , 截圖數組[length-3]) > 一定相似度)
頁面加載完畢;
else{
截圖數組.add(當前截圖);
等待0.5秒;
}
}
如此,既能夠保證兩張對比圖的時間間隔,同時也可以在0.5ms內完成響應。
實施——圖像對比
初步的圖像對比工作,已經在實現截圖的過程中完成了。邏輯如下:
if ( 當前圖片.width != 基準圖片.width || 當前圖片.height != 基準圖片.height){
圖片不一致,返回;
}
相似像素數 = 0;
for(遍歷 當前圖片.width){
for( 遍歷 當前圖片.height){
if ( 當前圖片元素RGB數組[x][y] - 基準圖片元素RGB數組[x][y] < 色差閾值 ){
相似像素數++;
}
}
}
相似度 = 相似像素數 / 總像素數;
if( 相似度 > 0.9 )
相似;
else
不相似;
已經可以對兩張圖片的相似度進行對比,但是在調試中發現,由于像素點較多,如果只有很小的一部分有所更改,這種方式便很難發現,對比的精確度有待提高。因此又將圖片進行了水平和垂直的切分,將圖片切成 水平切分數*垂直切分數個圖塊,然后對每個圖塊進行相似度對比,從而提高了圖片的相似度。
隨后又發現,在截圖過程中也會存在頁面對部分樣式進行了細微調整,比如對某個元素的向左偏了1px,對于用戶來說,是看不出來這種差別的,而我們的對比結果卻會因為這種原因而變得不準確。圍繞著以用戶視覺為基準的原則,又對當前的算法進行了優化,對每個像素進行了偏移量支持,并以圖塊為單位進行整體偏移驗證。
再后來,面對實際的用戶需求,對于某些頁面,可能會有一些動態文字,隨著時間的不同有所不同,比如時間類的文字。對于用戶來說,這個是不在頁面差異的范圍內的,但是我們的截圖會由于獲取時間不同而存在或多或少的差異。于是,有添加了對于執行區域不進行驗證的功能。
實施——結果圖生成
結果圖的目的主要還是為了更快的找到頁面的差異,例如下面這張結果圖,對于頁面的不同一眼就能看出來。(右上角的不同是個人手機截圖的問題)
分享及優化
功能都實現完畢,接下來就帶給大組的同事們一次分享。在最后的Q&A階段,有一個問題引起了后續的思考。有一位同學提到截圖的性能問題。如果截圖的底層是經由adb實現,由于android sd卡I/O瓶頸,則很難在2秒的時間完成截圖、保存、傳輸到PC端這個過程。于是就讀了下selenium的截圖實現,實現流程大致如下:
AndroidDriver
從這里乍一看貌似是返回了一個圖像信息的字符串
AndroidWebDriver
ViewAdapter
最優經由反射機制調用WebView的capturePicture方法,獲取瀏覽器返回的截圖數據,經由response返回。
在閱讀源碼之前,也對當前截圖的耗時進行了驗證,平均截圖時間在1秒左右,也驗證了這種B/S形式傳輸的效率要由于adb。既然截圖會存在一定的耗時,那么,對于我們現在的截圖功能來說,實際獲得的截圖則會比獲得完整截圖時的時間早1秒左右,同時我想到能不能去并行截圖呢?
嘗試了一下,發現截圖的時間反倒慢了,看了下Android webview的實現,由于synchronized(obj)的原因,只能同時進行一個頁面的截圖。最后采取了比較折中的方式,每0.5秒進行一次截圖任務的派送,經由截圖隊列將任務發送至截圖線程,從而降低了由于截圖耗時導致的無效等待時間。以下是優化后的部分代碼。
CaptureThread,進行截圖工作
@Override public void run() { System.out.println("截圖線程"+ this.id + "已啟動"); while(true){ if(mission== null){ continue; } //獲取隊列數據 String currentSessionId = String.copyValueOf(CaptureMissionManager.getInstance(this.managerId).sessionId.toCharArray()); try { SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); String beginTime = df.format(new Date()); System.out.println("截圖開始時間為:"+beginTime); File tmpfile = ((TakesScreenshot)mission.getDriver()).getScreenshotAs(OutputType.FILE); // 關鍵代碼,執行屏幕截圖,默認會把截圖保存到temp目錄 FileUtils.copyFile(tmpfile, new File(CompareImage.captureDir + File.separator +mission.getCaptureName() + ".jpg")); //同一session時,會將截圖信息保存到圖片列表 if(currentSessionId.equals(CaptureMissionManager.getInstance(this.managerId).sessionId)){ CaptureMissionManager.getInstance(this.managerId).p_w_picpathList.add(mission.getCaptureName()); //重新排序,避免由于截圖完成時間不同導致的判斷失誤 Collections.sort(CaptureMissionManager.getInstance(this.managerId).p_w_picpathList); System.out.println(CaptureMissionManager.getInstance(this.managerId).p_w_picpathList); } this.mission = null; this.isUsed = false; } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } } }
CaptureMissionManager 負責截圖線程池管理及任務發送
public class CaptureMissionManager extends Thread{ private static HashMap<String,CaptureMissionManager> managers = null; public BlockingQueue queue = new BlockingQueue(30); private static final int MAX_THREAD_COUNT = 1; //最大線程數 public ArrayList<String> p_w_picpathList = new ArrayList<String>(); public String sessionId = ""; /** * 圖片截取線程池 */ public ArrayList<CaptureThread> threadPool = new ArrayList<CaptureThread>(); public CaptureMissionManager(String id){ this.updateSessionId(); //創建線程池資源 for(int i=0; i<MAX_THREAD_COUNT ;i++){ CaptureThread thread = new CaptureThread(i+1,id); threadPool.add(thread); thread.start(); } System.out.println("啟動截圖管理線程"); this.start(); } /** * 獲取可用線程 * @return */ private CaptureThread getThread(){ for(int i = 0 ; i < threadPool.size() ; i ++){ CaptureThread thread = threadPool.get(i); if(!thread.isUsed()){ return thread; } } return null; } public static CaptureMissionManager getInstance(String key){ CaptureMissionManager manager = CaptureMissionManager.managers.get(key); if( manager == null){ manager = new CaptureMissionManager(key); CaptureMissionManager.managers.put(key, manager); } return manager; } /** * 添加截圖任務 * @param captureName */ public void addCaptureMission(WebDriver driver,String captureName){ try { CaptureMission mission = new CaptureMission(driver, captureName); queue.put(mission); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } /** * 清空任務及任務記錄 */ public void clearAllMissionAndRecord(){ this.updateSessionId(); this.queue.clear(); this.p_w_picpathList.clear(); } public void updateSessionId(){ Calendar c = Calendar.getInstance(); this.sessionId = c.getTimeInMillis() + ""; } @Override public void run() { while(true){ CaptureThread thread = this.getThread(); if(thread != null && this.queue.size() > 0 ){ try { CaptureMission mission = (CaptureMission)this.queue.get(); thread.setMission(mission); thread.setUsed(true); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } } } }
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。