استخدام "كوروتين في لغة Kotlin" في تطبيق Android

في هذا الدرس التطبيقي حول الترميز، ستتعرّف على كيفية استخدام الكوروتينات في Kotlin في تطبيق Android، وهي طريقة جديدة لإدارة سلاسل الخلفية يمكنها تبسيط الرمز البرمجي من خلال تقليل الحاجة إلى عمليات معاودة الاتصال. الروتينات المشتركة هي إحدى ميزات Kotlin التي تحوّل عمليات معاودة الاتصال غير المتزامنة للمهام الطويلة الأمد، مثل الوصول إلى قاعدة البيانات أو الشبكة، إلى رمز تسلسلي.

إليك مقتطف رمز برمجي لتعطيك فكرة عمّا ستفعله.

// Async callbacks
networkRequest { result ->
   // Successful network request
   databaseSave(result) { rows ->
     // Result saved
   }
}

سيتم تحويل الرمز المستند إلى معاودة الاتصال إلى رمز تسلسلي باستخدام إجراءات فرعية.

// The same code with coroutines
val result = networkRequest()
// Successful network request
databaseSave(result)
// Result saved

ستبدأ بتطبيق حالي تم إنشاؤه باستخدام مكوّنات البنية ويستخدم نمط معاودة الاتصال للمهام التي تستغرق وقتًا طويلاً.

في نهاية هذا الدرس التطبيقي حول الترميز، ستكون لديك خبرة كافية لاستخدام الروتينات الفرعية في تطبيقك لتحميل البيانات من الشبكة، وستتمكّن من دمج الروتينات الفرعية في تطبيق. ستتعرّف أيضًا على أفضل الممارسات المتعلّقة بالروتينات الفرعية، وكيفية كتابة اختبار للرمز الذي يستخدم الروتينات الفرعية.

المتطلبات الأساسية

  • الإلمام بمكوّنات Architecture Components ViewModel وLiveData وRepository وRoom
  • خبرة في استخدام بنية Kotlin، بما في ذلك دوال الإضافة وlambdas
  • فهم أساسي لاستخدام سلاسل المحادثات على Android، بما في ذلك سلسلة المحادثات الرئيسية وسلاسل المحادثات في الخلفية وعمليات معاودة الاتصال

المهام التي ستنفذها

  • كتابة رمز المكالمة باستخدام إجراءات فرعية متزامنة والحصول على النتائج
  • استخدِم دوال التعليق لجعل الرمز غير المتزامن تسلسليًا.
  • استخدِم launch وrunBlocking للتحكّم في طريقة تنفيذ الرمز.
  • تعرَّف على تقنيات لتحويل واجهات برمجة التطبيقات الحالية إلى إجراءات روتينية مشتركة باستخدام suspendCoroutine.
  • استخدام الروتينات المشتركة مع "مكوّنات البنية"
  • تعرَّف على أفضل الممارسات لاختبار الروتينات المشتركة.

المتطلبات

  • الإصدار 3.5 من "استوديو Android" (قد يعمل الترميز مع إصدارات أخرى، ولكن قد تكون بعض العناصر غير متوفرة أو تبدو مختلفة).

إذا واجهت أي مشاكل (مثل أخطاء في الرمز أو أخطاء نحوية أو صياغة غير واضحة أو غير ذلك) أثناء العمل على هذا الدرس العملي، يُرجى الإبلاغ عن المشكلة من خلال الرابط الإبلاغ عن خطأ في أسفل يمين الدرس العملي.

تنزيل الرمز

انقر على الرابط التالي لتنزيل كل الرموز البرمجية لهذا الدرس العملي:

تنزيل ملف Zip

... أو استنسِخ مستودع GitHub من سطر الأوامر باستخدام الأمر التالي:

$ git clone https://github.com/googlecodelabs/kotlin-coroutines.git

الأسئلة الشائعة

لنبدأ بالتعرّف على الشكل الذي يبدو عليه نموذج التطبيق الأوّلي. اتّبِع هذه التعليمات لفتح التطبيق النموذجي في Android Studio.

  1. إذا نزّلت ملف kotlin-coroutines zip، فكّ ضغط الملف.
  2. افتح مشروع coroutines-codelab في "استوديو Android".
  3. اختَر وحدة تطبيق start.
  4. انقر على الزر execute.pngتشغيل، ثم اختَر محاكيًا أو وصِّل جهاز Android الذي يجب أن يكون قادرًا على تشغيل Android Lollipop (الحد الأدنى لإصدار حزمة تطوير البرامج (SDK) المتوافق هو 21). يجب أن تظهر شاشة "الروتينات الفرعية في Kotlin" (Kotlin Coroutines) على النحو التالي:

يستخدم هذا التطبيق المبدئي سلاسل محادثات لزيادة العدد بعد تأخير قصير بعد الضغط على الشاشة. سيتم أيضًا جلب عنوان جديد من الشبكة وعرضه على الشاشة. جرِّب هذه الخطوات الآن، ومن المفترض أن يتغيّر العدد والرسالة بعد فترة تأخير قصيرة. في هذا الدرس التطبيقي حول الترميز، ستحوّل هذا التطبيق لاستخدام الروتينات المشتركة.

يستخدم هذا التطبيق "مكوّنات البنية" لفصل رمز واجهة المستخدم في MainActivity عن منطق التطبيق في MainViewModel. توقّف لحظة للتعرّف على بنية المشروع.

  1. تعرض MainActivity واجهة المستخدم، وتسجّل أدوات معالجة الأحداث الخاصة بالنقر، ويمكنها عرض Snackbar. تنقل هذه السمة الأحداث إلى MainViewModel وتعدّل الشاشة استنادًا إلى LiveData في MainViewModel.
  2. تعالج MainViewModel الأحداث في onMainViewClicked وستتواصل مع MainActivity باستخدام LiveData.
  3. تحدّد Executors BACKGROUND, التي يمكنها تشغيل عناصر في سلسلة محادثات في الخلفية.
  4. يجلب TitleRepository النتائج من الشبكة ويحفظها في قاعدة البيانات.

إضافة إجراءات روتينية إلى مشروع

لاستخدام الروتينات الفرعية في Kotlin، يجب تضمين مكتبة coroutines-core في ملف build.gradle (Module: app) الخاص بمشروعك. وقد تم تنفيذ ذلك في مشاريع الدروس التطبيقية حول الترميز، لذا لن تحتاج إلى تنفيذ ذلك لإكمال الدرس التطبيقي.

تتوفّر إجراءات Coroutines على Android كإحدى المكتبات الأساسية، بالإضافة إلى إضافات خاصة بنظام التشغيل Android:

  • kotlinx-corountines-core : الواجهة الرئيسية لاستخدام الروتينات المشتركة في Kotlin
  • kotlinx-coroutines-android : إتاحة استخدام سلسلة التعليمات الرئيسية في Android في الكوروتينات

يتضمّن تطبيق البدء التبعيات في build.gradle.عند إنشاء مشروع تطبيق جديد، عليك فتح build.gradle (Module: app) وإضافة تبعيات الروتينات المشتركة إلى المشروع.

dependencies {
  ...
  implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:x.x.x"
  implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:x.x.x"
}

على Android، من الضروري تجنُّب حظر سلسلة التعليمات الرئيسية. سلسلة التعليمات الرئيسية هي سلسلة تعليمات واحدة تعالج جميع التحديثات التي تطرأ على واجهة المستخدم. وهي أيضًا سلسلة التعليمات التي تستدعي جميع معالجات النقر وعمليات معاودة الاتصال الأخرى في واجهة المستخدم. لذلك، يجب أن يعمل التطبيق بسلاسة لضمان تقديم تجربة رائعة للمستخدم.

لكي يظهر تطبيقك للمستخدم بدون أي توقّفات مؤقتة مرئية، يجب أن تعدّل سلسلة التعليمات الرئيسية الشاشة كل 16 ملي ثانية أو أكثر، أي حوالي 60 لقطة في الثانية. تستغرق العديد من المهام الشائعة وقتًا أطول من ذلك، مثل تحليل مجموعات بيانات JSON الكبيرة أو كتابة البيانات في قاعدة بيانات أو جلب البيانات من الشبكة. لذلك، يمكن أن يؤدي استدعاء رمز برمجي مثل هذا من سلسلة التعليمات الرئيسية إلى إيقاف التطبيق مؤقتًا أو تقطّعه أو حتى تجميده. وإذا حظرت سلسلة المحادثات الرئيسية لفترة طويلة جدًا، قد يتعطّل التطبيق ويظهر مربّع الحوار التطبيق لا يستجيب.

