É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.