uiautomator2自动化测试

原理

        uiautomator2是用python编写的Android手机测试框架。Google在Android中提供了uiautomator框架用于手机的自动化测试,所以以前做Android的自动化测试,都是通过自己写一个测试APK。在测试APK的单元测试中使用uiautomator框架来写一些自动化测试用例,在调试测试APK的时候执行自动化测试用例,这样就可以做到Android手机的自动化测试。

        但是这种测试方法有个缺点,就是每一次单独的自动化测试都需要单独写一个测试APK。而且调试自动化测试用例的时候也不太方便,因为单独执行某一条自动化测试用例也需要先执行测试APK的安装,再执行调试。所以我们可不可以先写一个测试APK,在这个测试APK中实现了所有uiautomator控制手机的方法,然后再给这个测试APK写一个socket服务。让这个测试APK在调试的时候先开启socket服务器,一直监听某个端口是否有操作手机的信息发送过来。这时我们就可以在其他软件上通过socket连接上测试APK的socket服务器,然后给测试APK发送操作手机的信息。例如我们发送一个滑动的信息,测试APK就执行相应的滑动方法,这样就实现了在测试APK外部使用测试脚本来控制手机的目的。我们就再也不用反复的去写测试APK了,也不用通过修改测试APK来修改自动化测试用例了。

        python中的uiautomator2测试框架就类似这样的一种测试工具,通过安装在手机上的uiautomator测试APK在调试的时候开启http rpc服务,一直监听7912端口。我们在电脑端通过python脚本给手机的7912端口发送操作手机的信息,信息中包含操作手机使用的方法,以及执行方法时要传入的参数。看起来操作方法还挺复杂的,但是这些复杂的操作方式都已经被封装到了uiautomator2框架中,我们在写python脚本的时候只需要调用uiautomator2中的方法,就能轻松实现手机的控制了。下面我将为大家介绍uiautomator2测试框架。

uiautomator2测试框架

        源项目地址:https://github.com/openatx/uiautomator2

安装uiautomator2库

        安装指令:pip install --pre uiautomator2

        等待uiautomator2的库文件下载完成,如果你缺少uiautomator2的依赖库,它还会自动下载依赖库。下载完成后,我们就可以在py文件中导入uiautomator2了。因为uiautomator2的名字太长,在导入后我们会给它取别名为u2。(import uiautomator2 as u2)

测试APK的安装

        测试APK通常是自动安装,我们也可以手动安装。前提是手机通过USB线与电脑连接,并且手机打开了USB调试。用于测试的文件有6个,会自动推送到手机的/data/local/tmp目录下,其中两个APK会安装到手机上。

测试APK是下面两个:

        app-uiautomator.apk  包名:com.github.uiautomator

下载地址:https://tool.appetizer.io/openatx/android-uiautomator-server/releases/download/2.3.3/app-uiautomator.apk

        app-uiautomator-test.apk  包名:com.github.uiautomator.test

下载地址:https://tool.appetizer.io/openatx/android-uiautomator-server/releases/download/2.3.3/app-uiautomator-test.apk

测试APK守护进程文件:

        atx-agent  防止测试APK被系统回收

下载地址:https://tool.appetizer.io/openatx/atx-agent/releases/download/0.10.0/atx-agent_0.10.0_linux_arm64.tar.gz

测试辅助文件是下面3个:

        minicap  实时投屏或截屏

下载地址:https://tool.appetizer.io/openatx/stf-binaries/raw/0.3.0/node_modules/@devicefarmer/minicap-prebuilt/prebuilt/arm64-v8a/bin/minicap

        minicap.so

下载地址:https://tool.appetizer.io/openatx/stf-binaries/raw/0.3.0/node_modules/@devicefarmer/minicap-prebuilt/prebuilt/arm64-v8a/lib/android-25/minicap.so

        minitouch  精确定位UI界面元素信息

下载地址:https://tool.appetizer.io/openatx/stf-binaries/raw/0.3.0/node_modules/@devicefarmer/minitouch-prebuilt/prebuilt/arm64-v8a/bin/minitouch

自动安装

        如果我们的电脑是连接了网络的,我们在执行uiautomator2中的方法时,它会查看7912端口的监听服务器是否存在,如果不存在,则检查服务是否启动或手机上是否有测试APK,如果没有安装APK就从在你电脑上的某个文件夹(一般在当前用户目录下有个隐藏的.uiautomator2文件夹)中去找,如果电脑上有测试APK就通过adb install给手机安装上;如果没有则通过网络到git上去下载,下载后再安装到手机上。

手动安装

        安装指令:python -m uiautomator2 init

        上面指令的意思就是初始化uiautomator2,效果就跟我们去执行uiautomator2中的方法时一样。

        如果你的测试电脑没有连接网络,则提前去git上下载上面说的6个测试文件。把两个测试APK用adb install安装到手机上,4个测试文件用adb push推送到/data/local/tmp目录下。

连接手机

        uiautomator2安装好了,就可以在py文件中调用uiautomator2中操作手机的方法。

        第一步连接要操作的手机,有两种连接方式。一种是通过USB线来连接,另一种是通过WiFi来连接。

        通过USB连接时,只需要用USB线连接电脑和手机,手机打开了USB调试,在电脑终端(Linux:bash,Windows:cmd)使用adb devices指令查看手机序列号,把手机序列号放到uiautomator2的connect_usb()函数中。具体代码如下:

import uiautomator2 as u2  # 导入uiautomator2库取别名为u2

device = u2.connect_usb("手机序列号")  # 定义要连接的手机,初始化uiautomator2

        通过wifi连接时,需要电脑和手机处于同一个WiFi网络中(局域网也行)。手机打开了USB调试,在手机设置中查看手机的IP地址,把手机的IP地址放到uiautomator2的connect_wifi()函数中。具体代码如下:

import uiautomator2 as u2  # 导入uiautomator2库取别名为u2

device = u2.connect_wifi("手机IP地址")  # 定义要连接的手机,初始化uiautomator2

通过上面的两种方式,都可以连接到手机,并把uiautomator2的实例赋值给了变量device,下面我就接着使用公共变量device给大家展示操作手机的方法。

操作手机

        操作手机的方法有3类,设备类操作方法、UI元素类操作方法、基础类操作方法(暂未公开)。

设备类方法

