一. 背景

在实际应用中,我们在使用一个第三方库的时候,有时候发现这个第三方库并不是特别满足我们的需要,比如说,少了一些API,或者我需要在原来的API上增加一些Hook操作,这时候大家可能很容易想到的是:实现这个第三方库的子类,重写API;

使用适配器模式,动态决定如何调用;

实现一个新类跟新API,新类持有一个第三方库的对象。

但是,这几种方式都有很大的弊端

第一种方案,只适合一些简单封装的Python第三方库,很多用了魔法方法的第三方库没办法满足需求,你会发现到时候会有很多API调用不到。(具体后面进行说明)

第二种方案跟第三种方案,比较死板,因为你改变了原来的API方式,而且你可以封装一部分API,但如果其他人想使用其他之前没调用到的API怎么办?

其实,Python提供了一些魔法函数,可以很方便实现我们的需求。

下面我们用一个第三方库pyuiautomator的拓展举例。

二.案例

pyuiautomator是一个Android端的UI测试库,在使用过程中我们发现,本身的控件行为,是没有做一些重试或者存在判断的。对于大部分使用者,不封装这些操作是没什么问题的,但是在一些特定场景下,我们可能需要在每次控件查找时,截一次图,方便生成事件操作流;对控件做操作时,先判断存不存在,并且允许一定的重试操作。相当于,我们需要改变部分API本身。

我们先来看看pyuiautomator的使用方法

from uiautomator import Device
d = Device('aa0f41a8')
print d.server.adb.device_serial() # 打印设备串号
d.press.home() # 按下home
e = d(className='android.widget.TextView', text='target ui') # 查找元素
if e.wait.exist:
e.click() # 点击元素

可以发现,Device在这里,似乎是构造函数调用了两次?但是我们可以通过代码发现,不是这样的。

class AutomatorDevice(object):
...
def __init__(self, serial=None, local_port=None, adb_server_host=None, adb_server_port=None):
self.server = AutomatorServer(
serial=serial,
local_port=local_port,
adb_server_host=adb_server_host,
adb_server_port=adb_server_port
)
def __call__(self, **kwargs):
return AutomatorDeviceObject(self, Selector(**kwargs))

我们可以发现,Device('aa0f41a8')调用的是__init__,但是d(className='...', text='...')调用的是__call__了,因此我们可以发现,实际上,在框架里,Device承载的是多个其他设备类的能力,在这时候,如果通过实现这个第三方库的子类,重写API,或者其他模式,都是不实用的。

正确方法如下

class MTDevice(Device):
def __init__(self, serial):
super(MTDevice, self).__init__(serial)
self.serial = serial
self.obj = UIObject()
def __call__(self, **kwargs):
s = super(MTDevice, self).__call__(**kwargs)
self.obj(session=s)
return self.obj
def press_back(self):
try:
self.press.back()
except Exception as e:
raise ActionError(e.message)
return True
def click(self, x=None, y=None, retry=3, strict_mode=True):
if retry == 0:
return on_retry_max(strict_mode, "{} click {} {} exception.".format(self.serial, x, y))
time.sleep(0.5)
if super(MTDevice, self).click(x, y):
return True
else:
time.sleep(0.2)
return self.click(x, y, retry-1, strict_mode)
class UIObject(object):
def __init__(self):
self.session = None
def click(self, retry=3, strict_mode=True):
if retry == 0:
return on_retry_max(strict_mode, str(self.session.info['text'].encode('utf-8')) + " click failed")
if self.session.wait.exists(timeout=10000):
if self.session.click():
return True
else:
logging.info(str(self.session.info['text'].encode('utf-8')) + " not click success, retry.")
time.sleep(0.2)
self.click(retry-1)
else:
time.sleep(0.2)
return self.click(retry-1)
def set_text(self, text, strict_mode=True):
if not self.session.wait.exists(timeout=10 * 1000):
if strict_mode:
return WaitTimeoutError(str(self.session.info['text'].encode('utf-8')) + " wait timeout")
else:
return False
return self.__set_text(text=text, strict_mode=strict_mode)
def __set_text(self, text, retry=3, strict_mode=True):
if retry == 0:
return on_retry_max(strict_mode, str(self.session.info['text'].encode('utf-8'))
+ " set_text {} failed".format(text))
if not self.session.set_text(text):
time.sleep(0.2)
return self.__set_text(text, retry-1, strict_mode)
else:
return True
def __getattr__(self, item):
try:
return getattr(self.session, item)
except Exception as e:
logging.error(e)
time.sleep(1)
return getattr(self.session, item)
def __call__(self, **kwargs):
self.__dict__.update(kwargs)
print
def __del__(self):
del self.session

在这里,可以看到,我这边扩展的库依然也是实现子类,但是在d(className='...', text='...')触发__call__时,调用的是一个持有super(MTDevice, self).__call__(**kwargs)返回的对象,因此,我可以把部分我们需要实现的API替换掉,或者增加一些重试操作,但是原来API调用方式全部没有任何影响。

class MTDevice(Device):
def __init__(self, serial):
super(MTDevice, self).__init__(serial)
self.serial = serial
self.obj = UIObject()
def __call__(self, **kwargs):
s = super(MTDevice, self).__call__(**kwargs)
self.obj(session=s)
return self.obj
...
class UIObject(object):
def __init__(self):
self.session = None
def click(self, retry=3, strict_mode=True):
if retry == 0:
return on_retry_max(strict_mode, str(self.session.info['text'].encode('utf-8')) + " click failed")
if self.session.wait.exists(timeout=10000):
if self.session.click():
return True
else:
logging.info(str(self.session.info['text'].encode('utf-8')) + " not click success, retry.")
time.sleep(0.2)
self.click(retry-1)
else:
time.sleep(0.2)
return self.click(retry-1)
def set_text(self, text, strict_mode=True):
if not self.session.wait.exists(timeout=10 * 1000):
if strict_mode:
return WaitTimeoutError(str(self.session.info['text'].encode('utf-8')) + " wait timeout")
else:
return False
return self.__set_text(text=text, strict_mode=strict_mode)
def __set_text(self, text, retry=3, strict_mode=True):
if retry == 0:
return on_retry_max(strict_mode, str(self.session.info['text'].encode('utf-8'))
+ " set_text {} failed".format(text))
if not self.session.set_text(text):
time.sleep(0.2)
return self.__set_text(text, retry-1, strict_mode)
else:
return True
def __getattr__(self, item):
try:
return getattr(self.session, item)
except Exception as e:
logging.error(e)
time.sleep(1)
return getattr(self.session, item)
def __call__(self, **kwargs):
self.__dict__.update(kwargs)
print

在这里,我替换掉了AutomatorDeviceObject类中的click/set_text操作,而如果其他人想用这个库的其他API,那这里会通过__getattr__来调用到该session对象里的原有API。

以上只有几十行代码,但是就可以轻松拓展一个第三方库,而且用这个方法,可以给原来第三方库的部分API打上patch。