Exécuter JavaScript et WebAssembly

Évaluation JavaScript

La bibliothèque Jetpack JavaScriptEngine permet à une application d'évaluer le code JavaScript sans créer d'instance WebView.

Pour les applications nécessitant une évaluation JavaScript non interactive, la bibliothèque JavaScriptEngine présente les avantages suivants:

  • Réduction de la consommation de ressources, car il n'est pas nécessaire d'allouer une instance WebView

  • Cette opération peut être effectuée dans un service (tâche WorkManager).

  • Plusieurs environnements isolés avec de faibles coûts, permettant à l'application d'exécuter plusieurs extraits de code JavaScript simultanément

  • Vous savez transmettre de grandes quantités de données à l'aide d'un appel d'API.

Utilisation de base

Pour commencer, créez une instance de JavaScriptSandbox. Cela représente une connexion au moteur JavaScript hors processus.

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

Il est recommandé d'aligner le cycle de vie du bac à sable sur celui du composant qui nécessite une évaluation JavaScript.

Par exemple, un composant hébergeant le bac à sable peut être Activity ou Service. Un seul élément Service peut être utilisé pour encapsuler l'évaluation JavaScript pour tous les composants d'application.

Vous conservez l'instance JavaScriptSandbox, car son allocation est assez coûteuse. Une seule instance JavaScriptSandbox est autorisée par application. Une erreur IllegalStateException est générée lorsqu'une application tente d'allouer une deuxième instance JavaScriptSandbox. Toutefois, si plusieurs environnements d'exécution sont requis, plusieurs instances JavaScriptIsolate peuvent être allouées.

Lorsqu'elle n'est plus utilisée, fermez l'instance de bac à sable pour libérer des ressources. L'instance JavaScriptSandbox implémente une interface AutoCloseable, qui permet d'effectuer des essais avec des ressources pour des cas d'utilisation de blocage simples. Assurez-vous également que le cycle de vie de l'instance JavaScriptSandbox est géré par le composant d'hébergement, en le fermant dans le rappel onStop() pour une activité ou pendant onDestroy() pour un service:

jsSandbox.close();

Une instance JavaScriptIsolate représente un contexte d'exécution de code JavaScript. Elles peuvent être allouées en cas de besoin, ce qui fournit des limites de sécurité faibles pour les scripts d'origines différentes ou permet une exécution JavaScript simultanée, car JavaScript est par nature à thread unique. Les appels ultérieurs à la même instance partagent le même état. Il est donc possible de créer d'abord des données, puis de les traiter ultérieurement dans la même instance de JavaScriptIsolate.

JavaScriptIsolate jsIsolate = jsSandbox.createIsolate();

Libérez explicitement JavaScriptIsolate en appelant sa méthode close(). La fermeture d'une instance d'isolation exécutant du code JavaScript (dont le code Future est incomplet) entraîne une erreur IsolateTerminatedException. L'isolement est ensuite nettoyé en arrière-plan si l'implémentation prend en charge JS_FEATURE_ISOLATE_TERMINATION, comme décrit dans la section Gérer les plantages du bac à sable plus loin sur cette page. Sinon, le nettoyage est reporté jusqu'à ce que toutes les évaluations en attente soient terminées ou que le bac à sable soit fermé.

Une application peut créer une instance JavaScriptIsolate et y accéder à partir de n'importe quel thread.

L'application est maintenant prête à exécuter du code 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);

Le même extrait de code JavaScript doit être correctement mis en forme:

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

L'extrait de code est transmis en tant que String et le résultat transmis en tant que String. Notez que l'appel de evaluateJavaScriptAsync() renvoie le résultat évalué de la dernière expression dans le code JavaScript. Elle doit être de type JavaScript String. Sinon, l'API de la bibliothèque renvoie une valeur vide. Le code JavaScript ne doit pas utiliser de mot clé return. Si le bac à sable prend en charge certaines fonctionnalités, d'autres types renvoyés (par exemple, un Promise qui se résout en String) peuvent être possibles.

La bibliothèque accepte également l'évaluation de scripts se présentant sous la forme d'un AssetFileDescriptor ou d'un ParcelFileDescriptor. Pour en savoir plus, consultez evaluateJavaScriptAsync(AssetFileDescriptor) et evaluateJavaScriptAsync(ParcelFileDescriptor). Ces API conviennent mieux à l'évaluation à partir d'un fichier sur disque ou dans des répertoires d'applications.

La bibliothèque prend également en charge la journalisation de la console, qui peut être utilisée à des fins de débogage. Vous pouvez le configurer à l'aide de setConsoleCallback().

Comme le contexte persiste, vous pouvez importer du code et l'exécuter plusieurs fois pendant la durée de vie de 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);

Bien sûr, les variables sont également persistantes. Vous pouvez donc poursuivre l'extrait précédent avec:

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

