自动化工具 轮子:基于 adb 的 UI 自动化 “微框架”

甬力君 for 新秀群 · 2017年05月23日 · 最后由 甬力君 回复于 2020年09月04日 · 2168 次阅读
本帖已被设为精华帖!

背景

这段时间尝鲜了自动化框架 Appium 后,觉得对于安卓 UI 自动化实在是太重了(一堆环境就足以把人搞死、每次写完测试脚本发给别人跑就是个蛋疼的事,稍微吐槽下),是不是可以自己造个轻点的轮子,刚好自己学了一点 python 知识,于是,开干...

Talking

  • 第一步,找元素
    实现原理:使用 adb -s shell uiautomator dump /sdcard/uidump.xml 命令 dump 出当前 UI 布局树,使用 python 解析 xml,通过指定常见的 id, text, xpath 等等获取节点的坐标信息;拿到元素坐标,我们就可以进行下一步操作了。

  • 第二步,做动作
    实现原理:使用 adb -s shell input 等命令,结合上一步获取到的坐标,就可以实现常用用户操作;可以用户操作,我们可以下一步了。

  • 第三步,做判断
    实现原理:这一步比较灵活,我常用的方法有这些:第一是判断某个元素是否存在(通过第一步找元素判断),此方法用的最多;第二是截图对比,这个方法一般用在网络加载一个带图片的列表时。

Talking is cheap, show me your code.

代码不是很多:主框架 300 行左右, 两个 python 文件(mobtest.py 和 AndroidKeycode.py)。

  • 微框架里面只包含了使用于我自己工作的部分方法,范围比较小;
  • 代码写的烂,努力搬砖中;
  • 欢迎留言一起扯。

mobtest.py

# coding:utf-8
# Name   : mobtest
# Author : wanyor@qq.com
# Desc   : A simple ui automation framework for android
# Time   : 2017-1-16 15:10:48

import os
import re
import time
import tempfile
import xml.etree.cElementTree as ET
from AndroidKeycode import Keycode


class Utils(object):
    """docstring for Utils"""

    def __init__(self):
        super(Utils, self).__init__()

    def find_devices(self):
        rst = os.popen('adb devices').read()
        devices = re.findall(r'(.*?)\s+device', rst)
        if len(devices) > 1:
            return devices[1:]
        else:
            # raise Exception('DeviceNotFound')
            return []

