Wykonywanie JavaScriptu i WebAssembly

Ocena JavaScript

Biblioteka Jetpack JavaScriptEngine umożliwia aplikacji ocenę kodu JavaScript bez tworzenia instancji WebView.

W przypadku aplikacji wymagających nieinteraktywnej oceny JavaScriptu korzystanie z biblioteki JavaScriptEngine zapewnia takie korzyści:

  • Niższe zużycie zasobów, ponieważ nie trzeba przydzielać instancji WebView.

  • Można to zrobić w usłudze Service (zadaniu WorkManager).

  • Wiele izolowanych środowisk z niskimi nakładami pracy, co umożliwia aplikacji uruchamianie kilku fragmentów kodu JavaScript jednocześnie.

  • Możliwość przekazywania dużych ilości danych przy użyciu wywołania interfejsu API.

Podstawowe użycie

Zacznij od utworzenia instancji JavaScriptSandbox. Reprezentuje to połączenie z mechanizmem JavaScript poza procesem.

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

Zalecamy dopasowanie cyklu życia piaskownicy do cyklu życia komponentu, który wymaga oceny JavaScriptu.

Na przykład komponent hostujący piaskownicę może być komponentem Activity lub Service. Do oceny kodu JavaScript dla wszystkich komponentów aplikacji można użyć pojedynczego elementu Service.

Utrzymuj instancję JavaScriptSandbox, ponieważ jej przydział jest dość kosztowny. Dozwolona jest tylko jedna instancja JavaScriptSandbox na aplikację. Gdy aplikacja próbuje przydzielić drugą instancję JavaScriptSandbox, zgłaszany jest IllegalStateException. Jeśli jednak wymaganych jest wiele środowisk wykonawczych, można przydzielić kilka instancji JavaScriptIsolate.

Gdy instancja nie jest już używana, zamknij ją, aby zwolnić zasoby. Instancja JavaScriptSandbox implementuje interfejs AutoCloseable, który umożliwia wykorzystanie zasobów w prostych przypadkach użycia do blokowania. Możesz też sprawdzić, czy cykl życia instancji JavaScriptSandbox jest zarządzany przez komponent hostujący, zamykając go w wywołaniu zwrotnym onStop() dla aktywności lub podczas onDestroy() w przypadku usługi:

jsSandbox.close();

Wystąpienie JavaScriptIsolate reprezentuje kontekst wykonywania kodu JavaScript. W razie potrzeby można je przydzielać, co zapewnia słabe granice zabezpieczeń dla skryptów z różnych źródeł lub umożliwia równoczesne wykonywanie kodu JavaScript, ponieważ jest z natury jednowątkowy. Kolejne wywołania tej samej instancji mają ten sam stan, dlatego można najpierw utworzyć dane, a potem je przetworzyć w tej samej instancji JavaScriptIsolate.

JavaScriptIsolate jsIsolate = jsSandbox.createIsolate();

Zwolnij JavaScriptIsolate, wywołując jej metodę close(). Zamknięcie izolacji instancji z kodem JavaScript (z niekompletną wartością Future) skutkuje wyświetleniem IsolateTerminatedException. Izolacja jest następnie czyszczona w tle, jeśli implementacja obsługuje JS_FEATURE_ISOLATE_TERMINATION, zgodnie z opisem w dalszej części tej strony, w sekcji dotyczącej obsługi awarii piaskownicy. W przeciwnym razie czyszczenie jest odłożone do czasu zakończenia wszystkich oczekujących ocen lub zamknięcia piaskownicy.

Aplikacja może utworzyć instancję JavaScriptIsolate i uzyskać do niej dostęp z dowolnego wątku.

Teraz aplikacja jest gotowa do wykonywania kodu 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);

Ten sam fragment kodu JavaScript poprawnie sformatowany:

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

Fragment kodu jest przekazywany jako String, a wynik jest dostarczany jako String. Pamiętaj, że wywołanie funkcji evaluateJavaScriptAsync() zwraca obliczony wynik ostatniego wyrażenia w kodzie JavaScript. Musi to być typ String JavaScript. W przeciwnym razie interfejs API biblioteki zwraca pustą wartość. W kodzie JavaScript nie należy używać słowa kluczowego return. Jeśli piaskownica obsługuje określone funkcje, mogą być możliwe dodatkowe typy zwracanych (np. Promise przekierowujący do String).

Biblioteka obsługuje też ocenę skryptów w postaci AssetFileDescriptor lub ParcelFileDescriptor. Więcej informacji znajdziesz w sekcjach evaluateJavaScriptAsync(AssetFileDescriptor) i evaluateJavaScriptAsync(ParcelFileDescriptor). Te interfejsy API lepiej nadają się do oceniania na podstawie pliku na dysku lub katalogu aplikacji.

Biblioteka obsługuje też logowanie konsoli, które można wykorzystać do debugowania. Możesz to skonfigurować, używając: setConsoleCallback().

Kontekst nie zmienia się, dlatego możesz przesłać kod i wykonać go kilka razy w trakcie życia klasy 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);

Oczywiście zmienne też są trwałe, więc możesz kontynuować poprzedni fragment kodu za pomocą:

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

