引子

继多版本模拟器的支持工作告一段落之后,如何利用这些技术产生更大的价值,成为了接下来需要思考的问题。当然,接下来的课题就涉及到了今天的图像对比技术。说来有点内疚,虽然也算是科班出身,只可惜大学还没有真正理解图像处理的价值,现在又要为自己的过去买单,看来出来混,迟早是要换的。


大环境

先谈一下图像对比在我厂使用的大环境,调研了几类产品,虽然不能说很全,但是也可以略见一斑。

面对海量的图片数据,使用最多的就是使用全局特征及局部特征进行去重、分类,这个主要应用于图片相关的部门。

还有一种需求可以归纳为测试需要,什么性能测试、竞品测试及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,进行截图工作

@Overridepublicvoidrun(){System.out.println("截图线程"+this.id+"已启动");while(true){if(mission==null){continue;}//获取队列数据StringcurrentSessionId=String.copyValueOf(CaptureMissionManager.getInstance(this.managerId).sessionId.toCharArray());try{SimpleDateFormatdf=newSimpleDateFormat("yyyy-MM-ddHH:mm:ss");StringbeginTime=df.format(newDate());System.out.println("截图开始时间为:"+beginTime);Filetmpfile=((TakesScreenshot)mission.getDriver()).getScreenshotAs(OutputType.FILE);//关键代码,执行屏幕截图,默认会把截图保存到temp目录FileUtils.copyFile(tmpfile,newFile(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(IOExceptione){//TODOAuto-generatedcatchblocke.printStackTrace();}}}


CaptureMissionManager 负责截图线程池管理及任务发送

publicclassCaptureMissionManagerextendsThread{privatestaticHashMap<String,CaptureMissionManager>managers=null;publicBlockingQueuequeue=newBlockingQueue(30);privatestaticfinalintMAX_THREAD_COUNT=1;//最大线程数publicArrayList<String>p_w_picpathList=newArrayList<String>();publicStringsessionId="";/***图片截取线程池*/publicArrayList<CaptureThread>threadPool=newArrayList<CaptureThread>();publicCaptureMissionManager(Stringid){this.updateSessionId();//创建线程池资源for(inti=0;i<MAX_THREAD_COUNT;i++){CaptureThreadthread=newCaptureThread(i+1,id);threadPool.add(thread);thread.start();}System.out.println("启动截图管理线程");this.start();}/***获取可用线程*@return*/privateCaptureThreadgetThread(){for(inti=0;i<threadPool.size();i++){CaptureThreadthread=threadPool.get(i);if(!thread.isUsed()){returnthread;}}returnnull;}publicstaticCaptureMissionManagergetInstance(Stringkey){CaptureMissionManagermanager=CaptureMissionManager.managers.get(key);if(manager==null){manager=newCaptureMissionManager(key);CaptureMissionManager.managers.put(key,manager);}returnmanager;}/***添加截图任务*@paramcaptureName*/publicvoidaddCaptureMission(WebDriverdriver,StringcaptureName){try{CaptureMissionmission=newCaptureMission(driver,captureName);queue.put(mission);}catch(InterruptedExceptione){//TODOAuto-generatedcatchblocke.printStackTrace();}}/***清空任务及任务记录*/publicvoidclearAllMissionAndRecord(){this.updateSessionId();this.queue.clear();this.p_w_picpathList.clear();}publicvoidupdateSessionId(){Calendarc=Calendar.getInstance();this.sessionId=c.getTimeInMillis()+"";}@Overridepublicvoidrun(){while(true){CaptureThreadthread=this.getThread();if(thread!=null&&this.queue.size()>0){try{CaptureMissionmission=(CaptureMission)this.queue.get();thread.setMission(mission);thread.setUsed(true);}catch(InterruptedExceptione){//TODOAuto-generatedcatchblocke.printStackTrace();}}}}}