شاهِد الفيديو أدناه للحصول على مقدّمة حول كيفية حلّ الروتينات المشتركة لهذه المشكلة على Android من خلال توفير ميزة "أمان التنفيذ على سلسلة التعليمات الرئيسية".

نمط معاودة الاتصال

تتمثّل إحدى الطرق لتنفيذ المهام التي تستغرق وقتًا طويلاً بدون حظر سلسلة التعليمات الرئيسية في استخدام عمليات رد الاتصال. باستخدام عمليات معاودة الاتصال، يمكنك بدء مهام طويلة الأمد في سلسلة محادثات في الخلفية. عند اكتمال المهمة، يتم استدعاء دالة معاودة الاتصال لإعلامك بالنتيجة في سلسلة التعليمات البرمجية الرئيسية.

إليك مثال على نمط رد الاتصال.

// Slow request with callbacks
@UiThread
fun makeNetworkRequest() {
    // The slow network request runs on another thread
    slowFetch { result ->
        // When the result is ready, this callback will get the result
        show(result)
    }
    // makeNetworkRequest() exits after calling slowFetch without waiting for the result
}

بما أنّ هذا الرمز البرمجي يتضمّن التعليق التوضيحي @UiThread، يجب أن يتم تنفيذه بسرعة كافية على سلسلة التعليمات الرئيسية. وهذا يعني أنّه يجب أن يتم عرضها بسرعة كبيرة حتى لا يتأخر تحديث الشاشة التالية. ومع ذلك، بما أنّ إكمال slowFetch سيستغرق ثوانٍ أو حتى دقائق، لا يمكن لسلسلة التعليمات الرئيسية انتظار النتيجة. تسمح دالة رد الاتصال show(result) بتشغيل slowFetch في سلسلة محادثات في الخلفية وعرض النتيجة عندما تصبح جاهزة.

استخدام الروتينات الفرعية لإزالة عمليات معاودة الاتصال

تُعدّ عمليات معاودة الاتصال نمطًا رائعًا، ولكن لها بعض العيوب. يمكن أن يصبح الرمز البرمجي الذي يستخدم عمليات معاودة الاتصال بشكل كبير صعب القراءة ويصعب فهمه. بالإضافة إلى ذلك، لا تسمح عمليات الرجوع باستخدام بعض ميزات اللغة، مثل الاستثناءات.

تتيح لك إجراءات Kotlin الفرعية تحويل الرمز البرمجي المستند إلى معاودة الاتصال إلى رمز برمجي تسلسلي. عادةً ما يكون من الأسهل قراءة الرمز المكتوب بالتسلسل، ويمكن حتى استخدام ميزات اللغة مثل الاستثناءات.

في النهاية، تؤدي كلتا الطريقتين الوظيفة نفسها، وهي الانتظار إلى أن تتوفّر نتيجة من مهمة تستغرق وقتًا طويلاً ومواصلة التنفيذ. ومع ذلك، تبدو مختلفة تمامًا في الرمز.

الكلمة الرئيسية suspend هي طريقة Kotlin لتحديد دالة أو نوع دالة متاحَين للروتينات الفرعية. عندما تستدعي روتين فرعي دالة تم وضع علامة suspend عليها، بدلاً من الحظر إلى أن تعرض هذه الدالة نتيجة مثل استدعاء دالة عادية، فإنّها تعلّق التنفيذ إلى أن تصبح النتيجة جاهزة، ثم تستأنف من حيث توقّفت مع النتيجة. أثناء تعليقها في انتظار نتيجة، تزيل الحظر عن سلسلة التعليمات البرمجية التي يتم تشغيلها عليها حتى تتمكّن الدوال أو الروتينات الفرعية الأخرى من التشغيل.

على سبيل المثال، في الرمز البرمجي أدناه، كل من makeNetworkRequest() وslowFetch() هما دالتان suspend.

// Slow request with coroutines
@UiThread
suspend fun makeNetworkRequest() {
    // slowFetch is another suspend function so instead of 
    // blocking the main thread  makeNetworkRequest will `suspend` until the result is 
    // ready
    val result = slowFetch()
    // continue to execute after the result is ready
    show(result)
}

// slowFetch is main-safe using coroutines
suspend fun slowFetch(): SlowResult { ... }

كما هو الحال مع إصدار دالة رد الاتصال، يجب أن تعرض الدالة makeNetworkRequest نتيجة من سلسلة التعليمات الرئيسية على الفور لأنّها تحمل العلامة @UiThread. وهذا يعني أنّه عادةً لا يمكنه استدعاء طرق الحظر مثل slowFetch. هنا يأتي دور الكلمة الرئيسية suspend.

مقارنةً بالرمز المستند إلى دوال رد الاتصال، يحقّق رمز الروتين الفرعي النتيجة نفسها المتمثّلة في إلغاء حظر سلسلة التعليمات الحالية باستخدام رمز أقل. وبفضل أسلوبه التسلسلي، يسهل ربط عدة مهام طويلة الأمد بدون إنشاء عدة عمليات ردّ. على سبيل المثال، يمكن كتابة الرمز الذي يسترد نتيجة من نقطتَي نهاية على الشبكة ويحفظها في قاعدة البيانات كدالة في إجراءات مشتركة بدون عمليات ردّ اتصال. على النحو التالي:

// Request data from network and save it to database with coroutines

// Because of the @WorkerThread, this function cannot be called on the
// main thread without causing an error.
@WorkerThread
suspend fun makeNetworkRequest() {
    // slowFetch and anotherFetch are suspend functions
    val slow = slowFetch()
    val another = anotherFetch()
    // save is a regular function and will block this thread
    database.save(slow, another)
}

// slowFetch is main-safe using coroutines
suspend fun slowFetch(): SlowResult { ... }
// anotherFetch is main-safe using coroutines
suspend fun anotherFetch(): AnotherResult { ... }

ستتعرّف على الروتينات المشتركة في التطبيق النموذجي في القسم التالي.

في هذا التمرين، ستكتب روتينًا فرعيًا متزامنًا لعرض رسالة بعد تأخير. للبدء، تأكَّد من فتح الوحدة start في Android Studio.

التعرّف على CoroutineScope

في Kotlin، يتم تشغيل جميع الروتينات الفرعية داخل CoroutineScope. يتحكّم النطاق في مدة بقاء الروتينات الفرعية من خلال مهمتها. عند إلغاء مهمة نطاق، يتم إلغاء جميع الروتينات الفرعية التي تم بدءها في هذا النطاق. على نظام التشغيل Android، يمكنك استخدام نطاق لإلغاء جميع الروتينات الفرعية قيد التشغيل عندما ينتقل المستخدم مثلاً من Activity أو Fragment. تتيح لك النطاقات أيضًا تحديد أداة إرسال تلقائية. يتحكّم برنامج الإرسال في سلسلة التعليمات البرمجية التي تنفّذ روتينًا فرعيًا.

بالنسبة إلى الروتينات المشتركة التي تبدأها واجهة المستخدم، من الصحيح عادةً بدء تشغيلها على Dispatchers.Main وهو سلسلة التعليمات البرمجية الرئيسية على Android. لن تحظر روتين فرعي تم بدء تشغيله على Dispatchers.Main سلسلة التعليمات الرئيسية أثناء تعليقه. بما أنّ روتينًا فرعيًا من النوع ViewModel يحدّث واجهة المستخدم دائمًا تقريبًا في سلسلة التعليمات البرمجية الرئيسية، فإنّ بدء الروتينات الفرعية في سلسلة التعليمات البرمجية الرئيسية يوفّر عليك عمليات تبديل إضافية لسلسلة التعليمات البرمجية. يمكن أن تبدّل روتينًا فرعيًا تم بدء تشغيله في سلسلة التعليمات البرمجية الرئيسية أدوات الإرسال في أي وقت بعد بدء تشغيله. على سبيل المثال، يمكن استخدام أداة إرسال أخرى لتحليل نتيجة JSON كبيرة خارج سلسلة التعليمات الرئيسية.