Na przykład pełny fragment kodu służący do przydzielania wszystkich niezbędnych obiektów i wykonywania kodu JavaScript wygląda tak:

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

Zalecamy użycie zasobów próbnych, aby mieć pewność, że wszystkie przydzielone zasoby zostaną zwolnione i nie będą już używane. Zamknięcie piaskownicy spowoduje, że wszystkie oczekujące oceny we wszystkich instancjach JavaScriptIsolate zakończą się niepowodzeniem z użyciem SandboxDeadException. Gdy podczas oceny JavaScriptu wystąpi błąd, tworzony jest element JavaScriptException. Bardziej szczegółowe wyjątki znajdziesz w jego podklasach.

Postępowanie w przypadku awarii piaskownicy

Cały JavaScript jest wykonywany w oddzielnym procesie piaskownicy poza głównym procesem aplikacji. Jeśli kod JavaScript spowoduje awarię procesu w piaskownicy, np. z powodu przekroczenia limitu pamięci, nie wpłynie to na główny proces aplikacji.

Awaria piaskownicy spowoduje zakończenie wszystkich procesów izolacji w tej piaskownicy. Najbardziej oczywistym objawem jest to, że wszystkie oceny będą zaczynać się niepowodzeniem od IsolateTerminatedException. W zależności od okoliczności mogą być zgłaszane bardziej szczegółowe wyjątki, np. SandboxDeadException lub MemoryLimitExceededException.

Obsługa awarii w przypadku każdej indywidualnej oceny nie zawsze jest praktyczna. Co więcej, izolacja może zakończyć się poza wyraźnie zażądaną oceną z powodu zadań w tle lub ocen w innych izolacjach. Logikę obsługi awarii można scentralizować, dołączając wywołanie zwrotne za pomocą 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);

Opcjonalne funkcje piaskownicy

W zależności od bazowej wersji WebView implementacja w piaskownicy może oferować różne zestawy funkcji. Dlatego musisz wysyłać zapytania do każdej wymaganej funkcji za pomocą funkcji JavaScriptSandbox.isFeatureSupported(...). Ważne jest, aby sprawdzić stan funkcji przed wywołaniem metod korzystających z tych funkcji.

Metody JavaScriptIsolate, które mogą być niedostępne w niektórych miejscach, są oznaczone adnotacją RequiresFeature, co ułatwia rozpoznanie tych wywołań w kodzie.

Parametry do zaliczenia

Jeśli kod JavaScriptSandbox.JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT jest obsługiwany, żądania oceny wysyłane do mechanizmu JavaScript nie są powiązane z limitami powiązanych transakcji. Jeśli ta funkcja nie jest obsługiwana, wszystkie dane przesyłane do JavaScriptEngine są realizowane w ramach transakcji Binder. Ogólny limit rozmiaru transakcji obowiązuje w przypadku każdego wywołania, które przekazuje dane lub je zwraca.

Odpowiedź jest zawsze zwracana jako ciąg znaków i podlega limitowi maksymalnego rozmiaru transakcji Binder, jeśli element JavaScriptSandbox.JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT nie jest obsługiwany. Wartości inne niż ciągi muszą zostać bezpośrednio skonwertowane na ciąg JavaScript. W przeciwnym razie zwracany jest pusty ciąg. Jeśli funkcja JS_FEATURE_PROMISE_RETURN jest obsługiwana, kod JavaScript może zwrócić odpowiedź na żądanie String.

Do przekazywania dużych tablic bajtów do instancji JavaScriptIsolate możesz użyć interfejsu API provideNamedData(...). Korzystanie z tego interfejsu API nie jest objęte limitami transakcji Binder. Każda tablica bajtowa musi być przekazywana za pomocą unikalnego identyfikatora, którego nie można użyć ponownie.

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

Uruchomiony kod Wasm

Kod WebAssembly (Wasm) można przekazać za pomocą interfejsu API provideNamedData(...), a następnie skompilować i wykonać w zwykły sposób, jak pokazano poniżej.

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

Separacja izolacji JavaScript

Wszystkie instancje JavaScriptIsolate są od siebie niezależne i niczego nie udostępniają. Ten fragment kodu daje wynik

Hi from AAA!5

i

Uncaught Reference Error: a is not defined

ponieważ instancja „jsTwo” nie ma widoczności obiektów utworzonych w „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);

Zespół pomocy Kotlin

Aby używać tej biblioteki Jetpack z współprogramami Kotlin, dodaj zależność do kotlinx-coroutines-guava. Umożliwia to integrację z usługą ListenableFuture.

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

Interfejsy API biblioteki Jetpack można teraz wywoływać z zakresu współużytkowanego, jak pokazano poniżej:

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

Parametry konfiguracji

Jeśli wysyłasz żądanie instancji izolowanego środowiska, możesz dostosować jej konfigurację. Aby dostosować konfigurację, przekaż instancję IsolateStartupParameters do instancji JavaScriptSandbox.createIsolate(...).

Obecnie parametry pozwalają określić maksymalny rozmiar stosu oraz maksymalny rozmiar zwracanych wartości i błędów oceny.