device_info

        device_info()方法可以返回设备的信息,例如当前屏幕状态(竖屏或横屏,0 or 1)、当前页面显示的app包名、屏幕大小(高、宽)、系统SDK版本等信息。使用方式如下:

info = device.device_info
print(info)
window_size()

        window_size()方法可以返回设备的屏幕大小(宽、高)。使用方式如下:

width, height = device.window_size()
print(height, width)
screenshot()

        screenshot()方法可以截屏,返回当前屏幕图像,支持jpg和png格式。使用方式如下:

images = device.screenshot()  # 截取当前屏幕图像
images.save('screen.jpg')  # 保存为screen.jpg在当前目录下
implicitly_wait()

        implicitly_wait()方法可以设置元素查找等待时间,默认是20秒,修改它会影响所有跟元素相关的方法。使用方式如下:

device.implicitly_wait(10)  # 设置元素查找等待时间为10秒,10秒后任未找到该元素则报错
touch

        touch方法可以实现多点触控,触摸移动事件(手指触摸屏幕,到处移动,离开屏幕)。使用方式如下:

touch = device.touch  # 定义一个触摸事件
touch.down(100, 100)  # 在坐标100,100的位置触摸屏幕
touch.move(500, 500)  # 触摸着移动到500,500的位置
touch.sleep(1)  # 触摸着不动1秒钟
touch.move(100, 100)  # 触摸着移动到100,100的位置
touch.up(500, 500)  # 触摸着移动到500,500的位置并离开屏幕

我们可以定义多个触摸事件,实现屏幕的多点触控。

click()

        click()方法可以点击屏幕上任意一点一次。使用方式如下:

device.click(100, 100)  # 点击屏幕上坐标100,100的位置
double_click()

        double_click()方法可以点击屏幕上任意一点两次,默认点击间隔为0.1秒,使用duration关键字参数可以修改点击间隔。使用方式如下:

device.double_click(100, 100, duration=0.5)  # 点击坐标位置100,100两次,间隔0.5秒
long_click()

        long_click()方法可以长按屏幕上任意一点,默认长按事件为0.5秒,使用duration关键字参数可以修改长按时间。使用方式如下:

device.long_click(100, 100, duration=1)  # 长按坐标位置100,100,按住1秒钟
swipe()

        swipe()方法可以在屏幕上滑动,从一个点滑动到另一个点,默认滑动步数为55。使用duration关键字参数可以修改滑动时间,step = duration X 200。使用关键字参数step可以修改滑动步数,但是duration和step同时设置时,只有step会生效。使用方法如下:

device.swipe(100, 100, 500, 500)  # 从坐标位置100,100滑动到坐标位置500,500
swipe_points()

        swipe_points()方法可以在屏幕上连续滑动,从点1滑动到点2...滑动到点n,默认滑动间隔为0.5秒,使用duration关键字参数可以修改滑动间隔。使用方式如下:

device.swipe_points([(100, 100), (500, 100), (100, 500), (500, 500)])
# 从坐标位置100,100滑动到500,100滑动到100,500滑动到500,500滑出一个Z字形轨迹

swipe_points()用在解除图案锁上非常方便。但我们在使用这种方式去解锁的时候不要傻傻的去使用真实的坐标,我们应该使用解锁图案元素的百分比来计算真实坐标,这样做不管在任何分辨率的手机上都能实现解锁。我们甚至可以封装一个函数,只要传入一个元素的ID属性信息,就能在这个元素上画出图形。

drag()

        drag()方法跟swipe()函数具有相同的功能,也是从一个点滑动到另一个点,默认滑动时间为0.5秒,使用duration关键字参数可以修改滑动时间。使用方式如下:

device.drag(100, 100, 500, 500)  # 从坐标位置100,100滑动到坐标位置500,500
press()

        press()方法可以执行按键事件,可传入的键有home、back、left、right、up、down、center、menu、search、enter、delete(or del)、recent(recent apps)、volume_up、volume_down、volume_mute、camera、power。使用方式如下:

device.press("home")  # 按home键
screen_on()

        screen_on()方法可以设置屏幕为亮屏(唤醒屏幕)。使用方式如下:

device.screen_on()
screen_off()

        screen_off()方法可以设置屏幕为灭屏(休眠屏幕)。使用方式如下:

device.screen_off()
orientation

        orientation方法可以返回屏幕的旋转状态:left、right、natural、upsidedown,分别表示向右旋转、向左旋转、正常、上下颠倒。使用方式如下:

state = device.orientation
print(state)
set_orientation()

        set_orientation()方法可以设置屏幕的旋转方向,正常状态可传入的值有(0, "natural", "n", 0);向左旋转可传入的值有 (1, "left", "l", 90);上下颠倒可传入的值有(2, "upsidedown", "u", 180);向右旋转可传入的值有(3, "right", "r", 270)。使用方式如下:

device.set_orientation('left')  # 设置屏幕为向左旋转
freeze_rotation()

        freeze_rotation()方法可以控制屏幕是否能旋转,传入True时冻结屏幕旋转,屏幕将无法旋转;传入False时解除冻结,屏幕可以旋转。使用方式如下:

device.freeze_rotation(True)  # 冻结屏幕旋转,屏幕不能再进行旋转
open_notification()

        open_notification()方法可以打开下拉通知栏。使用方式如下:

device.open_notification()  # 打开下拉通知栏
open_quick_settings()

        open_quick_settings()方法可以打开下拉菜单。使用方式如下:

device.open_quick_settings()  # 打开下拉菜单
open_url()

        open_url()方法可以打开默认浏览器访问指定的网页,使用的就是adb shell am start -a android.intent.action.VIEW -d指令。我们传入一个url地址,手机就会去访问相应的网页。使用方式如下:

device.open_url('http://www.baidu.com')  # 打开默认浏览器访问百度网页
exists()

        exists()方法可以查看界面上是否存在某个元素,如果存在返回True,不存在返回False。使用方式如下:

if device.exists(text='hello'):  # 判断界面中是否存在文本hello
    print('yes')
else:
    print('no')
clipboard

        clipboard方法可以得到粘贴板中的内容和设置粘贴板中的内容,用于复制粘贴操作。使用方法如下:

device.clipboard = "hello"  # 设置内容
text = device.clipboard  # 获取内容
print(text)  # hello
set_clipboard()

        set_clipboard()方法可以设置粘贴板中的内容。使用方式如下:

device.set_clipboard('hello')  # 设置粘贴板中的内容为hello
keyevent()

        keyevent()方法可以发送按键事件,使用的就是adb shell input keyevent指令。使用方式如下:

device.keyevent('3')  # 发送按home键事件
serial

        serial方法可以获取当前设备的手机序列号。使用方式如下:

device_number = device.serial
print(device_number)
show_float_window()

        show_float_window()方法可以设置测试APK的悬浮窗,悬浮窗会增加测试APK的稳定性。当我们传入True时,开启悬浮窗;传入False时关闭悬浮窗。使用的就是adb shell am start -n com.github.uiautomator/.ToastActivity -e showFloatWindow指令。使用方式如下:

device.show_float_window(True)  # 开启悬浮窗
toast

        Toast是Android中的一种消息弹窗,在应用发生崩溃时,我们看到的应用未响应的弹窗就是Toast弹窗。我们先来定义一个toast,后面的介绍中都会使用这个公共变量toast。定义方式如下:

toast = device.toast  # 定义一个toast

        toast中有get_message()方法可以获得最近一段时间范围内的toast弹窗中的信息,使用关键字参数wait_timeout设置等待toast出现的时间,默认10秒;使用关键字参数cache_timeout设置上一次toast出现的时间范围,默认10秒;使用default关键字参数设置没有toast出现时返回的默认消息。使用方式如下:

toast.get_message(wait_timeout=5, cache_timeout=5, default='没有toast出现')
# 看前5秒时间范围内是否出现过toast,没有出现就再等5秒钟看会不会出现toast,5秒钟后还是没有toast出现就返回'没有toast出现'

        toast中有reset()方法可以重置当前的toast(清除缓存),如果toast中保存了上一次toast弹窗中的信息,我们想清除掉信息就使用reset()方法。使用方式如下:

toast.reset()  # 重置toast

        toast中有show()方法可以在手机界面展示一个toast弹窗,我们传入什么消息就展示什么消息,可以使用关键字参数duration设置展示的时间,默认1秒。使用方式如下:

toast.show('hello world')  # 在手机界面弹出hello world消息弹窗1秒钟
open_identify()

        open_identify()方法可以打开识别,只能传入black和red。使用的就是adb shell am start -W -n com.github.uiautomator/.IdentifyActivity -e theme指令。使用方式如下:

device.open_identify('red')
set_fastinput_ime()

        set_fastinput_ime()方法可以切换输入法为FastInputIME,传入True时打开FastInputIME输入法,传入False时关闭FastInputIME输入法。使用方式如下:

device.set_fastinput_ime(True)
send_keys()

        send_keys()方法可以在输入框中输入字符串,通过广播的方式输入,使用的是adb shell am broadcast -a (ADB_SET_TEXT or ADB_INPUT_TEXT) --es text指令。传入什么字符串就输入什么字符串,还可以通过关键字参数clear决定是否清除前面的字符串(如果输入框中本来就有字符串的话),默认为False。clear=True时为清除前面的字符串,clear=False时不清除前面的字符串。使用方式如下:

device.send_keys('hello')
send_action()

        send_action()方法可以发送一些输入法中的事件,比如在百度中输入要搜索的字符后,输入法中会有搜索按钮,发送短信的时候输入法中会有发送按钮等等。可以接受的值有go、search、send、next、done、previous。使用方式如下:

device.send_action('send')  # 在微信聊天输入框中输入内容后点击输入法中的发送按钮
clear_text()

        clear_text()方法可以清空输入框中的文本,使用的是adb shell am broadcast -a ADB_CLEAR_TEXT指令。使用方式如下:

device.clear_text()
wait_fastinput_ime()

        wait_fastinput_ime()方法是用来等待FastInputIME输入法出现的,我们点击一个输入框以后一般间隔1秒后才会弹出输入法。默认的等待时间是5秒,可以自己设置。使用方式如下:

device.wait_fastinput_ime(10)  # 等待FastInputIME输入法在10秒内弹出,10秒后未弹出会报错
current_ime()

        current_ime()方法可以得到当前输入法的状态。使用方式如下:

state = device.current_ime()
print(state)  # ("com.github.uiautomator/.FastInputIME", True)
app_current()

        app_current()方法可以返回当前界面APP的包名和活动(package and activity)。使用方式如下:

now_app = device.app_current()
print(now_app)
app_install()

        app_install()方法可以通过url安装APP。使用方式如下:

device.app_install('下载APP的网址')
wait_activity()

        wait_activity()方法是等待APP启动的,当前的活动为APP的主活动时结束等待。需要传入主活动的名称,使用timeout参数可以修改等待时间,默认为10秒。使用方式如下:

# 等待当前活动为MainActivity,等待20秒,20秒后未出现MainActivity则返回False
device.wait_activity('MainActivity', 20)
app_start()

        app_start()方法可以启动APP,必须传入APP的包名,可选择传入主活动名;当不传入主活动名时,会使用atx-agent解析出APP的主活动名。可使用wait参数控制是否等待APP启动完成,可使用stop参数控制是否在启动之前先关闭此APP,可使用use_monkey参数控制是否使用monkey命令启动APP。使用方式如下:

# 启动com.example.battery并在20秒内等待启动完成,启动之前如果com.example.battery存活先关闭它
device.app_start('com.example.battery', 'com.example.battery/com.example.battery.launcher', wait=True, stop=True)
app_wait()

        app_wait()方法是等待APP启动的,当APP被启动后结束等待。需要传入APP包名,使用timeout参数可以修改等待时间,默认为20秒,使用front参数可以控制是否等待APP完全启动(主活动处于界面最上层时)。使用方式如下:

# 完全启动com.example.battery
device.app_wait('com.example.battery', front=True)
app_list()

        app_list()方法可以获得APP包名列表,使用的就是adb shell pm list packages指令。可选择使用filter参数控制得到那种类型的APP包名,可接受的值有-f、-d、-e、-s、-3、-i、-u、--user USER_ID、FILTER。使用方式如下:

app_list = device.app_list('-3')  # 得到第三方安装的APP
print(app_list)
app_list_running()

        app_list_running()方法可以得到正在运行的APP列表,就是使用pm list packages和ps -A,把它们得到的结果使用正则表达式处理,再求交集。使用方式如下:

app_list = device.app_list_running()  # 得到正在运行的APP列表
print(app_list)
app_stop()

        app_stop()方法可以关闭APP,需要传入APP的包名,就是使用adb shell am force-stop指令。使用方式如下:

device.app_stop('com.example.battery')  # 关闭com.example.battery
app_stop_all()

        app_stop_all()方法可以关闭除了测试APP以外的所有正在运行的APP,可以使用excludes参数设置不想关闭的APP。使用方式如下:

# 关闭除了com.example.battery和com.example.reboot以外的APP
device.app_stop_all(['com.example.battery', 'com.example.reboot'])
app_clear()

        app_clear()方法可以清空APP数据,需要传入APP的包名,就是使用adb shell pm clear指令。使用方式如下:

device.app_clear('com.example.battery')
app_uninstall()

        app_uninstall()方法可以卸载APP,需要传入包名,就是使用adb shell pm uninstall指令。使用方式如下:

device.app_uninstall('com.example.battery')
app_uninstall_all()

        app_uninstall_all()方法可以卸载除了测试APP以外的所有第三方APP,可以使用excludes参数设置不想卸载的APP,verbose参数可以控制是否打印卸载信息,默认不打印。使用方式如下:

# 卸载除了com.example.battery以外的所有第三方APP,并打印每个APP卸载信息
device.app_uninstall_all(excludes=['com.example.battery'], verbose=True)
app_info()

        app_info()方法可以得到APP的信息,例如包名、标签、版本、大小等信息,需要传入包名。使用方式如下:

info = device.app_info('com.example.battery')
print(info)
app_icon()

        app_icon()方法可以得到APP的图标。使用方法如下:

icon = device.app_icon('com.example.battery')
with open(r'battery.icon', "wb") as fi:
    fi.write(icon)
settings

        settings方法返回一个类似字典的类,可以获取和修改设置属性的值,settings中的get方法可以获取某个设置属性的值,使用给字典修改值的方式修改设置属性的值。使用方式如下:

setting = device.settings
print(setting)
# 打印出所有的设置属性
# {"wait_timeout": 20.0,  # 查找元素等待时间
#     "xpath_debug": False,  # xpath debug开启状态
#     "operation_delay": (0, 0),  # 点击前后延时
#     "operation_delay_methods": ["click", "swipe"],  # 需要点击延时的函数
#     "fallback_to_blank_screenshot": False}  # 返回空白截图

# 获取属性wait_timeout的值
timeout = setting.get("wait_timeout")
print(timeout)  # 20.0

# 修改属性xpath_debug的值
setting["xpath_debug"] = True

# 修改属性operation_delay_methods的值
setting["operation_delay_methods"] = ["click", "swipe", "long_lick", "press", "drag"]
watch_context()(不推荐)

        watch_context()方法返回一个文本监听器,当界面中出现要监听的文本时可以选择点击,每2秒检查一次。autostart参数控制是否自启监听器,默认True;builtin参数控制是否使用内置监听,默认False。使用方式如下:

watcher = device.watch_context()
# 注册监听文本及点击事件
watcher.when("停止响应").when("OK").click()  # 当"停止响应"和"OK"同时出现时点击"OK"
watcher.when("取消").click()  # 当"取消"出现时点击"取消"
# 其他脚本逻辑语句
# 需要结束监听时
watcher.stop()

# 或者使用上下文管理的方式
with device.watch_context() as watcher:
    # 注册监听文本及点击事件
    watcher.when("停止响应").when("OK").click()  # 当"停止响应"和"OK"同时出现时点击"OK"
    watcher.when("取消").click()  # 当"取消"出现时点击"取消"
    # 其他脚本逻辑语句

watch_context是依赖threading库来执行多线程运行的,所以我们完全不需要使用watch_context。我们自己写一个监听方法,再用threading开一个子线程,可以比watch_context实现更多的功能。

watcher(不推荐)

        watcher方法可以返回一个监听器,用法跟watch_context差不多,只是比watch_context多了remove功能,可以动态的选择性移除一些监听,多了running功能可以获取监听器当前运行状态,多了reset功能可以重置监听器(停止监听器并移除所有监听),多了函数回调功能可以指定元素出现时要触发的事件。使用方式如下:

# 匿名监听器
device.watcher.when("停止响应").when("OK").click()  # 当"停止响应"和"OK"同时出现时点击"OK"
device.watcher.when("取消").click()  # 当"取消"出现时点击"取消"

# 命名监听器
device.watcher('crash').when("停止响应").when("OK").click()  # 当"停止响应"和"OK"同时出现时点击"OK"
device.watcher('pop up').when("取消").click()  # 当"取消"出现时点击"取消"

# 使用回调函数,指定元素出现时要执行的函数
device.wacther('video').when("GO IT").call(lambda devices: devices.set_orientation('l'))  # 使用匿名函数指定一个事件

# 开启监听器子线程
device.watcher.start(3)  # 3秒监听一次,默认2秒

# 移除crash监听
device.watcher.remove('crash')

# 强制运行没有被移除的监听
device.watcher.run()

# 需要结束监听时
device.watcher.stop()

# 重置监听器,停止所有监听,移除所有监听
device.watcher.reset()

watcher和watch_context一样是通过threading实现子线程的,只不过watcher多了些选择,但最好的选择还是自己实现的方法,可以完全满足自己的需求。

UI类方法

        使用UI类方法就是操作UI元素的方法,首先需要得到UI元素。

UI元素获取

        UI元素的获取方式,可以使用AndroidSDK中的uiautomatorviewer元素获取工具,也可以使用WEditor(推荐)。

安装WEditor

        WEditor是网页版UI元素获取工具,集成了uiautomator2的功能,WEditor在UI元素获取上表现出了比AndroidSDK中自带的元素获取工具uiautomatorviewer更强的能力。不管是静态还是动态元素,WEditor都能很好的获取。

        WEditor安装指令:pip install --pre --upgrade weditor==0.6.4

网络不好翻墙困难可以加-i参数,使用清华源快速下载。

-i https://pypi.tuna.tsinghua.edu.cn/simple

因为目前0.6.5版本存在编码问题,所以我们指定版本0.6.4。WEditor安装完成后,我们可以在cmd窗口执行python -m weditor启动WEditor,也可以使用命令创建桌面快捷方式,创建快捷方式后直接双击快捷方式就能启动WEditor。

        创建快捷方式指令:python -m weditor -shortcut

