執行 JavaScript 和 WebAssembly

JavaScript 評估

Jetpack 程式庫 JavaScriptEngine 可讓應用程式在不建立 WebView 執行個體的情況下評估 JavaScript 程式碼。

如果應用程式需要評估非互動式 JavaScript,使用 JavaScriptEngine 程式庫有以下優點:

  • 由於不需要分配 WebView 執行個體,資源消耗量會降低。

  • 可在 Service (WorkManager 工作) 中完成。

  • 多個負擔較低的獨立環境,可讓應用程式同時執行多個 JavaScript 程式碼片段。

  • 能夠透過 API 呼叫傳送大量資料。

基本用法

首先,請建立 JavaScriptSandbox 的執行個體。這代表與程序外 JavaScript 引擎的連線。

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

建議將沙箱的生命週期與需要 JavaScript 評估的元件生命週期保持一致。

舉例來說,代管沙箱的元件可能是 ActivityService。單一 Service 可能會用來封裝所有應用程式元件的 JavaScript 評估。

保留 JavaScriptSandbox 執行個體的分配成本相當高,每個應用程式只能有一個 JavaScriptSandbox 執行個體。當應用程式嘗試分配第二個 JavaScriptSandbox 執行個體時,系統會擲回 IllegalStateException。不過,如果需要多個執行環境,可以分配多個 JavaScriptIsolate 執行個體。

不再使用時,關閉沙箱執行個體以釋出資源。JavaScriptSandbox 執行個體會實作 AutoCloseable 介面,提供簡單的封鎖用途「 try-with-資源」。或者,請確認 JavaScriptSandbox 執行個體生命週期是由代管元件管理,在活動的 onStop() 回呼中或服務的 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 關鍵字。如果沙箱支援特定功能,或許可以使用其他傳回類型 (例如解析為 StringPromise)。

該程式庫也支援評估格式為 AssetFileDescriptorParcelFileDescriptor 的指令碼。詳情請參閱 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 引擎的評估要求與繫結器交易限制無關。如果不支援該功能,系統會透過 Binder 交易處理所有傳送至 JavaScriptEngine 的資料。一般交易大小限制適用於傳入資料或傳回資料的每次呼叫。

系統一律會以字串的形式傳回回應,如果系統不支援 JavaScriptSandbox.JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT,則須遵守 Binder 交易大小上限。非字串值必須明確轉換為 JavaScript 字串,否則會傳回空字串。如果支援 JS_FEATURE_PROMISE_RETURN 功能,JavaScript 程式碼也可能會將解析結果傳回 String

如要將大型位元組陣列傳遞至 JavaScriptIsolate 執行個體,可以使用 provideNamedData(...) API。這個 API 的使用不受 Binder 交易限制。每個位元組陣列都必須使用無法重複使用的專屬 ID。

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

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(...)

目前參數可讓您指定評估傳回值和錯誤時的堆積大小上限和大小上限。