Выполнение JavaScript и WebAssembly

Оценка JavaScript

Библиотека Jetpack JavaScriptEngine предоставляет приложению возможность оценить код JavaScript без создания экземпляра WebView.

Для приложений, требующих неинтерактивной оценки JavaScript, использование библиотеки JavaScriptEngine имеет следующие преимущества:

  • Меньшее потребление ресурсов, поскольку нет необходимости выделять экземпляр WebView.

  • Это можно сделать в Сервисе (задача WorkManager).

  • Несколько изолированных сред с низкими накладными расходами, позволяющие приложению одновременно запускать несколько фрагментов JavaScript.

  • Возможность передачи больших объемов данных с помощью вызова API.

Основное использование

Для начала создайте экземпляр JavaScriptSandbox . Это представляет собой соединение с внепроцессным движком JavaScript.

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

Рекомендуется согласовать жизненный цикл песочницы с жизненным циклом компонента, который требует оценки JavaScript.

Например, компонентом, размещающим песочницу, может быть Activity или Service . Для инкапсуляции оценки JavaScript для всех компонентов приложения можно использовать одну Service .

Поддерживайте экземпляр JavaScriptSandbox , поскольку его размещение обходится довольно дорого. Для каждого приложения разрешен только один экземпляр JavaScriptSandbox . IllegalStateException возникает, когда приложение пытается выделить второй экземпляр JavaScriptSandbox . Однако если требуется несколько сред выполнения, можно выделить несколько экземпляров JavaScriptIsolate .

Когда он больше не используется, закройте экземпляр песочницы, чтобы освободить ресурсы. Экземпляр JavaScriptSandbox реализует интерфейс AutoCloseable , который позволяет использовать попытку с ресурсами для простых случаев использования блокировки. Альтернативно, убедитесь, что жизненный цикл экземпляра JavaScriptSandbox управляется хост-компонентом, закрывая его в обратном вызове onStop() для действия или во время onDestroy() для службы:

jsSandbox.close();

Экземпляр JavaScriptIsolate представляет контекст для выполнения кода JavaScript. При необходимости их можно выделить, обеспечивая слабые границы безопасности для сценариев различного происхождения или обеспечивая одновременное выполнение JavaScript, поскольку JavaScript по своей природе является однопоточным. Последующие вызовы одного и того же экземпляра имеют одно и то же состояние, поэтому можно сначала создать некоторые данные, а затем обработать их позже в том же экземпляре JavaScriptIsolate .

JavaScriptIsolate jsIsolate = jsSandbox.createIsolate();

Освободите JavaScriptIsolate явно, вызвав его метод close() . Закрытие экземпляра изоляции, выполняющего код JavaScript (с неполным Future ), приводит к возникновению IsolateTerminatedException . Изолят впоследствии очищается в фоновом режиме, если реализация поддерживает JS_FEATURE_ISOLATE_TERMINATION , как описано в разделе обработки сбоев в песочнице далее на этой странице. В противном случае очистка откладывается до завершения всех ожидающих оценок или закрытия песочницы.

Приложение может создать экземпляр JavaScriptIsolate и получить к нему доступ из любого потока.

Теперь приложение готово выполнить некоторый код 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);

Тот же фрагмент JavaScript в красивом формате:

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

Фрагмент кода передается как String , а результат доставляется как String . Обратите внимание, что вызов метода evaluateJavaScriptAsync() возвращает вычисленный результат последнего выражения в коде JavaScript. Это должен быть тип JavaScript String ; в противном случае API библиотеки возвращает пустое значение. Код JavaScript не должен использовать ключевое слово return . Если песочница поддерживает определенные функции, могут быть возможны дополнительные типы возврата (например, Promise , который преобразуется в String ).

Библиотека также поддерживает оценку скриптов в форме AssetFileDescriptor или ParcelFileDescriptor . Дополнительные сведения см. evaluateJavaScriptAsync(AssetFileDescriptor) и evaluateJavaScriptAsync(ParcelFileDescriptor) . Эти API лучше подходят для оценки из файла на диске или в каталогах приложений.

Библиотека также поддерживает ведение журнала консоли, которое можно использовать в целях отладки. Это можно настроить с помощью setConsoleCallback() .

Поскольку контекст сохраняется, вы можете загружать код и выполнять его несколько раз за время существования 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);

