基于图像识别的Android控件遍历
Keep Team Lv4

背景

  在追求测试自动化的道路上,我想让脚本具有一定的自适应能力,能自主识别出可操作(可点击、可长按、可编辑)的控件,并能自主控制脚本运行逻辑(依据策略自适应)。
  能识别出可操作控件则是这一目标的第一步。Airtest使用的POCO具有获取控件属性的作用,同时Airtest封装Opencv用于图像识别。因此这是一个实现该目标较理想的平台。

逻辑

  Android界面无论采用何种布局,都可以理解为是以图层的原理,逐层叠加出来的。所以利用POCO获取子节点的方法,从根节点开始依次遍历其所有子节点以及子节点的子节点(孙节点),并保留可操作(可点击、可长按、可编辑)的节点。如此即可获得想要的控件列表。

实现

  首先利用Airtest的截图功能G.DEVICE.snapshot获取当前屏幕截图(已启动需遍历的app)。然后通过offspring获取所有子节点,为了加速遍历速度,我们使用freeze()将界面元素冻结,以加速子节点的遍历。
  拿到子节点后通过获取其属性判断是否具有可操作性,在poco中获取属性方式很简单,通过attr即可。

def getUseableAttrs(self, item):
        attrsArray = []
        if(item.attr('touchable')):
            attrsArray.append('touchable')
        if(item.attr('touchable')):
            attrsArray.append('touchable')
        if(item.attr('editalbe')):
            attrsArray.append('editalbe')
        return attrsArray
def parseFreezeLayout(self, elementsArray):
        sleep(5.0)
        freeze = self.poco.freeze()
        screen = G.DEVICE.snapshot(os.path.join(self.datapath, "snap.jpg"))
        for item in freeze().offspring():
            attrs = self.getUseableAttrs(item)
            if(attrs):
                 elementsArray.append(item)
        return index

  这样我们就可以快速获得当前屏幕上可操作的控件元素。

问题

  遍历获取到的控件仅仅是屏幕区域内的控件,屏幕外的控件是无法获取的。换句话讲上述方面仅能获取到当前显示区域内的控件,一旦需要滑动屏幕才可显示的内容则一概获取不到。而屏幕滑动之后(无论滑动距离有多小),界面即刷新,而offspring又会从根节点出发重新依次遍历当前显示区域内的所有控件。如果能去掉重复的控件,那么我们就可以通过滑动获取到APP当前页面的所有可操作控件了。
  首先看看通过attr是否可获取类似Android R.id这样全局唯一的ID。梦想很美好,现实很残酷。在某些APP中似乎并没有对每一个可操作控件定义不一样的ID,这导致无法通过ID进行控件的区分。那么控件上的文字描述呢?比如按钮上显示的确定或取消字样。这也仅对部分app有效,因为随着UX设计越来越人性化,某些功能的按钮多以图形来表示,如:添加,撤销,搜索等等。至此我们已无法单纯的利用控件基本属性作为去重的判断依据。
  那么人在页面刷新后又是如何判断那些控件是新出现的,那些是之前已有的呢?这里不深入探讨视觉神经的工作原理,我们假设一个不识字的小孩可以怎样识别出新增控件的。答案很简单,依靠图形对比,在我们假设的这个小孩眼里只会有“绿色背景带有加号图形的方块”或“红色背景上面有些文字的圆圈”,并以此作为显示区域前后两次刷新对比的依据,“绿色背景带有加号图形的方块”在之前已出现过,“红色的家伙”之前没有。
  有了这样的假设,我们就可以以图像识别为抓手,对原有代码进行升级改造了。

改进

  思路很简单,之前的逻辑已可以识别出当前显示区域所有可操作控件,那么我们可以对每个控件做区域截图(也就是仅把控件截图保存)。并将截图与其它控件的截图做图像对比,若图像一致,则认为是同一个控件,否则为新增控件。

