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;
}