Mengeksekusi JavaScript dan WebAssembly

Evaluasi JavaScript

Library Jetpack JavaScriptEngine menyediakan cara bagi aplikasi untuk mengevaluasi kode JavaScript tanpa membuat instance WebView.

Untuk aplikasi yang memerlukan evaluasi JavaScript non-interaktif, penggunaan library JavaScriptEngine memiliki keuntungan berikut:

  • Konsumsi resource lebih rendah karena tidak perlu mengalokasikan instance WebView.

  • Dapat dilakukan di Layanan (tugas WorkManager).

  • Beberapa lingkungan terisolasi dengan overhead rendah, sehingga memungkinkan aplikasi menjalankan beberapa cuplikan JavaScript secara bersamaan.

  • Kemampuan untuk meneruskan data dalam jumlah besar dengan menggunakan panggilan API.

Penggunaan Dasar

Untuk memulai, buat instance JavaScriptSandbox. Ini mewakili koneksi ke mesin JavaScript di luar proses.

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

Sebaiknya selaraskan siklus proses sandbox dengan siklus proses komponen yang memerlukan evaluasi JavaScript.

Misalnya, komponen yang menghosting sandbox dapat berupa Activity atau Service. Satu Service dapat digunakan untuk mengenkapsulasi evaluasi JavaScript untuk semua komponen aplikasi.

Pertahankan instance JavaScriptSandbox karena alokasinya cukup mahal. Hanya satu instance JavaScriptSandbox per aplikasi yang diizinkan. IllegalStateException ditampilkan saat aplikasi mencoba mengalokasikan instance JavaScriptSandbox kedua. Namun, jika diperlukan beberapa lingkungan eksekusi, beberapa instance JavaScriptIsolate dapat dialokasikan.

Jika tidak lagi digunakan, tutup instance sandbox untuk membebaskan resource. Instance JavaScriptSandbox mengimplementasikan antarmuka AutoCloseable, yang memungkinkan penggunaan try-with-resource untuk kasus penggunaan pemblokiran sederhana. Atau, pastikan siklus proses instance JavaScriptSandbox dikelola oleh komponen hosting, menutupnya dalam callback onStop() untuk Aktivitas atau selama onDestroy() untuk Layanan:

jsSandbox.close();

Instance JavaScriptIsolate mewakili konteks untuk mengeksekusi kode JavaScript. Library ini dapat dialokasikan jika diperlukan, sehingga memberikan batas keamanan yang lemah untuk skrip dengan origin yang berbeda atau memungkinkan eksekusi JavaScript serentak karena pada dasarnya JavaScript bersifat thread tunggal. Panggilan berikutnya ke instance yang sama memiliki status yang sama, sehingga Anda dapat membuat beberapa data terlebih dahulu lalu memprosesnya nanti dalam instance JavaScriptIsolate yang sama.

JavaScriptIsolate jsIsolate = jsSandbox.createIsolate();

Rilis JavaScriptIsolate secara eksplisit dengan memanggil metode close(). Menutup instance isolasi yang menjalankan kode JavaScript (memiliki Future yang tidak lengkap) menghasilkan IsolateTerminatedException. Isolasi selanjutnya akan dibersihkan di latar belakang jika implementasi tersebut mendukung JS_FEATURE_ISOLATE_TERMINATION, seperti yang dijelaskan di bagian menangani error sandbox nanti di halaman ini. Jika tidak, pembersihan akan ditunda hingga semua evaluasi yang tertunda selesai atau sandbox ditutup.

Aplikasi dapat membuat dan mengakses instance JavaScriptIsolate dari thread mana pun.

Sekarang, aplikasi siap menjalankan beberapa kode 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);

Cuplikan JavaScript yang sama diformat dengan baik:

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

Cuplikan kode diteruskan sebagai String dan hasilnya dikirim sebagai String. Perhatikan bahwa memanggil evaluateJavaScriptAsync() akan menampilkan hasil evaluasi ekspresi terakhir dalam kode JavaScript. Jenis nilai ini harus berjenis String JavaScript; jika tidak, API library akan menampilkan nilai kosong. Kode JavaScript tidak boleh menggunakan kata kunci return. Jika sandbox mendukung fitur tertentu, jenis nilai yang ditampilkan tambahan (misalnya, Promise yang di-resolve ke String) mungkin dapat dilakukan.

Library ini juga mendukung evaluasi skrip yang berbentuk AssetFileDescriptor atau ParcelFileDescriptor. Lihat evaluateJavaScriptAsync(AssetFileDescriptor) dan evaluateJavaScriptAsync(ParcelFileDescriptor) untuk detail selengkapnya. API ini lebih cocok untuk mengevaluasi dari file pada disk atau dalam direktori aplikasi.

Library ini juga mendukung logging konsol yang dapat digunakan untuk tujuan proses debug. Ini dapat disiapkan menggunakan setConsoleCallback().

