常用示例

可直接复制的日常任务写法,涵盖 Python、Node.js 和 shell:从全新启动应用、关闭弹窗,到读取列表、重试、一次性验证码和获取新的 IP 地址。

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

每个示例解决一个常见任务。选择一次语言,页面上的所有示例都会随之切换。示例假设 d 是一部已连接的手机(Python 中为 d = connect(),Node.js 中为 const d = await connect());在 shell 中,每条 droidline 命令都会自己找到手机。

确认手机已就绪

唤醒屏幕,并检查它没有被 PIN 锁住,因为 Droidline 无法解锁 PIN:

d.wake()
if d.locked():
    raise SystemExit("Unlock the phone, or turn off its screen lock, first")
if d.battery()["level"] < 15:
    print("Battery is low")
await d.wake();
if (await d.locked()) throw new Error("Unlock the phone, or turn off its screen lock, first");
if ((await d.battery()).level < 15) console.log("Battery is low");
droidline wake
if [ "$(droidline locked)" = "true" ]; then
  echo "Unlock the phone first" >&2; exit 1
fi

从干净的状态启动应用

launch 会把应用带到前台,停在它上次离开的地方。要从第一个界面开始,先停止它。要像刚安装一样开始,就清除它的数据:

d.kill("com.example.shop")         # 强制停止:需要几秒钟
d.launch("com.example.shop")

d.clear_data("com.example.shop")   # 也会清除登录状态和设置
d.launch("com.example.shop")
await d.kill("com.example.shop");        // 强制停止:需要几秒钟
await d.launch("com.example.shop");

await d.clearData("com.example.shop");   // 也会清除登录状态和设置
await d.launch("com.example.shop");
droidline kill com.example.shop
droidline launch com.example.shop

kill 和 clear_data 会替你点击 Android 设置中的按钮,所以需要几秒钟,屏幕上也会短暂显示这些操作。droidline apps 会列出包名。

关闭可能出现也可能不出现的弹窗

广告、评分请求和提示时有时无。用条件逐一检查,条件从不等待,也从不失败:

for text in ["Not now", "Close ad", "Skip", "Later"]:
    if d.exists("text", text):
        d.touch("text", text)
for (const text of ["Not now", "Close ad", "Skip", "Later"]) {
  if (await d.exists("text", text)) await d.touch("text", text);
}
for text in "Not now" "Close ad" "Skip" "Later"; do
  if [ "$(droidline exists text "$text")" = "true" ]; then
    droidline touch text "$text"
  fi
done

如果弹窗在界面打开之后片刻才出现,就像下一个示例那样,用 which 等待最先出现的那一个。

处理结果不确定的步骤

点击之后,应用可能显示下一个界面、一条错误或一个弹窗。which 等待几个候选项中最先出现的那一个,并告诉你出现的是哪一个:

d.touch("text", "Pay")
result = d.which([("text", "Payment complete"),
                  ("textContains", "declined"),
                  ("text", "Confirm")], timeout=30)
if result == 2:
    d.touch("text", "Confirm")
elif result != 0:
    d.screenshot("payment-problem.png")
await d.touch("text", "Pay");
const result = await d.which([["text", "Payment complete"],
                              ["textContains", "declined"],
                              ["text", "Confirm"]], { timeout: 30 });
if (result === 2) await d.touch("text", "Confirm");
else if (result !== 0) await d.screenshot("payment-problem.png");
droidline touch text Pay
result=$(droidline which text="Payment complete" textContains=declined text=Confirm --timeout 30)
case "$result" in
  2) droidline touch text Confirm ;;
  0) ;;
  *) droidline screenshot payment-problem.png ;;
esac

等待某件事完成

等待加载动画或“Loading”标签消失,或者等待结果出现:

d.wait_gone("id", "progress", 60)
d.wait("textContains", "Upload complete", 120)
await d.waitGone("id", "progress", 60);
await d.wait("textContains", "Upload complete", 120);
droidline wait_gone id progress 60
droidline wait textContains "Upload complete" 120

输入文字和按键

input 填写输入框,替换原有内容;append=True 则改为追加。sendkey 按下一个键,例如按 Enter 提交搜索:

d.input("id", "search", "android automation")
d.sendkey("enter")

d.input("id", "note", " and more", append=True)
d.clear("id", "note")
await d.input("id", "search", "android automation");
await d.sendkey("enter");

await d.input("id", "note", " and more", { append: true });
await d.clear("id", "note");
droidline input id search "android automation"
droidline sendkey enter

任何语言和文字都可以输入,包括韩文、中文和 emoji。键名包括 enter、tab、del、space、escape、back 和 home。

读取列表中的每一项

dump 返回整棵树,所以用几行普通代码就能收集其中的值:

def texts(node):
    if node.get("text"):
        yield node["text"]
    for child in node.get("children", []):
        yield from texts(child)

screen = d.dump()
print(list(texts(screen["tree"])))
function* texts(node) {
  if (node.text) yield node.text;
  for (const child of node.children ?? []) yield* texts(child);
}

const screen = await d.dump();
console.log([...texts(screen.tree)]);
droidline dump | jq -r '.. | .text? // empty | select(. != "")'

对于很长的列表,收集一次,向上滑动,再收集,直到没有新内容出现为止:

seen = []
while True:
    new = [t for t in texts(d.dump()["tree"]) if t not in seen]
    if not new:
        break
    seen += new
    d.swipe("up")
print(seen)
const seen = [];
for (;;) {
  const fresh = [...texts((await d.dump()).tree)].filter((t) => !seen.includes(t));
  if (fresh.length === 0) break;
  seen.push(...fresh);
  await d.swipe("up");
}
console.log(seen);

失败时保留截图

把一次运行包起来,这样任何失败都会留下一张截图和一份界面转储:

from datetime import datetime
from droidline import DroidlineError

try:
    run_my_steps(d)
except DroidlineError:
    stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
    d.screenshot(f"fail-{stamp}.png")
    d.dump(f"fail-{stamp}.json")
    raise
try {
  await runMySteps(d);
} catch (e) {
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
  await d.screenshot(`fail-${stamp}.png`);
  await d.dump(`fail-${stamp}.json`);
  throw e;
}
trap 'droidline screenshot "fail-$(date +%s).png"' ERR

重试偶尔失败的步骤

错误会说明重试是否可能有用。只重试这类错误:

import time
from droidline import DroidlineError

for attempt in range(3):
    try:
        d.touch("text", "Refresh")
        break
    except DroidlineError as e:
        if not e.retryable or attempt == 2:
            raise
        time.sleep(2)
import { DroidlineError } from "droidline";

for (let attempt = 0; ; attempt++) {
  try {
    await d.touch("text", "Refresh");
    break;
  } catch (e) {
    if (!(e instanceof DroidlineError) || !e.retryable || attempt === 2) throw e;
    await new Promise((r) => setTimeout(r, 2000));
  }
}
for attempt in 1 2 3; do
  droidline touch text Refresh && break
  sleep 2
done

打开网页或深层链接

d.chrome.go("droidline.dev")                 # 在 Chrome 的当前标签页中打开
d.open_url("https://droidline.dev/docs/")    # 在默认浏览器中打开
d.open_url("myapp://orders/42")              # 直接进入支持深层链接的应用中的某个界面
await d.chrome.go("droidline.dev");
await d.openUrl("https://droidline.dev/docs/");
await d.openUrl("myapp://orders/42");
droidline chrome.go droidline.dev
droidline open_url https://droidline.dev/docs/

对于 launch 无法直接打开的界面,深层链接往往是唯一的到达途径。

等待通知中的验证码

先允许应用的通知发送到电脑(只需一次,用 notify_filter 或在应用的状态标签页中设置)。然后等待消息,并取出其中的数字:

import re

d.notify_filter(["com.google.android.apps.messaging"])
n = d.wait_notification("textContains", "code", 120)
code = re.search(r"\d{4,8}", n["text"]).group()
d.input("id", "otp", code)
await d.notifyFilter(["com.google.android.apps.messaging"]);
const n = await d.waitNotification("textContains", "code", 120);
const code = n.text.match(/\d{4,8}/)[0];
await d.input("id", "otp", code);
code=$(droidline --json wait_notification textContains code 120 | jq -r '.value.text' | grep -oE '[0-9]{4,8}' | head -1)
droidline input id otp "$code"

通过剪贴板复制和粘贴

d.clipboard("text to paste")
d.long_touch("id", "message")      # 输入框的菜单中有 Paste
d.touch("text", "Paste")

print(d.clipboard())               # 再读取出来
await d.clipboard("text to paste");
await d.longTouch("id", "message");
await d.touch("text", "Paste");

console.log(await d.clipboard());
droidline clipboard "text to paste"
droidline clipboard

在 Android 10 及以上版本上读取剪贴板,需要把 Droidline 键盘设为当前键盘。

获取新的移动 IP 地址

开关一次飞行模式,运营商就会分配一个新地址。在此期间手机会断开连接,所以要把它作为一个 batch 运行,由手机自行完成:

d.batch([("airplane", True), ("sleep", 3000), ("airplane", False)], wait=True)
print(d.network())                 # {'type': 'mobile', 'airplane': False, 'metered': True}
await d.batch([["airplane", true], ["sleep", 3000], ["airplane", false]], { wait: true });
console.log(await d.network());
droidline batch '[["airplane",true],["sleep",3000],["airplane",false]]' --wait

飞行模式关闭后,手机会自行重新连接电脑;概念一页说明了这类命令如何报告结果。

在每部手机上运行相同的步骤

from concurrent.futures import ThreadPoolExecutor
from droidline import Droidline

dl = Droidline()
names = [p["name"] for p in dl.devices() if p["online"]]

def job(name):
    d = dl.device(name)
    d.launch("com.android.chrome")
    return name, d.current()["package"]

with ThreadPoolExecutor(len(names) or 1) as pool:
    print(list(pool.map(job, names)))
import { Droidline } from "droidline";

const dl = new Droidline();
const online = (await dl.devices()).filter((p) => p.online);
console.log(await Promise.all(online.map(async (p) => {
  const d = dl.device(p.name);
  await d.launch("com.android.chrome");
  return [p.name, (await d.current()).package];
})));
for name in $(droidline --json devices | jq -r '.value[] | select(.online) | .name'); do
  droidline -d "$name" launch com.android.chrome &
done
wait

多部手机一页介绍了更多内容:命名、为每部手机设置代理,以及让一整架手机保持正常运行。