def saveCropScreen(self, screen, x, y, width, height, page, index): 
    cropscreen = aircv.crop_image(screen, [x,y,x+width,y+height]) 
    path = self.datapath 
    for dir in page.split('-'): 
        path = os.path.join(path, dir) 
    if(not os.path.exists(path)): 
        os.makedirs(path) 
    path = os.path.join(path, '{}.jpg'.format(index)) 
    aircv.imwrite(path, cropscreen, ST.SNAPSHOT_QUALITY, ST.IMAGE_MAXSIZE) 
return path

  而图像识别则是通过计算两张图片的哈希值,然后通过计算出汉明距离,来判断2张图像是否一致。一共采用了3种哈希算法,这里就不深入探讨彼此的优劣了。

import cv2
import numpy as np
import collections
from airtest.core.api import *

class UIConfidence:
    @staticmethod
    def flatten(x):
        def iselement(e):
            return not(isinstance(e, collections.Iterable) and not isinstance(e, str))
        for el in x:
            if(iselement(el)):
                yield el
            else:
                yield from UIConfidence.flatten(el)
    @staticmethod
    def getAllHashValue(imgres):
        return [UIConfidence.aHash(imgres), UIConfidence.dHash(imgres), UIConfidence.pHash(imgres)]
    # 均值哈希算法
    @staticmethod
    def aHash(imgres):
        img = cv2.imread(imgres)
        # 缩放为8*8
        img = cv2.resize(img, (8, 8), interpolation=cv2.INTER_CUBIC)
        # 转换为灰度图
        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        # s为像素和初值为0,hash_str为hash值初值为''
        s = 0
        hash_str = ''
        # 遍历累加求像素和
        for i in range(8):
            for j in range(8):
                s = s + gray[i, j]
        # 求平均灰度
        avg = s / 64
        # 灰度大于平均值为1相反为0生成图片的hash值
        for i in range(8):
            for j in range(8):
                if gray[i, j] > avg:
                    hash_str = hash_str + '1'
                else:
                    hash_str = hash_str + '0'
        return hash_str
    # 差值感知算法
    @staticmethod
    def dHash(imgres):
        img = cv2.imread(imgres)
        # 缩放8*9
        img = cv2.resize(img, (9, 8),interpolation=cv2.INTER_CUBIC)
        # 转换灰度图
        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        hash_str = ''
        # 每行前一个像素大于后一个像素为1,相反为0,生成哈希
        for i in range(8):
            for j in range(8):
                if gray[i, j] > gray[i, j + 1]:
                    hash_str = hash_str + '1'
                else:
                    hash_str = hash_str + '0'
        return hash_str
    # 感知哈希算法(pHash)
    @staticmethod
    def pHash(imgres):        
        img = cv2.imread(imgres,0)
        img = cv2.resize(img,(64,64), interpolation=cv2.INTER_CUBIC)
        h,w=img.shape[:2]
        vis0 = np.zeros((h,w),np.float32)
        vis0[:h,:w] = img
        vis1 = cv2.dct(cv2.dct(vis0))
        vis1.resize(32,32)
        img_list = list(UIConfidence.flatten(vis1.tolist()))
        avg = sum(img_list)*1./len(img_list)
        avg_list = ['0' if i<avg else '1' for i in img_list]
        return ''.join(['%x' % int(''.join(avg_list[x:x+4]),2) for x in range(0,32*32,4)])
         # Hash值对比
    @staticmethod
    def cmpHash(hash1, hash2):
        n = 0
        # hash长度不同则返回-1代表传参出错
        if len(hash1)!=len(hash2):
            return -1
        # 遍历判断
        n = sum([ch1 != ch2 for ch1, ch2 in zip(hash1, hash2)])
        print(n)
        return n

遗留

  虽然引入了opencv这样的大佬,但实际运行的效果还是差强人意,在对比背景颜色接近的控件时,大概率识别为同一个控件。

com.android.settings

  如上图中桌面壁纸以及显示亮度均会被识别为与移动网络很相似(汉明距离小于2)。由此可见仅依赖简单的图像识别,似乎还不能接近完美的工作,后面我将继续探索如何更准确识别控件的方法 。