概念

Droidline 每条命令背后的概念,一次讲清:屏幕是一棵树、选择器、自动等待、条件、错误、多部手机、会切断网络的命令,以及为什么命令不会执行两次。

本页为译文。如与英文版不一致,以英文版为准。 English

本页说明 Droidline 如何理解一部手机。入门时不需要掌握全部内容,但每一节都回答了一个人们在第一周就会遇到的问题。示例使用 Python 和 Node.js;命令参考列出了每条命令在每种接口中的写法。

屏幕是一棵树

Android 把屏幕上的内容描述为一棵元素树,屏幕阅读器为视障用户服务时用的也是这棵树。一个界面是一个大框;里面有工具栏、列表等较小的框;这些框里面又有按钮、文字和图片。

dump() 返回这棵树。每个元素都带有这些字段:

{
  "text": "Log in",
  "id": "com.example:id/login",
  "desc": "",
  "class": "android.widget.Button",
  "bounds": [60, 1000, 1020, 1140],
  "clickable": true,
  "enabled": true,
  "checked": false,
  "children": []
}
字段含义
text元素显示的文字。
id应用开发者给它的资源 ID。很多元素没有。
desc内容描述,是为屏幕阅读器写的。图标按钮通常只有这个字段。
class元素的类型,例如 android.widget.Button。
bounds元素的位置,格式为 [left, top, right, bottom],单位是屏幕像素。
clickable、enabled、checked、selected它的状态。
d.dump("screen.json")          # 把树保存到电脑上的文件
tree = d.dump()                # 或者以 dict 返回
await d.dump("screen.json");   // 把树保存到电脑上的文件
const tree = await d.dump();   // 或者以对象返回

查找元素一步步介绍如何阅读界面转储并挑选正确的元素。

选择器:字段和值

操作单个元素的命令,前两个参数是:要比较哪个字段(by)和要查找的值。两者合在一起就是选择器。

by匹配示例
texttext,完全一致touch("text", "Log in")
textContainstext 的一部分touch("textContains", "Log")
idid。只写名称时匹配任意包名:"login" 能匹配 com.example:id/logintouch("id", "login")
descdesc,完全一致touch("desc", "Search")
descContainsdesc 的一部分touch("descContains", "Sear")
classclass,通常与 nth 一起使用touch("class", "android.widget.Button", nth=2)

有多个元素匹配时,用 nth 选出其中一个,按它们在屏幕上出现的顺序从 0 开始计数。touchById、touchByText 和 touchByDesc 是三个最常用字段的简写:

d.touch("id", "login")
d.touchById("login")                                   # 效果相同
d.touch("class", "android.widget.CheckBox", nth=1)     # 第二个复选框
await d.touch("id", "login");
await d.touchById("login");                                      // 效果相同
await d.touch("class", "android.widget.CheckBox", { nth: 1 });   // 第二个复选框

该选哪个字段:id 不受语言变化影响,大多数应用更新后也不变,所以只要有就优先用它。text 易读,但会随手机语言变化。图标按钮通常带有 desc。

等待是内置的

手机慢起来很难预料:应用启动要花一点时间,列表要从网络加载。Droidline 会替你处理这些。touch、long_touch、input、clear 和 wait 会等待它们的元素出现,默认 10 秒。不要在它们前面自己加 sleep。

d.touch("text", "Done")                 # 最多等待 10 秒
d.touch("text", "Done", timeout=30)     # 最多等待 30 秒
d.wait("text", "Upload complete", 120)  # 只等待,不点击
d.wait_gone("text", "Loading")          # 等待某个元素消失
await d.touch("text", "Done");                    // 最多等待 10 秒
await d.touch("text", "Done", { timeout: 30 });   // 最多等待 30 秒
await d.wait("text", "Upload complete", 120);     // 只等待,不点击
await d.waitGone("text", "Loading");              // 等待某个元素消失

等待在手机上进行,所以等待期间不占用任何网络流量。

点击是如何完成的

touch 找到元素后,会依次尝试三种点击方式,并在回复的 via 字段中报告哪一种成功了:

  1. node:元素自身的点击操作,最干净的方式。
  2. parent:最近的可点击父元素,适用于可点击行中的标签。
  3. gesture:在元素 bounds 的中心进行一次真实的点击。

input 的工作方式相同:它通过无障碍服务设置文字(via: "set_text");对于忽略这种方式的输入框,改用 Droidline 键盘键入(via: "ime")。

条件立即作答

有些命令是提问:exists、get_text、checked、enabled、selected、count、in_app、keyboard_shown、last_toast、color、installed,以及 battery 等设备检查。它们从不等待,也从不失败。元素不存在时,它们返回 false、""、0 或 -1,而不是报错,所以可以直接放进 if:

if d.exists("text", "Close ad"):
    d.touch("text", "Close ad")

if d.battery()["level"] < 20:
    print("charge me")
if (await d.exists("text", "Close ad")) {
  await d.touch("text", "Close ad");
}

