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의 최상위 문(top-level statements)이고, 템플릿이 켜 두는 암시적 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()로 받은 기기는 그 연결을 같이 쓰므로, 기기를 dispose해도 아무 일도 일어나지 않습니다.

명령 호출

명령어 레퍼런스의 모든 명령은 메서드입니다. 이름은 명령 이름을 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
아무것도 없음그냥 TaskHomeAsync(), 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");                         // 화면 트리를 PC의 파일로 저장
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");      // 경로가 없으면 바이트만

경로는 PC 기준이며, 프로그램을 실행한 폴더에 대한 상대 경로입니다. 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}");
}
예외코드보통 뜻하는 것
NotFoundExceptionNOT_FOUND타임아웃 안에 요소가 나타나지 않음
AmbiguousExceptionAMBIGUOUS여러 요소가 일치했는데 명령에는 하나가 필요함
NotClickableExceptionNOT_CLICKABLE요소를 탭하는 모든 방법이 실패함
DroidlineTimeoutExceptionTIMEOUT폰에서 명령이 너무 오래 걸림
NoAccessibilityException, NoImeException, NoPermissionExceptionNO_ACCESSIBILITY, NO_IME, NO_PERMISSION폰에서 권한이 꺼져 있음
AppNotFoundExceptionAPP_NOT_FOUND그 패키지명의 앱이 없음
DeviceOfflineException, DeviceNotFoundException, DeviceAmbiguousExceptionDEVICE_*어느 폰인지, 또는 폰에 닿을 수 있는지에 관한 문제
AgentRestartedExceptionAGENT_RESTARTED앱이 재시작됨. 명령이 실행됐을 수도, 안 됐을 수도 있음
BadArgsExceptionBAD_ARGS파라미터가 빠졌거나 타입이 틀림
ServerNotRunningExceptionSERVER_NOT_RUNNINGdroidline serve가 실행 중이 아님
ConnectionLostExceptionCONNECTION_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은 나머지 폰을 끝까지 기다린 다음 첫 번째 실패를 던집니다. 폰마다 결과를 남기려면 람다 안에서 예외를 잡으세요. 더 많은 패턴은 여러 폰에 있습니다.

알림과 이벤트

먼저 폰이 어떤 앱의 알림을 넘길지 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을 돌려줍니다. dispose하면 콜백이 멈추고, 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을 돌려줍니다. 답장, 클릭, 웹훅은 알림에서 다룹니다.

요소, 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()와 같지만 빈 폰을 빌려 다른 스크립트가 못 쓰게 합니다. 기기를 dispose하면 돌려줍니다. 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으로 끝납니다.