JavaScript und WebAssembly ausführen

JavaScript-Evaluierung

Die Jetpack-Bibliothek JavaScriptEngine bietet einer Anwendung die Möglichkeit, JavaScript-Code auszuwerten, ohne eine WebView-Instanz zu erstellen.

Für Anwendungen, die eine nicht interaktive JavaScript-Evaluierung erfordern, bietet die Verwendung der JavaScriptEngine-Bibliothek folgende Vorteile:

  • Geringerer Ressourcenverbrauch, da keine WebView-Instanz zugewiesen werden muss.

  • Kann in einem Dienst (WorkManager-Aufgabe) ausgeführt werden.

  • Mehrere isolierte Umgebungen mit geringem Aufwand, sodass die Anwendung mehrere JavaScript-Snippets gleichzeitig ausführen kann

  • Möglichkeit, große Datenmengen mithilfe eines API-Aufrufs zu übergeben.

Grundlegende Verwendung

Erstellen Sie zuerst eine Instanz von JavaScriptSandbox. Dies stellt eine Verbindung zur Out-of-Process-JavaScript-Engine dar.

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

Es wird empfohlen, den Lebenszyklus der Sandbox auf den Lebenszyklus der Komponente abzustimmen, für die eine JavaScript-Bewertung erforderlich ist.

Eine Komponente zum Hosten der Sandbox kann beispielsweise ein Activity oder ein Service sein. Eine einzelne Service kann verwendet werden, um die JavaScript-Auswertung für alle Anwendungskomponenten zu kapseln.

Behalten Sie die Instanz JavaScriptSandbox bei, da ihre Zuweisung recht teuer ist. Es ist nur eine JavaScriptSandbox-Instanz pro Anwendung zulässig. Ein IllegalStateException wird ausgelöst, wenn eine Anwendung versucht, eine zweite JavaScriptSandbox-Instanz zuzuweisen. Wenn jedoch mehrere Ausführungsumgebungen erforderlich sind, können mehrere JavaScriptIsolate-Instanzen zugewiesen werden.

Wenn sie nicht mehr verwendet wird, schließen Sie die Sandbox-Instanz, um Ressourcen freizugeben. Die Instanz JavaScriptSandbox implementiert eine AutoCloseable-Schnittstelle, die die Nutzung von „Ausprobieren“ mit Ressourcen für einfache blockierende Anwendungsfälle ermöglicht. Alternativ können Sie dafür sorgen, dass der Lebenszyklus der JavaScriptSandbox-Instanz von der Hosting-Komponente verwaltet wird. Schließen Sie dazu sie im onStop()-Callback für eine Aktivität oder während onDestroy() für einen Dienst:

jsSandbox.close();

Eine JavaScriptIsolate-Instanz stellt einen Kontext zum Ausführen von JavaScript-Code dar. Sie können bei Bedarf zugewiesen werden, um schwache Sicherheitsgrenzen für Skripts unterschiedlicher Herkunft bereitzustellen oder die gleichzeitige JavaScript-Ausführung zu ermöglichen, da JavaScript von Natur aus Single-Threaded ist. Nachfolgende Aufrufe derselben Instanz haben denselben Status. Daher ist es möglich, zuerst einige Daten zu erstellen und später in derselben Instanz von JavaScriptIsolate zu verarbeiten.

JavaScriptIsolate jsIsolate = jsSandbox.createIsolate();

Geben Sie JavaScriptIsolate explizit durch Aufrufen der Methode close() frei. Das Schließen einer isolierten Instanz, auf der JavaScript-Code ausgeführt wird (mit einem unvollständigen Future), führt zu einem IsolateTerminatedException. Das Isolations-Tool wird anschließend im Hintergrund bereinigt, wenn die Implementierung JS_FEATURE_ISOLATE_TERMINATION unterstützt, wie im Abschnitt Umgang mit Sandbox-Abstürzen weiter unten auf dieser Seite beschrieben. Andernfalls wird die Bereinigung verschoben, bis alle ausstehenden Bewertungen abgeschlossen sind oder die Sandbox geschlossen ist.

Eine Anwendung kann eine JavaScriptIsolate-Instanz von jedem Thread aus erstellen und darauf zugreifen.

Jetzt kann die Anwendung JavaScript-Code ausführen:

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

Dasselbe JavaScript-Snippet ist gut formatiert:

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

Das Code-Snippet wird als String übergeben und das Ergebnis als String übergeben. Wenn Sie evaluateJavaScriptAsync() aufrufen, wird das ausgewertete Ergebnis des letzten Ausdrucks im JavaScript-Code zurückgegeben. Sie muss vom Typ String in JavaScript sein. Andernfalls gibt die Library API einen leeren Wert zurück. Der JavaScript-Code sollte kein return-Keyword enthalten. Wenn die Sandbox bestimmte Funktionen unterstützt, sind unter Umständen zusätzliche Rückgabetypen möglich (z. B. ein Promise, das zu einem String aufgelöst wird).

Die Bibliothek unterstützt auch die Bewertung von Skripts in Form eines AssetFileDescriptor- oder ParcelFileDescriptor-Skripts. Weitere Informationen finden Sie unter evaluateJavaScriptAsync(AssetFileDescriptor) und evaluateJavaScriptAsync(ParcelFileDescriptor). Diese APIs eignen sich besser für die Auswertung anhand einer Datei auf einem Laufwerk oder in Anwendungsverzeichnissen.

Die Bibliothek unterstützt auch das Logging in der Konsole, das für die Fehlerbehebung verwendet werden kann. Dies kann mit setConsoleCallback() eingerichtet werden.

Da der Kontext bestehen bleibt, können Sie Code hochladen und während der Lebensdauer von JavaScriptIsolate mehrmals ausführen:

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

