你的第一个脚本

针对模拟手机,一步步构建一个完整的登录脚本,逐行解释,提供 Python、Node.js 和 shell 三种写法。

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

在本教程中,你会写一个脚本:打开一个应用,如果有广告就关掉它,登录,检查登录是否成功,从屏幕上读取两个值,并保存一张截图。在这个过程中,你会接触到每个脚本都会用到的概念:查找元素、等待、条件和错误。

你不需要手机。Droidline 自带 droidline-fakephone,这个小程序会假装成一部运行着演示应用的手机。它使用与真实应用完全相同的协议,所以你在这里写的脚本在真机上也能运行。

开始之前,请完成安装的第 1、2 和 5 步:把 droidline 程序放进 PATH;如果你用 Python 或 Node.js,再安装对应语言的库。

1. 启动服务器和模拟手机

你需要三个终端窗口。

终端 1 运行服务器。保持它打开:

droidline serve

终端 2 运行模拟手机。它会在你的网络中找到服务器,并输出一个配对码:

droidline-fakephone
Found MY-PC at 192.168.0.12:8779

Pairing code: 482913
On the PC run: droidline pair 482913

终端 3 是你工作的地方。批准这个代码,并给手机起个名字:

droidline pair 482913 --name fake-01
droidline devices
NAME     ID        MODEL           ANDROID  STATE   ROUTE  MISSING
fake-01  lpez3oxg  Droidline Fake  14       online  lan

2. 先查看屏幕

为任何应用写脚本之前,先看看 Droidline 是怎样看待它的界面的。打开演示应用并保存它的界面:

droidline launch dev.droidline.demo
droidline dump screen.json

screen.json 描述了屏幕上的每个元素。下面是登录界面中重要的部分,省略了其他字段:

{
  "package": "dev.droidline.demo",
  "activity": ".LoginActivity",
  "tree": {
    "children": [
      { "text": "Sign in",           "id": "",                               "class": "android.widget.TextView" },
      { "text": "",                  "id": "dev.droidline.demo:id/email",    "desc": "Email", "class": "android.widget.EditText" },
      { "text": "",                  "id": "dev.droidline.demo:id/password", "desc": "Password", "class": "android.widget.EditText" },
      { "text": "Keep me signed in", "id": "dev.droidline.demo:id/auto_login", "class": "android.widget.CheckBox", "checked": false },
      { "text": "Log in",            "id": "dev.droidline.demo:id/login",    "class": "android.widget.Button", "enabled": false },
      { "text": "Close ad",          "id": "dev.droidline.demo:id/ad_close", "class": "android.widget.Button" }
    ]
  }
}

有三个字段可以区分元素:

  • text 是元素显示的内容,例如 Log in。
  • id 是应用开发者给元素起的名字。它不会随手机语言改变,所以只要有,它就是最可靠的选择。:id/ 之前的部分可以省略:email 能匹配 dev.droidline.demo:id/email。
  • desc 是给屏幕阅读器用的描述,通常是图标唯一的标签。

字段和值合在一起称为选择器:("text", "Log in") 或 ("id", "email")。每个操作元素的命令都接收一个选择器。注意 Log in 是 enabled: false:应用只在输入邮箱之后才启用它。

3. 一步步编写脚本

下面每一步都会加上几行代码。在任意一个示例中选择你的语言,页面其余部分会随之切换。

连接并打开应用

from droidline import connect

d = connect()
d.launch("dev.droidline.demo")
d.wait("text", "Sign in")
import { connect } from "droidline";

const d = await connect();
await d.launch("dev.droidline.demo");
await d.wait("text", "Sign in");
#!/usr/bin/env bash
set -e   # 遇到第一条失败的命令就停止

droidline launch dev.droidline.demo
droidline wait text "Sign in"

connect() 找到唯一在线的手机,返回一个向它发送命令的对象,按惯例命名为 d。launch 按包名启动应用,包名是每个 Android 应用独有的名称。wait 会一直等到某个元素出现,默认最多 10 秒,这样下一行就不会在仍在加载的界面上执行。

在 shell 中不需要连接:每条 droidline 命令都会自己找到服务器和手机。

只在有广告时关闭它

if d.exists("text", "Close ad"):
    d.touch("text", "Close ad")
if (await d.exists("text", "Close ad")) {
  await d.touch("text", "Close ad");
}
if [ "$(droidline exists text "Close ad")" = "true" ]; then
  droidline touch text "Close ad"
