تنفيذ JavaScript وWebAssembly

تقييم JavaScript

توفّر مكتبة Jetpack JavaScriptEngine طريقة لأي تطبيق لتقييم رمز JavaScript بدون إنشاء مثيل WebView.

بالنسبة إلى التطبيقات التي تتطلب تقييم JavaScript غير تفاعلي، فإن استخدام مكتبة JavaScriptEngine له المزايا التالية:

  • استهلاك أقل للموارد، لعدم الحاجة إلى تخصيص مثيل WebView.

  • يمكن تنفيذ ذلك في إحدى الخدمات (مهمة WorkManager).

  • بيئات معزولة متعددة ذات أعباء منخفضة، ما يتيح للتطبيق تشغيل مقتطفات JavaScript متعددة في الوقت نفسه.

  • القدرة على تمرير كميات كبيرة من البيانات باستخدام طلب بيانات من واجهة برمجة التطبيقات

الاستخدام الأساسي

للبدء، قم بإنشاء مثيل JavaScriptSandbox. ويمثل هذا اتصالاً بمحرك JavaScript خارج المعالجة.

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

يُنصَح بمواءمة دورة حياة وضع الحماية مع دورة حياة المكوِّن الذي يحتاج إلى تقييم JavaScript.

على سبيل المثال، قد يكون المكوِّن الذي يستضيف وضع الحماية Activity أو Service. يمكن استخدام Service واحد لتغليف تقييم JavaScript لجميع مكوّنات التطبيق.

الإبقاء على مثيل 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. يجب أن يكون هذا النوع من النوع String في JavaScript، وإلا ستعرض واجهة برمجة التطبيقات Library قيمة فارغة. يجب ألا يستخدم رمز JavaScript أي كلمة رئيسية return. وإذا كان وضع الحماية يتيح استخدام ميزات معيّنة، قد يكون من الممكن عرض أنواع إضافية من الإرجاع (على سبيل المثال، Promise الذي يؤدي إلى String).

وتتيح المكتبة أيضًا تقييم النصوص البرمجية التي تكون بتنسيق AssetFileDescriptor أو ParcelFileDescriptor. يمكنك الاطّلاع على evaluateJavaScriptAsync(AssetFileDescriptor) وevaluateJavaScriptAsync(ParcelFileDescriptor) لمزيد من التفاصيل. تكون واجهات برمجة التطبيقات هذه أكثر ملاءمة للتقييم من ملف على قرص أو في أدلة التطبيقات.

تدعم المكتبة أيضًا تسجيل وحدة التحكم الذي يمكن استخدامه لأغراض تصحيح الأخطاء. يمكن إعداد هذا الخيار باستخدام 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. ارجع إلى الفئات الفرعية للحصول على استثناءات أكثر تحديدًا.

التعامل مع أعطال وضع الحماية

يتم تنفيذ جميع رموز 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، يمكنك استخدام 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) باستخدام واجهة برمجة التطبيقات 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"
}

يمكن الآن استدعاء واجهات برمجة التطبيقات لمكتبة 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(...).

تسمح المعلمات حاليًا بتحديد الحدّ الأقصى لحجم كومة الذاكرة المؤقتة والحد الأقصى للحجم الذي يعرضه التقييم والأخطاء فيه.