if ((await d.battery()).level < 20) {
  console.log("charge me");
}

等待几个界面中的一个

which 是唯一会等待的条件。给它一组选择器,它返回最先出现的那一个在列表中的位置;如果超时前都没有出现,则返回 -1。一个步骤可能有不同结果时,用它处理最干净:

state = d.which([("text", "Log in"), ("id", "main_tab")], timeout=15)
if state == 0:      # 先出现的是登录界面
    d.input("id", "email", "me@example.com")
elif state == -1:   # 两者都没有出现
    d.screenshot("unknown.png")
const state = await d.which([["text", "Log in"], ["id", "main_tab"]], { timeout: 15 });
if (state === 0) {          // 先出现的是登录界面
  await d.input("id", "email", "me@example.com");
} else if (state === -1) {  // 两者都没有出现
  await d.screenshot("unknown.png");
}

错误

命令无法完成任务时,会以错误失败。每个错误都有一个 code、一条易读的消息,以及 retryable,它说明重试是否可能有用。

NOT_FOUND: Could not find text 'Log in' within 10s. Current screen: com.example / .MainActivity
接口失败时的表现
Python一个异常,是 DroidlineError 的子类:NotFoundError、DeviceOfflineError 等
Node.js一个带有 code、retryable 和 data 的 DroidlineError
CLI消息输出到标准错误,退出状态为 1
HTTP该错误码对应的 HTTP 状态,响应体是同样的 JSON
套接字一行带有 "ok": false 的回复
from droidline import NotFoundError, DroidlineError

try:
    d.touch("text", "Log in", timeout=5)
except NotFoundError:
    d.back()
except DroidlineError as e:
    print(e.code, e.retryable, e)
import { DroidlineError } from "droidline";

try {
  await d.touch("text", "Log in", { timeout: 5 });
} catch (e) {
  if (!(e instanceof DroidlineError)) throw e;
  if (e.code === "NOT_FOUND") await d.back();
  else console.log(e.code, e.retryable, e.message);
}

消息使用服务器的语言:英语、韩语或中文,取决于 config.toml 中的 lang 设置或你的系统语言。错误码始终不变。错误码列出了全部错误码。

一部手机还是多部

只有一部手机在线时,不必指定它。有多部手机时,请指明你要用的那部手机,否则调用会以 DEVICE_AMBIGUOUS 失败:

from droidline import Droidline

dl = Droidline()
for info in dl.devices():
    print(info["name"], info["online"], info.get("route"))

a = dl.device("shelf-01")
b = dl.device("shelf-02")
a.launch("com.android.chrome")
import { Droidline } from "droidline";

const dl = new Droidline();
for (const info of await dl.devices()) {
  console.log(info.name, info.online, info.route);
}

const a = dl.device("shelf-01");
const b = dl.device("shelf-02");
await a.launch("com.android.chrome");
droidline --device shelf-01 launch com.android.chrome

所有接口还会读取 DROIDLINE_DEVICE 环境变量,作为默认手机。用 droidline rename <id> <name> 给手机起简短的名字。

发给同一部手机的命令按发送顺序逐条执行。发给不同手机的命令同时执行。多部手机介绍如何并行操控一整架手机。

如果手机离线,命令会最多等待 30 秒让它重新上线(config.toml 中的 offline_wait),然后以 DEVICE_OFFLINE 失败。

会断开手机自身连接的命令

data(False)、wifi(False) 和 airplane(True) 会断开手机与电脑通信所用的网络。Droidline 用两种方式处理这种情况。

默认情况下,这些命令在执行之前先回复 {"accepted": true},最终结果在手机重新连接后才到达。传入 wait=True(CLI:--wait),就能把最终结果作为正常的返回值接收:

d.airplane(True, wait=True)
await d.airplane(true, { wait: true });
droidline airplane true --wait

手机离线期间必须完成的操作,都放进同一个 batch。手机会自行执行整个列表,重新上线后再报告结果:

d.batch([("airplane", True), ("sleep", 3000), ("airplane", False)], wait=True)
await d.batch([["airplane", true], ["sleep", 3000], ["airplane", false]], { wait: true });
droidline batch '[["airplane",true],["sleep",3000],["airplane",false]]' --wait

这是获取新的移动网络 IP 地址的常用方法。sleep 只能在 batch 中使用。

不会执行两次

使用移动数据时,电脑与手机之间的连接随时可能中断,包括在命令执行到一半时。Droidline 确保命令仍然恰好执行一次:

  1. 每条命令都带有一个 ID。
  2. 手机会保留每条回复,直到电脑确认收到。
  3. 如果连接中断,手机重新上线后,电脑会用相同的 ID 重发命令。
  4. 手机认出这个 ID,发送保存的回复,而不是再执行一次命令。

如果手机上的 Droidline 应用在此期间重启过,它记住的 ID 就丢失了。这时命令会以 AGENT_RESTARTED 失败,由你的代码判断再执行一次是否安全。