Thực thi JavaScript và WebAssembly

Đánh giá JavaScript

Thư viện Jetpack JavaScriptEngine cung cấp một cách để ứng dụng đánh giá mã JavaScript mà không cần tạo một thực thể WebView.

Đối với các ứng dụng yêu cầu đánh giá JavaScript không tương tác, việc sử dụng thư viện JavaScriptEngine có các ưu điểm sau:

  • Mức tiêu thụ tài nguyên thấp hơn vì không cần phân bổ phiên bản WebView.

  • Có thể thực hiện trong một Dịch vụ (tác vụ WorkManager).

  • Nhiều môi trường tách biệt có mức hao tổn thấp, cho phép ứng dụng chạy đồng thời nhiều đoạn mã JavaScript.

  • Có thể truyền một lượng lớn dữ liệu bằng cách sử dụng lệnh gọi API.

Cách sử dụng cơ bản

Để bắt đầu, hãy tạo một thực thể của JavaScriptSandbox. Điều này thể hiện việc kết nối với công cụ JavaScript ngoài quy trình.

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

Bạn nên căn chỉnh vòng đời của hộp cát với vòng đời của thành phần cần đánh giá JavaScript.

Ví dụ: một thành phần lưu trữ hộp cát có thể là Activity hoặc Service. Bạn có thể dùng một Service duy nhất để đóng gói đánh giá JavaScript cho tất cả các thành phần của ứng dụng.

Duy trì thực thể JavaScriptSandbox vì quá trình phân bổ của nó khá tốn kém. Mỗi ứng dụng chỉ được phép có một thực thể JavaScriptSandbox. Hệ thống sẽ gửi IllegalStateException khi ứng dụng cố gắng phân bổ thực thể JavaScriptSandbox thứ hai. Tuy nhiên, nếu cần nhiều môi trường thực thi, bạn có thể phân bổ nhiều thực thể JavaScriptIsolate.

Khi không dùng nữa, hãy đóng thực thể hộp cát để giải phóng tài nguyên. Thực thể JavaScriptSandbox triển khai giao diện AutoCloseable, cho phép sử dụng tính năng thử dùng tài nguyên trong các trường hợp sử dụng chặn đơn giản. Ngoài ra, hãy đảm bảo vòng đời của thực thể JavaScriptSandbox do thành phần lưu trữ quản lý, đóng vòng đời đó trong lệnh gọi lại onStop() đối với một Hoạt động hoặc trong onDestroy() đối với một Dịch vụ:

jsSandbox.close();

Thực thể JavaScriptIsolate đại diện cho ngữ cảnh thực thi mã JavaScript. Bạn có thể phân bổ các API này khi cần thiết, cung cấp ranh giới bảo mật yếu cho các tập lệnh có nguồn gốc khác nhau hoặc cho phép thực thi JavaScript đồng thời vì JavaScript về bản chất là đơn luồng. Các lệnh gọi tiếp theo đến cùng một thực thể sẽ có cùng trạng thái. Do đó, bạn có thể tạo một số dữ liệu trước rồi xử lý dữ liệu đó sau trong cùng một thực thể của JavaScriptIsolate.

JavaScriptIsolate jsIsolate = jsSandbox.createIsolate();

Phát hành JavaScriptIsolate một cách rõ ràng bằng cách gọi phương thức close(). Việc đóng một thực thể tách biệt đang chạy mã JavaScript (có Future không hoàn chỉnh) sẽ dẫn đến IsolateTerminatedException. Phần tách biệt sẽ được dọn dẹp sau đó trong nền nếu phương thức triển khai hỗ trợ JS_FEATURE_ISOLATE_TERMINATION, như mô tả trong phần xử lý sự cố hộp cát ở phần sau của trang này. Nếu không, quá trình dọn dẹp sẽ bị trì hoãn cho đến khi tất cả quá trình đánh giá đang chờ xử lý hoàn tất hoặc hộp cát đóng.

Một ứng dụng có thể tạo và truy cập vào một thực thể JavaScriptIsolate từ bất kỳ luồng nào.

Bây giờ, ứng dụng đã sẵn sàng thực thi một số mã 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);

Cùng một đoạn mã JavaScript được định dạng độc đáo:

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

Đoạn mã được truyền dưới dạng String và kết quả được cung cấp dưới dạng String. Lưu ý rằng việc gọi evaluateJavaScriptAsync() sẽ trả về kết quả được đánh giá của biểu thức cuối cùng trong mã JavaScript. Giá trị này phải thuộc loại String của JavaScript; nếu không, API thư viện sẽ trả về một giá trị trống. Mã JavaScript không được sử dụng từ khoá return. Nếu hộp cát hỗ trợ một số tính năng, thì có thể có thêm các loại dữ liệu trả về (ví dụ: Promise phân giải thành String).

Thư viện này cũng hỗ trợ việc đánh giá các tập lệnh ở dạng AssetFileDescriptor hoặc ParcelFileDescriptor. Hãy xem evaluateJavaScriptAsync(AssetFileDescriptor)evaluateJavaScriptAsync(ParcelFileDescriptor) để biết thêm thông tin chi tiết. Các API này phù hợp hơn để đánh giá từ một tệp trên ổ đĩa hoặc trong thư mục ứng dụng.

Thư viện này cũng hỗ trợ tính năng ghi nhật ký trên bảng điều khiển có thể dùng cho mục đích gỡ lỗi. Bạn có thể thiết lập chế độ này bằng setConsoleCallback().

Vì ngữ cảnh này vẫn tồn tại nên bạn có thể tải mã lên và thực thi mã nhiều lần trong suốt thời gian hoạt động của 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);