class Device(object):
    # 元素内部类
    class Element(object):
        """docstring for Element"""

        def __init__(self, x, y, device_id):
            self.x = str(int(x))
            self.y = str(int(y))
            self.device_id = device_id

        def click(self):
            Device(self.device_id).click(self.x, self.y)

        def input(self, text):
            self.click()
            Device(self.device_id).input_text(text)

    # 设备未找到异常类
    class DeviceNotFoundException(Exception):
        def __init__(self, err='设备未找到' ):
            Exception.__init__(self, err)

    # 元素未找到异常类
    class ElementNotFoundException(Exception):
        def __init__(self, err='未找到此元素'):
            Exception.__init__(self, err)

    # activity未找到异常类
    class ActivityNotFoundException(Exception):
        def __init__(self, err='未找到此activity'):
            Exception.__init__(self, err)

    # 设备初始化方法

    def __init__(self, device_id):

        super(Device, self).__init__()

        self.device_id = device_id
        devices = re.findall(r'(.*?)\s+device', os.popen('adb devices').read())[1:]

        # print devices
        if device_id not in devices:
            self.DeviceNotFoundException()

        self.tempFile = tempfile.gettempdir()
        self.pattern = re.compile(r"\d+")
        # 安装unicode输入法并激活
        # self.install_app(os.getcwd() + '/bin/apk/MobInput.apk')
        # self.shell_cmd('ime enable org.mobtest.input/.InputService')
        # self.shell_cmd('ime set org.mobtest.input/.InputService')

    # 辅助类
    def get_abi(self):
        return self.shell_cmd('getprop ro.product.cpu.abi').strip()

    def get_sdk(self):
        return self.shell_cmd('getprop ro.build.version.sdk').strip()

    def shell_cmd(self, cmd):
        return os.popen('adb -s ' + str(self.device_id) + ' shell ' + cmd).read()

    def push_file(self, local_path, remote_path):
        os.popen('adb -s ' + str(self.device_id) + ' push ' + local_path + ' ' + remote_path)

    def pull_file(self, local_path, remote_path):
        os.popen('adb -s ' + str(self.device_id) + ' pull ' + local_path + ' ' + remote_path)

    def check_root(self):
        os.popen('adb -s ' + self.device_id + ' root')
        ret = os.popen('adb -s ' + self.device_id + ' remount').read().strip()
        if 'remount succeeded' in ret:
            return True
        else:
            return False

    # 截图方法先获取系统tmp目录全路径
    tempDir = tempfile.gettempdir()

    def save_screenshot(self, save_path=tempDir):
        self.shell_cmd('screencap /sdcard/sc.png')
        self.pull_file('/sdcard/sc.png', save_path)

    # device methods
    def get_screensize(self):
        temp = self.shell_cmd('wm size').split(':')[1].strip()
        return {'width': int(temp.split('x')[0]), 'height': int(temp.split('x')[1])}

    def get_device_datetime(self):
        return self.shell_cmd('date "+%Y-%m-%d_%H:%M:%S"').strip()

    def get_current_activity(self):
        ret = self.shell_cmd('dumpsys activity top').split('ACTIVITY')[1].split('\n')[0].split()[0].strip()
        return ret.split('/')[0] + '/' + ret.split('/')[0] + ret.split('/')[1]

    def get_current_pkg(self):
        return self.get_current_activity().split('/')[0].strip()

    def wait_for_activity(self, activity, waitMs=5000):
        time.sleep(waitMs/1000)
        if self.get_current_activity() == activity:
            return True
        else:
            raise self.ActivityNotFoundException()

    def get_current_input_method(self):
        return self.shell_cmd('ime list -s')

    # 应用相关方法
    def reset_app(self, pkgname):
        self.shell_cmd('pm clear ' + pkgname)

    def start_activity(self, app_pkg, app_main):
        self.shell_cmd('am start -n ' + app_pkg + '/' + app_main)

    def stop_app(self, app_pkg):
        self.shell_cmd('am force-stop ' + app_pkg)

    def install_app(self, apk_path):
        os.popen('adb -s ' + self.device_id + ' install -r ' + apk_path)

    def uninstall_app(self, pkgname):
        self.shell_cmd('pm uninstall ' + pkgname)

    def get_installed_app(self):
        ret = self.shell_cmd('pm list packages')
        pkgs = []
        for x in xrange(0, len(ret)):
            pkgs[x] = pkgs[x].split(':')[1].strip('\r\n')
        return pkgs

    def get_pkgs_by_type(self, app_type):
        if app_type == 'sys':
            ret = os.popen('adb -s ' + self.device_id + ' shell pm list packages -s').readlines()
        elif app_type == 'user':
            ret = os.popen('adb -s ' + self.device_id + ' shell pm list packages -3').readlines()
        pkgs = []
        for x in xrange(0, ret):
            pkgs[x] = ret[x].split(':')[1].strip('\r\n')
        return pkgs

    def is_app_installed(self, pkg):
        pkgs = self.get_installed_app()
        if pkg in pkgs:
            return True
        else:
            return False

    # 用户事件相关方法
    def click(self, x, y):
        self.shell_cmd("input tap " + str(x) + ' ' + str(y))

    def press_keycode(self, keyname):
        self.shell_cmd('input keyevent ' + str(Keycode().get(keyname)))

    def swipe(self, start_x, start_y, end_x, end_y, duration=50):
        self.shell_cmd('input swipe ' + start_x + ' ' + start_y + ' ' + end_x + ' ' + end_y + ' ' + str(duration))

    def input_text(self, text):
        # self.shell_cmd('input text "' + text + '"')
        print isinstance(text, unicode)
        if not isinstance(text, unicode):
            import unicodedata
            text = unicodedata.normalize('NFKD', text).encode('ascii', 'ignore')
        print text
        self.shell_cmd('am broadcast -a MOB_INPUT_TEXT --es text ' + text)

    def reboot(self, to=None):
        if to == 'recovery':
            # recovery mode
            self.shell_cmd('reboot recovery')
        elif to == 'bootloader':
            # fastboot mode
            self.shell_cmd('reboot bootloader')

    # UI元素相关方法
    def __uidump__(self):
        # Dump Current control tree  --compressed
        temp = self.shell_cmd('"uiautomator dump /data/local/tmp/uidump.xml|cat /data/local/tmp/uidump.xml"')
        with open(self.tempFile + '\\uidump.xml', 'w') as f:
            f.write(str(temp))
            f.close()
        # os.popen("adb -s " + self.device_id + " pull /data/local/tmp/uidump.xml " + self.tempFile)

    def __element__(self, attrib, name):
        # 同属性单个元素,返回单个坐标元组      
        self.__uidump__()
        tree = ET.ElementTree(file=self.tempFile + "\\uidump.xml")
        for elem in tree.iter(tag="node"):
            if elem.attrib[attrib] == name:
                bounds = elem.attrib["bounds"]
                coord = self.pattern.findall(bounds)
                x = (int(coord[2]) - int(coord[0])) / 2.0 + int(coord[0])
                y = (int(coord[3]) - int(coord[1])) / 2.0 + int(coord[1])
                ele = self.Element(x, y, self.device_id)
                return ele

    def __elements__(self, attrib, name):
        # 同属性多个元素,返回坐标元组列表          
        elements = []
        self.__uidump__()
        tree = ET.ElementTree(file=self.tempFile + "\\uidump.xml")
        for elem in tree.iter(tag="node"):
            if elem.attrib[attrib] == name:
                bounds = elem.attrib["bounds"]
                coord = self.pattern.findall(bounds)
                x = (int(coord[2]) - int(coord[0])) / 2.0 + int(coord[0])
                y = (int(coord[3]) - int(coord[1])) / 2.0 + int(coord[1])
                ele = self.Element(x, y, self.device_id)
                elements.append(ele)
        return elements

    def find_element_by_id(self, res_id):
        element = self.__element__('resource-id', res_id)
        if element is None:
            raise self.ElementNotFoundException()
        return element

    def find_elements_by_id(self, res_id):
        elements = self.__elements__('resource-id', res_id)
        if elements is None:
            raise self.ElementNotFoundException()
        return elements

    def find_element_by_name(self, name):
        element = self.__element__('text', name)
        if element is None:
            raise self.ElementNotFoundException()
        return element

    def find_elements_by_name(self, name):
        elements = self.__elements__('text', name)
        if elements is None:
            raise self.ElementNotFoundException()
        return elements

    def find_element_by_class(self, classname):
        element = self.__element__('class', classname)
        if element is None:
            raise self.ElementNotFoundException()
        return element

    def find_elements_by_class(self, classname):
        elements = self.__elements__('class', classname)
        if elements is None:
            raise self.ElementNotFoundException()
        return elements

    '''
    def find_element_by_xpath(self, xpath):
        return Element.findElementByName(xpath)

    def find_elements_by_xpath(self, res_id):
        return self.find_element_by(xpath)
    '''