استخدام viewModelScope

تضيف مكتبة AndroidX lifecycle-viewmodel-ktx CoroutineScope إلى ViewModels تم إعدادها لبدء إجراءات روتينية متعلقة بواجهة المستخدم. لاستخدام هذه المكتبة، يجب تضمينها في ملف build.gradle (Module: start) الخاص بمشروعك. تم تنفيذ هذه الخطوة في مشاريع الدرس العملي.

dependencies {
  ...
  implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:x.x.x"
}

تضيف المكتبة viewModelScope كدالة إضافية للفئة ViewModel. يرتبط هذا النطاق بـ Dispatchers.Main وسيتم إلغاؤه تلقائيًا عند محو ViewModel.

التبديل من سلاسل المحادثات إلى الروتينات الفرعية

في MainViewModel.kt، ابحث عن مهمة TODO التالية مع هذا الرمز:

MainViewModel.kt

/**
* Wait one second then update the tap count.
*/
private fun updateTaps() {
   // TODO: Convert updateTaps to use coroutines
   tapCount++
   BACKGROUND.submit {
       Thread.sleep(1_000)
       _taps.postValue("$tapCount taps")
   }
}

يستخدم هذا الرمز BACKGROUND ExecutorService (المحدّد في util/Executor.kt) للتشغيل في سلسلة محادثات في الخلفية. بما أنّ sleep تحظر سلسلة التعليمات الحالية، سيؤدي استدعاؤها في سلسلة التعليمات الرئيسية إلى تجميد واجهة المستخدم. بعد ثانية واحدة من نقر المستخدم على العرض الرئيسي، يتم طلب شريط معلومات.

يمكنك ملاحظة ذلك من خلال إزالة BACKGROUND من الرمز البرمجي وتشغيله مرة أخرى. لن يظهر مؤشر التحميل الدوّار، وستنتقل جميع العناصر إلى الحالة النهائية بعد ثانية واحدة.

MainViewModel.kt

/**
* Wait one second then update the tap count.
*/
private fun updateTaps() {
   // TODO: Convert updateTaps to use coroutines
   tapCount++
   Thread.sleep(1_000)
   _taps.postValue("$tapCount taps")
}

استبدِل updateTaps بهذا الرمز المستند إلى الروتين الفرعي الذي ينفّذ الإجراء نفسه. عليك استيراد launch وdelay.

MainViewModel.kt

/**
* Wait one second then display a snackbar.
*/
fun updateTaps() {
   // launch a coroutine in viewModelScope
   viewModelScope.launch {
       tapCount++
       // suspend this coroutine for one second
       delay(1_000)
       // resume in the main dispatcher
       // _snackbar.value can be called directly from main thread
       _taps.postValue("$tapCount taps")
   }
}

يفعل هذا الرمز البرمجي الشيء نفسه، حيث ينتظر ثانية واحدة قبل عرض شريط المعلومات. ومع ذلك، هناك بعض الاختلافات المهمة:

  1. سيؤدي viewModelScope.launch إلى بدء روتين فرعي في viewModelScope. وهذا يعني أنّه عند إلغاء المهمة التي تم تمريرها إلى viewModelScope، سيتم إلغاء جميع الروتينات الفرعية في هذه المهمة أو النطاق. إذا ترك المستخدم النشاط قبل عرض delay، سيتم إلغاء روتين coroutine هذا تلقائيًا عند استدعاء onCleared عند إيقاف ViewModel.
  2. بما أنّ viewModelScope تتضمّن أداة إرسال تلقائية بقيمة Dispatchers.Main، سيتم تشغيل روتين coroutine هذا في سلسلة التعليمات الرئيسية. سنتعرّف لاحقًا على كيفية استخدام سلاسل محادثات مختلفة.
  3. الدالة delay هي دالة suspend. يظهر ذلك في "استوديو Android" من خلال الرمز في الهامش الأيسر. على الرغم من أنّ روتين الإجراءات الفرعية هذا يتم تنفيذه في سلسلة التعليمات الرئيسية، لن تحظر الدالة delay سلسلة التعليمات لمدة ثانية واحدة. بدلاً من ذلك، سيحدّد المرسل موعدًا لاستئناف الروتين الفرعي بعد ثانية واحدة عند العبارة التالية.

يمكنك الآن تشغيلها. عند النقر على طريقة العرض الرئيسية، من المفترض أن يظهر شريط معلومات بعد ثانية واحدة.

في القسم التالي، سنتناول كيفية اختبار هذه الدالة.

في هذا التمرين، ستكتب اختبارًا للرمز الذي كتبته للتو. يوضّح لك هذا التمرين كيفية اختبار الروتينات المشتركة التي تعمل على Dispatchers.Main باستخدام مكتبة kotlinx-coroutines-test. في وقت لاحق من هذا الدرس العملي، ستنفّذ اختبارًا يتفاعل مع الروتينات الفرعية مباشرةً.

مراجعة الرمز الحالي

افتح MainViewModelTest.kt في المجلد androidTest.

MainViewModelTest.kt

class MainViewModelTest {
   @get:Rule
   val coroutineScope =  MainCoroutineScopeRule()
   @get:Rule
   val instantTaskExecutorRule = InstantTaskExecutorRule()

   lateinit var subject: MainViewModel

   @Before
   fun setup() {
       subject = MainViewModel(
           TitleRepository(
                   MainNetworkFake("OK"),
                   TitleDaoFake("initial")
           ))
   }
}

القاعدة هي طريقة لتشغيل الرمز قبل وبعد تنفيذ الاختبار في JUnit. يتم استخدام قاعدتَين للسماح لنا باختبار MainViewModel في اختبار خارج الجهاز:

  1. InstantTaskExecutorRule هي قاعدة JUnit تضبط LiveData لتنفيذ كل مهمة بشكل متزامن
  2. MainCoroutineScopeRule هي قاعدة مخصّصة في قاعدة الرموز هذه، وتضبط Dispatchers.Main لاستخدام TestCoroutineDispatcher من kotlinx-coroutines-test. يسمح ذلك للاختبارات بتقديم ساعة افتراضية للاختبار، كما يسمح للرمز البرمجي باستخدام Dispatchers.Main في اختبارات الوحدات.

في طريقة setup، يتم إنشاء مثيل جديد من MainViewModel باستخدام عناصر اختبارية زائفة، وهي عمليات تنفيذ زائفة للشبكة وقاعدة البيانات يتم توفيرها في رمز البداية للمساعدة في كتابة الاختبارات بدون استخدام الشبكة أو قاعدة البيانات الحقيقية.

في هذا الاختبار، لا نحتاج إلى العناصر الوهمية إلا لتلبية متطلبات MainViewModel. في وقت لاحق من هذا الدرس التطبيقي، ستعدّل العناصر الوهمية لتتوافق مع الروتينات المشتركة.

كتابة اختبار يتحكّم في الروتينات الفرعية

أضِف اختبارًا جديدًا يضمن تعديل عدد النقرات بعد ثانية واحدة من النقر على العرض الرئيسي:

MainViewModelTest.kt

@Test
fun whenMainClicked_updatesTaps() {
   subject.onMainViewClicked()
   Truth.assertThat(subject.taps.getValueForTest()).isEqualTo("0 taps")
   coroutineScope.advanceTimeBy(1000)
   Truth.assertThat(subject.taps.getValueForTest()).isEqualTo("1 taps")
}

من خلال استدعاء onMainViewClicked، سيتم تشغيل الروتين الفرعي الذي أنشأناه للتو. يتحقّق هذا الاختبار من أنّ نص النقرات يظل "0 نقرات" بعد استدعاء onMainViewClicked مباشرةً، ثم يتم تعديله بعد ثانية واحدة ليصبح "1 نقرات".

