Como executar JavaScript e WebAssembly

Avaliação de JavaScript

A biblioteca Jetpack JavaScriptEngine oferece uma maneira de um aplicativo avaliar o código JavaScript sem criar uma instância da WebView.

Para aplicativos que exigem avaliação JavaScript não interativa, o uso da biblioteca JavaScriptEngine tem as seguintes vantagens:

  • Menor consumo de recursos, já que não é necessário alocar um WebView instância.

  • Pode ser feito em um serviço (tarefa do WorkManager).

  • Vários ambientes isolados com baixa sobrecarga, permitindo que o aplicativo execute vários snippets JavaScript simultaneamente.

  • Capacidade de transmitir grandes quantidades de dados usando uma chamada de API.

Uso básico

Para começar, crie uma instância de JavaScriptSandbox. Isso representa ao mecanismo JavaScript fora do processo.

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

É recomendável alinhar o ciclo de vida do sandbox com o ciclo de vida do componente que precisa de avaliação do JavaScript.

Por exemplo, um componente que hospeda o sandbox pode ser um Activity ou um Service. Um único Service pode ser usado para encapsular a avaliação do JavaScript para todos os componentes do aplicativo.

Mantenha a instância JavaScriptSandbox porque a alocação dela é bastante cara. Só é permitida uma instância do JavaScriptSandbox por aplicativo. Uma IllegalStateException é gerada quando um aplicativo tenta alocar uma segunda instância de JavaScriptSandbox. No entanto, se vários ambientes de execução forem necessários, várias instâncias de JavaScriptIsolate poderão ser alocadas.

Quando ela não for mais usada, feche a instância do sandbox para liberar recursos. O A instância JavaScriptSandbox implementa uma interface AutoCloseable, que permite o uso de tentativa com recursos para casos de uso simples com bloqueio. Como alternativa, verifique se o ciclo de vida da instância do JavaScriptSandbox é gerenciado por componente de hospedagem, fechando-o no callback onStop() para uma atividade ou durante onDestroy() para um Serviço:

jsSandbox.close();

Uma instância de JavaScriptIsolate representa um contexto para executar código JavaScript. Eles podem ser alocados quando necessário, fornecendo limites de segurança fracos para scripts de origem diferente ou permitindo a execução simultânea de JavaScript, já que o JavaScript é de único encadeamento por natureza. Chamadas subsequentes para da mesma instância compartilham o mesmo estado. Por isso, é possível criar alguns dados e processar depois na mesma instância de JavaScriptIsolate.

JavaScriptIsolate jsIsolate = jsSandbox.createIsolate();

Libere JavaScriptIsolate explicitamente chamando o método close(). O fechamento de uma instância isolada que executa código JavaScript (com um Future incompleto) resulta em um IsolateTerminatedException. O isolamento é limpo posteriormente em segundo plano se a implementação oferecer suporte a JS_FEATURE_ISOLATE_TERMINATION, conforme descrito na seção como lidar com falhas de sandbox mais adiante nesta página. Caso contrário, a limpeza é adiada até que todas as avaliações pendentes sejam concluídas ou o sandbox seja fechado.

Um aplicativo pode criar e acessar uma instância de JavaScriptIsolate de qualquer linha de execução.

Agora, o aplicativo está pronto para executar alguns códigos 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);

O mesmo snippet de JavaScript formatado corretamente:

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

O snippet de código é transmitido como um String e o resultado, como um String. Chamar evaluateJavaScriptAsync() retorna o resultado avaliado da última expressão no código JavaScript. Ele precisa ser do tipo String do JavaScript. Caso contrário, a API da biblioteca vai retornar um valor vazio. O código JavaScript não pode usar uma palavra-chave return. Se o sandbox oferecer suporte a determinados recursos, outros tipos de retorno (por exemplo, um Promise que se resolve em um String) poderão ser possíveis.

A biblioteca também oferece suporte à avaliação de scripts na forma de um AssetFileDescriptor ou um ParcelFileDescriptor. Consulte evaluateJavaScriptAsync(AssetFileDescriptor) e evaluateJavaScriptAsync(ParcelFileDescriptor) para mais detalhes. Essas APIs são mais adequadas para avaliar usando um arquivo em disco ou no app diretórios.

A biblioteca também oferece suporte à geração de registros do console, que pode ser usada para fins de depuração. Isso pode ser configurado usando setConsoleCallback().