# 微框架方法测试
if __name__ == '__main__':
    util = Utils()
    device_id = util.find_devices()[0]
    device = Device(device_id)
    device.__uidump__()
    xy = device.find_element_by_name(u'相机')
    print xy.x, xy.y
    print device.check_root()
    # 演示获取sdk level
    print device.get_sdk()
    # 演示获取abi
    print device.get_abi()
    # 演示获取屏幕分辨率
    print device.get_screensize()
    # 演示获取设备时间
    print device.get_device_datetime()
    # 演示获取设备截屏
    device.save_screenshot()
    # 演示获取当前activity
    print device.get_current_activity()
    # 演示获取当前包
    print device.get_current_pkg()
    # 演示等待activity启动
    device.wait_for_activity('net.oneplus.launcher/net.oneplus.launcher.Launcher', 2)
    # # 演示等待activity启动未找到抛异常
    # device.wait_for_activity('aa')
    # 演示列出当前输入法
    print device.get_current_input_method()

AndroidKeycode.py

# coding:utf-8

class Keycode(object):
    """docstring for Keycode"""
    # Keycode for android

    def __init__(self):
        super(Keycode, self).__init__()

    event_code_list = {
        # Keycode for phone
        'KEYCODE_CALL': 5,
        'KEYCODE_ENDCALL': 6,
        'KEYCODE_HOME': 3,
        'KEYCODE_MENU': 82,
        'KEYCODE_BACK': 4,
        'KEYCODE_SEARCH': 84,
        'KEYCODE_CAMERA': 27,
        'KEYCODE_FOCUS': 80,
        'KEYCODE_POWER': 26,
        'KEYCODE_NOTIFICATION': 83,
        'KEYCODE_VOLUME_MUTE': 164,
        'KEYCODE_VOLUME_UP': 24,
        'KEYCODE_VOLUME_DOWN': 25,
        # Keycode for control
        'KEYCODE_ENTER': 66,
        'KEYCODE_ESCAPE': 111,
        'KEYCODE_DPAD_CENTER': 23,
        'KEYCODE_DPAD_UP': 19,
        'KEYCODE_DPAD_DOWN': 20,
        'KEYCODE_DPAD_LEFT': 21,
        'KEYCODE_DPAD_RIGHT': 22,
        'KEYCODE_MOVE_HOME': 122,
        'KEYCODE_MOVE_END': 123,
        'KEYCODE_PAGE_UP': 92,
        'KEYCODE_PAGE_DOWN': 93,
        'KEYCODE_DEL': 67,
        'KEYCODE_FORWARD_DEL': 112,
        'KEYCODE_INSERT': 124,
        'KEYCODE_TAB': 61,
        'KEYCODE_NUM_LOCK': 143,
        'KEYCODE_CAPS_LOCK': 115,
        'KEYCODE_BREAK': 121,
        'KEYCODE_SCROLL_LOCK': 116,
        'KEYCODE_ZOOM_IN': 168,
        'KEYCODE_ZOOM_OUT': 169,
        # Keycode for basic
        'KEYCODE_0': 7,
        'KEYCODE_1': 8,
        'KEYCODE_2': 9,
        'KEYCODE_3': 10,
        'KEYCODE_4': 11,
        'KEYCODE_5': 12,
        'KEYCODE_6': 13,
        'KEYCODE_7': 14,
        'KEYCODE_8': 15,
        'KEYCODE_9': 16,
        'KEYCODE_A': 29,
        'KEYCODE_B': 30,
        'KEYCODE_C': 31,
        'KEYCODE_D': 32,
        'KEYCODE_E': 33,
        'KEYCODE_F': 34,
        'KEYCODE_G': 35,
        'KEYCODE_H': 36,
        'KEYCODE_I': 37,
        'KEYCODE_J': 38,
        'KEYCODE_K': 39,
        'KEYCODE_L': 40,
        'KEYCODE_M': 41,
        'KEYCODE_N': 42,
        'KEYCODE_O': 43,
        'KEYCODE_P': 44,
        'KEYCODE_Q': 45,
        'KEYCODE_R': 46,
        'KEYCODE_S': 47,
        'KEYCODE_T': 48,
        'KEYCODE_U': 49,
        'KEYCODE_V': 50,
        'KEYCODE_W': 51,
        'KEYCODE_X': 52,
        'KEYCODE_Y': 53,
        'KEYCODE_Z': 54,
        # Keycode for symbol
        'KEYCODE_PLUS': 3,
        'KEYCODE_MINUS': 3,
    }

    def get(self, keyname):
        return self.event_code_list[keyname]
