Node.js 与 TypeScript
Node.js SDK 的全部内容:安装、连接、用选项对象调用命令、文件、等待、错误、用 Promise.all 并行操控手机、通知,以及 TypeScript。
本页为译文。如与英文版不一致,以英文版为准。 English
Node.js 包是 droidline serve 的一个轻量客户端。等待、重试和回退都由服务器和手机完成;这个包负责发送命令,成功时以回复兑现(resolve),失败时以 DroidlineError 拒绝(reject)。它需要 Node.js 18 或更高版本,以 ES 模块发布,并包含 TypeScript 类型定义。
安装
npm install droidline
你的脚本必须是 ES 模块:把它命名为 something.mjs,或者在 package.json 中设置 "type": "module"。在服务器运行时检查它是否可用:
node -e "import('droidline').then(async ({ connect }) => console.log(await (await connect()).info()))"
连接
import { connect } from "droidline";
const d = await connect(); // 唯一在线的手机
const e = await connect("shelf-01"); // 按名称或 ID 指定手机
connect() 兑现为一个 Device:这个对象的方法会向一部手机发送命令。如果 droidline serve 没有运行,它会立即以错误码 SERVER_NOT_RUNNING 拒绝。
要连接另一台机器上的服务器,传入选项,或者设置 DROIDLINE_HOST、DROIDLINE_PORT、DROIDLINE_TOKEN 和 DROIDLINE_DEVICE:
const d = await connect({ device: "shelf-01", host: "192.168.0.12", port: 8780, token: "dlc_..." });
要操作多部手机,或者使用 devices、pair 等服务器命令,创建一个 Droidline 客户端,再向它获取设备:
import { Droidline } from "droidline";
const dl = new Droidline(); // host、port、token 选项相同
console.log(await dl.devices());
const a = dl.device("shelf-01");
const b = dl.device("shelf-02");
没有待处理的操作时,进程会自行退出,所以脚本中不需要调用 close()。长时间运行的程序可以在结束时调用 dl.close()。
调用命令
命令参考中的每条命令都是一个方法。必需参数在前;可选参数可以按位置跟在后面,也可以放进作为最后一个参数的选项对象中:
await d.touch("text", "Log in");
await d.touch("text", "Log in", 0, 30); // 按位置传入 nth=0、timeout=30
await d.touch("text", "Log in", { timeout: 30 }); // 更清楚:选项对象
await d.swipe("up"); // 一个方向...
await d.swipe(540, 1600, 540, 400, 300); // ...或者坐标和持续时间
await d.chrome.go("droidline.dev"); // 带点的命令是属性
由多个单词组成的名称两种写法都有:get_text 和 getText,scroll_to 和 scrollTo;本指南使用 camelCase。最常用的选择器字段有简写:d.touchById("login")、d.touchByText("OK")、d.touchByDesc("Search")。
每个方法都返回一个 promise,所以别忘了 await。忘记 await 是最常见的错误:手机还什么都没做,脚本就继续往下执行了。
返回值
| 命令返回 | promise 兑现为 | 示例 |
|---|---|---|
| 没有特别的内容 | 一个包含 via、ms 等详细信息的对象 | await d.touch(...) 得到 { via: "node", ms: 208 } |
| 一个值 | 值本身 | await d.exists(...) 得到 true,await d.getText(...) 得到一个字符串 |
| 多个字段 | 一个对象 | await d.battery() 得到 { level: 84, charging: false, temperature: 31.5 } |
文件:界面转储和截图
await d.dump("screen.json"); // 把界面树写入电脑上的文件
const tree = await d.dump(); // 或者直接兑现为界面树
await d.screenshot("shot.png"); // 扩展名决定 PNG 还是 JPEG
await d.screenshot("small.jpg", { scale: 0.5, quality: 70 });
const png = await d.screenshot({ format: "png" }); // 不传路径:返回 Buffer
路径指的是你电脑上的路径,相对于运行脚本时所在的文件夹。
等待与超时
操作元素的命令会等待它出现,默认 10 秒:
await d.touch("text", "Next", { timeout: 30 });
await d.wait("text", "Upload complete", 120); // 只等待
await d.waitGone("id", "progress"); // 等到某个元素消失
不需要在命令前用 setTimeout 暂停,那只会让脚本变慢。
条件
条件立即作答,元素不存在时也不会拒绝:
if (await d.exists("text", "Allow")) await d.touch("text", "Allow");
if (!(await d.inApp("com.android.chrome"))) await d.launch("com.android.chrome");
const state = await d.which([["text", "Log in"], ["id", "main_tab"]], { timeout: 15 }); // 0、1 或 -1
错误
每个失败都是一个 DroidlineError,带有 code、retryable 和 data(服务器发来的额外字段):
import { connect, DroidlineError } from "droidline";
const d = await connect();
try {
await d.touch("text", "Log in", { timeout: 5 });
} catch (e) {
if (!(e instanceof DroidlineError)) throw e;
switch (e.code) {
case "NOT_FOUND":
console.log("not on screen:", e.data.screen);
break;
case "DEVICE_OFFLINE":
console.log("the phone is offline");
break;
default:
console.log(e.code, e.retryable, e.message);
}
}
除了错误码中的错误码,SDK 还会使用 SERVER_NOT_RUNNING(没有程序在该端口上监听)和 CONNECTION_LOST(调用期间连接断开;命令可能执行了,也可能没有)。
对标记为可重试的错误,一个简单的重试写法:
async function retry(fn, attempts = 3) {
for (let i = 0; ; i++) {
try {
return await fn();
} catch (e) {
if (!(e instanceof DroidlineError) || !e.retryable || i >= attempts - 1) throw e;
await new Promise((r) => setTimeout(r, 2000));
}
}
}
await retry(() => d.touch("text", "Refresh"));
并行操控多部手机
发给同一部手机的命令按顺序执行;发给不同手机的命令同时执行。Promise.all 可以一次操控一整架手机:
import { Droidline } from "droidline";
const dl = new Droidline();
const online = (await dl.devices()).filter((p) => p.online);
const results = await Promise.all(online.map(async (p) => {
const d = dl.device(p.name);
await d.wake();
await d.launch("com.android.chrome");
await d.chrome.go("droidline.dev");
return [p.name, (await d.current()).package];
}));
console.log(results);
如果一部手机失败不应影响其他手机,改用 Promise.allSettled。多部手机中有更多写法。
通知与事件
先选择手机转发哪些应用的通知:用 notifyFilter,或者在应用的状态标签页中选择。然后等待一条通知,或者让每条通知触发回调:
await d.notifyFilter(["com.google.android.gm"]);
const n = await d.waitNotification("textContains", "verification code", 60);
console.log(n.title, n.text);
const stop = await d.onNotification({ package: "com.google.android.gm" }, (n) => console.log(n.title, n.text));
// ... 稍后
stop();
通知监听器处于活动状态时,进程会一直运行;调用 stop() 让它退出。通知介绍了回复、点击和 Webhook。
SDK 还不认识的命令
如果服务器比你的包更新,可以按名称调用命令:
await d.call("some_new_command", { value: 1 });
TypeScript
这个包自带类型定义。参数名、选项对象和结果都有类型:
import { connect, DroidlineError, type Device } from "droidline";
async function login(d: Device, email: string): Promise<boolean> {
await d.input("id", "email", email);
await d.touchById("login");
return (await d.which([["text", "Log in"], ["id", "main_tab"]], { timeout: 15 })) === 1;
}
const d = await connect();
console.log(await login(d, "knife"));
用 npx tsx script.ts 直接运行 TypeScript,或者像往常一样用 tsc 编译。
完整脚本
你的第一个脚本中的脚本,加上明确的退出码:
import { connect, DroidlineError } from "droidline";
async function main() {
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");
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");
if ((await d.which([["text", "Log in"], ["id", "main_tab"]], { timeout: 15 })) !== 1) {
await d.screenshot("login-failed.png");
return 1;
}
console.log(await d.getText("id", "greeting"));
return 0;
}
try {
process.exitCode = await main();
} catch (e) {
if (!(e instanceof DroidlineError)) throw e;
console.error(`${e.code}: ${e.message}`);
process.exitCode = 2;
}