يستخدم هذا الاختبار الوقت الافتراضي للتحكّم في تنفيذ الروتين الفرعي الذي تم إطلاقه بواسطة onMainViewClicked. تتيح لك MainCoroutineScopeRule إيقاف تنفيذ الروتينات الفرعية التي يتم تشغيلها على Dispatchers.Main مؤقتًا أو استئنافه أو التحكّم فيه. في هذا المثال، نستدعي الدالة advanceTimeBy(1_000) التي ستؤدي إلى تنفيذ الموزّع الرئيسي على الفور للروتينات الفرعية المُجدوَلة لاستئناف العمل بعد ثانية واحدة.

هذا الاختبار محدّد بالكامل، ما يعني أنّه سيتم تنفيذه دائمًا بالطريقة نفسها. وبما أنّه يتحكّم بشكل كامل في تنفيذ الروتينات الفرعية التي يتم تشغيلها على Dispatchers.Main، ليس عليه الانتظار لثانية واحدة حتى يتم ضبط القيمة.

إجراء الاختبار الحالي

  1. انقر بزر الماوس الأيمن على اسم الفئة MainViewModelTest في المحرّر لفتح قائمة السياق.
  2. في قائمة السياق، اختَر execute.pngتشغيل MainViewModelTest.
  3. بالنسبة إلى عمليات التشغيل المستقبلية، يمكنك اختيار إعداد الاختبار هذا من الإعدادات بجانب الزر execute.png في شريط الأدوات. سيتم تلقائيًا تسمية الإعداد MainViewModelTest.

من المفترض أن تظهر لك عبارة "تم اجتياز الاختبار". ومن المفترض أن يستغرق تشغيله أقل من ثانية واحدة.

في التمرين التالي، ستتعرّف على كيفية الانتقال من واجهات برمجة تطبيقات معاودة الاتصال الحالية إلى استخدام الروتينات المشتركة.

في هذه الخطوة، ستبدأ في تحويل مستودع إلى استخدام إجراءات فرعية. ولإجراء ذلك، سنضيف إجراءات فرعية إلى ViewModel وRepository وRoom وRetrofit.

من المستحسن فهم مسؤولية كل جزء من بنية التطبيق قبل التبديل إلى استخدام الروتينات المشتركة.

  1. تنفِّذ MainDatabase قاعدة بيانات باستخدام Room تحفظ Title وتحمّله.
  2. تنفّذ MainNetwork واجهة برمجة تطبيقات شبكة تجلب عنوانًا جديدًا. يستخدم هذا التطبيق Retrofit لجلب العناوين. تم ضبط Retrofit لعرض أخطاء أو بيانات وهمية بشكل عشوائي، ولكنها تتصرف بخلاف ذلك كما لو كانت تُجري طلبات شبكة حقيقية.
  3. تنفّذ TitleRepository واجهة برمجة تطبيقات واحدة لجلب العنوان أو إعادة تحميله من خلال الجمع بين البيانات من الشبكة وقاعدة البيانات.
  4. يمثّل MainViewModel حالة الشاشة ويتعامل مع الأحداث. سيطلب من المستودع إعادة تحميل العنوان عندما ينقر المستخدم على الشاشة.

بما أنّ طلب الشبكة يتم تشغيله من خلال أحداث واجهة المستخدم، ونريد بدء روتين فرعي استنادًا إليها، فإنّ المكان الطبيعي لبدء استخدام الروتينات الفرعية هو في ViewModel.

إصدار معاودة الاتصال

افتح MainViewModel.kt للاطّلاع على بيان refreshTitle.

MainViewModel.kt

/**
* Update title text via this LiveData
*/
val title = repository.title


// ... other code ...


/**
* Refresh the title, showing a loading spinner while it refreshes and errors via snackbar.
*/
fun refreshTitle() {
   // TODO: Convert refreshTitle to use coroutines
   _spinner.value = true
   repository.refreshTitleWithCallbacks(object: TitleRefreshCallback {
       override fun onCompleted() {
           _spinner.postValue(false)
       }

       override fun onError(cause: Throwable) {
           _snackBar.postValue(cause.message)
           _spinner.postValue(false)
       }
   })
}

يتم استدعاء هذه الدالة في كل مرة ينقر فيها المستخدم على الشاشة، وسيؤدي ذلك إلى إعادة تحميل العنوان وكتابة العنوان الجديد في قاعدة البيانات.

يستخدم هذا التنفيذ دالة ردّ الاتصال لتنفيذ بعض الإجراءات:

  • قبل بدء طلب البحث، يعرض مؤشر سريان عمل التحميل مع _spinner.value = true
  • عندما يحصل على نتيجة، يمحو مؤشر التحميل الدوّار باستخدام _spinner.value = false
  • إذا حدث خطأ، سيطلب من شريط المعلومات المؤقت عرض الخطأ وإزالة مؤشر التحميل.

يُرجى العِلم أنّه لا يتم تمرير title إلى onCompleted. بما أنّنا نكتب جميع العناوين في قاعدة بيانات Room، يتم تعديل واجهة المستخدم إلى العنوان الحالي من خلال مراقبة LiveData الذي يتم تعديله بواسطة Room.

في التحديث على الروتينات الفرعية، سنحافظ على السلوك نفسه تمامًا. من الأنماط الجيدة استخدام مصدر بيانات قابل للمراقبة، مثل قاعدة بيانات Room، لإبقاء واجهة المستخدم محدَّثة تلقائيًا.

إصدار الروتينات الفرعية

لنعد كتابة refreshTitle باستخدام الروتينات الفرعية.

بما أنّنا سنحتاج إليها على الفور، لننشئ دالة تعليق فارغة في المستودع (TitleRespository.kt). عرِّف دالة جديدة تستخدم عامل التشغيل suspend لإخبار Kotlin بأنّها تعمل مع الروتينات الفرعية.

TitleRepository.kt

suspend fun refreshTitle() {
    // TODO: Refresh from network and write to database
    delay(500)
}

عند الانتهاء من هذا الدرس البرمجي، ستعدّل هذا الرمز البرمجي لاستخدام Retrofit وRoom من أجل جلب عنوان جديد وكتابته في قاعدة البيانات باستخدام الروتينات الفرعية. في الوقت الحالي، سيستغرق الأمر 500 مللي ثانية فقط للتظاهر بتنفيذ العمل ثم المتابعة.

في MainViewModel، استبدِل إصدار معاودة الاتصال من refreshTitle بإصدار يطلق روتينًا فرعيًا جديدًا:

MainViewModel.kt

/**
* Refresh the title, showing a loading spinner while it refreshes and errors via snackbar.
*/
fun refreshTitle() {
   viewModelScope.launch {
       try {
           _spinner.value = true
           repository.refreshTitle()
       } catch (error: TitleRefreshError) {
           _snackBar.value = error.message
       } finally {
           _spinner.value = false
       }
   }
}

لنتتبّع خطوات هذه الدالة:

viewModelScope.launch {

كما هو الحال مع الروتين الفرعي لتعديل عدد النقرات، ابدأ بتشغيل روتين فرعي جديد في viewModelScope. سيتم استخدام Dispatchers.Main، وهذا أمر مقبول. على الرغم من أنّ refreshTitle سيُجري طلب شبكة واستعلام قاعدة بيانات، يمكنه استخدام الروتينات الفرعية لعرض واجهة آمنة للاستخدام في سلسلة التعليمات الرئيسية. وهذا يعني أنّه سيكون من الآمن استدعاؤه من سلسلة التعليمات الرئيسية.

بما أنّنا نستخدم viewModelScope، سيتم تلقائيًا إلغاء العمل الذي بدأته هذه الروتين الفرعي عندما يغادر المستخدم هذه الشاشة. وهذا يعني أنّه لن يتم إجراء طلبات إضافية على الشبكة أو طلبات بحث في قاعدة البيانات.

تستدعي سطور الرمز القليلة التالية الدالة refreshTitle في repository.

try {
    _spinner.value = true
    repository.refreshTitle()
}

قبل أن تنفّذ هذه الدالة البرمجية أي إجراء، تبدأ في عرض مؤشر التحميل الدوّار، ثم تستدعي refreshTitle تمامًا مثل دالة عادية. ومع ذلك، بما أنّ refreshTitle هي دالة تعليق، يتم تنفيذها بشكل مختلف عن الدالة العادية.

ليس علينا تمرير دالة رد الاتصال. سيتم تعليق الروتين الفرعي إلى أن يتم استئنافه بواسطة refreshTitle. على الرغم من أنّها تبدو مثل استدعاء دالة حظر عادية، إلا أنّها ستنتظر تلقائيًا إلى أن يكتمل طلب البحث في الشبكة وقاعدة البيانات قبل استئناف التنفيذ بدون حظر سلسلة التعليمات الرئيسية.

} catch (error: TitleRefreshError) {
    _snackBar.value = error.message
} finally {
    _spinner.value = false
}