如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 11 条回复 时间 点赞

可以交流下,最近添加了新能力,OCR 识别和模板匹配,先公司内部试用下,然后 opensource

思寒_seveniruby 将本帖设为了精华贴 05月23日 16:57

发现一个错别字 bug

给楼主点个赞👍
我之前也写过这个.感觉有些地方可以讨论一下
比如

def get_current_activity(self):
        ret = self.shell_cmd('dumpsys activity top').split('ACTIVITY')[1].split('\n')[0].split()[0].strip()
        return ret.split('/')[0] + '/' + ret.split('/')[0] + ret.split('/')[1]

我使用的 (windows 环境)

def get_current_activity(self):
    return self.shell_cmd('dumpsys activity | findstr "mFocusedActivity"').stdout.read().split('/')[-1].split()[0]

当然还有另外一种方式:

def get_current_activity(self):
    return adb.shell('dumpsys window w | findstr \/| findstr name=').stdout.read().split('/')[-1].split(')')[0]

如果是 unix 可以用 grep 代替 findstr
当然条条大路通罗马,能达到目的都是可行的.😀

加精理由: 之前也有同学实现过. 不过鼓励这种动手实践

甬力君 回复

还可以按照系统做个简单的区分使用 grep 和 findstr👍

淼淼淼 回复

谢谢 nil,想法是尽量用 python 解决 😄

xuxu 回复

刚看了下,大神写的比我好😊。

感谢楼主,最近在研究这个方向,看见你的帖子,正好学习到了,十分感谢

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册