Esecuzione di JavaScript e WebAssembly

Valutazione JavaScript

La libreria Jetpack, JavaScriptEngine, consente a un'applicazione di Valutare il codice JavaScript senza creare un'istanza WebView.

Per le applicazioni che richiedono la valutazione di JavaScript non interattivo, l'utilizzo della libreria JavaScriptEngine presenta i seguenti vantaggi:

  • Minore consumo di risorse, in quanto non è necessario allocare un'istanza WebView.

  • Può essere eseguita in un servizio (attività WorkManager).

  • Più ambienti isolati con overhead ridotto, consentendo all'applicazione eseguire contemporaneamente più snippet JavaScript.

  • Possibilità di trasmettere grandi quantità di dati utilizzando una chiamata API.

Utilizzo di base

Per iniziare, crea un'istanza di JavaScriptSandbox. Questo rappresenta connessione al motore JavaScript out-of-process.

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

È consigliabile allineare il ciclo di vita della sandbox a quello della che richiede la valutazione JavaScript.

Ad esempio, un componente che ospita la sandbox può essere un Activity o un Service. È possibile utilizzare un singolo Service per incapsulare la valutazione JavaScript per tutti i componenti dell'applicazione.

Mantieni l'istanza JavaScriptSandbox perché la sua allocazione è abbastanza costosa. È consentita una sola istanza JavaScriptSandbox per applicazione. Un IllegalStateException viene generato quando un'applicazione tenta di allocare una seconda istanza JavaScriptSandbox. Tuttavia, se più ambienti di esecuzione è possibile allocare diverse istanze JavaScriptIsolate.

Quando non è più utilizzata, chiudi l'istanza della sandbox per liberare risorse. L'istanza JavaScriptSandbox implementa un'interfaccia AutoCloseable, che consente l'utilizzo di try-with-resources per semplici casi d'uso di blocco. In alternativa, assicurati che il ciclo di vita dell'istanza JavaScriptSandbox sia gestito da il componente hosting, chiudendolo nel callback onStop() per un'attività o durante il giorno onDestroy() per un servizio:

jsSandbox.close();

Un'istanza JavaScriptIsolate rappresenta un contesto per l'esecuzione codice JavaScript. Possono essere allocati quando necessario, fornendo confini di sicurezza deboli per script di origini diverse o attivando l'esecuzione di JavaScript concorrente poiché JavaScript è per sua natura a thread singolo. Le chiamate successive alla stessa istanza condividono lo stesso stato, quindi è possibile creare alcuni dati in un primo momento ed elaborarli in un secondo momento nella stessa istanza di JavaScriptIsolate.

JavaScriptIsolate jsIsolate = jsSandbox.createIsolate();

Rilascia JavaScriptIsolate esplicitamente chiamando il relativo metodo close(). La chiusura di un'istanza isolata che esegue codice JavaScript (con un Future incompleto) genera un IsolateTerminatedException. L'isolamento viene successivamente ripulito in background se l'implementazione supporta JS_FEATURE_ISOLATE_TERMINATION, come descritto nella sezione Gestire gli arresti anomali della sandbox di questa pagina. In caso contrario, la pulizia viene posticipata fino a quando tutte le valutazioni in attesa viene completata o la sandbox viene chiusa.

Un'applicazione può creare e accedere a un'istanza JavaScriptIsolate da qualsiasi thread.

Ora l'applicazione è pronta per eseguire del codice 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);

Lo stesso snippet JavaScript formattato correttamente:

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

Lo snippet di codice viene passato come String e il risultato viene restituito come String. Tieni presente che la chiamata a evaluateJavaScriptAsync() restituisce i risultati risultato dell'ultima espressione nel codice JavaScript. Deve essere di tipo JavaScript String; altrimenti l'API della libreria restituisce un valore vuoto. Il codice JavaScript non deve utilizzare una parola chiave return. Se la sandbox supporta alcune funzionalità, tipi di reso aggiuntivi (ad esempio, un Promise che si risolve in un String).

La libreria supporta anche la valutazione di script sotto forma di AssetFileDescriptor o ParcelFileDescriptor. Consulta: evaluateJavaScriptAsync(AssetFileDescriptor) e evaluateJavaScriptAsync(ParcelFileDescriptor) per ulteriori dettagli. Queste API sono più adatte per la valutazione da un file su disco o nelle directory dell'app.

La libreria supporta anche il logging della console, che può essere utilizzato per scopi di debugging. Questa impostazione può essere configurata utilizzando setConsoleCallback().

Poiché il contesto è persistente, puoi caricare il codice ed eseguirlo più volte durante la vita del 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);

Ovviamente, anche le variabili sono permanenti, quindi puoi continuare con snippet con:

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