fi

exists 是一个条件:它立即回答 true 或 false,永远不会失败。所以它适合处理可能出现也可能不出现的东西,例如广告和弹窗。touch 会找到元素,必要时等待它出现,然后点击它。

输入邮箱

d.input("id", "email", "knife")
await d.input("id", "email", "knife");
droidline input id email knife

input 接收一个选择器和要输入的文字。它会替换输入框中原有的内容。这里的选择器使用 id,因为邮箱输入框本身没有可以匹配的文字。

如果复选框没有勾选,就勾选它

if not d.checked("id", "auto_login"):
    d.touch("id", "auto_login")
if (!(await d.checked("id", "auto_login"))) {
  await d.touch("id", "auto_login");
}
if [ "$(droidline checked id auto_login)" = "false" ]; then
  droidline touch id auto_login
fi

点击复选框会切换它的状态,所以不加判断地点击,会把已经勾选的复选框取消勾选。checked 先读取当前状态。

登录并检查是否成功

d.touch("text", "Log in")

screen = d.which([("text", "Log in"), ("id", "main_tab")], timeout=15)
if screen != 1:
    d.screenshot("login-failed.png")
    raise SystemExit("Did not reach the main screen")
await d.touch("text", "Log in");

const screen = await d.which([["text", "Log in"], ["id", "main_tab"]], { timeout: 15 });
if (screen !== 1) {
  await d.screenshot("login-failed.png");
  throw new Error("Did not reach the main screen");
}
droidline touch text "Log in"

screen=$(droidline which text="Log in" id=main_tab --timeout 15)
if [ "$screen" != "1" ]; then
  droidline screenshot login-failed.png
  echo "Did not reach the main screen" >&2
  exit 1
fi

点击 Log in 之后,可能出现两种情况:主界面打开;或者因为出了问题,登录界面一直停留。which 等待它的候选项中最先出现的那一个,并返回它在列表中的位置:第一个返回 0,第二个返回 1,超时前都没有出现则返回 -1。失败时保存一张截图,之后就很容易看出哪里出了问题。

读取数值并保存截图

print(d.get_text("id", "greeting"))
print("Balance:", d.get_text("id", "balance"))
print("Chats:", d.count("class", "android.widget.CheckBox"))
d.screenshot("after-login.png")
console.log(await d.getText("id", "greeting"));
console.log("Balance:", await d.getText("id", "balance"));
console.log("Chats:", await d.count("class", "android.widget.CheckBox"));
await d.screenshot("after-login.png");
droidline get_text id greeting
echo "Balance: $(droidline get_text id balance)"
echo "Chats: $(droidline count class android.widget.CheckBox)"
droidline screenshot after-login.png

get_text 返回元素的文字,count 返回匹配的元素有多少个。截图保存在你的电脑上,而不是手机上。

4. 完整脚本

from droidline import connect

d = connect()

# 打开演示应用,等待登录界面出现。
d.launch("dev.droidline.demo")
d.wait("text", "Sign in")

# 关闭广告,但只在它出现时才关闭。
if d.exists("text", "Close ad"):
    d.touch("text", "Close ad")

# 输入邮箱;如果 "Keep me signed in" 没有勾选,就勾选它。
d.input("id", "email", "knife")
if not d.checked("id", "auto_login"):
    d.touch("id", "auto_login")

# 登录,然后检查出现的是哪个界面。
d.touch("text", "Log in")
screen = d.which([("text", "Log in"), ("id", "main_tab")], timeout=15)
if screen != 1:
    d.screenshot("login-failed.png")
    raise SystemExit("Did not reach the main screen")

# 读取应用显示的内容,并保存一张图片。
print(d.get_text("id", "greeting"))
print("Balance:", d.get_text("id", "balance"))
print("Chats:", d.count("class", "android.widget.CheckBox"))
d.screenshot("after-login.png")
print("done")
import { connect } from "droidline";

const d = await connect();

// 打开演示应用,等待登录界面出现。
await d.launch("dev.droidline.demo");
await d.wait("text", "Sign in");

// 关闭广告,但只在它出现时才关闭。
if (await d.exists("text", "Close ad")) {
  await d.touch("text", "Close ad");
}

// 输入邮箱;如果 "Keep me signed in" 没有勾选,就勾选它。
await d.input("id", "email", "knife");
if (!(await d.checked("id", "auto_login"))) {
  await d.touch("id", "auto_login");
}

