Cómo ejecutar JavaScript y WebAssembly

Evaluación de JavaScript

La biblioteca de Jetpack JavaScriptEngine proporciona una forma para que una aplicación evalúe el código JavaScript sin crear una instancia de WebView.

Para las aplicaciones que requieren evaluación no interactiva de JavaScript, usar la biblioteca JavaScriptEngine tiene las siguientes ventajas:

  • Reducir el consumo de recursos, ya que no es necesario asignar una instancia de WebView

  • Se puede realizar en un servicio (tarea de WorkManager).

  • Varios entornos aislados con baja sobrecarga, lo que permite que la aplicación ejecute varios fragmentos de JavaScript de forma simultánea

  • Capacidad de pasar grandes cantidades de datos mediante una llamada a la API

Uso básico

Para comenzar, crea una instancia de JavaScriptSandbox. Esto representa una conexión con el motor de JavaScript fuera del proceso.

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

Se recomienda alinear el ciclo de vida de la zona de pruebas con el ciclo de vida del componente que necesita la evaluación de JavaScript.

Por ejemplo, un componente que aloja la zona de pruebas puede ser un Activity o un Service. Se puede usar una sola Service a fin de encapsular la evaluación de JavaScript para todos los componentes de la aplicación.

Mantén la instancia JavaScriptSandbox porque su asignación es bastante costosa. Solo se permite una instancia de JavaScriptSandbox por aplicación. Se arroja IllegalStateException cuando una aplicación intenta asignar una segunda instancia de JavaScriptSandbox. Sin embargo, si se requieren varios entornos de ejecución, se pueden asignar varias instancias de JavaScriptIsolate.

Cuando ya no se use, cierra la instancia de la zona de pruebas para liberar recursos. La instancia JavaScriptSandbox implementa una interfaz AutoCloseable, que permite el uso de prueba con recursos para casos de uso de bloqueo simples. Como alternativa, asegúrate de que el componente de hosting administre el ciclo de vida de la instancia JavaScriptSandbox y ciérralo en la devolución de llamada onStop() de una actividad o durante onDestroy() para un servicio:

jsSandbox.close();

Una instancia de JavaScriptIsolate representa un contexto para ejecutar código JavaScript. Se pueden asignar cuando sea necesario, lo que proporciona límites de seguridad débiles para secuencias de comandos de diferente origen o habilita la ejecución simultánea de JavaScript, ya que JavaScript es de un solo subproceso por naturaleza. Las llamadas posteriores a la misma instancia comparten el mismo estado, por lo que es posible crear algunos datos primero y, luego, procesarlos en la misma instancia de JavaScriptIsolate.

JavaScriptIsolate jsIsolate = jsSandbox.createIsolate();

Libera JavaScriptIsolate de forma explícita llamando a su método close(). Cerrar una instancia aislada que ejecuta el código JavaScript (que tiene un Future incompleto) genera una IsolateTerminatedException. El aislamiento se borra posteriormente en segundo plano si la implementación admite JS_FEATURE_ISOLATE_TERMINATION, como se describe en la sección Manejo de fallas de la zona de pruebas más adelante en esta página. De lo contrario, la limpieza se pospondrá hasta que se completen todas las evaluaciones pendientes o se cierre la zona de pruebas.

Una aplicación puede crear una instancia de JavaScriptIsolate y acceder a ella desde cualquier subproceso.

Ahora, la aplicación está lista para ejecutar código de 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);

El mismo fragmento de JavaScript tiene un buen formato:

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

El fragmento de código se pasa como un String y el resultado se entrega como un String. Ten en cuenta que llamar a evaluateJavaScriptAsync() muestra el resultado evaluado de la última expresión en el código JavaScript. Debe ser del tipo String de JavaScript; de lo contrario, la API de la biblioteca mostrará un valor vacío. El código JavaScript no debe usar una palabra clave return. Si la zona de pruebas admite ciertas funciones, es posible que se muestren tipos de datos adicionales (por ejemplo, Promise que se resuelve en String).

La biblioteca también admite la evaluación de secuencias de comandos que tienen el formato de un AssetFileDescriptor o un ParcelFileDescriptor. Consulta evaluateJavaScriptAsync(AssetFileDescriptor) y evaluateJavaScriptAsync(ParcelFileDescriptor) para obtener más información. Estas APIs son más adecuadas para la evaluación desde un archivo en un disco o en los directorios de la app.

La biblioteca también admite el registro de la consola, que se puede usar con fines de depuración. Esto se puede configurar con setConsoleCallback().

Dado que el contexto persiste, puedes subir un código y ejecutarlo varias veces durante la vida útil de 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);

