首先我们先看一个官方给的uiautomator2的例子
# coding: utf-8
import uiautomator2 as u2
def main():
u = u2.connect_usb()
u.app_start('com.netease.cloudmusic')
u(text='私人FM').click()
u(description='转到上一层级').click()
u(text='每日歌曲推荐').click()
u(description='转到上一层级').click()
u(text='歌单').click()
u(description='转到上一层级').click()
u(text='排行榜').click()
u(description='转到上一层级').click()
if __name__ == '__main__':
main()
由于云音乐版本的变化 可能这里面有些跑不通的情况,不过这个不是重点。从代码上至少我们大概能明白整个流程。 首先连接设备获取到设备对象后。开始对查找的文本或者描述信息进行查找后点击。
首先我们看这个代码不是解释这个例子要怎么写。而是想通过这个例子来看看uiautomator2背后的过程。
现在我们一步步的进行分析看看。
首先我们先从u = u2.connect_usb()
从这里能看出来,因为通过这个方法会返回一个uiautomator的对象,后续的元素定位等都是通过这个对象进行的, 所以我们需要知道具体这个方法到底做了什么?
def connect_usb(serial=None):
"""
Args:
serial (str): android device serial
Returns:
UIAutomatorServer
"""
# 实例化了adb的对象,同时做了一个端口转发将本地某个端口(lport)的tcp数据转发到手机上的7912端口上,而手机上监听这个端口的服务实际上就是atx-agent。
# 同时调用一个connect_wifi的方法我们看到UIAutomatorServer的实例化对象是由这个方法返回的
# 同时判断agent是否已启动,若没有则手动拉起agent。
adb = adbutils.Adb(serial)
lport = adb.forward_port(7912)
d = connect_wifi('127.0.0.1:' + str(lport))
if not d.agent_alive:
warnings.warn("backend atx-agent is not alive, start again ...",
RuntimeWarning)
# TODO: /data/local/tmp might not be execuable and atx-agent can be somewhere else
adb.shell("/data/local/tmp/atx-agent", "server", "-d")
deadline = time.time() + 3
while time.time() < deadline:
if d.alive:
break
elif not d.alive:
warnings.warn("backend uiautomator2 is not alive, start again ...",
RuntimeWarning)
d.reset_uiautomator()
return d
def connect_wifi(addr=None):
"""
Args:
addr (str) uiautomator server address.
Returns:
UIAutomatorServer
Examples:
connect_wifi("10.0.0.1")
"""
# 这里的代码就比较简单了,就是对参数地址做补全,然后实例化了UIAutomatorServer
if '://' not in addr:
addr = 'http://' + addr
if addr.startswith('http://'):
u = urlparse.urlparse(addr)
host = u.hostname
port = u.port or 7912
return UIAutomatorServer(host, port)
else:
raise RuntimeError("address should start with http://")
而具体UIAutomatorServer类的构造函数如下:
class UIAutomatorServer(object):
__isfrozen = False
__plugins = {}
# 从构造函数看的话,没有看出具体的内容,只是做了一些初始化的工作而已。但是这里比较关键的是实例化了Session这个类
def __init__(self, host, port=7912):
"""
Args:
host (str): host address
port (int): port number
Raises:
EnvironmentError
"""
self._host = host
self._port = port
self._reqsess = TimeoutRequestsSession(
) # use requests.Session to enable HTTP Keep-Alive
self._server_url = 'http://{}:{}'.format(host, port)
self._server_jsonrpc_url = self._server_url + "/jsonrpc/0"
self._default_session = Session(self, None)
self._cached_plugins = {}
self.__devinfo = None
self._hooks = {}
self.platform = None # hot fix for weditor
self.ash = AdbShell(self.shell) # the powerful adb shell
self.wait_timeout = 20.0 # wait element timeout
self.click_post_delay = None # wait after each click
self._freeze() # prevent creating new attrs
# self._atx_agent_check()
我们再来看看Session这个类的构造函数
class Session(object):
__orientation = ( # device orientation
(0, "natural", "n", 0), (1, "left", "l", 90),
(2, "upsidedown", "u", 180), (3, "right", "r", 270))
# 这里的server实际上就是uiautomatorServer的实例化对象,相当于在session这个类中完全能够调用uiautomatorServer所有暴露的方法。
# 另外这里面的jsonrpc,以及shell其实是两个比较关键的点,但是这里我们先不介绍 因为下面具体的执行还是会涉及到的
def __init__(self, server, pkg_name=None, pid=None):
self.server = server
self._pkg_name = pkg_name
self._pid = pid
self._jsonrpc = server.jsonrpc
if pid and pkg_name:
jsonrpc_url = server.path2url('/session/%d:%s/jsonrpc/0' %
(pid, pkg_name))
self._jsonrpc = server.setup_jsonrpc(jsonrpc_url)
# hot fix for session missing shell function
self.shell = self.server.shell
另外回到connect_usb
函数的逻辑中
判断atx-agent是否在线
@property
def agent_alive(self):
# 通过发送http请求获取axt-agent的版本,若获取失败则 atx-agent启动失败
try:
r = self._reqsess.get(self.path2url('/version'), timeout=2)
return r.status_code == 200
except:
return False
那么我们再看看 u.app_start('com.netease.cloudmusic')
这个方法主要做了什么
def app_start(self,
pkg_name,
activity=None,
extras={},
wait=True,
stop=False,
unlock=False):
""" Launch application
Args:
pkg_name (str): package name
activity (str): app activity
stop (bool): Stop app before starting the activity. (require activity)
"""
# 这里判断设备是锁屏状态,如果是的话则解锁操作
if unlock:
self.unlock()
# 这里判断是否带有activity的参数 如果有的话 则启动指定的activity
if activity:
# -D: enable debugging
# -W: wait for launch to complete
# -S: force stop the target app before starting the activity
# --user | current: Specify which user to run as; if not
# specified then run as the current user.
# -e
# --ei
# --ez
args = ['am', 'start', '-a', 'android.intent.action.MAIN',
'-c', 'android.intent.category.LAUNCHER']
if wait:
args.append('-W')
if stop:
args.append('-S')
args += ['-n', '{}/{}'.format(pkg_name, activity)]
# -e --ez
extra_args = []
for k, v in extras.items():
if isinstance(v, bool):
extra_args.extend(['--ez', k, 'true' if v else 'false'])
elif isinstance(v, int):
extra_args.extend(['--ei', k, str(v)])
else:
extra_args.extend(['-e', k, v])
args += extra_args
# 'am', 'start', '-W', '-n', '{}/{}'.format(pkg_name, activity))
self.shell(args)
else:
# 若没有则通过shell 命令运行monkey的命令将对应的app 唤起 这个方式很有意思
if stop:
self.app_stop(pkg_name)
self.shell([
'monkey', '-p', pkg_name, '-c',
'android.intent.category.LAUNCHER', '1'
])
下来则到了我们的真正重要的内容了 u(text='私人FM').click()
到这里的话我们首先要注意一个事情,因为前面我们有讲过connect_usb
返回的是一个uiautomator的对象,而这里的调用是将一个类对象通过函数传参的方式进行调用了。所以这里会涉及到一个方法叫__call__()
所有的函数都是可调用对象。一个类实例也可以变成一个可调用对象,只需要实现一个特殊方法
__call__()
所以我们这里需要看的是UIAutomatorServer的__call__方法的实现了。
# 从这里我们又可以看到这里实际上又调用了Session这个类的__call__方法
def __call__(self, **kwargs):
return self._default_session(**kwargs)
这里顺便普及下python中*args和**kwargs
*args表示任何多个无名参数,它是一个tuple;**kwargs表示关键字参数,它是一个 dict。
所以u(text='私人FM')
实际上就是个关键字传参,所以kwargs 实际上就是 {text: ‘私人FM’}
我们继续往下看
# 嗯 这里返回了一个UiObject的对象回去了。 我们暂时就先不看Selecotr这个类了
def __call__(self, **kwargs):
# 这里传了两个参数 一个是session的实例化对象,另外一个是Selector的实例化对象
return UiObject(self, Selector(**kwargs))
以下则是UiObject类的构造函数, 以下其实就是几个赋值,重点的还是jsonrpc的赋值,其实这个jsonrpc是uiautomatorServer中的jsonrpc,这一点我们要注意。
class UiObject(object):
def __init__(self, session, selector):
self.session = session
self.selector = selector
self.jsonrpc = session.jsonrpc
uiobject的对象已经实例化完成了,下来当然就是最后的click()
方法的内容了
def click(self, timeout=None, offset=None):
"""
Click UI element.
Args:
timeout: seconds wait element show up
offset: (xoff, yoff) default (0.5, 0.5) -> center
The click method does the same logic as java uiautomator does.
1. waitForExists 2. get VisibleBounds center 3. send click event
Raises:
UiObjectNotFoundError
"""
self.must_wait(timeout=timeout)
x, y = self.center(offset=offset)
# ext.htmlreport need to comment bellow code
# if info['clickable']:
# return self.jsonrpc.click(self.selector)
self.session.click(x, y)
delay = self.session.server.click_post_delay
if delay:
time.sleep(delay)
这里我们重点先关注self.must_wait(timeout=timeout)
因为这个是真正的去查找元素的方法,我们代码继续往下看。
def wait(self, exists=True, timeout=None):
"""
Wait until UI Element exists or gone
Args:
timeout (float): wait element timeout
Example:
d(text="Clock").wait()
d(text="Settings").wait("gone") # wait until it's gone
"""
if timeout is None:
timeout = self.wait_timeout
http_wait = timeout + 10
if exists:
try:
return self.jsonrpc.waitForExists(
self.selector, int(timeout * 1000), http_timeout=http_wait)
except requests.ReadTimeout as e:
warnings.warn("waitForExists readTimeout: %s" %
e, RuntimeWarning)
return self.exists()
else:
try:
return self.jsonrpc.waitUntilGone(
self.selector, int(timeout * 1000), http_timeout=http_wait)
except requests.ReadTimeout as e:
warnings.warn("waitForExists readTimeout: %s" %
e, RuntimeWarning)
return not self.exists()
这里我们会发现其实wait方法最后调用到的是self.jsonrpc.waitForExists( self.selector, int(timeout * 1000), http_timeout=http_wait)
这个方法上了,所以我们还是前面说的 我们这里就需要好好的分析下jsonrpc到底是做什么的了, 我们回到uiautomatorServer的类中去查看。
# 实际上jsonrpc是一个 JSONRpcWrapper 这个内部类的一个实例化对象,
def setup_jsonrpc(self, jsonrpc_url=None):
"""
Wrap jsonrpc call into object
Usage example:
self.setup_jsonrpc().pressKey("home")
"""
if not jsonrpc_url:
jsonrpc_url = self._server_jsonrpc_url
class JSONRpcWrapper():
def __init__(self, server):
self.server = server
self.method = None
def __getattr__(self, method):
self.method = method # jsonrpc function name
return self
def __call__(self, *args, **kwargs):
http_timeout = kwargs.pop('http_timeout', HTTP_TIMEOUT)
params = args if args else kwargs
return self.server.jsonrpc_retry_call(jsonrpc_url, self.method,
params, http_timeout)
return JSONRpcWrapper(self)
self.jsonrpc.waitForExists
这个方法的调用我们会发现 实际上JSONRpcWrapper这个类中根本就没有这个方法存在,但是根据python的特性 它实际上是首先调用了__getattr__这个方法。所以其实是给method进行了赋值,再来根据我们前面讲到的__call__所以其实self.jsonrpc.waitForExists(self.selector, int(timeout * 1000), http_timeout=http_wait)
这个方法实际上调用的就是JSONRpcWrapper中的__call__方法。 所以我们又要继续往下看jsonrpc_retry_call
这个方法了。
# 实际上 jsonrpc_retry_call最后是调用jsonrpc_call。我们可以发现其实这里的逻辑就是发送一个http请求给到我们前面我们本地的一个http,端口则是前面说到端口转发的时候获取到的一个本地端口,通过这个http adb则会将这个端口转发到手机的7912端口 由atx-agent进行处理 返回相应的处理结果。
def jsonrpc_call(self, jsonrpc_url, method, params=[], http_timeout=60):
""" jsonrpc2 call
Refs:
- http://www.jsonrpc.org/specification
"""
request_start = time.time()
data = {
"jsonrpc": "2.0",
"id": self._jsonrpc_id(method),
"method": method,
"params": params,
}
data = json.dumps(data).encode('utf-8')
res = self._reqsess.post(
jsonrpc_url, # +"?m="+method, #?method is for debug
headers={"Content-Type": "application/json"},
timeout=http_timeout,
data=data)
if DEBUG:
print("Shell$ curl -X POST -d '{}' {}".format(data, jsonrpc_url))
print("Output> " + res.text)
if res.status_code == 502:
raise GatewayError(res, "gateway error, time used %.1fs" %
(time.time() - request_start))
if res.status_code == 410: # http status gone: session broken
raise SessionBrokenError("app quit or crash", jsonrpc_url,
res.text)
if res.status_code != 200:
raise UiaError(jsonrpc_url, data, res.status_code, res.text,
"HTTP Return code is not 200", res.text)
jsondata = res.json()
error = jsondata.get('error')
if not error:
return jsondata.get('result')
# error happends
err = JsonRpcError(error, method)
if isinstance(err.data, six.string_types) and 'UiAutomation not connected' in err.data:
err.__class__ = UiAutomationNotConnectedError
elif err.message:
if 'uiautomator.UiObjectNotFoundException' in err.message:
err.__class__ = UiObjectNotFoundError
elif 'android.support.test.uiautomator.StaleObjectException' in err.message:
# StaleObjectException
# https://developer.android.com/reference/android/support/test/uiautomator/StaleObjectException.html
# A StaleObjectException exception is thrown when a UiObject2 is used after the underlying View has been destroyed.
# In this case, it is necessary to call findObject(BySelector) to obtain a new UiObject2 instance.
err.__class__ = StaleObjectExceptionError
elif 'java.lang.NullObjectException' in err.message:
err.__class__ = NullObjectExceptionError
elif 'java.lang.NullPointerException' == err.message:
err.__class__ = NullPointerExceptionError
raise err
所以我们再回到UIObject的click方法中重新梳理下这个流程
def click(self, timeout=None, offset=None):
"""
Click UI element.
Args:
timeout: seconds wait element show up
offset: (xoff, yoff) default (0.5, 0.5) -> center
The click method does the same logic as java uiautomator does.
1. waitForExists 2. get VisibleBounds center 3. send click event
Raises:
UiObjectNotFoundError
"""
# 通过http请求发送method为waitForExists的请求 确认元素是否存在
self.must_wait(timeout=timeout)
# 通过http请求发送method为objectInfo的请求获取元素的的可见区域,再定位到元素的中点
x, y = self.center(offset=offset)
# ext.htmlreport need to comment bellow code
# if info['clickable']:
# return self.jsonrpc.click(self.selector)
# 通过http请求发送method为click的请求点击对应的坐标
self.session.click(x, y)
delay = self.session.server.click_post_delay
if delay:
time.sleep(delay)
至此u(text='私人FM').click()
这个过程就结束了,那么剩下的其他步骤其实整个逻辑基本都是这样子的。
不过其实我们还需要了解atx-agent的逻辑以另外真正执行操作的apk的逻辑才能够真正清楚uiautomator2的整个过程。