Par exemple, l'extrait complet permettant d'allouer tous les objets nécessaires et d'exécuter un code JavaScript peut se présenter comme suit:

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

Nous vous recommandons d'utiliser try-with-resources pour vous assurer que toutes les ressources allouées sont libérées et ne sont plus utilisées. La fermeture du bac à sable entraîne l'échec de toutes les évaluations en attente dans toutes les instances de JavaScriptIsolate avec l'erreur SandboxDeadException. Lorsque l'évaluation JavaScript rencontre une erreur, un élément JavaScriptException est créé. Reportez-vous à ses sous-classes pour obtenir des exceptions plus spécifiques.

Gérer les plantages du bac à sable

Tout le code JavaScript est exécuté dans un processus de bac à sable distinct du processus principal de l'application. Si le code JavaScript entraîne le plantage de ce processus en bac à sable, par exemple en épuisant une limite de mémoire, le processus principal de l'application n'est pas affecté.

Le plantage du bac à sable entraînera l'arrêt de toutes les isolations de ce bac à sable. Le symptôme le plus évident est que toutes les évaluations commenceront à échouer avec IsolateTerminatedException. Selon les cas, des exceptions plus spécifiques telles que SandboxDeadException ou MemoryLimitExceededException peuvent être générées.

La gestion des plantages pour chaque évaluation n'est pas toujours pratique. De plus, un isolé peut s'arrêter en dehors d'une évaluation explicitement demandée en raison de tâches en arrière-plan ou d'évaluations dans d'autres environnements isolés. Vous pouvez centraliser la logique de gestion des plantages en associant un rappel à l'aide de 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);

Fonctionnalités de bac à sable facultatives

Selon la version WebView sous-jacente, une mise en œuvre de bac à sable peut disposer de différents ensembles de fonctionnalités. Il est donc nécessaire d'interroger chaque caractéristique requise à l'aide de JavaScriptSandbox.isFeatureSupported(...). Il est important de vérifier l'état des fonctionnalités avant d'appeler les méthodes qui s'appuient sur ces fonctionnalités.

Les méthodes JavaScriptIsolate qui peuvent ne pas être disponibles partout sont annotées avec RequiresFeature, ce qui permet de repérer plus facilement ces appels dans le code.

Paramètres de transmission

Si JavaScriptSandbox.JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT est compatible, les requêtes d'évaluation envoyées au moteur JavaScript ne sont pas liées par les limites des transactions de liaison. Si la fonctionnalité n'est pas compatible, toutes les données envoyées à JavaScriptEngine sont réalisées via une transaction de liaison. La limite générale de taille de transaction s'applique à chaque appel qui transmet ou renvoie des données.

La réponse est toujours renvoyée sous forme de chaîne et est soumise à la limite de taille maximale de la transaction de liaison si JavaScriptSandbox.JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT n'est pas compatible. Les valeurs qui ne sont pas des chaînes doivent être explicitement converties en chaîne JavaScript, sans quoi une chaîne vide est renvoyée. Si la fonctionnalité JS_FEATURE_PROMISE_RETURN est compatible, le code JavaScript peut également renvoyer une promesse résolvant une erreur String.

Pour transmettre des tableaux d'octets volumineux à l'instance JavaScriptIsolate, vous pouvez utiliser l'API provideNamedData(...). L'utilisation de cette API n'est pas limitée par les limites de transaction de Binder. Chaque tableau d'octets doit être transmis à l'aide d'un identifiant unique qui ne peut pas être réutilisé.

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

Exécuter le code Wasm

Le code WebAssembly (Wasm) peut être transmis à l'aide de l'API provideNamedData(...), puis compilé et exécuté de la manière habituelle, comme illustré ci-dessous.

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

Séparation JavaScriptIsolate

Toutes les instances de JavaScriptIsolate sont indépendantes les unes des autres et ne partagent rien. L'extrait de code suivant renvoie

Hi from AAA!5

and

Uncaught Reference Error: a is not defined

car l'instance "jsTwo" n'a pas de visibilité sur les objets créés dans "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);

Compatibilité avec Kotlin

Pour utiliser cette bibliothèque Jetpack avec des coroutines Kotlin, ajoutez une dépendance à kotlinx-coroutines-guava. Cela permet l'intégration avec ListenableFuture.

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

Les API de la bibliothèque Jetpack peuvent désormais être appelées à partir d'un champ d'application de coroutine, comme illustré ci-dessous:

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

Paramètres de configuration

Lorsque vous demandez une instance d'environnement isolé, vous pouvez ajuster sa configuration. Pour modifier la configuration, transmettez l'instance IsolateStartupParameters à JavaScriptSandbox.createIsolate(...).

Actuellement, les paramètres permettent de spécifier la taille maximale du tas de mémoire et la taille maximale pour les valeurs de retour et les erreurs d'évaluation.