Por supuesto, las variables también son persistentes, por lo que puedes continuar con el fragmento anterior con lo siguiente:

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

Por ejemplo, el fragmento completo para asignar todos los objetos necesarios y ejecutar un código JavaScript podría verse de la siguiente manera:

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

Te recomendamos que uses la función de prueba con recursos para asegurarte de que todos los recursos asignados se lancen y ya no se usen. El cierre de la zona de pruebas hará que todas las evaluaciones pendientes en todas las instancias de JavaScriptIsolate fallen con un SandboxDeadException. Cuando la evaluación de JavaScript encuentra un error, se crea una JavaScriptException. Consulta sus subclases para obtener excepciones más específicas.

Controla las fallas de la zona de pruebas

Todo el código JavaScript se ejecuta en un proceso independiente de zona de pruebas lejos del proceso principal de tu aplicación. Si el código JavaScript hace que falle este proceso de zona de pruebas, por ejemplo, cuando se agota el límite de memoria, el proceso principal de la aplicación no se verá afectado.

Una falla en la zona de pruebas provocará que finalicen todos los elementos aislados en esa zona de pruebas. El síntoma más evidente de esto es que todas las evaluaciones comenzarán a fallar con IsolateTerminatedException. Según las circunstancias, es posible que se generen excepciones más específicas, como SandboxDeadException o MemoryLimitExceededException.

Manejar las fallas para cada evaluación individual no siempre es práctico. Además, un aislamiento puede terminar fuera de una evaluación solicitada de forma explícita debido a tareas en segundo plano o evaluaciones en otros aislamientos. La lógica de control de fallas se puede centralizar si adjuntas una devolución de llamada con 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);

Funciones opcionales de la zona de pruebas

Según la versión de WebView subyacente, una implementación de zona de pruebas puede tener diferentes conjuntos de funciones disponibles. Por lo tanto, es necesario consultar cada función requerida con JavaScriptSandbox.isFeatureSupported(...). Es importante verificar el estado de las funciones antes de llamar a los métodos que dependen de ellas.

Los métodos JavaScriptIsolate que podrían no estar disponibles en todas partes se anotan con la anotación RequiresFeature, lo que facilita la detección de estas llamadas en el código.

Pasa parámetros

Si se admite JavaScriptSandbox.JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT, las solicitudes de evaluación enviadas al motor de JavaScript no están vinculadas por los límites de transacciones de Binder. Si no se admite la función, todos los datos a JavaScriptEngine se realizan a través de una transacción de Binder. El límite general de tamaño de la transacción se aplica a cada llamada que pasa datos o muestra datos.

La respuesta siempre se muestra como una String y está sujeta al límite de tamaño máximo de la transacción de Binder si no se admite JavaScriptSandbox.JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT. Los valores que no son de cadena deben convertirse explícitamente en una cadena de JavaScript. De lo contrario, se mostrará una cadena vacía. Si se admite la función JS_FEATURE_PROMISE_RETURN, el código JavaScript puede mostrar como alternativa una promesa que resuelva una String.

Para pasar arreglos de bytes grandes a la instancia JavaScriptIsolate, puedes usar la API de provideNamedData(...). El uso de esta API no está sujeto a los límites de transacciones de Binder. Cada array de bytes se debe pasar con un identificador único que no se pueda volver a usar.

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

Ejecutando código de Wasm

El código de WebAssembly (Wasm) se puede pasar con la API de provideNamedData(...) y, luego, compilarse y ejecutarse de la manera habitual, como se muestra a continuación.

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

Separación de JavaScriptIsolate

Todas las instancias de JavaScriptIsolate son independientes entre sí y no comparten nada. El siguiente fragmento da como resultado

Hi from AAA!5

and

Uncaught Reference Error: a is not defined

ya que la instancia “jsTwo” no tiene visibilidad de los objetos creados en “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);

Compatibilidad con Kotlin

Para usar esta biblioteca de Jetpack con las corrutinas de Kotlin, agrega una dependencia a kotlinx-coroutines-guava. Esto permite la integración con ListenableFuture.

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

Ahora se puede llamar a las APIs de la biblioteca de Jetpack desde un alcance de corrutinas, como se muestra a continuación:

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

Parámetros de configuración

Cuando solicitas una instancia de entorno aislado, puedes ajustar su configuración. Para modificar la configuración, pasa la instancia IsolateStartupParameters a JavaScriptSandbox.createIsolate(...).

Actualmente, los parámetros permiten especificar el tamaño máximo de montón y el tamaño máximo de los errores y valores que se muestran de la evaluación.