הפעלת JavaScript ו-WebAssembly

הערכת JavaScript

ספריית Jetpack JavaScript מאפשרת לאפליקציה לבדוק קוד JavaScript בלי ליצור מופע של WebView.

עבור אפליקציות שמחייבות הערכה לא אינטראקטיבית של JavaScript, יש להשתמש במשתנה לספריית JavaScript יש את היתרונות הבאים:

  • צריכת משאבים נמוכה יותר, כי אין צורך להקצות מכונה של WebView.

  • אפשר לעשות זאת בשירות (משימה ב-WorkManager).

  • סביבות מבודדות מרובות עם תקורה נמוכה, וכך האפליקציה יכולה להריץ כמה קטעי קוד JavaScript בו-זמנית.

  • יכולת להעביר כמויות גדולות של נתונים באמצעות קריאה ל-API.

שימוש בסיסי

כדי להתחיל, יוצרים מופע של JavaScriptSandbox. הוא מייצג חיבור למנוע JavaScript מחוץ לתהליך.

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

מומלץ להתאים את מחזור החיים של ארגז החול למחזור החיים של הרכיב שצריך להעריך את JavaScript.

לדוגמה, רכיב שמארח את ה-Sandbox עשוי להיות Activity או Service. אפשר להשתמש ב-Service אחד כדי להכיל את הערכת ה-JavaScript לכל רכיבי האפליקציה.

כדאי לתחזק את המכונה JavaScriptSandbox כי ההקצאה שלה יקרה למדי. בכל אפליקציה יכולה להיות רק מופע אחד של JavaScriptSandbox. IllegalStateException מושלכת כשאפליקציה מנסה להקצות למופע JavaScriptSandbox השני. עם זאת, אם נדרשות כמה סביבות הפעלה, אפשר להקצות כמה מכונות JavaScriptIsolate.

אם לא משתמשים יותר בו, סוגרים את המופע של ה-Sandbox כדי לפנות משאבים. במכונה JavaScriptSandbox מוטמע ממשק AutoCloseable, שמאפשר לנסות להשתמש במשאבים בתרחישים לדוגמה פשוטים של חסימה. לחלופין, מוודאים שמחזור החיים של המכונה JavaScriptSandbox מנוהל על ידי רכיב האירוח, וסוגרים אותו בקריאה החוזרת (callback) של onStop() לפעילות או במהלך onDestroy() לשירות:

jsSandbox.close();

מכונה של JavaScriptIsolate מייצגת הקשר לביצוע קוד JavaScript. אפשר להקצות אותם במקרה הצורך, וכך לשפר את האבטחה גבולות לסקריפטים ממקור שונה או שמאפשרים JavaScript בו-זמנית מפני ש-JavaScript הוא חלק משרשור יחיד בטבע. קריאות חוזרות לאותו מופע משתפות את אותו מצב, כך שאפשר ליצור נתונים מסוימים ואז לעבד אותם מאוחר יותר באותו מופע של JavaScriptIsolate.

JavaScriptIsolate jsIsolate = jsSandbox.createIsolate();

משחררים את JavaScriptIsolate באופן מפורש על ידי קריאה ל-method‏ close() שלו. סגירת מכונה מבודדת שמריצה קוד JavaScript (אם השדה Future לא הושלם) התוצאה תהיה IsolateTerminatedException. לאחר מכן, המבודד ינוקה ברקע אם ההטמעה תומכת ב-JS_FEATURE_ISOLATE_TERMINATION, כפי שמתואר בקטע טיפול בקריסות ב-sandbox בהמשך הדף. אחרת, הניקוי נדחה עד שכל ההערכות שבהמתנה יושלם או ש-Sandbox ייסגר.

האפליקציה יכולה ליצור מופע של 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. הערך חייב להיות מסוג String של JavaScript. אחרת, ה-API של הספרייה יחזיר ערך ריק. קוד ה-JavaScript לא יכול להשתמש במילת מפתח מסוג return. אם מדובר ב-Sandbox תומכת בתכונות מסוימות ובסוגי החזרה נוספים (לדוגמה, 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);

מומלץ להיעזר במשאבי 'התנסות עם משאבים' כדי לוודא שכל החשבונות מוקצים משאבים משוחררים ולא נמצאים בשימוש עוד. סגירת התוצאות של ארגז החול בכל ההערכות הממתינות בכל המופעים של JavaScriptIsolate שנכשלו עם SandboxDeadException. כשהערכת JavaScript נתקלת בשגיאה, נוצר JavaScriptException. מידע על מחלקות המשנה שלו לקבלת חריגים ספציפיים יותר.

טיפול בקריסות Sandbox

כל הקוד ב-JavaScript מופעל בתהליך נפרד בקונטיינר של חול, מחוץ לתהליך הראשי של האפליקציה. אם קוד ה-JavaScript יגרום לקריסה של התהליך בארגז החול, למשל על ידי מיצוי מגבלת הזיכרון, התהליך הראשי של האפליקציה לא יושפע מכך.

כאשר מתרחש קריסה בארגז החול, כל הבידוד בארגז החול הזה מסתיים. במידה הרבה ביותר תסמין ברור לכך הוא שכל ההערכות יתחילו להיכשל IsolateTerminatedException בהתאם לנסיבות, יכול להיות שיופעלו חריגים ספציפיים יותר כמו SandboxDeadException או MemoryLimitExceededException.

לא תמיד יש צורך לטפל בקריסות בכל הערכה בנפרד. בנוסף, יכול להיות ש-isolate יסתיים מחוץ להערכה שביקשת באופן מפורש בגלל משימות רקע או הערכות ב-isolates אחרים. הקריסה אפשר לרכז את לוגיקת הטיפול באמצעות צירוף של קריאה חוזרת באמצעות 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 הבסיסית, הטמעה של Sandbox עשויה להיות קבוצות שונות של תכונות זמינות. לכן צריך לשלוח שאילתה לגבי כל באמצעות 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);

התמיכה של Kotlin

כדי להשתמש בספריית Jetpack הזו עם פונקציות קורוטין ב-Kotlin, מוסיפים תלות ב-kotlinx-coroutines-guava. כך אפשר לשלב ListenableFuture

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

עכשיו אפשר לקרוא לממשקי ה-API של ספריית Jetpack מהיקף של Coroutine, הדוגמה הבאה:

// 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(...).

נכון לעכשיו, הפרמטרים מאפשרים לציין את גודל האשפה המקסימלי ואת הגודל המקסימלי של הערכים והשגיאות שמוחזרים מההערכה.