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.