Tất nhiên, các biến cũng tồn tại cố định, vì vậy, bạn có thể tiếp tục đoạn mã trước đó bằng:

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

Ví dụ: đoạn mã hoàn chỉnh để phân bổ tất cả các đối tượng cần thiết và thực thi mã JavaScript có thể có dạng như sau:

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

Bạn nên sử dụng hàm try-with-resources để đảm bảo tất cả tài nguyên được phân bổ được giải phóng và không còn được dùng nữa. Việc đóng hộp cát sẽ dẫn đến tất cả hoạt động đánh giá đang chờ xử lý trong mọi thực thể JavaScriptIsolate không thành công bằng SandboxDeadException. Khi quy trình đánh giá JavaScript gặp lỗi, JavaScriptException sẽ được tạo. Hãy tham khảo các lớp con của lớp này để biết các trường hợp ngoại lệ cụ thể hơn.

Xử lý sự cố hộp cát

Tất cả JavaScript được thực thi trong một quy trình hộp cát riêng biệt, tách biệt với quy trình chính của ứng dụng. Nếu mã JavaScript khiến quy trình hộp cát này gặp sự cố, chẳng hạn như do sử dụng hết giới hạn bộ nhớ, thì quy trình chính của ứng dụng sẽ không bị ảnh hưởng.

Sự cố hộp cát sẽ khiến tất cả các vùng tách biệt trong hộp cát đó bị chấm dứt. Dấu hiệu rõ ràng nhất của việc này là tất cả quy trình đánh giá đều sẽ bắt đầu không thành công khi dùng IsolateTerminatedException. Tuỳ thuộc vào trường hợp, các trường hợp ngoại lệ cụ thể hơn như SandboxDeadException hoặc MemoryLimitExceededException có thể được gửi.

Việc xử lý sự cố đối với từng lần đánh giá riêng lẻ không phải lúc nào cũng hiệu quả. Ngoài ra, một vùng phân tách có thể chấm dứt bên ngoài yêu cầu đánh giá rõ ràng do các tác vụ trong nền hoặc hoạt động đánh giá trong các vùng tách biệt khác. Bạn có thể tập trung logic xử lý sự cố bằng cách đính kèm một lệnh gọi lại thông qua 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);

Tính năng Hộp cát không bắt buộc

Tuỳ thuộc vào phiên bản WebView cơ bản, quy trình triển khai hộp cát có thể cung cấp các nhóm tính năng khác nhau. Vì vậy, bạn cần truy vấn từng tính năng bắt buộc bằng cách sử dụng JavaScriptSandbox.isFeatureSupported(...). Bạn cần kiểm tra trạng thái của tính năng trước khi gọi các phương thức dựa vào các tính năng này.

Các phương thức JavaScriptIsolate có thể không có ở mọi nơi được chú giải bằng chú thích RequiresFeature, giúp bạn dễ dàng phát hiện các lệnh gọi này trong mã hơn.

Chuyển tham số

Nếu JavaScriptSandbox.JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT được hỗ trợ, các yêu cầu đánh giá được gửi đến công cụ JavaScript sẽ không bị ràng buộc bởi các giới hạn giao dịch liên kết. Nếu tính năng này không được hỗ trợ, thì tất cả dữ liệu gửi đến JavaScriptEngine sẽ xảy ra thông qua giao dịch Binder. Giới hạn kích thước giao dịch chung áp dụng cho mọi lệnh gọi chuyển dữ liệu hoặc trả về dữ liệu.

Phản hồi luôn được trả về dưới dạng Chuỗi và phải tuân theo giới hạn kích thước tối đa của giao dịch Binder nếu JavaScriptSandbox.JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT không được hỗ trợ. Bạn phải chuyển đổi rõ ràng các giá trị không phải chuỗi thành Chuỗi JavaScript, nếu không, hệ thống sẽ trả về chuỗi trống. Nếu tính năng JS_FEATURE_PROMISE_RETURN được hỗ trợ, thì mã JavaScript có thể trả về một Promise giải quyết cho một String.

Để truyền các mảng byte lớn đến thực thể JavaScriptIsolate, bạn có thể sử dụng API provideNamedData(...). Việc sử dụng API này không bị ràng buộc bởi các giới hạn giao dịch Binder. Mỗi mảng byte phải được chuyển bằng một giá trị nhận dạng duy nhất mà không thể sử dụng lại.

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

Chạy mã Wasm

Bạn có thể chuyển mã WebAssembly (Oncem) bằng API provideNamedData(...), sau đó được biên dịch và thực thi theo cách thông thường, như minh họa dưới đây.

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

Tách JavaScript

Tất cả thực thể JavaScriptIsolate đều độc lập với nhau và không dùng chung bất cứ thứ gì. Đoạn mã sau đây dẫn đến

Hi from AAA!5

Uncaught Reference Error: a is not defined

vì thực thể "jsTwo" không có chế độ hiển thị của các đối tượng được tạo trong "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);

Hỗ trợ Kotlin

Để sử dụng thư viện Jetpack này với coroutine Kotlin, hãy thêm phần phụ thuộc vào kotlinx-coroutines-guava. Việc này cho phép tích hợp với ListenableFuture.

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

API thư viện Jetpack hiện có thể được gọi từ một phạm vi coroutine, như minh hoạ dưới đây:

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

Thông số cấu hình

Khi yêu cầu một thực thể môi trường tách biệt, bạn có thể điều chỉnh cấu hình của thực thể đó. Để điều chỉnh cấu hình, hãy chuyển thực thể IsolateStartupParameters đến JavaScriptSandbox.createIsolate(...).

Hiện tại, các tham số cho phép chỉ định kích thước vùng nhớ khối xếp tối đa và kích thước tối đa để đánh giá các giá trị trả về và lỗi.