Natürlich sind die Variablen auch persistent, sodass Sie das vorherige Snippet so fortsetzen können:

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

Das vollständige Snippet zum Zuweisen aller erforderlichen Objekte und zum Ausführen eines JavaScript-Codes könnte beispielsweise so aussehen:

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

Es empfiehlt sich, „try-with-resources“ zu verwenden, um sicherzustellen, dass alle zugewiesenen Ressourcen freigegeben und nicht mehr verwendet werden. Wenn Sie die Sandbox schließen, schlagen alle ausstehenden Bewertungen in allen JavaScriptIsolate-Instanzen fehl und die Fehlermeldung SandboxDeadException wird zurückgegeben. Wenn bei der JavaScript-Bewertung ein Fehler auftritt, wird ein JavaScriptException erstellt. Spezifischere Ausnahmen finden Sie in den zugehörigen abgeleiteten Klassen.

Sandbox-Abstürze verarbeiten

JavaScript wird in einem separaten Sandbox-Prozess außerhalb des Hauptprozesses Ihrer Anwendung ausgeführt. Wenn der JavaScript-Code dazu führt, dass dieser in der Sandbox ausgeführte Prozess abstürzt, z. B. weil ein Arbeitsspeicherlimit erschöpft ist, ist der Hauptprozess der Anwendung nicht betroffen.

Ein Sandbox-Absturz führt dazu, dass alle Isolationen in dieser Sandbox beendet werden. Das offensichtlichste Anzeichen dafür ist, dass alle Bewertungen mit IsolateTerminatedException fehlschlagen. Je nach den Umständen werden spezifischere Ausnahmen wie SandboxDeadException oder MemoryLimitExceededException ausgelöst.

Der Umgang mit Abstürzen bei jeder einzelnen Bewertung ist nicht immer praktikabel. Außerdem kann ein Isolationsprozess außerhalb einer explizit angeforderten Bewertung aufgrund von Hintergrundaufgaben oder Bewertungen in anderen Isolationen beendet werden. Die Logik für die Absturzbehandlung kann zentralisiert werden, indem mit JavaScriptIsolate.addOnTerminatedCallback() ein Callback angehängt wird.

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

Optionale Sandbox-Funktionen

Je nach zugrunde liegender WebView-Version stehen für eine Sandbox-Implementierung möglicherweise unterschiedliche Funktionen zur Verfügung. Daher müssen Sie jedes erforderliche Feature mit JavaScriptSandbox.isFeatureSupported(...) abfragen. Es ist wichtig, den Featurestatus zu prüfen, bevor Methoden aufgerufen werden, die diese Features nutzen.

JavaScriptIsolate-Methoden, die möglicherweise nicht überall verfügbar sind, werden mit der Annotation RequiresFeature versehen, sodass diese Aufrufe im Code leichter zu erkennen sind.

Parameter übergeben

Wenn JavaScriptSandbox.JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT unterstützt wird, sind die an die JavaScript-Engine gesendeten Bewertungsanfragen nicht an die Transaktionslimits der Binder gebunden. Wenn die Funktion nicht unterstützt wird, werden alle Daten an die JavaScriptEngine über eine Binder-Transaktion ausgeführt. Die allgemeine Größenbeschränkung für Transaktionen gilt für jeden Aufruf, bei dem Daten übergeben oder zurückgegeben werden.

Die Antwort wird immer als String zurückgegeben und unterliegt der Größenbeschränkung für Binder-Transaktionen, wenn JavaScriptSandbox.JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT nicht unterstützt wird. Nicht-String-Werte müssen explizit in einen JavaScript-String konvertiert werden. Andernfalls wird ein leerer String zurückgegeben. Wenn das JS_FEATURE_PROMISE_RETURN-Feature unterstützt wird, kann JavaScript-Code alternativ ein Promise-Objekt an ein String zurückgeben.

Zum Übertragen großer Byte-Arrays an die JavaScriptIsolate-Instanz können Sie die provideNamedData(...) API verwenden. Die Nutzung dieser API ist nicht an die Binder-Transaktionslimits gebunden. Jedes Bytearray muss mit einer eindeutigen Kennzeichnung übergeben werden, die nicht wiederverwendet werden kann.

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

Wasm-Code ausführen

WebAssembly-Code (Wasm) kann mit der provideNamedData(...) API übergeben und dann wie unten gezeigt kompiliert und ausgeführt werden.

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

JavaScript-Isolierung

Alle JavaScriptIsolate-Instanzen sind unabhängig voneinander und haben nichts gemeinsam. Das folgende Snippet führt zu

Hi from AAA!5

und

Uncaught Reference Error: a is not defined

da die Instanz „jsTwo“ keine Sichtbarkeit der in „jsOne“ erstellten Objekte hat.

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

Kotlin-Unterstützung

Wenn Sie diese Jetpack-Bibliothek mit Kotlin-Coroutinen verwenden möchten, fügen Sie kotlinx-coroutines-guava eine Abhängigkeit hinzu. Dies ermöglicht die Integration mit ListenableFuture.

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

Die Jetpack Library APIs können jetzt wie unten gezeigt aus einem Koroutinenbereich aufgerufen werden:

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

Konfigurationsparameter

Wenn Sie eine Instanz einer isolierten Umgebung anfordern, können Sie deren Konfiguration optimieren. Übergeben Sie die Instanz IsolateStartupParameters an JavaScriptSandbox.createIsolate(...), um die Konfiguration zu optimieren.

Derzeit können mit Parametern die maximale Heap-Größe und die maximale Größe für Rückgabewerte und Fehler bei der Auswertung angegeben werden.