Airtest 介绍
Airtest Project 是网易游戏推出的一款自动化测试框架,其项目由以下几个部分构成
Airtest : 一个跨平台的,基于图像识别的 UI 自动化测试框架,适用于游戏和 App , 支持 Windows, Android 和 iOS 平台, 基于 Python 实现。
Poco : 一款基于 UI 组件识别的自动化测试框架,目前支持 Unity3D , cocos2dx, Android 原生 App , iOS 原生 App 和 微信小程序, 也可以在其他引擎中自行介入 poco-sdk 使用, 基于 Python 实现
AirtestIDE : 提供一个跨平台的 UI 自动化测试编辑器, 内置了 Airtest 和 Poco 的相关插件功能,能够快速,简单的编写 Airtest 和 Poco 代码
AirLab: 真机自动化云测试平台, 目前提供 Top 100 手机兼容性测试,海外云真机兼容性测试等服务
私有化手机集群技术: 从硬件到软件, 提供在企业内部私有化手机集群解决方案
总之,Airtest 建立了一个比较完善的自动化测试方案,我们利用它实现所见即所爬,个人认为比 Appium 更加简单易用。本节我们先了解一下 AirtestIDE 的基本使用
准备工作
确保安装好 AirtestIDE , Airtest Python 库 和 Poco Python 库
只使用 AirtestIDE 实现自动化模拟和数据爬取也是没问题的, 因为它里面已经内置了 Python 模块, Airtest Python 库和 Poco Python 库,并且提供了非常便捷的可视化点选和代码生成等功能,即使使用者没有任何 Python 基础, 也能自动化控制 App 和完成数据爬取
但是对于需要爬取大量数据和控制页面跳转的场景而言, 仅依靠可视化点选和自动生成代码来自动化控制 App , 其实是不灵活的。 进一步讲,如果我们加入一些代码逻辑,例如流程控制,循环控制语句,就可以爬取批量的数据了,这时候需要依赖 Airtest, Poco 以及一些自定义逻辑和第三方库
Airtest 的官方文档: https://airtest.doc.io.netease.com/tutorial/1_quick_start_guide
详细介绍了 Airtest 的安装方式,包括 AirtestIDE , Airtest Python 库和 Poco Python 库,所以这里建议同时安装
下载路径: Airtest Project (netease.com)
下载后解压, 然后打开 AirtestIDE.exe 会弹出一个登录提示,跳过即可
安装完 AirtestIDE 之后, 它还会安装一个 Python 环境, 这个环境中附带安装了 Airtest Python 库和 Poco Python 库, 不过这个被打包在 AirtestIDE 里面的环境,和系统里安装的 Python 环境并不是同一个,所以推荐直接使用 pip 工具将 Airtest Python 库和 Poco Python 库安装到系统环境中
pip install airtest
pip install pocoui
安装完成之后,在 AirtestIDE 中把默认的 Python 环境由 AirtestIDE 附带的 Python 环境更换成系统的 Python 环境。
选项 ---- 设置 ---- 自定义 Python.exe 选择已有的 python 解释器即可
安装好之后,需要准备一台 Android 真机 或者这模拟器,真机需要通过 USB 线和电脑相连, 确保 adb 能够正常连接到手机
AirtestIDE 体验
我这里使用的是夜神模拟器,在 CMD 输入 adb devices
然后启动 AirtestIDE , 打开菜单中的 文件----新建脚本--- .air Airtest 项目, 新建一个脚本,选择一个路径,将脚本命名为 script.air 之后点击确定
正常情况下,已经链接上设备了,如果没有连接,刷新一下
我们可以点击页面中的屏幕对手机进行控制,如果出现了链接问题,可以参考官网
这时候可以点击 connect ,然后就可以看到手机屏幕了
至此,要确保所有的步骤都成功了,不然后面可能无法继续
我们来观察一下整个 AirtestIDE 页面,分为 左, 中, 右 三个部分, 一下内容为各组件介绍
左侧靠上的部分是 AirtestIDE 辅助窗, 可以通过一些点选操作实现基于图像识别的自动化配置
左侧中间偏上的部分是 Poco 辅助窗, 可以通过一些点选操作实现基于 UI 组件识别的自动化配置
中间靠上的部分是脚本编辑窗, 即代码编辑区域,可以通过 Airtest 辅助窗和 Poco 辅助窗自动生成代码,也可以自己编写代码,这个代码是基于 Python 语言的
中间靠下是 log 查看窗, 即日志区域, 会输出运行,调试的一些日志
右侧是设备窗,内容为手机屏幕,用鼠标点击这个屏幕,真机或模拟器的屏幕也会跟着变化,而且响应速度很快
Airtest 的图像识别与自动化控制
Airtest 可以基于图像识别来自动化控制 App , 本节我们就来体验一下这个功能。例如先点击作恶的 touch 按钮, 意思是点击屏幕的某个位置
这时 AirtestIDE 会提示我们在右侧手机屏幕上截图,这里我们截取的是 大众点评 的 APP 图标,会发现 script.air 脚本中出现了一行代码, 代码内容为 touch 方法, 其参数是我们刚截取的图片
然后点击右侧的手机屏幕上的 "大众点评" 图标, 进入这个 App , 再点击左侧的 wait 按钮, 意思是等待指令内容加载出来,之后同样根据提示截图, 如截取首页左上角的美食图标
再点击左侧的 swipe 按钮, 意思是滑动屏幕
这时 AirtestIDE 会提示我们框选一个位置, 联想自己平时滑动屏幕的场景,手指一开始先放在一个位置,然后滑动,到某个位置停止。那么这时的第一步需要框选的位置就是手指一开始需要放置的位置,如 1 的地方 ---中间菜单栏 (菜单栏下方加载的内容会变化, 故选择相比之下更加通用不变的菜单栏作为识别目标) 框选完毕后, AirtestIDE 会提示我们点选一个滑动目标位置,这时选择点选上方的一个点即可, 如果中标 2 的地方,此时会发现 scrpt.air 脚本生成了一个 swipe 方法, 其第一个参数是我们框选的菜单栏图片, 第二个参数是一个 vector, 代表滑动的方向。
这样我们就通过一些可视化完成了自动化控制
最后我们再通过左侧的 keyevent 按钮添加两个键盘事件, 在已经生成的代码开头和结尾分别加一个 HOME 键盘事件, 代表进入首页和返回首页
先让鼠标停留在需要添加事件的位置,然后点击 keyevent, 输入 HOME, 开头和结尾都是一样
现在总结一下我们实现自动化控制的流程
-
进入手机首页
-
点击 "大众点评" App的图标
-
等待左上角的 "美食" 图标加载出来
-
向上滑动手机屏幕
-
返回手机主页
接下来点击 script.air 脚本上方的运行按钮 (三角按钮), 会发现 AirtestIDE 可以驱动手机完成指定操作了,和我们期望的一样,点击,等待, 滑动操作顺序执行,切 log 查看窗会显示执行的具体过程
以上便是 Airtest 提供的基于图像识别来自动化控制 App 的过程,利用这项技术,我们不用编写任何代码就可以让手机自动操作
其实 script.air 脚本内部对应的就是 Python 代码, 只不过利用 AirtetIDE 封装了一层, 使得编写和操作更加简单了。 我们可以追踪一下源码, 在当前脚本的选项卡右击,在弹出的菜单选项中选择"打开当前项目目录" 就会看到源码内容
可以看到其中有一个 Python 脚本, 和 3 张刚才截取的图片,打开 Python 脚本
可以看到其内容和 AirtestIDE 中自动生成的代码基本一致,不同之处在于这里用了一个 Template 对象代替了图片, 该对象包含图片名,位置,分辨率三个参数,而 AirtestIDE 对图片进行了可视化, 使其更加直观
我们可以更具Python 环境运行这个脚本么?可以, 但是需要在代码开始的 auto_setup(file) 和 keyevent("HOME") 之间添加一行代码 init_device() , 调用 init_device 方法的作用完成一些手机初始化配置, 不做这一步可能会报错。 运行脚本后会产生同样的效果, 手机会被自动化控制执行一系列的操作, 同时控制台输出对应的操作日志
Airtest 的相关 API
上面的内容只是 Airtest Python 库的冰山一角,本节列举一些它提供的便捷 API 。 从刚才的 init_device 方法说起,这个方法是用来连接设备并初始化一些连接对象的。如果设置没有初始化则会先初始化设置,并把初始化后的设备当作当前设备
def init-device(platform="Android", uuid=None, **Kwargs)
用法示例:
device: Android = init_device('Android')
print(device)
<airtest.core.android.android.Android object at 0x00000234FE2EC510>
可以发现返回结果是一个 Android 对象, 这个 Android 对象实际上属于 airtest.core.android 包, 继承自 airtest.core.device.Device 类, 与之并列的对象还有 airtest.core.ios.ios.IOS airtest.core.Linux.linux.Linux airtest.core.win.win.Windows 等。这些对象都有一些用来操作设备的 API , 下面我们以 Android 对象的 API 为例总结一下
get_default_device : 获取默认设备
uuid : 获取当前设备 UUID
list_app : 列举设备上的所有 App
path_app : 打印出某个 App的完整路径
check_app: 检查某个APP 是否在当前设备上
start_app : 启动某个 APP
stop_app : 停止某个 APP
start_app_timing : 启动某个 APP 并计算启动时间
clear_app : 清空某个 APP 的全部数据
install_app : 安装某个 APP
install_multiple_app : 安装多个 APP
uninstall_app : 卸载某个 APP
snapshot : 获取屏幕截图
shell : 获取 adb shell 命令执行结果
keyevent : 执行键盘操作
wake : 唤醒当前设备
home : 点击 HOME 键
text ; 向设备输入内容
touch : 点击屏幕上的某处
double_click :双击屏幕某处
swipe : 滑动屏幕, 由一点滑动至另一点
pinch ; 通过手指的捏合操作放大或缩小屏幕
logcat : 记录日志
getprop : 获取某个特定属性的值
get_ip_address : 获取 IP 地址
get_top_activity :获取当前 Activity
get_top_activity_name_and_pid : 获取当前 Activity 的名称和进程号
get_top_activity_name : 获取当前 Ativity 的名称
is_keyboard_shown : 判断当前是否显示键盘了
is_locked : 判断设备是否锁定了
unlock : 解锁设备
get_display_info : 获取当前显示信息, 如屏幕宽高等
get_current_resolution ; 获取当前设备的分辨率
get_render_resolution : 获取当前渲染的分辨率
start_recording : 开始录制
stop_recording : 结束录制
adjust_all_screen : 调整屏幕的适配分辨率
下面做一些实例感受
from airtest.core.api import * from airtest.core.android import Android import logging logging.getLogger("airtest").setLevel(logging.WARNING) # 初始化设备 device: Android = init_device('Android') # 是否上锁 is_locked = device.is_locked() print(f'is_locked {is_locked}') # 如果上锁, 就解锁 if is_locked: device.unlock() # 唤醒设备 device.wake() # 列举设备上的 APP app_list = device.list_app() print(f'app_list {app_list}') # 获取当前设备 UUID uuid = device.uuid print(f'uuid {uuid}') # 获取当前显示信息, 如屏幕宽高等 display_info = device.get_display_info() print(f'display_info {display_info}') # 获取当前渲染的分辨率 resolution = device.get_render_resolution() print(f'resolution {resolution}') # 获取 IP 地址 ip_address = device.get_ip_address() print(f'ip_address {ip_address}') # :获取当前 Activity top_activity = device.get_top_activity() print(f'top_activity {top_activity}') # 判断当前是否显示键盘了 is_keyboard_shown = device.is_keyboard_shown() print(f'is_keyboard_shown {is_keyboard_shown}')
is_locked False
app_list ['com.android.cts.priv.ctsshim', 'com.android.providers.telephony' ]
uuid 127.0.0.1:62001
display_info {'width': 720, 'height': 1280, 'density': 1.5, 'orientation': 0, 'rotation': 0, 'max_x': 720, 'max_y': 1280}
resolution (0.0, 0.0, 720.0, 1280.0)
ip_address 172.16.38.15
top_activity ('com.android.launcher3', '.launcher3.Launcher', '2509')
is_keyboard_shown False
从结果可以看出, 借助一些常用的 API , 我们就完成了唤醒手机和获取 APP 列表, UUID ,显示器信息, 分辨率, IP地址, 当前运行的 Activity , 是否显示键盘等操作
获取当前设备
Airtest 中有一个全局变量 G, 它的 DEVICE 属性代表当前的设备对象。这直接调用 device 方法即可获取当前设备,该方法定义如下
def device():
return G.DEVICE
获取所有设备
print(G.DEVICE_LIST) uri = 'Android://127.0.0.1:5037/127.0.0.1:62001' device: Android = connect_device(uri) print(G.DEVICE_LIST)
[]
[<airtest.core.android.android.Android object at 0x0000028A8B6CE250>]
uri 获取
DEVICE_LIST 是一个列表,元素是 Airtest , 当前已经连接的设备,需要注意的是,在没有调用 connect_device 方法的时候, DEVICE_LIST 是空的, 调用 connect_device 方法后,DEVICE_LIST 会自动添加已经连接的设备
执行命令
可以调用 shell 方法,传入 cmd 参数来执行命令行,直接调用 adb 命令就可以了
@logwrap def shell(cmd): return G.DEVICE.shell(cmd) uri = 'android://127.0.0.1:5037/127.0.0.1:62001' connect_device(uri) result = shell('cat /proc/meminfo') print(result)
MemTotal: 3566528 kB
MemFree: 3061388 kB
MemAvailable: 3285952 kB
Buffers: 420 kB
Cached: 280936 kB
SwapCached: 0 kB
Active: 259808 kB
Inactive: 210492 kB
这样就获取了设备的内存信息
启动和停止
调用设备的 start_app 和 stop_app 方法,然后传入 App 的包名,即可启动和停止这个 App
@logwrap def start_app(package, activity=None): G.DEVICE.start_app(package, activity) @logwrap def stop_app(package): G.DEVICE.stop_app(package) uri = 'android://127.0.0.1:5037/127.0.0.1:62001' connect_device(uri) package = 'com.goldze.mvvmhabit' start_app(package) sleep(10) stop_app(package)
这里指定了 package 为 app5的包名,然后调用 start_app 方法启动了 app5, 等待 10 秒后, 调用 stop_app 方法停止了 app5 的运行
安装和卸载 app
调用设备的 install 和 uninstall 方法, 前者传入 APP 的保存路径,后者传入包名, 即可安装和卸载 app
安装 app
@logwrap
def install(filepath, **kwargs):
return G.DEVICE.install_app(filepath, **kwargs)
卸载 app
@logwrap
def uninstall(package):
return G.DEVICE.uninstall_app(package)
截图
利用 snapshot 方法获取屏幕截图, 可以通过参数设置存储截图的文件名称和图片的质量等
def snapshot(filename=None, msg=" ", quality= ST.SNAPSHOT_QUALITY)
用法示例
uri = 'android://127.0.0.1:5037/127.0.0.1:62001' connect_device(uri) package = 'com.goldze.mvvmhabit' G.DEVICE.start_app(package) sleep(3) snapshot('app5.png', quality=30) G.DEVICE.stop_app(package)
唤醒和回到首页
调用设备的wake 和 home 方法,即可唤醒 APP 和回到首页,两个方法的定义如下
唤醒
@logwrap
def wake():
G.DEVICE.wake()
回到首页
@logwrap
def home():
G.DEVICE.home()
这两个方法不需要参数,直接调用即可
点击屏幕
调用 touch 方法点击屏幕, 可传入要点击的图片或绝对位置,还可以指定点击次数,声明如下
@logwrap
def touch(v, times=1, **kwargs)
例如,我们从手机上截一张需要点击的 app 的图片,然后把这张图片声明成一个 Template 对象传入 touch 方法
uri = 'android://127.0.0.1:5037/127.0.0.1:62001' connect_device(uri) touch(Template('app.png'))
运行这段代码后,设备就会启动,然后点击这张图片
我们也可以传入绝对位置,具体位置以自己设备为准,需要测量
uri = 'android://127.0.0.1:5037/127.0.0.1:62001' connect_device(uri) home() touch((70, 645))
另外,touch 完全等同于 click 方法,如果需要双击,可以调用 double_click 方法,等同于 touch 的 times = 2 , click 方法的参数和 touch 的参数是一样的
滑动
调用 swipe 方法滑动屏幕, 可以传入起始位置和结束位置,两个位置都可以是图片或者绝对位置
声明如下
@logwrap
def swipe(v1, v2=None, vector=None, **kwargs)
例如我们想要控制手机向右滑动,可以实现如下代码
uri = 'android://127.0.0.1:5037/127.0.0.1:62001' connect_device(uri) home() swipe((200, 300), (900, 300))
放大缩小
放大缩小是调用 pinch 方法, 可以通过 in_or_out 参数指定放大还是缩小,还可以指定手指捏合的中心点位置和放大缩小的比例 ,该方法的声明如下
@logwrap
def pinch(in_or_out='in', center=None, percent=0.5)
用法如下
uri = 'android://127.0.0.1:5037/127.0.0.1:62001' connect_device(uri) home() pinch(in_or_out='out', center=(300, 300), percent=0.4)
这里我们调用了 pinch 方法, 并且制定了放大动作 out , 同时指定了捏合的中心点和捏合比例
键盘事件
调用 keyevent 方法来按下某个键, 例如 HOME 键,返回键等。声明如下
def keyevent(keyname, **kwargs)
用法示例
keyevent('HOME')
表示按下HOME 键
输入内容
调用 text 方法来输入内容,前提是目标 Widget 需要处于 active 状态。声明如下
@logwrap
def text(text, enter=True, **kwargs)
调用该方法后,目标 Widget 就会输入相应的字符,输入完之后会执行一次确认(按回车键)
基于 Poco 的 UI 组件自动化
在某些场景下,基于图像的识别来自自动化控制 App 是比较方便的,但也存在一定的局限性。例如图像识别速度可能不快,以及 App 中的某些 UI 如果更换了,就无法和之前截图的图片匹配成功,这些很可能影响自动化测试流程
所以,这里再介绍一些基于 Poco 的 UI 组件自动化控制,说白了就是基于 UI 名称和属性选择器的自动化控制,有点类似于 Appium, Selenium 中的 XPath
新建一个脚本,命名为 script2.air , 右侧同样连接好手机,然后点击左侧 Poco 辅助窗, 选择 Android ,这时会提示我们更新代码,点击确定后脚本中自动添加了代码
from poco.drivers.android.uiautomation import AndroidUiautomationPoco
poco = AndroidUiautomationPoco(use_airtest_input=True, screenshot_each_action=False)
意思是导入了 Poco 包的 AndroidUiautomationPoco 模块,然后声明了一个 poco 对象。接下来就可以通过 poco 对象选择一些内容了。 例如点击左侧 UI 组件树中的 "大众点评" 节点就会发现手机右侧屏幕上对应的 app 高亮显示了,在 Log 查看窗 还可以看到该节点对应的所有属性。这个操作有点像在浏览器开发者工具中选取网页源代码, 其中的 UI 组件树就相当于网页里的 HTML DOM 树
直接双击 "大众点评" 节点, script2.air 脚本就会出现对应的代码
poco("大众点评")
我们来看一下 poco 的 API ,这是一个 AndroidUiautomationPoco 对象
官方文档: https://poco.readthedocs.io/zh_CN/latest/source/poco.pocofw.html
其用法类似如下
poco = AndroidUiautomationPoco(...)
close_bin = poco('close', type='Button')
会发现 Poco 本身就是一个对象,但可以直接调用 UI 组件的名称, 这归根结底是因为实现了一个 call 方法:
def call(self, name=None, **kw):
if not name and len(kw) == 0:
warnings.warn("Wildcard selector may cause performance trouble, Please give at least one condition to shrink range of results")
return UIObjiectProxy(self, name, **kw)
可以看到 call 方法第一个参数是 name , 其他参数都以 kw 的形式传入,可以任意指定, 最后返回一个 UIObjectProxy 对象
回过头来, 我们看看 "大众点评" 这个节点的 name 参数值是什么, 这个在 Log查看窗 内显示的很清楚
可以看到其 name 就是 "大众点评" 而且整个 UI 树没有与其同名的节点, 于是可以直接调用 poco('大众点评') 选取这个节点,当然也可以任意指定 poco 的其他参数
poco("大众点评", type='android.widget.TextView')
poco("大众点评", text='大众点评')
poco("大众点评", text='大众点评', desc='大众点评')
这三种方法都能选取同样的节点
刚才说到 call 会返回一个 UIObjectProxy 对象, 现在我们来看一下这个对象的实现,其 API 链接为: https://poco.readthedocs.io/zh_CN/latest/source/poco.pocofw.html 从中可以看出它实现了 getitem , iter, len , child, children , offspring 等方法,所以可以实现链式调用,索引操作和循环遍历
其中一些比较常用的方法如下
child : 选择子节点。 第一个参数是 name , 即 UI 组件的名称, 如 android.widget.LinearLayout 等, 还可以传入一些属性辅助选择, 其返回结果也是 UIObjectProxy 对象
perent: 选择父节点。 该方法无需传入参数, 可以直接返回当前节点的父节点,返回同样是 UIObjectProxy 对象
sibling: 选择兄弟节点。 第一个参数是 name , 即 UI 组件的名称, 同样可以额外传入一些属性辅助选择,返回结果依然是 UIObjectProxy 对象
click, rclick , double_click, long_click : 分别是点击, 右击,双击, 长按。 UIObjectProxy 对象可以直接调用这几个方法, 参数 focus 用于指定点击的偏移量, sleep_interval 用于指定点击完成后的等待时间 (单位为 秒)
swipe :滑动操作。参数 direction 用于指导滑动方向, focus 用于指导滑动焦点的偏移量, duration 用于指导完成滑动所需的时间
wait , wait_for_appearance ; 等待某节点的出现。 参数 timeout 用于指定最长等待时间
attr : 获取节点的属性值, 参数 name 用于指定要获取的属性名, 如 visable , text , type, pos, size 等
get_text: 获取节点的文本值。 这个方法非常有用, 可以获取某个文本节点内部的文本数据。
下面调用 click 方法, 将代码改为
poco("大众点评").click()
这样就可以选中并点击 "大众点评" 节点, 点击之后,就进入 "大众点评" 这个 App ,然后可以设置一下等待条件, 等待某个节点加载出来, 证明已经进入 App 了 ,然后点击 左侧的 Poco Pause 按钮, 可以在右侧屏幕上点击想要查看的位置,左侧 UI 组件树就会自动定位到对应的节点, 同时 Log查看窗 会实时显示节点信息
双击左侧 UI 组件树中定位到的节点, script2.air 中又会增加如下内容
poco("com.dianping.v1:id/home_category_layout")
然后可以在后面加上等待时间
poco("com.dianping.v1:id/home_category_layout").wait_for_appearance(10)
代表等待 10 秒,如果加载不出来就报错
同样可以选中中间菜单栏的位置向上滑动
poco(desc="美食").swipe([0, -0.1])
这里往 swipe 方法的参数传入一个列表, 代表滑动方向, 列表第一个元素代表横向偏移量,第二个元素代表纵向偏移量, 由于我们要向上滑动, 因此第一个元素是 0 , 第二个元素是 -0.1
最后在代码开头和结尾添加键盘事件,回到首页,整理代码如下
-*- encoding=utf8 -*-
author = "86151"
from airtest.core.api import *
auto_setup(file)
from poco.drivers.android.uiautomation import AndroidUiautomationPoco
poco = AndroidUiautomationPoco(use_airtest_input=True, screenshot_each_action=False)
keyevent('HOME')
poco("大众点评").click()
poco("com.dianping.v1:id/home_category_layout").wait_for_appearance(25)
poco(desc="美食").swipe([0, -0.1])
keyevent('HOME')
运行这段代码之后,手机上就会先进入桌面, 然后点击 "大众点评" 图标进入 APP , 等待相应内容加载出来之后, 向上滑动, 最后返回桌面