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

Выполнение JavaScript

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

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

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

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

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

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

Основные функции

Сначала создайте экземпляр JavaScriptSandbox. Это означает, что установлено подключение к внешнему движку JavaScript.

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

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

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

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

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

jsSandbox.close();

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

JavaScriptIsolate jsIsolate = jsSandbox.createIsolate();

Освободите JavaScriptIsolate, вызвав метод close(). Если закрыть экземпляр Isolate, в котором выполняется код 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 может возвращать Promise, который разрешается в 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);
}

Изоляция JavaScriptIsolate

Все экземпляры 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);

Поддержка Kotlin

Чтобы использовать эту библиотеку 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(...).

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