تعمل الاستثناءات في الدوال المعلقة تمامًا مثل الأخطاء في الدوال العادية. إذا حدث خطأ في دالة تعليق، سيتم إرسال الخطأ إلى المتصل. وبالتالي، على الرغم من أنّ تنفيذها يختلف تمامًا، يمكنك استخدام كتل try/catch العادية للتعامل معها. ويكون ذلك مفيدًا لأنّه يتيح لك الاعتماد على ميزة دعم اللغة المضمّنة للتعامل مع الأخطاء بدلاً من إنشاء ميزة مخصّصة للتعامل مع الأخطاء لكل دالة ردّ.

وإذا أطلقت استثناءً من روتين فرعي، سيلغي هذا الروتين الفرعي الروتين الرئيسي تلقائيًا. وهذا يعني أنّه من السهل إلغاء عدة مهام مرتبطة معًا.

بعد ذلك، في كتلة finally، يمكننا التأكّد من إيقاف مؤشر التحميل دائمًا بعد تنفيذ الاستعلام.

أعِد تشغيل التطبيق من خلال اختيار إعدادات البدء ثم الضغط علىexecute.png، من المفترض أن يظهر لك مؤشر تحميل عند النقر في أي مكان. سيظل العنوان كما هو لأنّنا لم نربط شبكتنا أو قاعدة بياناتنا بعد.

في التمرين التالي، ستعدّل المستودع لتنفيذ العمل فعليًا.

في هذا التمرين، ستتعرّف على كيفية تبديل سلسلة التعليمات التي يتم تشغيل روتين فرعي عليها من أجل تنفيذ نسخة عاملة من TitleRepository.

مراجعة رمز معاودة الاتصال الحالي في refreshTitle

افتح TitleRepository.kt وراجِع عملية التنفيذ الحالية المستندة إلى معاودة الاتصال.

TitleRepository.kt

// TitleRepository.kt

fun refreshTitleWithCallbacks(titleRefreshCallback: TitleRefreshCallback) {
   // This request will be run on a background thread by retrofit
   BACKGROUND.submit {
       try {
           // Make network request using a blocking call
           val result = network.fetchNextTitle().execute()
           if (result.isSuccessful) {
               // Save it to database
               titleDao.insertTitle(Title(result.body()!!))
               // Inform the caller the refresh is completed
               titleRefreshCallback.onCompleted()
           } else {
               // If it's not successful, inform the callback of the error
               titleRefreshCallback.onError(
                       TitleRefreshError("Unable to refresh title", null))
           }
       } catch (cause: Throwable) {
           // If anything throws an exception, inform the caller
           titleRefreshCallback.onError(
                   TitleRefreshError("Unable to refresh title", cause))
       }
   }
}

في TitleRepository.kt، يتم تنفيذ الطريقة refreshTitleWithCallbacks باستخدام دالة رد الاتصال لإبلاغ المتصل بحالة التحميل والخطأ.

تنفّذ هذه الدالة عدة إجراءات من أجل تنفيذ عملية إعادة التحميل.

  1. التبديل إلى سلسلة محادثات أخرى باستخدام BACKGROUND ExecutorService
  2. نفِّذ طلب الشبكة fetchNextTitle باستخدام طريقة الحظر execute(). سيؤدي ذلك إلى تنفيذ طلب الشبكة في سلسلة المحادثات الحالية، وفي هذه الحالة إحدى سلاسل المحادثات في BACKGROUND.
  3. في حال نجاح النتيجة، احفظها في قاعدة البيانات باستخدام insertTitle واستدعِ الدالة onCompleted().
  4. إذا لم تنجح النتيجة أو حدث استثناء، استدعِ الدالة onError لإبلاغ المتصل بتعذُّر إعادة التحميل.

هذا التنفيذ المستند إلى معاودة الاتصال آمن للسلسلة الرئيسية لأنّه لن يحظر سلسلة التعليمات الرئيسية. ومع ذلك، يجب أن تستخدم دالة رد الاتصال لإبلاغ المتصل عند اكتمال العمل. ويتم أيضًا استدعاء عمليات الرجوع في سلسلة المحادثات BACKGROUND التي تم التبديل إليها.

حظر المكالمات من الروتينات الفرعية

بدون إضافة الروتينات الفرعية إلى الشبكة أو قاعدة البيانات، يمكننا جعل هذا الرمز آمنًا على سلسلة التعليمات الرئيسية باستخدام الروتينات الفرعية. سيسمح لنا ذلك بالتخلص من دالة الرجوع ويتيح لنا إعادة النتيجة إلى سلسلة التعليمات البرمجية التي استدعتها في البداية.

يمكنك استخدام هذا النمط في أي وقت تحتاج فيه إلى تنفيذ عمليات حظر أو عمليات مكثّفة لوحدة المعالجة المركزية من داخل روتين فرعي، مثل ترتيب قائمة كبيرة وتصفيتها أو القراءة من القرص.

للتبديل بين أيّ من أدوات الإرسال، تستخدم الروتينات الفرعية withContext. يتم التبديل إلى أداة الإرسال الأخرى فقط بالنسبة إلى lambda عند إجراء مكالمة withContext، ثم يتم الرجوع إلى أداة الإرسال التي استدعتها مع نتيجة lambda.

توفّر كوروتينات Kotlin تلقائيًا ثلاث أدوات إرسال: Main وIO وDefault. تم تحسين أداة توزيع الإدخال/الإخراج للعمليات التي تتضمّن إدخال/إخراج، مثل القراءة من الشبكة أو القرص، بينما تم تحسين أداة التوزيع التلقائية للمهام التي تتطلّب استخدامًا مكثّفًا لوحدة المعالجة المركزية.

TitleRepository.kt

suspend fun refreshTitle() {
   // interact with *blocking* network and IO calls from a coroutine
   withContext(Dispatchers.IO) {
       val result = try {
           // Make network request using a blocking call
           network.fetchNextTitle().execute()
       } catch (cause: Throwable) {
           // If the network throws an exception, inform the caller
           throw TitleRefreshError("Unable to refresh title", cause)
       }
      
       if (result.isSuccessful) {
           // Save it to database
           titleDao.insertTitle(Title(result.body()!!))
       } else {
           // If it's not successful, inform the callback of the error
           throw TitleRefreshError("Unable to refresh title", null)
       }
   }
}

يستخدم هذا التنفيذ حظر المكالمات للشبكة وقاعدة البيانات، ولكنّه يظل أبسط قليلاً من إصدار دالة رد الاتصال.

لا يزال هذا الرمز يستخدم ميزة حظر المكالمات. سيؤدي استدعاء كلّ من execute() وinsertTitle(...) إلى حظر سلسلة المحادثات التي يتم تشغيل هذا الروتين الفرعي فيها. ومع ذلك، من خلال التبديل إلى Dispatchers.IO باستخدام withContext، نحظر أحد سلاسل المحادثات في أداة توزيع الإدخال/الإخراج. سيتم تعليق الروتين الفرعي الذي استدعى هذا الإجراء، والذي قد يتم تنفيذه على Dispatchers.Main، إلى أن يكتمل تعبير lambda withContext.

مقارنةً بإصدار معاودة الاتصال، هناك اختلافان مهمّان:

  1. تعرض الدالة withContext النتيجة إلى الدالة Dispatcher التي استدعتها، وهي Dispatchers.Main في هذه الحالة. استدعت نسخة معاودة الاتصال عمليات معاودة الاتصال في سلسلة محادثات في خدمة التنفيذ BACKGROUND.
  2. لا يحتاج المتصل إلى تمرير دالة رد اتصال إلى هذه الدالة. ويمكنهم الاعتماد على الإيقاف المؤقت والاستئناف للحصول على النتيجة أو الخطأ.

