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 等详细信息的 dictd.touch(...) 返回 {"via": "node", "ms": 208}
一个值值本身d.exists(...) 返回 True,d.get_text(...) 返回 str
多个字段一个 dictd.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)
异常错误码通常表示
NotFoundErrorNOT_FOUND元素在超时前没有出现
AmbiguousErrorAMBIGUOUS多个元素匹配,而命令只需要一个
NotClickableErrorNOT_CLICKABLE所有点击方式都失败了
DroidlineTimeoutErrorTIMEOUT命令在手机上执行的时间过长
NoAccessibilityError、NoImeError、NoPermissionErrorNO_ACCESSIBILITY、NO_IME、NO_PERMISSION手机上有一项权限没有开启
AppNotFoundErrorAPP_NOT_FOUND没有这个包名的应用
MacroFailedErrorMACRO_FAILEDclear_data 等设置快捷操作未能完成
DeviceOfflineError、DeviceNotFoundError、DeviceAmbiguousErrorDEVICE_*指的是哪部手机,或者它能否连接
AgentRestartedErrorAGENT_RESTARTED应用重启了;命令可能执行了,也可能没有
BadArgsErrorBAD_ARGS缺少参数,或参数类型错误
ServerNotRunningErrorSERVER_NOT_RUNNINGdroidline serve 没有运行
ConnectionLostErrorCONNECTION_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)