WEditor界面

        启动WEditor后,我们可以看到如下界面:

我们需要把手机的序列号输入到Android后面的输入框中,然后点击Connect连接设备,再点击Dump Hierarchy按钮加载手机界面元素。上图就是加载出来后的样子,在界面的左边是手机的屏幕界面,我们可以用鼠标在界面中选择元素。元素的属性和值显示在中间部分,每个元素拥有的属性和对应的值都会显示出来。界面的右边显示了整个界面元素的路径分支,我们也可以在路径分支中选择界面元素。

元素类型

        uiautomator2源码中设定的元素定位关键字参数如下:

__fields = {
    "text": (0x01, None),  # MASK_TEXT,
    "textContains": (0x02, None),  # MASK_TEXTCONTAINS,
    "textMatches": (0x04, None),  # MASK_TEXTMATCHES,
    "textStartsWith": (0x08, None),  # MASK_TEXTSTARTSWITH,
    "className": (0x10, None),  # MASK_CLASSNAME
    "classNameMatches": (0x20, None),  # MASK_CLASSNAMEMATCHES
    "description": (0x40, None),  # MASK_DESCRIPTION
    "descriptionContains": (0x80, None),  # MASK_DESCRIPTIONCONTAINS
    "descriptionMatches": (0x0100, None),  # MASK_DESCRIPTIONMATCHES
    "descriptionStartsWith": (0x0200, None),  # MASK_DESCRIPTIONSTARTSWITH
    "checkable": (0x0400, False),  # MASK_CHECKABLE
    "checked": (0x0800, False),  # MASK_CHECKED
    "clickable": (0x1000, False),  # MASK_CLICKABLE
    "longClickable": (0x2000, False),  # MASK_LONGCLICKABLE,
    "scrollable": (0x4000, False),  # MASK_SCROLLABLE,
    "enabled": (0x8000, False),  # MASK_ENABLED,
    "focusable": (0x010000, False),  # MASK_FOCUSABLE,
    "focused": (0x020000, False),  # MASK_FOCUSED,
    "selected": (0x040000, False),  # MASK_SELECTED,
    "packageName": (0x080000, None),  # MASK_PACKAGENAME,
    "packageNameMatches": (0x100000, None),  # MASK_PACKAGENAMEMATCHES,
    "resourceId": (0x200000, None),  # MASK_RESOURCEID,
    "resourceIdMatches": (0x400000, None),  # MASK_RESOURCEIDMATCHES,
    "index": (0x800000, 0),  # MASK_INDEX,
    "instance": (0x01000000, 0)  # MASK_INSTANCE,
}

我们需要使用这些关键字参数去定位元素,可以一次使用多个关键字参数去定位一个元素。

定位元素

我们可以通过这些关键字参数去定位一个元素,具体使用方式如下:

text

# 找到界面中text属性为hello的元素,如果不存在这个元素会报错
device(text='hello')
# 找到界面中text属性包含hello的元素,如果不存在这个元素会报错
device(textContains='hello')
# 使用正则表达式匹配text属性,如果不存在这个元素会报错
device(textMatches='h.+o')
# 匹配以hello开头的text属性,如果不存在这个元素会报错
device(textStartsWith='hello')

class

# 找到class属性为android.widget.Button的元素,如果元素不存在会报错
device(className='android.widget.Button')
# 使用正则表达式匹配class属性,如果元素不存在会报错
device(classNameMatches='android.+Button')

description

# 找到description属性为hello的元素,如果元素不存在时会报错
device(description='hello')
# 找到description属性包含hello的元素,如果元素不存在时会报错
device(descriptionContains='hello')
# 使用正则表达式匹配description属性,如果元素不存在时会报错
device(descriptionMatches='h.+o')
# 找到description属性以hello开头的元素,如果元素不存在时会报错
device(descriptionStartsWith='hello')

checkable

# 找到有check属性的元素,找不到会报错
device(checkable=True)
# 找到没有check属性的元素,找不到会报错
device(checkable=False)

checked

# 找到有check属性且已被勾选的元素,找不到会报错
device(checked=True)
# 找到有check属性且未被勾选的元素,找不到会报错
device(checked=False)

clickable

# 找到有click属性的元素,找不到会报错
device(clickable=True)
# 找到没有click属性的元素,找不到会报错
device(clickable=False)

longClickable

# 找到有longClick属性的元素,找不到会报错
device(longClickable=True)
# 找到没有longClick属性的元素,找不到会报错
device(longClickable=False)

scrollable

# 找到有scroll属性的元素,找不到会报错
device(scrollable=True)
# 找到没有scroll属性的元素,找不到会报错
device(scrollable=False)

enabled

# 找到可操作的元素,找不到会报错
device(enabled=True)
# 找到不可操作的元素,找不到会报错
device(enabled=False)

focusable

# 找到有focus属性的元素,找不到会报错
device(focusable=True)
# 找到没有focus属性的元素,找不到会报错
device(focusable=False)

focused

# 找到有focus属性且已被聚焦的元素,找不到会报错
device(focused=True)
# 找到有focus属性且未被聚焦的元素,找不到会报错
device(focused=False)

selected

# 找到有select属性且已被选择的元素,找不到会报错
device(selected=True)
# 找到有select属性且未被选择的元素,找不到会报错
device(selected=False)

package

# 找到package属性为com.android.systemui的元素,找不到会报错
device(packageName='com.android.systemui')
# 使用正则表达式匹配package属性,找不到会报错
device(packageNameMatches='com.+systemui')

resourceId

# 找到id属性为com.android.systemui:id/frame的元素,找不到会报错
device(resourceId='com.android.systemui:id/frame')
# 使用正则表达式匹配id属性,找不到会报错
device(resourceIdMatches='com.+frame')

index

# 找到index属性为1的元素,找不到会报错
device(index=1)

instance

# 找到UI界面中text属性为hello的第3个元素
device(text='hello', instance=2)

instance和index用法差不多,它们的区别是:index是兄弟元素之间的排布顺序,instance是整个界面中某个属性相同的所有元素的排布顺序。但我们一般不使用instance,因为我们有更简单的方式,如下:

# 找到UI界面中text属性为hello的第3个元素
device(text='hello')[2]
多种属性组合定位

