JavaScript 및 WebAssembly 실행

JavaScript 평가

Jetpack 라이브러리 JavaScriptEngine은 애플리케이션이 WebView 인스턴스를 만들지 않고 자바스크립트 코드를 평가할 수 있는 방법을 제공합니다.

비대화형 JavaScript 평가가 필요한 애플리케이션에서 JavaScriptEngine 라이브러리를 사용하면 다음과 같은 이점이 있습니다.

  • WebView 인스턴스를 할당할 필요가 없으므로 리소스 소비가 적습니다.

  • 서비스 (WorkManager 작업)에서 실행할 수 있습니다.

  • 애플리케이션이 여러 자바스크립트 스니펫을 동시에 실행할 수 있도록 오버헤드가 낮은 여러 격리 환경

  • API 호출을 사용하여 대량의 데이터를 전달하는 능력

기본 사용법

시작하려면 JavaScriptSandbox의 인스턴스를 만듭니다. 이는 프로세스 외부 자바스크립트 엔진과의 연결을 나타냅니다.

ListenableFuture<JavaScriptSandbox> jsSandboxFuture =
               JavaScriptSandbox.createConnectedInstanceAsync(context);

샌드박스의 수명 주기를 JavaScript 평가가 필요한 구성요소의 수명 주기와 일치시키는 것이 좋습니다.

예를 들어 샌드박스를 호스팅하는 구성요소는 Activity 또는 Service일 수 있습니다. 단일 Service를 사용하여 모든 애플리케이션 구성요소의 자바스크립트 평가를 캡슐화할 수 있습니다.

JavaScriptSandbox 인스턴스는 할당 비용이 상당히 많이 들므로 이 인스턴스를 유지합니다. 애플리케이션당 하나의 JavaScriptSandbox 인스턴스만 허용됩니다. 애플리케이션이 두 번째 JavaScriptSandbox 인스턴스를 할당하려고 하면 IllegalStateException이 발생합니다. 그러나 여러 실행 환경이 필요한 경우 JavaScriptIsolate 인스턴스를 여러 개 할당할 수 있습니다.

더 이상 사용하지 않으면 샌드박스 인스턴스를 닫아 리소스를 확보합니다. JavaScriptSandbox 인스턴스는 간단한 차단 사용 사례에 try-with-resources 사용을 허용하는 AutoCloseable 인터페이스를 구현합니다. 또는 JavaScriptSandbox 인스턴스 수명 주기를 호스팅 구성요소에서 관리하여 활동의 onStop() 콜백에서, 서비스의 경우 onDestroy() 중에 닫습니다.

jsSandbox.close();

JavaScriptIsolate 인스턴스는 JavaScript 코드를 실행하기 위한 컨텍스트를 나타냅니다. 필요한 경우 할당하여 출처가 다른 스크립트에 취약한 보안 경계를 제공하거나 자바스크립트가 기본적으로 단일 스레드이므로 동시 자바스크립트 실행을 지원할 수 있습니다. 이후에 동일한 인스턴스를 호출하면 동일한 상태를 공유하므로 먼저 일부 데이터를 만든 다음 나중에 동일한 JavaScriptIsolate 인스턴스에서 처리할 수 있습니다.

JavaScriptIsolate jsIsolate = jsSandbox.createIsolate();

close() 메서드를 호출하여 JavaScriptIsolate를 명시적으로 해제합니다. JavaScript 코드를 실행 중인 격리 인스턴스(불완전한 Future가 있음)를 닫으면 IsolateTerminatedException이 발생합니다. 격리는 이 페이지 뒷부분의 샌드박스 비정상 종료 처리 섹션에 설명된 대로 구현이 JS_FEATURE_ISOLATE_TERMINATION를 지원하는 경우 이후에 백그라운드에서 삭제됩니다. 그렇지 않으면 대기 중인 모든 평가가 완료되거나 샌드박스가 닫힐 때까지 정리가 연기됩니다.

애플리케이션은 모든 스레드에서 JavaScriptIsolate 인스턴스를 만들고 이 인스턴스에 액세스할 수 있습니다.

이제 애플리케이션이 JavaScript 코드를 실행할 준비가 되었습니다.

final String code = "function sum(a, b) { let r = a + b; return r.toString(); }; sum(3, 4)";
ListenableFuture<String> resultFuture = jsIsolate.evaluateJavaScriptAsync(code);
String result = resultFuture.get(5, TimeUnit.SECONDS);

올바르게 형식이 지정된 동일한 자바스크립트 스니펫:

function sum(a, b) {
    let r = a + b;
    return r.toString(); // make sure we return String instance
};