// 登录,然后检查出现的是哪个界面。
await d.touch("text", "Log in");
const screen = await d.which([["text", "Log in"], ["id", "main_tab"]], { timeout: 15 });
if (screen !== 1) {
  await d.screenshot("login-failed.png");
  throw new Error("Did not reach the main screen");
}

// 读取应用显示的内容,并保存一张图片。
console.log(await d.getText("id", "greeting"));
console.log("Balance:", await d.getText("id", "balance"));
console.log("Chats:", await d.count("class", "android.widget.CheckBox"));
await d.screenshot("after-login.png");
console.log("done");
#!/usr/bin/env bash
set -e   # 遇到第一条失败的命令就停止

# 打开演示应用,等待登录界面出现。
droidline launch dev.droidline.demo
droidline wait text "Sign in"

# 关闭广告,但只在它出现时才关闭。
if [ "$(droidline exists text "Close ad")" = "true" ]; then
  droidline touch text "Close ad"
fi

# 输入邮箱;如果 "Keep me signed in" 没有勾选,就勾选它。
droidline input id email knife
if [ "$(droidline checked id auto_login)" = "false" ]; then
  droidline touch id auto_login
fi

# 登录,然后检查出现的是哪个界面。
droidline touch text "Log in"
screen=$(droidline which text="Log in" id=main_tab --timeout 15)
if [ "$screen" != "1" ]; then
  droidline screenshot login-failed.png
  echo "Did not reach the main screen" >&2
  exit 1
fi

# 读取应用显示的内容,并保存一张图片。
droidline get_text id greeting
echo "Balance: $(droidline get_text id balance)"
echo "Chats: $(droidline count class android.widget.CheckBox)"
droidline screenshot after-login.png
echo done

把它保存为 login.py、login.mjs 或 login.sh,然后运行:

python login.py
node login.mjs
bash login.sh
Welcome, knife
Balance: 12,500
Chats: 3
done

shell 版本还会输出每条命令的回复,例如 {"ms":0,"via":"node"}。在 Windows 上,请在 Git Bash 中运行 shell 版本,或者参阅 CLI 指南中同一脚本的 PowerShell 版本。

5. 出错时

把点击那一步中的 "Log in" 改成 "Sign up",再运行一次脚本。它会因下面这样的错误而停止(Python 版本把它显示为 traceback 的最后一行):

droidline._generated.NotFoundError: Could not find text 'Sign up' within 10s. Current screen: dev.droidline.demo / .LoginActivity

消息说明了查找的是什么、找了多久,以及当时显示的是哪个界面。每个错误还带有一个 code(这里是 NOT_FOUND),并说明重试是否可能有用。要处理错误而不是停止:

from droidline import connect, NotFoundError

d = connect()
try:
    d.touch("text", "Sign up", timeout=2)
except NotFoundError as e:
    print(e.code, e.retryable, e)
import { connect, DroidlineError } from "droidline";

const d = await connect();
try {
  await d.touch("text", "Sign up", { timeout: 2 });
} catch (e) {
  if (!(e instanceof DroidlineError)) throw e;
  console.log(e.code, e.retryable, e.message);
}
if ! droidline touch text "Sign up" --timeout 2; then
  echo "not there, carrying on"
fi

timeout=2 让命令在 2 秒后放弃,而不是 10 秒。在 shell 中,失败的命令会输出错误并以状态码 1 退出,if ! 会捕获它。错误码列出了所有错误码。

6. 在真机上运行

同样的脚本可以在真机上运行。只有与应用相关的细节需要改变:

  1. 配对真机(见快速开始)。如果模拟手机仍然在线,用 Ctrl+C 停止它,或者传入手机的名字:在 SDK 中用 connect("shelf-01"),在 CLI 中用 --device shelf-01。
  2. 把 dev.droidline.demo 换成你要自动化的应用的包名。droidline apps 会列出手机上的应用及其包名。
  3. 在手机上打开每个界面,运行 droidline dump screen.json,根据找到的内容选择选择器。查找元素说明了如何选得好。
  4. 文字要使用手机的语言。在设置为简体中文的手机上,英文中标为“Log in”的按钮很可能显示为“登录”。

接下来,阅读概念,了解每条命令背后发生了什么;或者浏览实用示例,获取现成的写法。