C# 与 .NET
.NET SDK 的全部内容:安装、连接、用命名参数调用 Async 方法、文件、等待、错误、用 Task.WhenAll 并行操控手机、通知、lease 和命令记录。
本页为译文。如与英文版不一致,以英文版为准。 English
.NET 包是 droidline serve 的一个轻量客户端。等待、重试和回退都由服务器和手机完成;这个包负责发送命令,把回复转成 .NET 的值返回,失败时抛出 DroidlineException。它面向 .NET Standard 2.0 和 .NET 8,因此可以在 .NET 8 及更高版本、.NET Framework 4.6.2 及更高版本上运行。
安装
在项目所在的文件夹中运行:
dotnet add package Droidline
要新建项目,dotnet new console -o first 会在 first 文件夹中创建一个控制台应用。本页的示例都按这种应用来写:代码是 Program.cs 中的顶级语句,并使用模板默认开启的隐式 using 指令。在服务器运行时,把下面的代码放进 Program.cs,用 dotnet run 运行,检查它是否可用:
using Droidline;
await using var d = await DroidlineClient.ConnectAsync();
Console.WriteLine(await d.InfoAsync());
连接
using Droidline;
await using var d = await DroidlineClient.ConnectAsync(); // 唯一在线的手机
await using var e = await DroidlineClient.ConnectAsync("shelf-01"); // 按名称或 ID 指定手机
ConnectAsync() 返回一个 Device:这个对象的方法会向一部手机发送命令。如果 droidline serve 没有运行,它会立即抛出 ServerNotRunningException。用 await using 声明时,变量离开作用域就会关闭连接。
要连接另一台机器上的服务器,传入命名参数,或者设置 DROIDLINE_HOST、DROIDLINE_PORT、DROIDLINE_TOKEN 和 DROIDLINE_DEVICE:
await using var d = await DroidlineClient.ConnectAsync("shelf-01", host: "192.168.0.12", port: 8780, token: "dlc_...");
要操作多部手机,或者使用 devices、pair 等服务器命令,创建一个 DroidlineClient,再向它获取设备:
using var client = new DroidlineClient(); // host、port、token 参数相同
foreach (var phone in await client.DevicesAsync())
Console.WriteLine(phone);
var a = client.Device("shelf-01");
var b = client.Device("shelf-02");
客户端在第一次调用时才连接,连接断开后会重新连接。多个任务(Task)可以共用一个客户端。释放(dispose)客户端会关闭连接;用 client.Device() 得到的设备共用这条连接,所以释放它们不会有任何效果。
调用命令
命令参考中的每条命令都是一个方法,方法名是把命令名改成 PascalCase 再加上 Async:touch 是 TouchAsync,long_tap 是 LongTapAsync,get_text 是 GetTextAsync。必需参数在前;可选参数就是 C# 的可选参数,可以按位置传入,也可以按名称传入:
await d.TouchAsync("text", "Log in");
await d.TouchAsync("text", "Log in", 0, 30); // 按位置传入 nth 0、timeout 30
await d.TouchAsync("text", "Log in", timeout: 30); // 更清楚:命名参数
await d.SwipeAsync("up"); // 一个方向...
await d.SwipeAsync(540, 1600, 540, 400, 300); // ...或者坐标和持续时间
await d.Chrome.GoAsync("droidline.dev"); // 带点的命令是属性
最常用的选择器字段有简写:d.TouchByIdAsync("login")、d.TouchByTextAsync("OK")、d.TouchByDescAsync("Search")。
每个方法都返回一个 Task,所以别忘了 await。漏掉它,手机还什么都没做,程序就继续往下执行了;编译器会对此给出 CS4014 警告。
每个方法的最后一个参数都是 CancellationToken,按名称传入:cancellationToken: cts.Token。取消后不再等待回复,并抛出 OperationCanceledException;已经发出的命令仍可能在手机上执行。
接受列表的参数,例如 which 的候选项和 batch 的步骤,传入 object[]。一对值写成元组,一个步骤写成元组或 object[]:
var state = await d.WhichAsync(new object[] { ("text", "Log in"), ("id", "main_tab") });
await d.BatchAsync(new object[] { new object[] { "home" }, ("sleep", 500), new object[] { "recents" } });
await d.NotifyFilterAsync(new[] { "com.google.android.gm" });
如果 batch 的步骤会关闭手机的网络,加上 cutsNetwork: true, wait: true。原因见概念。
返回值
| 命令返回 | Task 给你的值 | 示例 |
|---|---|---|
| 没有特别的内容 | 一个带 Via、Ms 等属性的结果对象 | await d.TouchAsync(...) 得到 Via 为 "node" 的 TouchResult |
| 一个值 | 值本身 | ExistsAsync 得到 bool,GetTextAsync 得到 string |
| 多个字段 | 每个字段对应一个属性的结果对象 | BatteryAsync() 得到带 Level、Charging 和 Temperature 的 BatteryResult |
| 什么都没有 | 普通的 Task | HomeAsync()、BackAsync() |
每个结果对象同时也是一个只读字典,装着服务器发来的字段。Get<T>("field") 可以读取任何字段,包括比这个 SDK 更新的字段;ToString() 把回复转成一行 JSON:
var battery = await d.BatteryAsync();
Console.WriteLine(battery.Level); // 87
Console.WriteLine(battery.Get<double>("temperature")); // 31.5
Console.WriteLine(battery); // {"charging":true,"level":87,"temperature":31.5}
文件:界面转储和截图
await d.DumpAsync("screen.json"); // 把界面树写入电脑上的文件
var screen = await d.DumpAsync(); // 或者直接返回:screen.Tree、screen.Package
await d.ScreenshotAsync("shot.png"); // 扩展名决定 PNG 还是 JPEG
await d.ScreenshotAsync("small.jpg", scale: 0.5, quality: 70);
byte[] png = await d.ScreenshotAsync(format: "png"); // 不传路径:只返回字节
路径指的是你电脑上的路径,相对于运行程序时所在的文件夹。ScreenshotAsync 总是返回图片的字节,传入路径时还会同时保存到文件。
等待与超时
操作元素的命令会等待它出现,默认 10 秒:
await d.TouchAsync("text", "Next", timeout: 30);
await d.WaitAsync("text", "Upload complete", 120); // 只等待
await d.WaitGoneAsync("id", "progress"); // 等到某个元素消失
不需要在命令前用 Task.Delay 暂停,那只会让程序变慢。
条件
条件立即作答,元素不存在时也不会抛出异常:
if (await d.ExistsAsync("text", "Allow"))
await d.TouchAsync("text", "Allow");
if (!await d.InAppAsync("com.android.chrome"))
await d.LaunchAsync("com.android.chrome");
var state = await d.WhichAsync(new object[] { ("text", "Log in"), ("id", "main_tab") }, timeout: 15); // 0、1 或 -1
错误
失败的命令会抛出 DroidlineException 的子类。每个异常都有 Code、Retryable 和 Fields(服务器发来的额外字段):
using Droidline;
await using var d = await DroidlineClient.ConnectAsync();
try
{
await d.TouchAsync("text", "Log in", timeout: 5);
}
catch (NotFoundException e)
{
Console.WriteLine($"not on screen: {e.Fields["screen"]}"); // 当时显示的界面
}
catch (DeviceOfflineException)
{
Console.WriteLine("the phone is offline");
}
catch (DroidlineException e)
{
Console.WriteLine($"{e.Code} {e.Retryable} {e.Message}");
}
| 异常 | 错误码 | 通常表示 |
|---|---|---|
NotFoundException | NOT_FOUND | 元素在超时前没有出现 |
AmbiguousException | AMBIGUOUS | 多个元素匹配,而命令只需要一个 |
NotClickableException | NOT_CLICKABLE | 所有点击方式都失败了 |
DroidlineTimeoutException | TIMEOUT | 命令在手机上执行的时间过长 |
NoAccessibilityException、NoImeException、NoPermissionException | NO_ACCESSIBILITY、NO_IME、NO_PERMISSION | 手机上有一项权限没有开启 |
AppNotFoundException | APP_NOT_FOUND | 没有这个包名的应用 |
DeviceOfflineException、DeviceNotFoundException、DeviceAmbiguousException | DEVICE_* | 指的是哪部手机,或者它能否连接 |
AgentRestartedException | AGENT_RESTARTED | 应用重启了;命令可能执行了,也可能没有 |
BadArgsException | BAD_ARGS | 缺少参数,或参数类型错误 |
ServerNotRunningException | SERVER_NOT_RUNNING | droidline serve 没有运行 |
ConnectionLostException | CONNECTION_LOST | 调用期间与服务器的连接断开了;下一次调用会重新连接 |
它们都在 Droidline 命名空间中。错误码解释了每个错误码。
对标记为可重试的错误,一个简单的重试写法:
static async Task<T> RetryAsync<T>(Func<Task<T>> action, int attempts = 3)
{
for (var i = 0; ; i++)
{
try
{
return await action();
}
catch (DroidlineException e) when (e.Retryable && i < attempts - 1)
{
await Task.Delay(2000);
}
}
}
await RetryAsync(() => d.TouchAsync("text", "Refresh"));
并行操控多部手机
发给同一部手机的命令按顺序执行;发给不同手机的命令同时执行。Task.WhenAll 可以一次操控一整架手机:
using Droidline;
using var client = new DroidlineClient();
var online = (await client.DevicesAsync()).Where(p => p.Get<bool>("online"));
var results = await Task.WhenAll(online.Select(async p =>
{
var name = p.Get<string>("name")!;
var d = client.Device(name);
await d.WakeAsync();
await d.LaunchAsync("com.android.chrome");
await d.Chrome.GoAsync("droidline.dev");
return (name, (await d.CurrentAsync()).Package);
}));
foreach (var (name, package) in results)
Console.WriteLine($"{name} {package}");
一部手机失败时,Task.WhenAll 仍会等其他手机全部完成,然后抛出第一个失败。要保留每部手机的结果,在 lambda 内部捕获异常。多部手机中有更多写法。
通知与事件
先选择手机转发哪些应用的通知:用 NotifyFilterAsync,或者在应用的状态标签页中选择。然后等待一条通知,或者让每条通知触发回调:
await d.NotifyFilterAsync(new[] { "com.google.android.gm" });
var n = await d.WaitNotificationAsync("textContains", "verification code", 60);
Console.WriteLine($"{n.Title} {n.Text}");
var stop = await d.OnNotificationAsync(x => Console.WriteLine($"{x.Title} {x.Text}"), package: "com.google.android.gm");
// ... 稍后
stop.Dispose();
OnNotificationAsync 返回一个 IDisposable;释放它就会停止回调,也可以用 using var 声明。回调在 SDK 的事件线程上逐个运行,所以要保持简短。其他事件,例如手机上线或离线,通过客户端接收:
using var client = new DroidlineClient();
await client.SubscribeAsync(new[] { "device" });
client.On("device", e => Console.WriteLine($"{e.Get<string>("name")} {e.Get<string>("state")}"));
On 同样返回 IDisposable。通知介绍了回复、点击和 Webhook。
元素、lease 和命令记录
FindAsync 返回一个 Element,FindAllAsync 返回它们的列表。凡是接受字段和值的方法,也都可以改为传入查询对象。在你的第一个脚本中那个演示应用的主界面上:
var chats = await d.FindAllAsync(new { textContains = "Chat", checkable = true });
foreach (var chat in chats)
Console.WriteLine($"{chat.Text} {chat.Bounds}");
await chats[0].ClickAsync();
Chat 1 (60, 600, 1020, 740)
Chat 2 (60, 760, 1020, 900)
Chat 3 (60, 920, 1020, 1060)
查询可以是匿名对象、字典或 Query。匿名对象会原样保留属性名,所以键要按查询参考中的拼写来写:textContains、long_clickable,而 class 要写成 @class。下面两行点击的是同一个按钮:
await d.TouchAsync(new { @class = "android.widget.Button", below = new { id = "auto_login" } });
await d.TouchAsync(new Query { Class = "android.widget.Button", Below = new Query { Id = "auto_login" } });
DroidlineClient.LeaseAsync() 与 ConnectAsync() 类似,但会借用一部空闲手机,让其他脚本无法使用;释放设备时归还。d.History 列出发给这部手机的最近请求,附带结果和耗时;Testing.FormatHistory 把它们转成文本:
await using var d = await DroidlineClient.LeaseAsync(wait: 60);
await d.LaunchAsync("dev.droidline.demo");
await d.WaitAsync("text", "Sign in");
Console.WriteLine(Testing.FormatHistory(d.History));
04:43:42 launch {"package": "dev.droidline.demo"} -> ok (0 ms)
04:43:42 wait {"by": "text", "value": "Sign in"} -> ok (0 ms)
在测试中,Testing.SaveFailureAsync 会把 screen.png、screen.json 以及保存命令记录的 commands.txt 存进一个文件夹。任何测试框架都能用:
try
{
await d.TouchByIdAsync("login");
}
catch
{
await Testing.SaveFailureAsync(d, "artifacts/login");
throw;
}
SDK 还不认识的命令
如果服务器比你的包更新,可以按名称调用命令。参数用匿名对象或字典传入;回复是一个 DroidlineObject,用 Get<T> 读取字段:
var reply = await d.CallAsync("some_new_command", new { value = 1 });
服务器命令用 client.CallAsync,写法相同。
完整脚本
你的第一个脚本中的脚本,加上明确的退出码:
using Droidline;
try
{
return await RunAsync();
}
catch (DroidlineException e)
{
Console.Error.WriteLine($"{e.Code}: {e.Message}");
return 2;
}
static async Task<int> RunAsync()
{
await using var d = await DroidlineClient.ConnectAsync();
await d.LaunchAsync("dev.droidline.demo");
await d.WaitAsync("text", "Sign in");
if (await d.ExistsAsync("text", "Close ad"))
await d.TouchAsync("text", "Close ad");
await d.InputAsync("id", "email", "knife");
if (!await d.CheckedAsync("id", "auto_login"))
await d.TouchAsync("id", "auto_login");
await d.TouchAsync("text", "Log in");
if (await d.WhichAsync(new object[] { ("text", "Log in"), ("id", "main_tab") }, timeout: 15) != 1)
{
await d.ScreenshotAsync("login-failed.png");
return 1;
}
Console.WriteLine(await d.GetTextAsync("id", "greeting"));
return 0;
}
用 dotnet run 运行。对着 droidline-fakephone 运行时,它会输出 Welcome, knife,并以 0 退出。