// Calculate and evaluate the expression
// NOTE: We are not in a function scope and the `return` keyword
// should not be used. The result of the evaluation is the value
// the last expression evaluates to.
sum(3, 4);

코드 스니펫은 String로 전달되고 결과는 String로 전달됩니다. evaluateJavaScriptAsync()를 호출하면 자바스크립트 코드에서 마지막 표현식의 평가된 결과가 반환됩니다. JavaScript String 유형이어야 합니다. 그렇지 않으면 라이브러리 API가 빈 값을 반환합니다. JavaScript 코드는 return 키워드를 사용해서는 안 됩니다. 샌드박스가 특정 기능을 지원하는 경우 추가 반환 유형 (예: String으로 확인되는 Promise)이 가능할 수도 있습니다.

라이브러리는 AssetFileDescriptor 또는 ParcelFileDescriptor 형식의 스크립트 평가도 지원합니다. 자세한 내용은 evaluateJavaScriptAsync(AssetFileDescriptor)evaluateJavaScriptAsync(ParcelFileDescriptor)를 참고하세요. 이러한 API는 디스크의 파일 또는 앱 디렉터리에서 평가하는 데 더 적합합니다.

또한 라이브러리는 디버깅 목적으로 사용할 수 있는 콘솔 로깅을 지원합니다. setConsoleCallback()를 사용하여 설정할 수 있습니다.

컨텍스트가 유지되므로 JavaScriptIsolate의 전체 기간 동안 코드를 업로드하고 여러 번 실행할 수 있습니다.

String jsFunction = "function sum(a, b) { let r = a + b; return r.toString(); }";
ListenableFuture<String> func = js.evaluateJavaScriptAsync(jsFunction);
String twoPlusThreeCode = "let five = sum(2, 3); five";
ListenableFuture<String> r1 = Futures.transformAsync(func,
       input -> js.evaluateJavaScriptAsync(twoPlusThreeCode)
       , executor);
String twoPlusThree = r1.get(5, TimeUnit.SECONDS);

String fourPlusFiveCode = "sum(4, parseInt(five))";
ListenableFuture<String> r2 = Futures.transformAsync(func,
       input -> js.evaluateJavaScriptAsync(fourPlusFiveCode)
       , executor);
String fourPlusFive = r2.get(5, TimeUnit.SECONDS);

물론 변수도 영구적이므로 다음을 사용하여 이전 스니펫을 계속 진행할 수 있습니다.

String defineResult = "let result = sum(11, 22);";
ListenableFuture<String> r3 = Futures.transformAsync(func,
       input -> js.evaluateJavaScriptAsync(defineResult)
       , executor);
String unused = r3.get(5, TimeUnit.SECONDS);

String obtainValue = "result";
ListenableFuture<String> r4 = Futures.transformAsync(func,
       input -> js.evaluateJavaScriptAsync(obtainValue)
       , executor);
String value = r4.get(5, TimeUnit.SECONDS);

예를 들어 필요한 모든 객체를 할당하고 JavaScript 코드를 실행하기 위한 전체 스니펫은 다음과 같을 수 있습니다.

final ListenableFuture<JavaScriptSandbox> sandbox
       = JavaScriptSandbox.createConnectedInstanceAsync(this);
final ListenableFuture<JavaScriptIsolate> isolate
       = Futures.transform(sandbox,
               input -> (jsSandBox = input).createIsolate(),
               executor);
final ListenableFuture<String> js
       = Futures.transformAsync(isolate,
               isolate -> (jsIsolate = isolate).evaluateJavaScriptAsync("'PASS OK'"),
               executor);
Futures.addCallback(js,
       new FutureCallback<String>() {
           @Override
           public void onSuccess(String result) {
               text.append(result);
           }
           @Override
           public void onFailure(Throwable t) {
               text.append(t.getMessage());
           }
       },
       mainThreadExecutor);

try-with-resources를 사용하여 할당된 모든 리소스가 해제되고 더 이상 사용되지 않는지 확인하는 것이 좋습니다. 샌드박스를 닫으면 모든 JavaScriptIsolate 인스턴스에서 대기 중인 모든 평가가 SandboxDeadException와 함께 실패합니다. JavaScript 평가에 오류가 발생하면 JavaScriptException이 생성됩니다. 더 구체적인 예외는 서브클래스를 참고하세요.

샌드박스 비정상 종료 처리

모든 JavaScript는 애플리케이션의 기본 프로세스와 별도로 샌드박스 처리된 프로세스에서 실행됩니다. 자바스크립트 코드로 인해 샌드박스 처리된 프로세스가 비정상 종료되는 경우(예: 메모리 한도 소진) 애플리케이션의 기본 프로세스는 영향을 받지 않습니다.