Конечно, переменные также являются постоянными, поэтому вы можете продолжить предыдущий фрагмент:

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

Например, полный фрагмент кода для размещения всех необходимых объектов и выполнения кода JavaScript может выглядеть следующим образом:

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

Рекомендуется использовать try-with-resources, чтобы убедиться, что все выделенные ресурсы освобождены и больше не используются. Закрытие песочницы приводит к тому, что все ожидающие оценки во всех экземплярах JavaScriptIsolate завершаются с ошибкой SandboxDeadException . Когда при оценке JavaScript обнаруживается ошибка, создается JavaScriptException . Обратитесь к его подклассам для получения более конкретных исключений.

Обработка сбоев в песочнице

Весь JavaScript выполняется в отдельном изолированном процессе, отдельном от основного процесса вашего приложения. Если код JavaScript приведет к сбою этого изолированного процесса, например, из-за исчерпания ограничения памяти, основной процесс приложения не будет затронут.

Сбой в песочнице приведет к прекращению работы всех изолятов в этой песочнице. Наиболее очевидным признаком этого является то, что все вычисления начнут завершаться сбоем из-за IsolateTerminatedException . В зависимости от обстоятельств могут быть выброшены более конкретные исключения, такие как SandboxDeadException или MemoryLimitExceededException .

Обработка сбоев для каждой отдельной оценки не всегда практична. Кроме того, изолят может завершиться вне явно запрошенной оценки из-за фоновых задач или оценок в других изолятах. Логику обработки сбоев можно централизовать, подключив обратный вызов с помощью 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);

Дополнительные функции песочницы

В зависимости от базовой версии WebView реализация песочницы может иметь разные наборы доступных функций. Итак, необходимо запросить каждую необходимую функцию, используя JavaScriptSandbox.isFeatureSupported(...) . Важно проверять состояние функций перед вызовом методов, использующих эти функции.

Методы JavaScriptIsolate , которые могут быть доступны не везде, помечены аннотацией RequiresFeature , что упрощает обнаружение этих вызовов в коде.

Передача параметров

Если поддерживается JavaScriptSandbox.JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT , запросы оценки, отправляемые в механизм JavaScript, не ограничиваются ограничениями транзакций связывателя. Если эта функция не поддерживается, все данные в JavaScriptEngine передаются через транзакцию Binder. Общий лимит размера транзакции применим к каждому вызову, который передает данные или возвращает данные.

Ответ всегда возвращается в виде строки , и на него распространяется ограничение максимального размера транзакции Binder, если JavaScriptSandbox.JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT не поддерживается. Нестроковые значения должны быть явно преобразованы в строку JavaScript, в противном случае будет возвращена пустая строка. Если функция JS_FEATURE_PROMISE_RETURN поддерживается, код JavaScript может альтернативно возвращать обещание, преобразующееся в String .

Для передачи больших массивов байтов в экземпляр JavaScriptIsolate вы можете использовать API provideNamedData(...) . Использование этого API не ограничено ограничениями транзакций Binder. Каждый массив байтов должен передаваться с использованием уникального идентификатора, который нельзя использовать повторно.

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

Код WebAssembly (Wasm) можно передать с помощью API- provideNamedData(...) , затем скомпилировать и выполнить обычным способом, как показано ниже.

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Изолировать разделение

Все экземпляры JavaScriptIsolate независимы друг от друга и не имеют ничего общего. Следующий фрагмент приводит к

Hi from AAA!5

и

Uncaught Reference Error: a is not defined

потому что экземпляр « jsTwo » не видит объекты, созданные в « 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);

Поддержка Котлина

Чтобы использовать эту библиотеку Jetpack с сопрограммами Kotlin, добавьте зависимость kotlinx-coroutines-guava . Это позволяет интегрироваться с ListenableFuture .

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

API-интерфейсы библиотеки Jetpack теперь можно вызывать из области сопрограммы, как показано ниже:

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

Параметры конфигурации

При запросе экземпляра изолированной среды вы можете настроить его конфигурацию. Чтобы настроить конфигурацию, передайте экземпляр IsolateStartupParameters в JavaScriptSandbox.createIsolate(...) .

В настоящее время параметры позволяют указать максимальный размер кучи и максимальный размер возвращаемых значений и ошибок оценки.