Como o contexto persiste, é possível fazer upload do código e executá-lo várias vezes durante a vida útil do 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);

Claro, as variáveis também são persistentes, então você pode continuar com a por:

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 exemplo, o snippet completo para alocar todos os objetos necessários e A execução de um código JavaScript pode ser semelhante ao seguinte:

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

Recomendamos usar "testar com recursos" para garantir que todos os recursos são liberados e não são mais usados. Como fechar os resultados do sandbox em todas as avaliações pendentes nas instâncias de JavaScriptIsolate com falha com um SandboxDeadException. Quando a avaliação JavaScript encontra um erro, uma JavaScriptException é criada. Consulte as subclasses para exceções mais específicas.

Como lidar com falhas no sandbox

Todo JavaScript é executado em um processo em sandbox separado, fora da sua o processo principal do aplicativo. Se o código JavaScript causar um erro nesse processo sandbox, por exemplo, esgotando um limite de memória, o processo principal do aplicativo não será afetado.

Uma falha do sandbox faz com que todos os isolados nesse sandbox sejam encerrados. O sintoma mais óbvio disso é que todas as avaliações vão começar a falhar com IsolateTerminatedException. Dependendo das circunstâncias, exceções específicas, como SandboxDeadException ou MemoryLimitExceededException pode ser gerada.

Nem sempre é prático lidar com falhas para cada avaliação individual. Além disso, um recurso isolado pode ser encerrado fora de uma avaliação solicitada explicitamente devido a tarefas em segundo plano ou avaliações em outros recursos isolados. A lógica de gerenciamento de falhas pode ser centralizada anexando um callback usando 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);

Recursos opcionais do sandbox

Dependendo da versão da WebView, uma implementação de sandbox pode ter diferentes conjuntos de recursos disponíveis. Portanto, é necessário consultar cada recurso necessário usando JavaScriptSandbox.isFeatureSupported(...). É importante para verificar o status do recurso antes de chamar métodos que dependem deles.

JavaScriptIsolate que podem não estar disponíveis em todos os lugares são com a anotação RequiresFeature, facilitando a detecção desses no código.

Parâmetros de transmissão

Se o JavaScriptSandbox.JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT for compatível, as solicitações de avaliação enviadas ao mecanismo JavaScript não serão vinculadas pelos limites de transação do vinculador. Se o recurso não for suportado, todos os dados a JavaScriptEngine ocorre por meio de uma transação de vinculação. O limite geral de tamanho de transação é aplicável a todas as chamadas que transmitem ou retornam dados.

A resposta é sempre retornada como uma string e está sujeita à o limite máximo de tamanho da transação se JavaScriptSandbox.JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT não é suporte. Os valores sem string precisam ser explicitamente convertidos em uma string JavaScript. caso contrário, uma string vazia será retornada. Se o recurso JS_FEATURE_PROMISE_RETURN tiver suporte, o código JavaScript poderá retornar uma promessa resolvida para um String.

Para transmitir grandes matrizes de bytes para a instância JavaScriptIsolate, use a API provideNamedData(...). O uso dessa API não é vinculado aos limites de transação do Binder. Cada matriz de bytes precisa ser transmitida usando um identificador que não pode ser reutilizado.

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

Como executar o código Wasm

O código WebAssembly (Wasm) pode ser transmitido usando o método provideNamedData(...) API do BigQuery, então compilada e executada da maneira usual, como demonstrado abaixo.

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

Separação JavaScript-Isolar

Todas as instâncias de JavaScriptIsolate são independentes umas das outras e não compartilham nada. O snippet a seguir resulta em

Hi from AAA!5

e

Uncaught Reference Error: a is not defined

porque a instância "jsTwo" não tem visibilidade dos objetos criados em "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);

Suporte ao Kotlin

Para usar essa biblioteca do Jetpack com corrotinas do Kotlin, adicione uma dependência ao kotlinx-coroutines-guava Isso permite a integração com ListenableFuture:

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

As APIs da biblioteca do Jetpack agora podem ser chamadas em um escopo de corrotina, como demonstrados abaixo:

// 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 configuração

Ao solicitar uma instância de ambiente isolada, você pode ajustar seu configuração do Terraform. Para ajustar a configuração, transmita a instância IsolateStartupParameters para JavaScriptSandbox.createIsolate(...).

Atualmente, os parâmetros permitem especificar o tamanho máximo do heap e o tamanho máximo para valores de retorno de avaliação e erros.