샌드박스 비정상 종료가 발생하면 해당 샌드박스의 모든 격리가 종료됩니다. 이에 대한 가장 명백한 증상은 모든 평가가 IsolateTerminatedException와 함께 실패하기 시작하는 것입니다. 상황에 따라 SandboxDeadException 또는 MemoryLimitExceededException과 같은 더 구체적인 예외가 발생할 수 있습니다.

개별 평가의 비정상 종료를 처리하는 것이 실용적이지 않을 때도 있습니다. 또한 격리는 백그라운드 작업 또는 다른 격리의 평가로 인해 명시적으로 요청된 평가 외부에서 종료될 수 있습니다. JavaScriptIsolate.addOnTerminatedCallback()를 사용하여 콜백을 연결하여 비정상 종료 처리 로직을 중앙화할 수 있습니다.

final ListenableFuture<JavaScriptSandbox> sandboxFuture =
    JavaScriptSandbox.createConnectedInstanceAsync(this);
final ListenableFuture<JavaScriptIsolate> isolateFuture =
    Futures.transform(sandboxFuture, sandbox -> {
      final IsolateStartupParameters startupParams = new IsolateStartupParameters();
      if (sandbox.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_ISOLATE_MAX_HEAP_SIZE)) {
        startupParams.setMaxHeapSizeBytes(100_000_000);
      }
      return sandbox.createIsolate(startupParams);
    }, executor);
Futures.transform(isolateFuture,
    isolate -> {
      // Add a crash handler
      isolate.addOnTerminatedCallback(executor, terminationInfo -> {
        Log.e(TAG, "The isolate crashed: " + terminationInfo);
      });
      // Cause a crash (eventually)
      isolate.evaluateJavaScriptAsync("Array(1_000_000_000).fill(1)");
      return null;
    }, executor);

선택적 샌드박스 기능

기본 WebView 버전에 따라 샌드박스 구현에서 다양한 기능을 사용할 수 있습니다. 따라서 JavaScriptSandbox.isFeatureSupported(...)를 사용하여 필요한 각 기능을 쿼리해야 합니다. 이러한 기능에 의존하는 메서드를 호출하기 전에 기능 상태를 확인하는 것이 중요합니다.

모든 곳에서 사용할 수 없는 JavaScriptIsolate 메서드에는 RequiresFeature 주석이 추가되어 코드에서 이러한 호출을 더 쉽게 찾을 수 있습니다.

매개변수 전달

JavaScriptSandbox.JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT가 지원되는 경우 JavaScript 엔진으로 전송된 평가 요청은 바인더 트랜잭션 제한에 바인딩되지 않습니다. 이 기능이 지원되지 않으면 JavaScriptEngine의 모든 데이터가 바인더 트랜잭션을 통해 발생합니다. 일반 트랜잭션 크기 제한은 데이터를 전달하거나 데이터를 반환하는 모든 호출에 적용됩니다.

응답은 항상 문자열로 반환되며 JavaScriptSandbox.JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT이 지원되지 않는 경우 바인더 트랜잭션 최대 크기 제한이 적용됩니다. 문자열이 아닌 값은 JavaScript 문자열로 명시적으로 변환해야 합니다. 그렇지 않으면 빈 문자열이 반환됩니다. JS_FEATURE_PROMISE_RETURN 기능이 지원되는 경우 자바스크립트 코드가 프로미스 확인을 String에 반환할 수도 있습니다.

JavaScriptIsolate 인스턴스에 큰 바이트 배열을 전달하려면 provideNamedData(...) API를 사용하면 됩니다. 이 API의 사용은 바인더 트랜잭션 한도의 제한을 받지 않습니다. 각 바이트 배열은 재사용할 수 없는 고유 식별자를 사용하여 전달되어야 합니다.

if (sandbox.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_PROVIDE_CONSUME_ARRAY_BUFFER)) {
    js.provideNamedData("data-1", "Hello Android!".getBytes(StandardCharsets.US_ASCII));
    final String jsCode = "android.consumeNamedDataAsArrayBuffer('data-1').then((value) => { return String.fromCharCode.apply(null, new Uint8Array(value)); });";
    ListenableFuture<String> msg = js.evaluateJavaScriptAsync(jsCode);
    String response = msg.get(5, TimeUnit.SECONDS);
}

Wasm 코드 실행

provideNamedData(...) API를 사용하여 WebAssembly (Wasm) 코드를 전달한 다음 아래와 같이 일반적인 방식으로 컴파일하고 실행할 수 있습니다.

