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 una valutazione JavaScript non interattiva, l'utilizzo della libreria JavaScriptEngine offre i seguenti vantaggi:

  • Riduzione del consumo di risorse, poiché non è necessario allocare un'istanza di WebView.

  • Questa operazione può essere eseguita in un servizio (attività WorkManager).

  • Più ambienti isolati con un overhead ridotto, che consente all'applicazione di eseguire più snippet JavaScript contemporaneamente.

  • Possibilità di passare grandi quantità di dati tramite una chiamata API.

Utilizzo di base

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

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

Ti consigliamo di allineare il ciclo di vita della sandbox a quello del componente che richiede la valutazione JavaScript.

Ad esempio, un componente che ospita la sandbox può essere Activity o Service. Potresti 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. Viene generato un errore IllegalStateException quando un'applicazione tenta di allocare una seconda istanza JavaScriptSandbox. Tuttavia, se sono necessari più ambienti di esecuzione, è possibile allocare più istanze JavaScriptIsolate.

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

jsSandbox.close();

Un'istanza JavaScriptIsolate rappresenta un contesto per l'esecuzione di codice JavaScript. Possono essere allocate quando necessario, fornendo limiti di sicurezza deboli per script di origine diversa o abilitando l'esecuzione simultanea di JavaScript, poiché JavaScript è per natura a thread singolo. Le chiamate successive alla stessa istanza condividono lo stesso stato, quindi è possibile creare prima alcuni dati e poi 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 elemento Future incompleto) genera un IsolateTerminatedException. L'isolamento viene pulito successivamente in background se l'implementazione supporta JS_FEATURE_ISOLATE_TERMINATION, come descritto nella sezione Gestione degli arresti anomali della sandbox più avanti in questa pagina. In caso contrario, la pulizia viene posticipata fino al completamento di tutte le valutazioni in attesa o alla chiusura della sandbox.

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

Ora l'applicazione è pronta per eseguire 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 trasmesso come String e il risultato viene pubblicato come String. Tieni presente che la chiamata a evaluateJavaScriptAsync() restituisce il risultato valutato dell'ultima espressione nel codice JavaScript. Deve essere di tipo JavaScript String; in caso contrario, l'API Library restituisce un valore vuoto. Il codice JavaScript non deve utilizzare una parola chiave return. Se la sandbox supporta determinate funzionalità, potrebbero essere possibili tipi restituiti aggiuntivi (ad esempio, un Promise che corrisponde a un String).

La libreria supporta anche la valutazione di script che sono sotto forma di AssetFileDescriptor o ParcelFileDescriptor. Visita le pagine evaluateJavaScriptAsync(AssetFileDescriptor) e evaluateJavaScriptAsync(ParcelFileDescriptor) per ulteriori dettagli. Queste API sono più adatte per la valutazione da un file su disco o nelle directory delle app.

La libreria supporta anche il logging della console, che può essere utilizzato per il debug. Questa opzione può essere configurata utilizzando setConsoleCallback().

Poiché il contesto persiste, puoi caricare il codice ed eseguirlo più volte durante il ciclo di vita di 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);

Naturalmente, le variabili sono permanenti, quindi puoi continuare lo snippet precedente 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 utilizzare prova con risorse per assicurarti che tutte le risorse allocate vengano rilasciate e non siano più utilizzate. La chiusura della sandbox comporta la visualizzazione di tutte le valutazioni in attesa in tutte le istanze JavaScriptIsolate che non presentano errori di SandboxDeadException. Quando la valutazione JavaScript rileva un errore, viene creato un elemento JavaScriptException. Fai riferimento alle relative sottoclassi per eccezioni più specifiche.

Gestione degli arresti anomali della sandbox

Tutto JavaScript viene eseguito in un processo sandbox separato, lontano dal processo principale dell'applicazione. Se il codice JavaScript causa l'arresto anomalo di questo processo con sandbox, ad esempio, causando l'esaurimento di un limite di memoria, il processo principale dell'applicazione non subirà modifiche.

Un arresto anomalo della sandbox causerà l'arresto di tutti gli isolati al suo interno. Il sintomo più evidente è che tutte le valutazioni inizieranno a non funzionare con IsolateTerminatedException. A seconda dei casi, potrebbero essere lanciate eccezioni più specifiche, come SandboxDeadException o MemoryLimitExceededException.

La gestione degli arresti anomali per ogni singola valutazione non è sempre pratica. Inoltre, un isolato può terminare al di fuori di una valutazione richiesta esplicitamente a causa di attività in background o valutazioni in altri isolati. La logica di gestione degli arresti anomali può essere centralizzata collegando un callback tramite 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 sandbox potrebbe avere set di funzionalità diversi. È quindi necessario eseguire una query su ogni funzionalità richiesta utilizzando JavaScriptSandbox.isFeatureSupported(...). È importante controllare lo stato delle funzionalità prima di chiamare i metodi che le utilizzano.

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

Passaggio dei parametri

Se JavaScriptSandbox.JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT è supportato, le richieste di valutazione inviate al motore JavaScript non sono vincolate dai limiti delle transazioni di binder. Se la funzionalità non è supportata, tutti i dati al JavaScriptEngine avvengono tramite una transazione Binder. Il limite generale di dimensione delle transazioni si applica a ogni chiamata che trasmette o restituisce dati.

La risposta viene sempre restituita come stringa ed è soggetta al limite di dimensione massima della transazione di Binder se JavaScriptSandbox.JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT non è supportato. I valori non 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ò in alternativa restituire una risoluzione Promise a 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 transazione di Binder. Ogni array di byte deve essere passato utilizzando un identificatore univoco 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 codice Wasm

Il codice WebAssembly (Wasm) può essere passato utilizzando l'API provideNamedData(...), quindi compilato ed eseguito nel solito modo, come mostrato 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 JavaScriptIsolate

Tutte le istanze JavaScriptIsolate sono indipendenti tra loro e non condividono nulla. Il seguente snippet genera

Hi from AAA!5

e

Uncaught Reference Error: a is not defined

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

Assistenza Kotlin

Per utilizzare questa libreria Jetpack con le coroutine Kotlin, aggiungi una dipendenza a kotlinx-coroutines-guava. Consente 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 a coroutine, come mostrato 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 la dimensione massima dello heap e la dimensione massima per i valori e gli errori restituiti della valutazione.