تشغيل التطبيق مرة أخرى

إذا شغّلت التطبيق مرة أخرى، ستلاحظ أنّ عملية التنفيذ الجديدة المستندة إلى الروتينات المشتركة تحمّل النتائج من الشبكة.

في الخطوة التالية، ستدمج الروتينات الفرعية في Room وRetrofit.

لمواصلة دمج الروتينات المشتركة، سنستخدم إمكانية دعم دوال التعليق في الإصدار الثابت من Room وRetrofit، ثم نبسّط الرمز الذي كتبناه بشكل كبير باستخدام دوال التعليق.

الروتينات المشتركة في Room

افتح أولاً MainDatabase.kt واجعل insertTitle دالة تعليق:

MainDatabase.kt

// add the suspend modifier to the existing insertTitle

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTitle(title: Title)

عند إجراء ذلك، سيجعل Room طلب البحث آمنًا على سلسلة التعليمات الرئيسية وينفّذه تلقائيًا على سلسلة تعليمات في الخلفية. ومع ذلك، يعني ذلك أيضًا أنّه لا يمكنك طلب هذا الاستعلام إلا من داخل روتين فرعي.

وهذا كل ما عليك فعله لاستخدام الروتينات الفرعية في Room. رائع جدًا.

الروتينات الفرعية في Retrofit

بعد ذلك، لنرَ كيفية دمج إجراءات روتينية مع Retrofit. افتح MainNetwork.kt وغيِّر fetchNextTitle إلى دالة تعليق.

MainNetwork.kt

// add suspend modifier to the existing fetchNextTitle
// change return type from Call<String> to String

interface MainNetwork {
   @GET("next_title.json")
   suspend fun fetchNextTitle(): String
}

لاستخدام دوال التعليق مع Retrofit، عليك إجراء ما يلي:

  1. إضافة معدِّل تعليق إلى الدالة
  2. أزِل غلاف Call من نوع القيمة المعروضة. في هذا المثال، نعرض String، ولكن يمكنك أيضًا عرض نوع معقّد يستند إلى JSON. إذا كنت لا تزال تريد توفير إذن الوصول إلى Result الكامل في Retrofit، يمكنك عرض Result<String> بدلاً من String من دالة التعليق.

ستعمل Retrofit تلقائيًا على جعل دوال التعليق آمنة على سلسلة التعليمات الرئيسية، ما يتيح لك استدعاءها مباشرةً من Dispatchers.Main.

استخدام Room وRetrofit

بعد أن أصبح Room وRetrofit يتيحان استخدام دوال التعليق، يمكننا استخدامها من المستودع. افتح TitleRepository.kt واطّلِع على كيفية تبسيط الدوال المعلقة للمنطق بشكل كبير، حتى مقارنةً بالإصدار الذي يحظر التنفيذ:

TitleRepository.kt

suspend fun refreshTitle() {
   try {
       // Make network request using a blocking call
       val result = network.fetchNextTitle()
       titleDao.insertTitle(Title(result))
   } catch (cause: Throwable) {
       // If anything throws an exception, inform the caller
       throw TitleRefreshError("Unable to refresh title", cause)
   }
}

واو، هذا أقصر بكثير. ما السبب؟ اتّضح أنّ الاعتماد على الإيقاف المؤقت والاستئناف يتيح كتابة رموز برمجية أقصر بكثير. تتيح لنا Retrofit استخدام أنواع الإرجاع مثل String أو كائن User هنا، بدلاً من Call. وهذا الإجراء آمن، لأنّه داخل دالة التعليق، يمكن لـ Retrofit تنفيذ طلب الشبكة في سلسلة محادثات في الخلفية واستئناف الروتين الفرعي عند اكتمال المكالمة.

والأفضل من ذلك، أنّنا أزلنا الرمز withContext. بما أنّ كلاً من Room وRetrofit يوفّران دوال تعليق آمنة على سلسلة التعليمات الرئيسية، من الآمن تنسيق هذا العمل غير المتزامن من Dispatchers.Main.

إصلاح أخطاء المحول البرمجي

يتضمّن الانتقال إلى الروتينات الفرعية تغيير توقيع الدوال، لأنّه لا يمكنك استدعاء دالة تعليق من دالة عادية. عند إضافة المعدِّل suspend في هذه الخطوة، تم إنشاء بعض أخطاء برنامج التجميع التي توضّح ما سيحدث إذا غيّرت وظيفة إلى وظيفة معلّقة في مشروع حقيقي.

انتقِل إلى المشروع وأصلِح أخطاء المحول البرمجي عن طريق تغيير الدالة إلى دالة معلّقة تم إنشاؤها. في ما يلي الحلول السريعة لكل منها:

TestingFakes.kt

عدِّل عمليات الاختبار الوهمية لتتوافق مع معدّلات الإيقاف المؤقت الجديدة.

TitleDaoFake

  1. اضغط على Alt+Enter لإضافة معدّلات التعليق إلى جميع الدوال في التسلسل الهرمي

MainNetworkFake

  1. اضغط على Alt+Enter لإضافة معدّلات التعليق إلى جميع الدوال في التسلسل الهرمي
  2. استبدال fetchNextTitle بهذه الدالة
override suspend fun fetchNextTitle() = result

MainNetworkCompletableFake

  1. اضغط على Alt+Enter لإضافة معدّلات التعليق إلى جميع الدوال في التسلسل الهرمي
  2. استبدال fetchNextTitle بهذه الدالة
override suspend fun fetchNextTitle() = completable.await()

TitleRepository.kt

  • احذف الدالة refreshTitleWithCallbacks لأنّها لم تعُد مستخدَمة.

تشغيل التطبيق

أعِد تشغيل التطبيق مرة أخرى، وبعد تجميعه، ستلاحظ أنّه يحمّل البيانات باستخدام إجراءات فرعية من ViewModel إلى Room وRetrofit.

تهانينا، لقد بدّلت هذا التطبيق بالكامل لاستخدام إجراءات فرعية. في الختام، سنتحدث قليلاً عن كيفية اختبار ما قمنا به للتو.

في هذا التمرين، ستكتب اختبارًا يستدعي الدالة suspend مباشرةً.

بما أنّ refreshTitle معروضة كواجهة برمجة تطبيقات عامة، سيتم اختبارها مباشرةً، ما يوضّح كيفية استدعاء دوال الروتينات المشتركة من الاختبارات.

إليك الدالة refreshTitle التي نفّذتها في التمرين الأخير:

TitleRepository.kt

suspend fun refreshTitle() {
   try {
       // Make network request using a blocking call
       val result = network.fetchNextTitle()
       titleDao.insertTitle(Title(result))
   } catch (cause: Throwable) {
       // If anything throws an exception, inform the caller
       throw TitleRefreshError("Unable to refresh title", cause)
   }
}

كتابة اختبار يستدعي دالة معلّقة

افتح TitleRepositoryTest.kt في المجلد test الذي يحتوي على مهمتَين.

حاوِل الاتصال بـ refreshTitle من الاختبار الأول whenRefreshTitleSuccess_insertsRows.

@Test
fun whenRefreshTitleSuccess_insertsRows() {
   val subject = TitleRepository(
       MainNetworkFake("OK"),
       TitleDaoFake("title")
   )

   subject.refreshTitle()
}

بما أنّ refreshTitle هي دالة suspend، لا يعرف Kotlin كيفية استدعائها إلا من روتين فرعي أو دالة تعليق أخرى، وسيظهر لك خطأ في المترجم، مثل "يجب استدعاء دالة التعليق refreshTitle فقط من روتين فرعي أو دالة تعليق أخرى".