当一个元素的属性有唯一性时,我们可以单独使用一个属性去定位元素,否则我们应该使用多种属性的组合去定位一个元素。例如:

device(text='hello', description='hello', enabled=True)
device(packageName='com.android.systemui', index=1)

正是以为uiautomator2拥有多种属性组合定位的定位方式,所以我在定位元素时从来不使用x-path路径定位。通过上面这些属性的各种组合,已经足以定位到我们想要的元素了。如果还是不能准确定位,我们下面还有通过父子关系、兄弟关系定位的方法(child and sibling),上下左右方向定位的方法(up、down、left and right)。

clone()

        clone()方法可以克隆当前定位的元素。使用方式如下:

button = device(text='hello')  # 定位到了一个名为hello的按钮赋值给button
button_s = button.clone()  # 克隆button
child()

        child()方法可以定位到当前元素的子级元素(如果当前元素存在子级元素的话),child()方法中也是使用元素的属性定位。使用方式如下:

device(text='hello').child(resourceId="com.android.launcher3:id/icon")
sibling()

        sibling()方法可以定位到当前元素的兄弟元素(如果当前元素存在兄弟元素的话),sibling()方法也是使用元素的属性定位。使用方式如下:

device(text='hello').sibling(resourceId="com.android.launcher3:id/icon")
update_instance()

        update_instance()方法可以修改当前元素instance的值,如果当前元素存在子元素或兄弟元素时,修改的是子元素或兄弟元素instance的值。使用方式如下:

device(text='hello').update_instance(1)  # 把当前元素instance的值改为1
wait_timeout

        wait_timeout方法可以得到元素查找等待时间。使用方式如下:

timeout = device().wait_timeout
print(timeout)
exists()

        exists()方法可以判断当前元素是否存在,存在返回True,不存在返回False。可以选择传入参数timeout来设置判断时间。使用方式如下:

device(text='hello').exists(timeout=5)  # 判断5秒内界面中是否会出现text属性为hello的元素
info

        info方法可以得到当前元素的信息,例如元素各种属性的值、坐标位置等。使用方式如下:

info = device(text='hello').info
print(info)
screenshot()

        screenshot()方法可以截取当前元素的图像,使用的是区域截图。使用方法如下:

images = device(text='hello').screenshot()
images.save('hello.jpg')
click()

        click()方法可以点击当前元素,默认是点击元素的中间位置,可以使用offset参数修改点击位置,可以使用timeout参数设置等待元素出现的时间。使用方法如下:

# 点击text属性为hello的元素宽的中间高的十分之一位置,如果1秒内该元素未出现抛出超时错误
device(text='hello').click(timeout=1, offset=(0.5, 0.1))
bounds()

        bounds()方法可以返回当前元素左上角和右下角的坐标,通过bounds方法我们就可以知道元素在屏幕中的位置,以及元素的高度和宽度。使用方法如下:

lx, ly, rx, ry = device(text='hello').bounds()
print(f'左上角坐标({lx}, {ly}),右下角坐标({rx}, {ry})')
center()

        center()方法可以返回元素中心点的坐标,其实就是使用bounds()方法得到坐标后,经过一通加减乘的运算得到中心点的坐标。使用方式如下:

x, y = device(text='hello').center()
click_gone()

        click_gone()方法可以一直点击当前元素直到当前元素消失,可以使用maxretry参数控制点击的时间,默认10秒,可以使用interval参数控制每次点击间隔,默认1秒。使用方法如下:

# 在10秒钟内每隔1秒点击一次text属性为hello的元素,直到元素消失时停止,如果10秒后元素还未消失也会停止点击
device(text='hello').click_gone()
click_exists()

        click_exists()方法可以点击当前元素(如果当前元素存在的话),可以使用timeout参数控制等待当前元素出现的时间,默认为0。使用方式如下:

# 如果在1秒钟内出现了text属性为hello的元素,点击该元素
device(text='hello').click_exists(timeout=1)
drag_to()

        drag_to()方法可以把当前元素拖动到屏幕的某一个坐标,可以使用关键字参数duration设置拖动时间,可以使用关键字参数timeout设置查找元素等待时间。使用方式如下:

swipe()

        swipe()方法可以在当前元素上滑动,以当前元素的中心为起点,可以选择向上下左右4个方向滑动。使用direction参数设置滑动方向,可以接受的值为"left"、"right"、"up"、"down"。可以使用steps参数设置滑动的步数,默认为10步。使用方式如下:

# 从text属性为hello的元素中心向该元素的左边界滑动
device(text='hello').swipe('left')
gesture()

        gesture()方法可以执行两指手势,用于图片的放大、缩小等情况,需要按顺序传入手指1的起点做标、手指2的起点坐标、手指1的终点坐标、手指2的终点坐标。可以使用steps参数设置步数,默认100。使用方式如下:

device(text='images').gesture((300, 500), (300, 500), (100, 500), (500, 500))
pinch_in()

        pinch_in()方法可以缩小当前元素,使用percent参数可以设置缩小的百分比,默认100,可以使用steps参数设置步数,默认50。使用方式如下:

device(text='images').pinch_in(50)  # 把text属性为images的元素缩小百分之50
pinch_out()

        pinch_out()方法可以放大当前元素,使用percent参数可以设置放大的百分比,默认100,可以使用steps参数设置步数,默认50。使用方式如下:

device(text='images').pinch_out(50)  # 把text属性为images的元素放大百分之50
wait()

        wait()方法可以等待当前元素出现或消失,可以使用exists参数设置元素的出现或消失,True为出现(存在)、False为消失,默认为True。可以使用timeout参数设置查找元素的等待时间,默认为20秒。使用方式如下:

device(text='hello').wait(exists=False, timeout=10)  # 等待text属性为hello的元素消失,等待时间10秒
wait_gone()

        wait_gone()方法可以等待当前元素消失,使用的就是wait()方法。可以使用timeout参数设置查找元素等待时间, 默认为20秒。使用方式如下:

device(text='hello').wait_gone(30)
must_wait()

        must_wait()方法可以等待当前元素出现或消失,如果当前元素在一定时间内未出现或消失,就会抛出错误。可以使用exists参数设置元素的出现或消失,True为出现(存在)、False为消失,默认为True。可以使用timeout参数设置查找元素的等待时间,默认为20秒。使用方式如下:

device(text='hello').must_wait(timeout=30)  # 等待text属性为hello的元素在30秒内出现,30秒后未出现会报错
set_text()

        set_text()方法可以在当前元素中输入文本内容(前提是当前元素是一个输入框),需要传入要输入的文本,可以使用timeout参数设置查找元素等待时间,默认20秒。使用方式如下:

device(description='input').set_text('hello', timeout=10)
send_keys()

        send_keys()方法可以在当前元素中输入文本内容,使用的就是set_text()方法,需要传入要输入的文本。使用方式如下:

device(description='input').send_keys('hello')
get_text()

        get_text()方法可以获取当前元素的文本信息,可以使用timeout参数设置查找元素等待时间。使用方式如下:

text = device(text='hello').get_text(timeout=5)
print(text)  # hello
clear_text()

        clear_text()方法可以清空当前元素中的输入文本(前提是当前元素是一个输入框),使用的就是set_text()方法,可以使用timeout参数设置查找元素等待时间。使用方式如下:

device(description='input').clear_text()
child()

        child()方法可以定位到当前元素的子级元素,child()方法同样是使用上面的各种属性来定位子级元素。使用方式如下:

device(text='hello').child(text='爆笑蛙')
sibling()

        sibling()方法定位到当前元素的兄弟元素,sibling()方法同样是使用上面的各种属性来定位兄弟元素。使用方式如下:

device(text='hello').sibling(text='爆笑蛙')
child_by_text()

        child_by_text()方法可以通过text属性值定位到当前元素的子级元素,可以选择使用关键字参数allow_scroll_search设置是否通过滑动查找子级元素。使用方式如下:

device(text='hello').child_by_text('爆笑蛙')
child_by_description()

        child_by_description()方法可以通过description属性值定位到当前元素的子级元素,可以选择使用关键字参数allow_scroll_search设置是否通过滑动查找子级元素。使用方式如下:

device(text='hello').child_by_description('input')
child_by_instance()

        child_by_instance()方法可以通过instance的值定位到当前元素的子级元素。使用方式如下:

device(text='hello').child_by_instance(2)

还不如就用child(instance=2)方法。

parent()

        parent()方法暂时不能使用,只会返回NotImplementedError的错误。

count()

        count()方法可以统计跟当前元素相似的元素有几个。使用方式如下:

device(text='hello').count()  # 统计界面中有几个text属性为hello的元素
right()

        right()方法可以定位到当前元素右边的元素,right()方法同样是使用上面的各种属性来定位右边的元素。使用方式如下:

# 找到text属性为hello元素右边的可选择元素,而且可选择元素已被勾选
device(text='hello').right(checked=True)
left()

        left()方法可以定位到当前元素左边的元素,left()方法同样是使用上面的各种属性来定位左边的元素。使用方式如下:

# 找到text属性为hello元素左边的图标元素
device(text='hello').left(description='icon')
up()

        up()方法可以定位到当前元素上方的元素,up()方法同样是使用上面的各种属性来定位上方的元素。使用方式如下:

device(text='hello').up(description='title')
down()

        down()方法可以定位到当前元素下方的元素,down()方法同样是使用上面的各种属性来定位下方的元素。使用方式如下:

device(text='hello').down(description='summary')
fling()

        fling()方法可以滚动屏幕,默认垂直方向滚动。使用垂直方向滚动的方法有vert、vertically、vertical,使用水平方向滚动的方法有horiz、horizental、horizentally。控制滚动到那个位置的方法有forward、backward、toBeginning、toEnd。forward方法为(垂直或水平方向)向前滚动一下;backward方法为(垂直或水平方向)向后滚动一下;toBeginning方法为(垂直或水平方向)滚动到最前方;toEnd方法为(垂直或水平方向)滚动到最后方。使用方式如下:

# 在拥有scrollable的元素上水平滚动,滚动到最后方
device(scrollable=True).filing.horiz.toEnd()
scroll()

        scroll()方法可以滚动屏幕,默认垂直方向滚动。使用垂直方向滚动的方法有vert、vertically、vertical,使用水平方向滚动的方法有horiz、horizental、horizentally。控制滚动到那个位置的方法有forward、backward、toBeginning、toEnd、to。forward方法为(垂直或水平方向)向前滚动一下;backward方法为(垂直或水平方向)向后滚动一下;toBeginning方法为(垂直或水平方向)滚动到最前方;toEnd方法为(垂直或水平方向)滚动到最后方;to方法为滚动到某个元素的位置,to()方法同样是使用上面的各种属性来定位元素。使用方式如下:

# 在拥有scrollable属性的元素上垂直滚动,滚动到text属性为hello的元素位置
device(scrollable=True).scroll.to(text='hello')
xpath()

        xpath()方法可以使用xpath路径定位元素,需要在WEditor中找到元素xpath的值。要注意的是xpath()返回的不是一个元素,而是一个XPath类的实例对象。XPath类中有一套自己操作元素和监听元素的方法,但实际上使用的还是上面介绍的这些方法,只是换个皮肤(名称)而已。我不想多解释XPath中的方法,因为我从来不用它。使用方式如下:

device.xpath('//*[@resource-id="com.android.systemui:id/center_group"]').click(timeout=3)

基础类方法

sleep()

        sleep()方法可以暂停一段时间,使用的就是time.sleep()。使用方式如下:

device.sleep(1)
shell()

        shell()方法可以执行shell指令,可以接受字符串或者字符串列表(连续执行多个指令时)。使用方式如下:

device.shell(['cd /sdcard', 'ls'])  # 进入/sdcard目录,查看文件
device.shell('input keyevent 3')  # 发送按键事件,按home键
device.shell('reboot')  # 发送重启指令,重启手机
http

        http方法可以返回测试服务,整个测试服务都是建立在此基础上的。http返回_AgentRequestSession(self),_AgentRequestSession继承于TimeoutRequestsSession,TimeoutRequestsSession又继承与requests.Session。requests做过接口测试的应该很熟悉吧,就是用来发请求的。http就可以发送get、post、delete请求,通过close关闭服务等。就不展示使用方式了,我们尽量别乱用http方法以免出现BUG,除非你对测试服务非常了解。

info

        info方法可以返回设备的信息。使用方式如下:

info = device.info
print(info)
wlan_ip

        wlan_ip方法可以返回服务的IP地址。使用方式如下:

ip = device.wlan_ip
print(ip)
uiautomator

        uiautomator方法可以返回uiautomator测试服务,可以使用start()方法启动服务,可以使用stop()方法停止服务,可以使用running获取服务运行状态。使用方式如下:

server = device.uiautomator
server.start()  # 启动服务
server.stop()  # 停止服务
state = server.running()  # 获取服务状态
reset_uiautomator()

        reset_uiautomator()方法可以重启uiautomator测试服务。使用方式如下:

device.reset_uiautomator()
push()

        push()方法可以推送文件到手机,需要传入要推送文件的路径和存放文件的地址。使用方式如下:

device.push('D:\file', '/sdcard')
pull()

        pull()方法可以拉取手机文件,需要传入要拉取的文件路径和存放文件的地址。使用方式如下:

device.pull('/sdcard/file', 'D:\')

在unittest中使用

        我们搭建测试框架的时候,首先应该写一个基础的测试方法类,其他的测试用例类都应继承基础测试方法类。

BaseTest.py

import logging
import os
import time
import unittest
import uiautomator2 as u2
from functools import wraps


class BaseTest(unittest.TestCase):
    """
    基础测试类

    继承unittest.TestCase

    使用uiautomator2中的方法完成基本测试方法的设计
    """
    device_number = "123456789"  # 如果要通过UI界面控制,需要在其他py文件中提供类似get_device()的接口函数

    @classmethod
    def setUpClass(cls) -> None:
        """测试开始前定义测试设备"""
        cls.device = u2.connect_usb(cls.device_number)

    def setUp(self) -> None:
        """每条测试用例开始前要做的初始化动作,确保用例能正常执行"""
        if not self.device.info.get('screenOn'):  # 判断屏幕状态
            self.device.screen_on()  # 亮屏
            self.device.sleep(1)
        if self.device(resourceId="com.android.systemui:id/lock_icon", packageName="com.android.systemui"
                       ).exists(timeout=1):  # 判断是否处于锁屏界面
            device_info = self.device.info  # 获取设备信息
            w, h = int(device_info['displayWidth']), int(device_info['displayHeight'])  # 从设备信息中得到屏幕的高宽
            x1 = w / 2
            y1 = h - 10
            self.device.swipe(x1, y1, x1, 10)  # 滑动解锁
            self.device.sleep(1)
        # 如果存在图案锁、密码锁等需要添加额外逻辑
        self.device.press("home")  # 回到主界面

    def tearDown(self) -> None:
        """每条测试用例执行结束后要做的收尾动作"""
        self.device.press("home")  # 回到主界面

    @classmethod
    def tearDownClass(cls) -> None:
        """测试结束后要做的动作,例如吧测试结果填到excel中,根据自己的需求写"""
        
    def open_menu(self):
        """打开主菜单"""
        device_info = self.device.info
        w, h = int(device_info['displayWidth']), int(device_info['displayHeight'])
        x1 = w - 10
        y1 = h / 2
        x2 = 10
        self.device.swipe(x1, y1, x2, y1)  # 向上滑动打开主菜单
        
    def scroll_click(self, name):
        """
        滚动找到文本并点击该文本

        :param name: 文本内容
        :return:
        """
        if self.device(text=name).exists(timeout=1):
            self.device(text=name).click()
        else:
            self.device(scrollable=True).scroll.to(text=name)
            self.device(text=name).click()

    def open_app(self, name):
        """
        打开app

        :param name: APP名称
        :return:
        """
        self.device.press('home')
        self.device.sleep(1)
        self.open_menu()
        self.device.sleep(1)
        if self.device(text=name).exists:
            self.device(text=name).click()
        else:
            self.device(scrollable=True).scroll.toEnd()
            self.scroll_click(name)
            
    def add_img(self):
        """手机截图保存到images文件夹"""
        now_time = time.strftime('%Y_%m_%d_%H_%M_%S')
        with open(r'%s\images\test_%s.jpg' % (os.getcwd(), now_time), "wb") as fi:
            fi.write(self.device.screenshot(format='raw'))
        logging.error("查看截图: test_%s.jpg" % now_time)

    def clear_all(self):
        """清空后台运行的软件"""
        win_size = self.device.window_size()
        x1 = win_size[0]
        y1 = win_size[1] / 2
        self.device.press("recent")
        for i in range(10):
            if self.device(text="CLEAR ALL").exists(timeout=1):
                self.device(text="CLEAR ALL").click()
                break
            elif self.device(text="Clear all").exists(timeout=1):
                self.device(text="Clear all").click()
                break
            elif self.device(description="No recent items").exists(timeout=1) or \
                    self.device(text="No recent items").exists(timeout=1):
                self.device.press("home")
                break
            self.device.swipe(20, y1, x1, y1, 0.1)
        else:
            self.device.press("home")
        

def catch_err(self=BaseTest):
    """如果代码出错截图清理后台进程并抛出错误"""

    def receive_func(func):

        @wraps(func)
        def dispose_func(*args, **kwargs):
            try:
                func(*args, **kwargs)
            except Exception as e:
                self.add_img(*args)
                self.clear_all(*args)
                raise e

        return dispose_func

    return receive_func

我们在BaseTest文件中定义一些基础的测试方法和测试用例的装饰器,我这里只是举例,你可以根据你的需求再写一些,其他的测试用例类全都继承基础测试类。

MyTest.py

from BaseTest import BaseTest, catch_err
import unittest
import os
import time
import HTMLTestRunner


class TestCase(BaseTest):
    
    @catch_err()
    def test_1(self):
        """xxx测试用例"""

    @catch_err()
    def test_2(self):
        """xxx测试用例"""


if __name__ == '__main__':
    suite = unittest.TestSuite()
    suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestCase))
    di_r = r"%s\report\test_%s.html" % (os.getcwd(), time.strftime('%Y_%m_%d_%H_%M_%S'))  # 定义测试报告文件
    filename = open(di_r, "wb")
    runner = HTMLTestRunner.HTMLTestRunner(stream=filename, title="xx测试报告", description=u"测试用例明细")
    runner.run(suite)
    filename.close()

我们在测试用例中直接使用基本测试类中的方法,来完成对测试用例的编写,如果你认为有的基础测试方法不适用,还可以重写此方法。我们还可以做一个UI界面,在UI界面中选择性的执行测试用例。

  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值