Python
Python SDK 的全部内容:安装、连接、调用命令、文件、等待、错误、多部手机与线程、通知、事件,以及用 pytest 测试。
本页为译文。如与英文版不一致,以英文版为准。 English
Python 包是 droidline serve 的一个轻量客户端。等待、重试和回退都由服务器和手机完成;这个包负责发送命令,把回复转换为 Python 值,把失败转换为异常。它需要 Python 3.9 或更高版本,没有任何依赖。
安装
pip install droidline
安装到虚拟环境中,可以与你的其他项目互不干扰;安装介绍了在各个系统上的做法。在服务器运行时检查它是否可用:
python -c "from droidline import connect; print(connect().info())"
连接
from droidline import connect
d = connect() # 唯一在线的手机
d = connect("shelf-01") # 按名称或 ID 指定手机
connect() 返回一个 Device:这个对象的方法会向一部手机发送命令。如果 droidline serve 没有运行,它会立即以 ServerNotRunningError 失败。
要连接另一台机器上的服务器,传入它的地址和客户端令牌,或者设置 DROIDLINE_HOST、DROIDLINE_PORT、DROIDLINE_TOKEN 和 DROIDLINE_DEVICE:
d = connect("shelf-01", host="192.168.0.12", port=8780, token="dlc_...")
要操作多部手机,或者使用 devices、pair 等服务器命令,创建一个 Droidline 客户端,再向它获取设备:
from droidline import Droidline
dl = Droidline() # host、port、token 参数与 connect() 相同
print(dl.devices())
a = dl.device("shelf-01")
b = dl.device("shelf-02")
两种对象都可以用在 with 中,结束时会关闭连接:
with Droidline() as dl:
dl.device("shelf-01").home()
调用命令
命令参考中的每条命令都是一个同名方法。必需参数在前,可以按位置传入;可选参数可以按位置或按关键字传入:
d.touch("text", "Log in")
d.touch("text", "Log in", 0, 30) # 按位置传入 nth=0、timeout=30
d.touch("text", "Log in", timeout=30) # 更清楚:按关键字
d.swipe("up") # swipe 可以接收一个方向...
d.swipe(540, 1600, 540, 400, 300) # ...也可以接收坐标和持续时间
d.chrome.go("droidline.dev") # 带点的命令是属性
最常用的选择器字段有简写:d.touchById("login")、d.touchByText("OK")、d.touchByDesc("Search")。
返回值
| 命令返回 | 你得到 | 示例 |
|---|---|---|
| 没有特别的内容 | 一个包含 via、ms 等详细信息的 dict | d.touch(...) 返回 {"via": "node", "ms": 208} |
| 一个值 | 值本身 | d.exists(...) 返回 True,d.get_text(...) 返回 str |
| 多个字段 | 一个 dict | d.battery() 返回 {"level": 84, "charging": False, "temperature": 31.5} |
这些方法带有类型提示,所以编辑器能补全参数名,并显示每个结果的结构。
文件:界面转储和截图
d.dump("screen.json") # 把界面树写入电脑上的文件
tree = d.dump() # 或者以 dict 返回
d.screenshot("shot.png") # 扩展名决定 PNG 还是 JPEG
d.screenshot("small.jpg", scale=0.5, quality=70)
png = d.screenshot(format="png") # 不传路径:以 bytes 返回图片
路径指的是你电脑上的路径,相对于运行脚本时所在的文件夹。要在内存中处理图片,用 Pillow 打开这些字节:Image.open(io.BytesIO(png))。
等待与超时
操作元素的命令会等待它出现,默认 10 秒。可以在每次调用时修改:
d.touch("text", "Next", timeout=30)
d.wait("text", "Upload complete", 120) # 只等待
d.wait_gone("id", "progress") # 等到某个元素消失
不要在命令前调用 time.sleep(),它只会让脚本变慢。只有当需要暂停的原因无法从屏幕上看出时,才使用 sleep,例如给服务器一些时间发送消息。
条件
条件立即作答,元素不存在时也不会抛出异常:
if d.exists("text", "Allow"):
d.touch("text", "Allow")
if not d.in_app("com.android.chrome"):
d.launch("com.android.chrome")
state = d.which([("text", "Log in"), ("id", "main_tab")], timeout=15) # 0、1 或 -1
错误
失败的命令会抛出 DroidlineError 的子类。每个异常都有 code、retryable 和 data(服务器发来的额外字段);这些字段也可以作为属性读取:
from droidline import connect, DroidlineError, NotFoundError, DeviceOfflineError
d = connect()
try:
d.touch("text", "Log in", timeout=5)
except NotFoundError as e:
print("not on screen:", e.screen) # 当时显示的界面
except DeviceOfflineError:
print("the phone is offline")
except DroidlineError as e:
print(e.code, e.retryable, e)
| 异常 | 错误码 | 通常表示 |
|---|---|---|
NotFoundError | NOT_FOUND | 元素在超时前没有出现 |
AmbiguousError | AMBIGUOUS | 多个元素匹配,而命令只需要一个 |
NotClickableError | NOT_CLICKABLE | 所有点击方式都失败了 |
DroidlineTimeoutError | TIMEOUT | 命令在手机上执行的时间过长 |
NoAccessibilityError、NoImeError、NoPermissionError | NO_ACCESSIBILITY、NO_IME、NO_PERMISSION | 手机上有一项权限没有开启 |
AppNotFoundError | APP_NOT_FOUND | 没有这个包名的应用 |
MacroFailedError | MACRO_FAILED | clear_data 等设置快捷操作未能完成 |
DeviceOfflineError、DeviceNotFoundError、DeviceAmbiguousError | DEVICE_* | 指的是哪部手机,或者它能否连接 |
AgentRestartedError | AGENT_RESTARTED | 应用重启了;命令可能执行了,也可能没有 |
BadArgsError | BAD_ARGS | 缺少参数,或参数类型错误 |
ServerNotRunningError | SERVER_NOT_RUNNING | droidline serve 没有运行 |
ConnectionLostError | CONNECTION_LOST | 调用期间与服务器的连接断开了 |
它们都可以从 droidline 导入。错误码解释了每个错误码。
对标记为可重试的错误,一个简单的重试写法:
import time
from droidline import DroidlineError
def retry(fn, attempts=3):
for i in range(attempts):
try:
return fn()
except DroidlineError as e:
if not e.retryable or i == attempts - 1:
raise
time.sleep(2)
retry(lambda: d.touch("text", "Refresh"))
多部手机与线程
一个 Droidline 客户端可以在多个线程中操控多部手机。发给同一部手机的命令按顺序执行;发给不同手机的命令同时执行:
from concurrent.futures import ThreadPoolExecutor
from droidline import Droidline
dl = Droidline()
def morning(name):
d = dl.device(name)
d.wake()
d.launch("com.android.chrome")
d.chrome.go("droidline.dev")
return name, d.current()["package"]
names = [p["name"] for p in dl.devices() if p["online"]]
with ThreadPoolExecutor(max_workers=len(names) or 1) as pool:
for name, pkg in pool.map(morning, names):
print(name, pkg)
多部手机中有更多操控一整架手机的写法。
通知与事件
可以等待一条通知,也可以让每条匹配的通知触发回调。先选择手机转发哪些应用的通知:用 notify_filter,或者在应用的状态标签页中选择:
d.notify_filter(["com.google.android.gm"])
n = d.wait_notification("textContains", "verification code", 60)
print(n["title"], n["text"])
stop = d.on_notification(package="com.google.android.gm",
callback=lambda n: print(n["title"], n["text"]))
# ... 稍后
stop()
回调在 SDK 的事件线程上运行,所以要保持简短,或者把工作交给队列。其他事件,例如手机上线或离线,通过客户端接收:
dl = Droidline()
dl.subscribe(["device"])
dl.on("device", lambda e: print(e["name"], e["state"]))
通知介绍了回复、点击和 Webhook。
SDK 还不认识的命令
如果服务器比你的包更新,可以按名称调用命令:
d.call("some_new_command", value=1)
用 pytest 测试
用一个 fixture 给每个测试提供一部手机,并从主屏幕开始:
# conftest.py
import pytest
from droidline import connect
@pytest.fixture
def phone():
d = connect()
d.home()
yield d
d.screenshot("last-test.png")
# test_login.py
def test_login(phone):
phone.launch("dev.droidline.demo")
phone.input("id", "email", "knife")
phone.touchById("login")
assert phone.which([("text", "Log in"), ("id", "main_tab")]) == 1
assert phone.get_text("id", "greeting") == "Welcome, knife"
这个测试不需修改,既可以在 CI 中针对 droidline-fakephone 运行,也可以针对你桌上的真机运行。
完整脚本
你的第一个脚本中的脚本,加上重试和明确的退出码:
import sys
from droidline import connect, DroidlineError
def main() -> int:
d = connect()
d.launch("dev.droidline.demo")
d.wait("text", "Sign in")
if d.exists("text", "Close ad"):
d.touch("text", "Close ad")
d.input("id", "email", "knife")
if not d.checked("id", "auto_login"):
d.touch("id", "auto_login")
d.touch("text", "Log in")
if d.which([("text", "Log in"), ("id", "main_tab")], timeout=15) != 1:
d.screenshot("login-failed.png")
return 1
print(d.get_text("id", "greeting"))
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except DroidlineError as e:
print(f"{e.code}: {e}", file=sys.stderr)
sys.exit(2)