final byte[] hello_world_wasm = {
   0x00 ,0x61 ,0x73 ,0x6d ,0x01 ,0x00 ,0x00 ,0x00 ,0x01 ,0x0a ,0x02 ,0x60 ,0x02 ,0x7f ,0x7f ,0x01,
   0x7f ,0x60 ,0x00 ,0x00 ,0x03 ,0x03 ,0x02 ,0x00 ,0x01 ,0x04 ,0x04 ,0x01 ,0x70 ,0x00 ,0x01 ,0x05,
   0x03 ,0x01 ,0x00 ,0x00 ,0x06 ,0x06 ,0x01 ,0x7f ,0x00 ,0x41 ,0x08 ,0x0b ,0x07 ,0x18 ,0x03 ,0x06,
   0x6d ,0x65 ,0x6d ,0x6f ,0x72 ,0x79 ,0x02 ,0x00 ,0x05 ,0x74 ,0x61 ,0x62 ,0x6c ,0x65 ,0x01 ,0x00,
   0x03 ,0x61 ,0x64 ,0x64 ,0x00 ,0x00 ,0x09 ,0x07 ,0x01 ,0x00 ,0x41 ,0x00 ,0x0b ,0x01 ,0x01 ,0x0a,
   0x0c ,0x02 ,0x07 ,0x00 ,0x20 ,0x00 ,0x20 ,0x01 ,0x6a ,0x0b ,0x02 ,0x00 ,0x0b,
};
final String jsCode = "(async ()=>{" +
       "const wasm = await android.consumeNamedDataAsArrayBuffer('wasm-1');" +
       "const module = await WebAssembly.compile(wasm);" +
       "const instance = WebAssembly.instance(module);" +
       "return instance.exports.add(20, 22).toString();" +
       "})()";
// Ensure that the name has not been used before.
js.provideNamedData("wasm-1", hello_world_wasm);
FluentFuture.from(js.evaluateJavaScriptAsync(jsCode))
           .transform(this::println, mainThreadExecutor)
           .catching(Throwable.class, e -> println(e.getMessage()), mainThreadExecutor);
}

JavaScript 격리 분리

모든 JavaScriptIsolate 인스턴스는 서로 독립적이며 아무것도 공유하지 않습니다. 다음 스니펫은

Hi from AAA!5

다음 URL은

Uncaught Reference Error: a is not defined

"jsTwo" 인스턴스는 "jsOne"에서 만든 객체에 대한 공개 상태를 갖지 않기 때문입니다.

JavaScriptIsolate jsOne = engine.obtainJavaScriptIsolate();
String jsCodeOne = "let x = 5; function a() { return 'Hi from AAA!'; } a() + x";
JavaScriptIsolate jsTwo = engine.obtainJavaScriptIsolate();
String jsCodeTwo = "a() + x";
FluentFuture.from(jsOne.evaluateJavaScriptAsync(jsCodeOne))
       .transform(this::println, mainThreadExecutor)
       .catching(Throwable.class, e -> println(e.getMessage()), mainThreadExecutor);

FluentFuture.from(jsTwo.evaluateJavaScriptAsync(jsCodeTwo))
       .transform(this::println, mainThreadExecutor)
       .catching(Throwable.class, e -> println(e.getMessage()), mainThreadExecutor);

Kotlin 지원

이 Jetpack 라이브러리를 Kotlin 코루틴과 함께 사용하려면 kotlinx-coroutines-guava에 종속 항목을 추가합니다. 이렇게 하면 ListenableFuture와 통합할 수 있습니다.

dependencies {
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.6.0"
}

이제 아래에 나온 것처럼 Jetpack 라이브러리 API를 코루틴 범위에서 호출할 수 있습니다.

// Launch a coroutine
lifecycleScope.launch {
    val jsSandbox = JavaScriptSandbox
            .createConnectedInstanceAsync(applicationContext)
            .await()
    val jsIsolate = jsSandbox.createIsolate()
    val resultFuture = jsIsolate.evaluateJavaScriptAsync("PASS")

    // Await the result
    textBox.text = resultFuture.await()
    // Or add a callback
    Futures.addCallback<String>(
        resultFuture, object : FutureCallback<String?> {
            override fun onSuccess(result: String?) {
                textBox.text = result
            }
            override fun onFailure(t: Throwable) {
                // Handle errors
            }
        },
        mainExecutor
    )
}

구성 매개변수

격리된 환경 인스턴스를 요청할 때 구성을 조정할 수 있습니다. 구성을 조정하려면 IsolateStartupParameters 인스턴스를 JavaScriptSandbox.createIsolate(...)에 전달합니다.

현재 매개변수를 사용하면 평가 반환 값 및 오류의 최대 힙 크기와 최대 크기를 지정할 수 있습니다.