执行 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-resources 用于简单的阻塞用例。或者,确保 JavaScriptSandbox 实例生命周期由托管组件管理,在 activity 的 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 关键字,如果沙盒支持某些功能,则可能还有其他返回值类型(例如,解析为 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 的所有数据都会通过 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 代码

您可以使用 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

and

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

目前,参数允许为评估返回值和错误指定最大堆大小和大小上限。