JavaScript と WebAssembly を実行する

JavaScript の評価

Jetpack ライブラリの JavaScriptEngine を使用すると、アプリで WebView インスタンスを作成せずに JavaScript コードを評価できます。

非対話型の JavaScript 評価を必要とするアプリケーションの場合、JavaScriptEngine ライブラリを使用すると次のような利点があります。

  • WebView インスタンスを割り当てる必要がないため、リソース消費量が削減される

  • Service(WorkManager タスク)内で実行できます。

  • オーバーヘッドが少ない複数の独立した環境で、アプリケーションが複数の JavaScript スニペットを同時に実行できる。

  • API 呼び出しを使用して大量のデータを渡す機能。

基本的な使用方法

まず、JavaScriptSandbox のインスタンスを作成します。これはプロセス外の JavaScript エンジンへの接続を表します。

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

サンドボックスのライフサイクルを、JavaScript の評価を必要とするコンポーネントのライフサイクルに合わせることをおすすめします。

たとえば、サンドボックスをホストするコンポーネントは ActivityService です。1 つの Service で、すべてのアプリケーション コンポーネントの JavaScript 評価をカプセル化できます。

この割り当てにはかなりの費用がかかるため、JavaScriptSandbox インスタンスを保持します。1 つのアプリケーションで使用できる JavaScriptSandbox インスタンスは 1 つだけです。アプリが 2 つ目の JavaScriptSandbox インスタンスを割り当てようとすると、IllegalStateException がスローされます。ただし、複数の実行環境が必要な場合は、複数の JavaScriptIsolate インスタンスを割り当てることができます。

使用されなくなったら、サンドボックス インスタンスを閉じてリソースを解放します。JavaScriptSandbox インスタンスは AutoCloseable インターフェースを実装しています。これにより、単純なブロックのユースケースで try-with-resources を使用できます。あるいは、JavaScriptSandbox インスタンスのライフサイクルがホスティング コンポーネントによって管理されるようにし、アクティビティの onStop() コールバックで、または Service の onDestroy() で閉じます。

jsSandbox.close();

JavaScriptIsolate インスタンスは、JavaScript コードを実行するためのコンテキストを表します。JavaScript は本質的にシングルスレッドであるため、必要に応じて割り当てることができ、生成元が異なるスクリプトにセキュリティ境界が弱くなるか、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);

同じ JavaScript スニペットを適切な形式にすると、次のようになります。

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 コード内の最後の式の評価結果が返されます。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 は、アプリケーションのメインプロセスとは別の、サンドボックス化されたプロセスで実行されます。JavaScript コードが原因で、このサンドボックス化されたプロセスが(メモリ上限に達するなどして)クラッシュしても、アプリケーションのメインプロセスには影響しません。

サンドボックスがクラッシュすると、そのサンドボックス内のすべての分離部分が終了します。最も明白な症状は、すべての評価が IsolateTerminatedException で失敗し始めることです。状況によっては、SandboxDeadExceptionMemoryLimitExceededException などのより具体的な例外がスローされる場合があります。

個々の評価ごとにクラッシュを処理することは、必ずしも現実的ではありません。また、アイソレートは、バックグラウンド タスクや他のアイソレーションでの評価が原因で、明示的に要求された評価外で終了することがあります。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 へのすべてのデータは Binder トランザクションを介して発生します。一般的なトランザクション サイズの上限は、データを渡すか、データを返すすべての呼び出しに適用されます。

レスポンスは常に文字列として返されJavaScriptSandbox.JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT がサポートされていない場合は、Binder トランザクションの最大サイズの上限が適用されます。文字列以外の値は明示的に JavaScript 文字列に変換する必要があります。変換しないと、空の文字列が返されます。JS_FEATURE_PROMISE_RETURN 機能がサポートされている場合、JavaScript コードは、String に解決する Promise を返すこともできます。

大きなバイト配列を JavaScriptIsolate インスタンスに渡すには、provideNamedData(...) API を使用できます。この API の使用は、Binder のトランザクション制限の制約を受けません。各バイト配列は、再利用できない一意の識別子を使用して渡す必要があります。

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 コードの実行

WebAssembly(Wasm)コードを provideNamedData(...) API を使用して渡した後、次に示すように通常の方法でコンパイルして実行できます。

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

JavaScriptIsolate の分離

すべての JavaScriptIsolate インスタンスは互いに独立しており、何も共有しません。次のスニペットでは、

Hi from AAA!5

および

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(...) に渡します。

現在、パラメータでは、最大ヒープサイズと、評価の戻り値およびエラーの最大サイズを指定できます。