لا يعرف مشغّل الاختبار أي شيء عن الروتينات المشتركة، لذا لا يمكننا تحويل هذا الاختبار إلى دالة تعليق. يمكننا launch روتينًا فرعيًا باستخدام CoroutineScope كما هو الحال في ViewModel، ولكن يجب أن تنفّذ الاختبارات الروتينات الفرعية حتى اكتمالها قبل أن تعرض النتائج. بعد أن تعرض دالة الاختبار نتيجة، ينتهي الاختبار. إنّ الروتينات الفرعية التي تبدأ بـ launch هي رموز غير متزامنة، وقد تكتمل في وقت ما في المستقبل. لذلك، لاختبار هذا الرمز غير المتزامن، تحتاج إلى طريقة ما لإخبار الاختبار بالانتظار إلى أن يكتمل الروتين الفرعي. بما أنّ launch هي عملية استدعاء غير حظر، يعني ذلك أنّها تعرض النتائج على الفور ويمكنها مواصلة تشغيل روتين فرعي بعد أن تعرض الدالة النتائج، ولكن لا يمكن استخدامها في الاختبارات. على سبيل المثال:

@Test
fun whenRefreshTitleSuccess_insertsRows() {
   val subject = TitleRepository(
       MainNetworkFake("OK"),
       TitleDaoFake("title")
   )

   // launch starts a coroutine then immediately returns
   GlobalScope.launch {
       // since this is asynchronous code, this may be called *after* the test completes
       subject.refreshTitle()
   }
   // test function returns immediately, and
   // doesn't see the results of refreshTitle
}

سيفشل هذا الاختبار في بعض الأحيان. سيتم عرض نتيجة طلب launch على الفور وتنفيذه في الوقت نفسه مع بقية حالة الاختبار. لا يمكن للاختبار معرفة ما إذا كان refreshTitle قد تم تنفيذه أم لا، وستكون أي تأكيدات، مثل التحقّق من تعديل قاعدة البيانات، غير موثوقة. وإذا طرحت الدالة refreshTitle استثناءً، لن يتم طرحه في سلسلة استدعاء الاختبار. سيتم بدلاً من ذلك طرحه في معالج الاستثناءات غير المرصودة في GlobalScope.

تحتوي المكتبة kotlinx-coroutines-test على الدالة runBlockingTest التي تحظر أثناء استدعاء دوال تعليق. عندما يستدعي runBlockingTest دالة تعليق أو launches روتينًا فرعيًا جديدًا، يتم تنفيذه على الفور تلقائيًا. يمكنك اعتبارها طريقة لتحويل الدوال المعلّقة والروتينات الفرعية إلى استدعاءات دالة عادية.

بالإضافة إلى ذلك، ستعيد runBlockingTest طرح الاستثناءات غير المعالَجة نيابةً عنك. يسهّل ذلك اختبار الحالات التي يعرض فيها روتين فرعي استثناءً.

تنفيذ اختبار باستخدام روتين فرعي واحد

لفّ طلب refreshTitle باستخدام runBlockingTest وأزِل برنامج تضمين GlobalScope.launch من subject.refreshTitle().

TitleRepositoryTest.kt

@Test
fun whenRefreshTitleSuccess_insertsRows() = runBlockingTest {
   val titleDao = TitleDaoFake("title")
   val subject = TitleRepository(
           MainNetworkFake("OK"),
           titleDao
   )

   subject.refreshTitle()
   Truth.assertThat(titleDao.nextInsertedOrNull()).isEqualTo("OK")
}

يستخدم هذا الاختبار البيانات الوهمية المقدَّمة للتحقّق من أنّ refreshTitle يدرج القيمة "OK" في قاعدة البيانات.

عندما تستدعي الدالة الاختبارية runBlockingTest، سيتم حظرها إلى أن يكتمل الروتين الفرعي الذي بدأته الدالة runBlockingTest. بعد ذلك، عندما نستدعي refreshTitle، تستخدم هذه الدالة آلية الإيقاف المؤقت والاستئناف العادية لانتظار إضافة صف قاعدة البيانات إلى العنصر الزائف.

بعد اكتمال الروتين الفرعي للاختبار، يتم عرض runBlockingTest.

كتابة اختبار المهلة

نريد إضافة مهلة قصيرة لطلب الشبكة. لنكتب الاختبار أولاً ثم ننفّذ المهلة. لإنشاء اختبار جديد:

TitleRepositoryTest.kt

@Test(expected = TitleRefreshError::class)
fun whenRefreshTitleTimeout_throws() = runBlockingTest {
   val network = MainNetworkCompletableFake()
   val subject = TitleRepository(
           network,
           TitleDaoFake("title")
   )

   launch {
       subject.refreshTitle()
   }

   advanceTimeBy(5_000)
}

يستخدم هذا الاختبار MainNetworkCompletableFake مزيفًا تم توفيره، وهو عبارة عن شبكة مزيفة مصمَّمة لتعليق المكالمات إلى أن يواصل الاختبار تنفيذها. عندما يحاول refreshTitle إجراء طلب شبكة، سيتعذّر عليه ذلك إلى الأبد لأنّنا نريد اختبار المهلات.

بعد ذلك، يتم تشغيل روتين فرعي منفصل لاستدعاء refreshTitle. هذا جزء أساسي من اختبار مهلات الاستجابة، ويجب أن تحدث مهلة الاستجابة في روتين فرعي مختلف عن الروتين الذي تنشئه الدالة runBlockingTest. من خلال إجراء ذلك، يمكننا استدعاء السطر التالي، advanceTimeBy(5_000) الذي سيقدّم الوقت بمقدار 5 ثوانٍ ويؤدي إلى انتهاء المهلة الزمنية للروتين الفرعي الآخر.

هذا اختبار كامل لانتهاء المهلة، وسينجح بعد أن ننفّذ انتهاء المهلة.

نفِّذ الأمر الآن واطّلِع على النتيجة:

Caused by: kotlinx.coroutines.test.UncompletedCoroutinesError: Test finished with active jobs: ["...]

إحدى ميزات runBlockingTest هي أنّها لن تسمح بتسرُّب الروتينات الفرعية بعد اكتمال الاختبار. إذا كانت هناك أي إجراءات روتينية غير مكتملة، مثل إجراءات التشغيل الروتينية، في نهاية الاختبار، سيتعذّر إجراء الاختبار.

إضافة مهلة

افتح TitleRepository وأضِف مهلة مدتها خمس ثوانٍ إلى عملية جلب البيانات من الشبكة. يمكنك إجراء ذلك باستخدام الدالة withTimeout:

TitleRepository.kt

suspend fun refreshTitle() {
   try {
       // Make network request using a blocking call
       val result = withTimeout(5_000) {
           network.fetchNextTitle()
       }
       titleDao.insertTitle(Title(result))
   } catch (cause: Throwable) {
       // If anything throws an exception, inform the caller
       throw TitleRefreshError("Unable to refresh title", cause)
   }
}

أجرِ الاختبار. عند إجراء الاختبارات، يجب أن تجتاز جميعها.

في التمرين التالي، ستتعرّف على كيفية كتابة دوال ترتيب أعلى باستخدام إجراءات فرعية.

في هذا التمرين، ستعيد تصميم الرمز refreshTitle في MainViewModel لاستخدام دالة عامة لتحميل البيانات. سيعلّمك هذا الدرس كيفية إنشاء دوال ترتيب أعلى تستخدم إجراءات فرعية.

يعمل التنفيذ الحالي لـ refreshTitle، ولكن يمكننا إنشاء روتين فرعي عام لتحميل البيانات يعرض دائمًا أداة التحميل. قد يكون ذلك مفيدًا في قاعدة رموز تحمّل البيانات استجابةً لعدة أحداث، وتريد التأكّد من عرض مؤشر التحميل بشكلٍ متّسق.

مراجعة التنفيذ الحالي لكل سطر باستثناء repository.refreshTitle() هي عبارة عن نموذج جاهز لعرض أداة التحميل وعرض الأخطاء.

// MainViewModel.kt

fun refreshTitle() {
   viewModelScope.launch {
       try {
           _spinner.value = true
           // this is the only part that changes between sources
           repository.refreshTitle() 
       } catch (error: TitleRefreshError) {
           _snackBar.value = error.message
       } finally {
           _spinner.value = false
       }
   }
}

استخدام الروتينات الفرعية في الدوال ذات الترتيب الأعلى

أضِف هذا الرمز إلى MainViewModel.kt

MainViewModel.kt

private fun launchDataLoad(block: suspend () -> Unit): Job {
   return viewModelScope.launch {
       try {
           _spinner.value = true
           block()
       } catch (error: TitleRefreshError) {
           _snackBar.value = error.message
       } finally {
           _spinner.value = false
       }
   }
}

الآن، أعِد تصميم refreshTitle() لاستخدام هذه الدالة ذات الترتيب الأعلى.

MainViewModel.kt

fun refreshTitle() {
   launchDataLoad {
       repository.refreshTitle()
   }
}

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

لإنشاء هذا التجريد، تأخذ launchDataLoad وسيطة block وهي دالة lambda معلّقة. تتيح لك دالة lambda المعلقة استدعاء دوال معلقة. هذه هي الطريقة التي تنفّذ بها لغة Kotlin أدوات إنشاء الروتينات الفرعية launch وrunBlocking التي استخدمناها في هذا الدرس العملي.

// suspend lambda

block: suspend () -> Unit

لإنشاء دالة lambda معلّقة، ابدأ بالكلمة الأساسية suspend. يكمل سهم الدالة ونوع الإرجاع Unit عملية التعريف.

في معظم الحالات، ليس عليك تعريف دوال lambda المعلقة الخاصة بك، ولكن يمكن أن تكون مفيدة لإنشاء تجريدات مثل هذه التي تغلف المنطق المتكرر.

في هذا التمرين، ستتعرّف على كيفية استخدام الرمز البرمجي المستند إلى الروتينات المشتركة من WorkManager.

ما هي WorkManager؟

تتوفّر العديد من الخيارات على Android لتنفيذ المهام التي يمكن تأجيلها في الخلفية. يوضّح لك هذا التمرين كيفية دمج WorkManager مع الروتينات الفرعية. ‫WorkManager هي مكتبة متوافقة ومرنة وبسيطة لتنفيذ المهام في الخلفية التي يمكن تأجيلها. ‫WorkManager هو الحلّ المقترَح لحالات الاستخدام هذه على Android.

‫WorkManager هو جزء من Android Jetpack، وهو أحد مكوّنات البنية لعمل الخلفية الذي يتطلّب مزيجًا من التنفيذ الانتهازي والمضمون. يعني التنفيذ الانتهازي أنّ WorkManager سينفّذ عمل الخلفية في أقرب وقت ممكن. يعني التنفيذ المضمون أنّ WorkManager سيتولّى تنفيذ منطق بدء عملك في مجموعة متنوعة من الحالات، حتى إذا انتقلت إلى تطبيق آخر.

لهذا السبب، يُعدّ WorkManager خيارًا جيدًا للمهام التي يجب إكمالها في النهاية.

في ما يلي بعض الأمثلة على المهام التي يمكن الاستفادة من WorkManager في تنفيذها:

  • تحميل السجلّات
  • تطبيق الفلاتر على الصور وحفظها
  • مزامنة البيانات المحلية مع الشبكة بشكل دوري

استخدام الروتينات المشتركة مع WorkManager

توفّر WorkManager عمليات تنفيذ مختلفة لفئة ListanableWorker الأساسية لحالات الاستخدام المختلفة.

تتيح لنا أبسط فئة Worker تنفيذ بعض العمليات المتزامنة من خلال WorkManager. ومع ذلك، بعد أن عملنا حتى الآن على تحويل قاعدة الرموز البرمجية لاستخدام إجراءات فرعية ودوال تعليق، فإنّ أفضل طريقة لاستخدام WorkManager هي من خلال الفئة CoroutineWorker التي تتيح تحديد doWork()الدالة كدالة تعليق.

للبدء، افتح RefreshMainDataWork. وهي تتضمّن CoroutineWorker، وعليك تنفيذ doWork.

داخل الدالة suspend doWork، استدعِ الدالة refreshTitle() من المستودع وأرجِع النتيجة المناسبة.

بعد إكمال مهمة TODO، سيظهر الرمز على النحو التالي:

override suspend fun doWork(): Result {
   val database = getDatabase(applicationContext)
   val repository = TitleRepository(network, database.titleDao)

   return try {
       repository.refreshTitle()
       Result.success()
   } catch (error: TitleRefreshError) {
       Result.failure()
   }
}

يُرجى العِلم أنّ CoroutineWorker.doWork() هي دالة تعليق. على عكس فئة Worker الأبسط، لا يتم تنفيذ هذا الرمز البرمجي على Executor المحدّد في إعدادات WorkManager، بل يتم استخدام أداة الإرسال في العنصر coroutineContext (Dispatchers.Default تلقائيًا).

اختبار CoroutineWorker

لا يمكن أن يكتمل أي رمز أساسي بدون اختبار.

توفّر WorkManager طريقتَين مختلفتَين لاختبار فئات Worker. لمعرفة المزيد عن البنية الأساسية الأصلية للاختبار، يمكنك قراءة المستندات.

تقدّم WorkManager الإصدار 2.1 مجموعة جديدة من واجهات برمجة التطبيقات لتوفير طريقة أبسط لاختبار فئات ListenableWorker، وبالتالي CoroutineWorker. في الرمز البرمجي، سنستخدم إحدى واجهات برمجة التطبيقات الجديدة هذه: TestListenableWorkerBuilder.

لإضافة اختبارنا الجديد، عدِّل الملف RefreshMainDataWorkTest ضمن المجلد androidTest.

محتوى الملف هو:

package com.example.android.kotlincoroutines.main

import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.work.ListenableWorker.Result
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.work.ListenableWorker.Result
import androidx.work.testing.TestListenableWorkerBuilder
import com.example.android.kotlincoroutines.fakes.MainNetworkFake
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4


@RunWith(JUnit4::class)
class RefreshMainDataWorkTest {

@Test
fun testRefreshMainDataWork() {
   val fakeNetwork = MainNetworkFake("OK")

   val context = ApplicationProvider.getApplicationContext<Context>()
   val worker = TestListenableWorkerBuilder<RefreshMainDataWork>(context)
           .setWorkerFactory(RefreshMainDataWork.Factory(fakeNetwork))
           .build()

   // Start the work synchronously
   val result = worker.startWork().get()

   assertThat(result).isEqualTo(Result.success())
}

}

قبل أن ننتقل إلى الاختبار، نخبر WorkManager عن المصنع حتى نتمكّن من إدخال الشبكة الوهمية.

يستخدم الاختبار نفسه TestListenableWorkerBuilder لإنشاء العامل الذي يمكننا تشغيله بعد ذلك من خلال استدعاء الطريقة startWork().

‫WorkManager هو مجرد مثال على كيفية استخدام الروتينات الفرعية لتبسيط تصميم واجهات برمجة التطبيقات.

في هذا الدرس العملي، تناولنا الأساسيات التي تحتاج إليها لبدء استخدام الروتينات المشتركة في تطبيقك.

تناولنا ما يلي:

  • كيفية دمج الروتينات الفرعية في تطبيقات Android من كلّ من واجهة المستخدم ومهام WorkManager لتبسيط البرمجة غير المتزامنة
  • كيفية استخدام الروتينات المشتركة داخل ViewModel لجلب البيانات من الشبكة وحفظها في قاعدة بيانات بدون حظر سلسلة التعليمات الرئيسية
  • وكيفية إلغاء جميع الروتينات المشتركة عند انتهاء ViewModel.

لاختبار الرمز البرمجي المستند إلى إجراءات روتينية، تناولنا كلا الأسلوبين من خلال اختبار السلوك بالإضافة إلى استدعاء دوال suspend مباشرةً من الاختبارات.

مزيد من المعلومات

يمكنك الاطّلاع على الدرس التطبيقي "الكوروتينات المتقدّمة باستخدام Flow وLiveData بلغة Kotlin" للتعرّف على المزيد من المعلومات حول الاستخدام المتقدّم للكوروتينات على Android.

تتضمّن إجراءات Kotlin الفرعية العديد من الميزات التي لم يتم تناولها في هذا الدرس التطبيقي. إذا كنت مهتمًا بمعرفة المزيد عن إجراءات Kotlin الفرعية، يمكنك الاطّلاع على أدلة الإجراءات الفرعية التي نشرتها JetBrains. يمكنك أيضًا الاطّلاع على المقالة تحسين أداء التطبيق باستخدام الكوروتينات في Kotlin للتعرّف على المزيد من أنماط استخدام الكوروتينات على Android.