执行 JavaScript 和 WebAssembly

JavaScript评估

Jetpack 库 JavaScriptEngine 提供了一种方法,允许应用评估 JavaScript 代码,无需创建 WebView 实例。

对于需要非交互式 JavaScript 评估的应用,使用 JavaScriptEngine 库具有以下优势:

  • 更低的资源消耗,因为无需分配 WebView 实例。

  • 可以在服务(WorkManager 任务)中完成。

  • 多个隔离的环境,开销低,使应用能够同时运行多个 JavaScript 代码段。

  • 能够通过 API 调用传递大量数据。

基本用法

首先,创建一个 JavaScriptSandbox 实例。这表示与进程外 JavaScript 引擎的连接。

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

建议将沙箱的生命周期与需要 JavaScript 评估的组件的生命周期对齐。

例如,托管沙箱的组件可以是 ActivityServiceService 可以用于封装所有应用组件的 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 事务限制的约束。如果该功能不受支持,则所有数据都会通过 Binder 事务传输到 JavaScriptEngine。 常规事务大小限制适用于传递数据或返回数据的每个调用。

响应始终作为字符串返回,如果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实例都是相互独立的,并且不共享任何内容。以下代码片段将导致

来自 AAA!5 的问候

未捕获的引用错误:a 未定义

因为“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(...)

当前参数允许指定最大堆大小以及评估返回值和错误的最大大小。