Karena konteksnya tetap ada, Anda dapat mengupload kode dan menjalankannya beberapa kali selama masa aktif 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);

Tentu saja, variabel juga bersifat persisten, sehingga Anda dapat melanjutkan cuplikan sebelumnya dengan:

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

Misalnya, cuplikan lengkap untuk mengalokasikan semua objek yang diperlukan dan mengeksekusi kode JavaScript mungkin terlihat seperti berikut:

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

Sebaiknya gunakan try-with-resources untuk memastikan semua resource yang dialokasikan dirilis dan tidak lagi digunakan. Jika sandbox ditutup, semua evaluasi yang tertunda di semua instance JavaScriptIsolate akan gagal dengan SandboxDeadException. Saat evaluasi JavaScript mengalami error, JavaScriptException akan dibuat. Lihat subclass-nya untuk pengecualian yang lebih spesifik.

Menangani Error Sandbox

Semua JavaScript dijalankan dalam proses sandbox terpisah yang berbeda dari proses utama aplikasi Anda. Jika kode JavaScript menyebabkan proses yang di-sandbox ini error, misalnya karena batas memori habis, proses utama aplikasi tidak akan terpengaruh.

Error sandbox akan menyebabkan semua isolasi di sandbox tersebut dihentikan. Gejala yang paling jelas dari hal ini adalah semua evaluasi akan mulai gagal dengan IsolateTerminatedException. Bergantung pada situasinya, pengecualian yang lebih spesifik seperti SandboxDeadException atau MemoryLimitExceededException dapat ditampilkan.

Menangani error untuk setiap evaluasi tidak selalu praktis. Selain itu, isolasi dapat berhenti di luar evaluasi yang diminta secara eksplisit karena ada tugas latar belakang atau evaluasi pada isolasi lain. Logika penanganan error dapat dipusatkan dengan menambahkan callback menggunakan 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);

Fitur Sandbox Opsional

Bergantung pada versi WebView yang mendasarinya, penerapan sandbox mungkin memiliki serangkaian fitur yang tersedia. Jadi, Anda perlu mengkueri setiap fitur yang diperlukan menggunakan JavaScriptSandbox.isFeatureSupported(...). Penting untuk memeriksa status fitur sebelum memanggil metode yang mengandalkan fitur ini.

Metode JavaScriptIsolate yang mungkin tidak tersedia di mana saja dianotasi dengan anotasi RequiresFeature, sehingga lebih mudah untuk menemukan panggilan ini dalam kode.

Meneruskan Parameter

Jika JavaScriptSandbox.JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT didukung, permintaan evaluasi yang dikirim ke mesin JavaScript tidak terikat oleh batas transaksi binder. Jika fitur ini tidak didukung, semua data ke JavaScriptEngine akan terjadi melalui transaksi Binder. Batas ukuran transaksi umum berlaku untuk setiap panggilan yang meneruskan data atau menampilkan data.

Respons selalu ditampilkan sebagai String dan tunduk pada batas ukuran maksimum transaksi Binder jika JavaScriptSandbox.JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT tidak didukung. Nilai non-string harus dikonversi secara eksplisit ke String JavaScript jika tidak, string kosong akan ditampilkan. Jika fitur JS_FEATURE_PROMISE_RETURN didukung, kode JavaScript dapat menampilkan penyelesaian Promise ke String.

Untuk meneruskan array byte besar ke instance JavaScriptIsolate, Anda dapat menggunakan provideNamedData(...) API. Penggunaan API ini tidak terikat oleh batas transaksi Binder. Setiap array byte harus diteruskan menggunakan ID unik yang tidak dapat digunakan kembali.

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

Menjalankan Kode Wasm

Kode WebAssembly (Wasm) dapat diteruskan menggunakan provideNamedData(...) API, lalu dikompilasi dan dieksekusi dengan cara biasa, seperti yang ditunjukkan di bawah.

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

Pemisahan JavaScript

Semua instance JavaScriptIsolate tidak saling bergantung dan tidak membagikan apa pun. Cuplikan berikut menghasilkan

Hi from AAA!5

dan

Uncaught Reference Error: a is not defined

karena instance ”jsTwo” tidak memiliki visibilitas objek yang dibuat di “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);

Dukungan Kotlin

Untuk menggunakan library Jetpack ini dengan coroutine Kotlin, tambahkan dependensi ke kotlinx-coroutines-guava. Hal ini memungkinkan integrasi dengan ListenableFuture.

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

API library Jetpack kini dapat dipanggil dari cakupan coroutine, seperti yang ditunjukkan di bawah:

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

Parameter Konfigurasi

Saat meminta instance lingkungan yang terisolasi, Anda dapat menyesuaikan konfigurasinya. Untuk mengubah konfigurasi, teruskan instance IsolateStartupParameters ke JavaScriptSandbox.createIsolate(...).

Saat ini, parameter memungkinkan penentuan ukuran heap maksimum dan ukuran maksimum untuk evaluasi menampilkan nilai dan error.