Como executar JavaScript e WebAssembly

Avaliação de JavaScript

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

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

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

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

  • Vários ambientes isolados com baixa sobrecarga, permitindo que o aplicativo execute diversos 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 uma conexão com o mecanismo JavaScript fora de processo.

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

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

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

Mantenha a instância JavaScriptSandbox, porque a alocação dela é bastante cara. Apenas uma instância do JavaScriptSandbox por aplicativo é permitida. 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 JavaScriptIsolate poderão ser alocadas.

Quando ele não for mais usado, feche a instância do sandbox para liberar recursos. A instância JavaScriptSandbox implementa uma interface AutoCloseable, que permite o uso de try-with-resources para casos de uso de bloqueio simples. Como alternativa, verifique se o ciclo de vida da instância JavaScriptSandbox é gerenciado pelo 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 o 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 do JavaScript, já que o JavaScript tem uma linha de execução única por natureza. As chamadas seguintes para a mesma instância compartilham o mesmo estado. Portanto, é possível criar alguns dados primeiro e depois processá-los na mesma instância de JavaScriptIsolate.

JavaScriptIsolate jsIsolate = jsSandbox.createIsolate();

Libere JavaScriptIsolate explicitamente chamando o método close(). O fechamento de uma instância de isolamento que executa o código JavaScript (com uma Future incompleta) resulta em uma IsolateTerminatedException. O isolado vai ser limpo posteriormente em segundo plano se a implementação oferecer suporte a JS_FEATURE_ISOLATE_TERMINATION, conforme descrito na seção Como processar falhas no sandbox mais adiante nesta página. Caso contrário, a limpeza será 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 a partir de qualquer linha de execução.

Agora, o aplicativo está pronto para executar um código 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 bem formatado:

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 é entregue como um String. Chamar evaluateJavaScriptAsync() retorna o resultado avaliado da última expressão no código JavaScript. Ele precisa ser do tipo JavaScript String. Caso contrário, a API da biblioteca retornará um valor vazio. O código JavaScript não pode usar uma palavra-chave return. Se o sandbox oferece suporte a determinados recursos, outros tipos de retorno (por exemplo, um Promise que é resolvido como String) podem ser possíveis.

A biblioteca também oferece suporte à avaliação de scripts que estão 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 em um arquivo em disco ou em diretórios de apps.

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 o ciclo de vida 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);

Como as variáveis também são persistentes, você pode continuar o snippet anterior com:

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 executar um código JavaScript pode ter esta aparência:

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

É recomendável usar o recurso try-with-resources para garantir que todos os recursos alocados sejam liberados e não sejam mais usados. O fechamento do sandbox faz com que todas as avaliações pendentes em todas as instâncias de JavaScriptIsolate falhem com um SandboxDeadException. Quando a avaliação do JavaScript encontra um erro, um JavaScriptException é criado. Consulte as subclasses para exceções mais específicas.

Como lidar com falhas do sandbox

Todo o JavaScript é executado em um processo em sandbox separado do processo principal do aplicativo. Se o código JavaScript fizer esse processo em sandbox falhar, por exemplo, ao esgotar um limite de memória, o processo principal do aplicativo não será afetado.

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

Lidar com falhas para cada avaliação individual nem sempre é prático. Além disso, um isolamento pode ser encerrado fora de uma avaliação explicitamente solicitada devido a tarefas em segundo plano ou avaliações em outros isolados. A lógica de processamento de falhas pode ser centralizada ao anexar 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 atributo necessário usando JavaScriptSandbox.isFeatureSupported(...). É importante verificar o status do recurso antes de chamar métodos que dependem desses recursos.

Os métodos JavaScriptIsolate que podem não estar disponíveis em todos os lugares são anotados com a anotação RequiresFeature, o que facilita a detecção dessas chamadas no código.

Como passar parâmetros

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

A resposta será sempre retornada como uma string e estará sujeita ao limite de tamanho máximo da transação de Binder se JavaScriptSandbox.JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT não for compatível. Os valores que não são de string precisam ser convertidos explicitamente em uma string JavaScript. Caso contrário, uma string vazia é retornada. Se o recurso JS_FEATURE_PROMISE_RETURN for compatível, o código JavaScript poderá retornar uma promessa que resolve um String.

Para transmitir matrizes de bytes grandes à instância JavaScriptIsolate, use a API provideNamedData(...). O uso dessa API não está vinculado aos limites de transação do Binder. Cada matriz de bytes precisa ser transmitida usando um identificador exclusivo 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);
}

Executando código Wasm

O código WebAssembly (Wasm) pode ser transmitido usando a API provideNamedData(...), depois compilado e executado da maneira normal, conforme 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 JavaScriptIsolate

Todas as instâncias de JavaScriptIsolate são independentes entre si 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 a 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, conforme demonstrado 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, é possível ajustar a configuração dela. Para ajustar a configuração, transmita a instância IsolateStartupParameters para JavaScriptSandbox.createIsolate(...).

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