Ad esempio, lo snippet completo per l'allocazione di tutti gli oggetti necessari e l'esecuzione di un codice JavaScript potrebbe avere il seguente aspetto:

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

Ti consigliamo di usare test-with-resources per assicurarti che tutte le risorse vengono rilasciate e non vengono più utilizzate. Chiusura dei risultati della sandbox in tutte le valutazioni in attesa e in tutte le JavaScriptIsolate istanze con errori con un SandboxDeadException. Quando la valutazione JavaScript rileva un errore, viene creato un JavaScriptException. Fai riferimento alle relative sottoclassi per eccezioni più specifiche.

Gestione degli arresti anomali della sandbox

Tutto il codice JavaScript viene eseguito in un processo sandbox separato dal processo principale dell'applicazione. Se il codice JavaScript causa questo processo sandbox si arresta in modo anomalo, ad esempio esaurendo un limite di memoria, e il processo non sarà interessato.

Un arresto anomalo della sandbox causerà la terminazione di tutti gli isolati in quella sandbox. Il sintomo più evidente è che tutte le valutazioni inizieranno a non riuscire con IsolateTerminatedException. A seconda delle circostanze, potrebbero essere lanciate eccezioni più specifiche come SandboxDeadException o MemoryLimitExceededException.

Gestire gli arresti anomali per ogni singola valutazione non è sempre pratico. Inoltre, un isolamento potrebbe terminare al di fuori di una valutazione esplicitamente richiesta a causa di attività in background o valutazioni in altri istanze. La logica di gestione degli arresti anomali può essere centralizzata collegando un callback utilizzando 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);

Funzionalità facoltative della sandbox

A seconda della versione di WebView sottostante, un'implementazione della sandbox potrebbe avere diversi insiemi di funzionalità disponibili. Pertanto, è necessario eseguire query su ogni funzionalità obbligatoria utilizzando JavaScriptSandbox.isFeatureSupported(...). È importante per controllare lo stato delle caratteristiche prima di chiamare metodi basati su queste caratteristiche.

I metodi di JavaScriptIsolate che potrebbero non essere disponibili ovunque sono annotati con l'annotazione RequiresFeature, che ne facilitano l'individuazione chiamate nel codice.

Trasmissione dei parametri

Se JavaScriptSandbox.JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT è supportato, le richieste di valutazione inviate al motore JavaScript non sono vincolate dai limiti di transazione del binder. Se la funzione non è supportata, tutti i dati per il motore JavaScript avviene tramite una transazione Binder. Il generale limite per le dimensioni delle transazioni si applica a ogni chiamata trasmessa nei dati o restituisce i dati.

La risposta viene sempre restaurata come stringa ed è soggetta al limite di dimensione massima della transazione Binder se JavaScriptSandbox.JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT non è supportato. I valori non di stringa devono essere convertiti esplicitamente in una stringa JavaScript altrimenti viene restituita una stringa vuota. Se la funzionalità JS_FEATURE_PROMISE_RETURN è supportata, il codice JavaScript può restituire in alternativa una promessa che si risolve in un String.

Per passare array di byte di grandi dimensioni all'istanza JavaScriptIsolate, puoi utilizzare l'API provideNamedData(...). L'utilizzo di questa API non è vincolato dai limiti di transazioni di Binder. Ogni array di byte deve essere passato utilizzando un indirizzo identificatore che non può essere riutilizzato.

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

Esecuzione del codice Wasm

Il codice WebAssembly (Wasm) può essere passato utilizzando l'API provideNamedData(...), quindi compilato ed eseguito nel solito modo, come dimostrato di seguito.

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

Separazione di JavaScriptIsolate

Tutte le istanze JavaScriptIsolate sono indipendenti l'una dall'altra e non condividono nulla. Il seguente snippet restituisce

Hi from AAA!5

e

Uncaught Reference Error: a is not defined

poiché l'istanza "jsTwo" non ha visibilità degli oggetti creati in "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);

Supporto di Kotlin

Per utilizzare questa libreria Jetpack con le coroutine Kotlin, aggiungi una dipendenza a kotlinx-coroutines-guava. In questo modo è possibile l'integrazione con ListenableFuture.

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

Ora le API della libreria Jetpack possono essere chiamate da un ambito di coroutine, come dimostrato di seguito:

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

Parametri di configurazione

Quando richiedi un'istanza di ambiente isolato, puoi modificarne la configurazione. Per modificare la configurazione, passa l'istanza IsolateStartupParameters a JavaScriptSandbox.createIsolate(...).

Attualmente i parametri consentono di specificare le dimensioni massime dell'heap e le dimensioni massime per i valori restituiti e gli errori di valutazione.