مقاربة حديثة للمنافسة في Android: Corotins at Kotlin

مرحبا يا هبر!

نذكرك أن لدينا بالفعل طلب مسبق للكتاب الذي طال انتظاره عن لغة Kotlin من سلسلة Big Nerd Ranch Guides الشهيرة. قررنا اليوم أن نلفت انتباهكم إلى ترجمة لمقال يخبرنا عن corotines Kotlin وعن العمل الصحيح مع تدفقات في Android. تتم مناقشة الموضوع بنشاط كبير ، وبالتالي ، للتأكد من اكتماله ، نوصي أيضًا بالاطلاع على هذه المقالة من Habr وهذه المشاركة المفصلة من مدونة برامج Axmor.

يعمل الإطار التنافسي الحديث في Java / Android على إلحاق الجحيم بالردود ويؤدي إلى حالات الحظر ، نظرًا لأن Android ليس لديه طريقة بسيطة إلى حد ما لضمان أمان سلاسل الرسائل.

corotines Kotlin هي مجموعة أدوات فعالة وكاملة للغاية مما يجعل إدارة المنافسة أسهل بكثير وأكثر إنتاجية.

وقفة وحظر: ما هو الفرق

لا يحل Coroutines محل الخيوط ، ولكنه يوفر إطارًا لإدارتها. تتمثل فلسفة corutin في تحديد سياق يتيح لك الانتظار حتى تكتمل عمليات الخلفية دون حظر الخيط الرئيسي.

هدف Corutin في هذه الحالة هو الاستغناء عن عمليات الاسترجاعات وتبسيط المنافسة.

مثال أبسط

بادئ ذي بدء ، دعنا نأخذ أبسط مثال: تشغيل coroutine في سياق Main (سلسلة رئيسية). في ذلك ، سنقوم باستخراج الصورة من دفق IO وإرسال هذه الصورة للمعالجة مرة أخرى إلى Main .

 launch(Dispatchers.Main) { val image = withContext(Dispatchers.IO) { getImage() } //    IO imageView.setImageBitmap(image) //     } 

الكود بسيط كدالة مفردة الترابط. علاوة على ذلك ، في حين يتم تنفيذ getImage في المجموعة المخصصة من مؤشرات ترابط IO ، فإن الخيط الرئيسي مجاني ويمكنه القيام بأي مهمة أخرى! الدالة withContext توقف مؤقتًا coroutine الحالي أثناء تشغيل الإجراء الخاص به ( getImage() ). بمجرد إرجاع getImage() وتصبح looper من الخيط الرئيسي ، سيستأنف coroutine العمل في الخيط الرئيسي ويدعو imageView.setImageBitmap(image) .

المثال الثاني: الآن نحتاج إلى إنجاز مهمتين في الخلفية حتى يمكن استخدامهما. سنستخدم duet async / انتظار بحيث يتم تنفيذ هاتين المهمتين بالتوازي ، واستخدام نتيجتهما في سلسلة العمليات الرئيسية بمجرد أن تكون كلتا المهمتين جاهزة:

 val job = launch(Dispatchers.Main) { val deferred1 = async(Dispatchers.Default) { getFirstValue() } val deferred2 = async(Dispatchers.IO) { getSecondValue() } useValues(deferred1.await(), deferred2.await()) } job.join() //    ,      

async غير async مشابه launch ، ولكنه يعود deferred (كيان Kotlin مكافئ Future ) ، لذلك يمكن الحصول على النتيجة باستخدام await() . عند استدعاء بدون معلمات ، يعمل في السياق الافتراضي للنطاق الحالي.

مرة أخرى ، يبقى الخيط الرئيسي مجانيًا بينما ننتظر قيمتين.
كما ترى ، تقوم وظيفة launch بإرجاع Job ، والتي يمكن استخدامها للانتظار حتى تكتمل العملية - يتم ذلك باستخدام دالة join() . إنه يعمل كما هو الحال في أي لغة أخرى ، مع تحذير أنه ببساطة يعلق coroutine ، ولا يمنع التدفق .

إيفاد

إيفاد هو مفهوم رئيسي عند العمل مع coroutines. يتيح لك هذا الإجراء "الانتقال" من سلسلة رسائل إلى أخرى.

فكر في الشكل المكافئ للإرسال في Main في java ، أي ،

 runOnUiThread: public final void runOnUiThread(Runnable action) { if (Thread.currentThread() != mUiThread) { mHandler.post(action); //  } else { action.run(); //   } } 

تنفيذ السياق Main لنظام Android هو Handler قائم على Handler . لذلك هذا هو في الواقع تنفيذ مناسب للغاية:

 launch(Dispatchers.Main) { ... } vs launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED) { ... } //   kotlinx 0.26: launch(Dispatchers.Main.immediate) { ... } 

launch(Dispatchers.Main) يرسل Runnable إلى Handler ، لذلك لا يتم تنفيذ التعليمات البرمجية الخاصة به على الفور.

launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED) سينفذ على الفور تعبير lambda في الخيط الحالي.

Dispatchers.Main يضمن أنه عندما يستأنف coroutine العمل ، سيتم توجيهه إلى الموضوع الرئيسي . بالإضافة إلى ذلك ، يتم استخدام Handler هنا كتطبيق Android أصلي لإرساله إلى حلقة حدث التطبيق.

التنفيذ الدقيق يشبه هذا:

 val Main: HandlerDispatcher = HandlerContext(mainHandler, "Main") 

إليك مقالة جيدة لمساعدتك في فهم تعقيدات الإرسال في Android:
فهم Android Core: Looper و Handler و HandlerThread .

السياق Coroutine

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

 val job = Job() val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable -> whatever(throwable) } launch(Disaptchers.Default+exceptionHandler+job) { ... } 

job.cancel() بإلغاء جميع coroutines الذي يكون الوالد هو job . سوف تحصل على استثناء هندلر جميع الاستثناءات التي ألقيت في هذه coroutines.

مجال

تبسط واجهة coroutineScope معالجة الأخطاء:
إذا فشل أي من ابنتها coroutines ، ثم سيتم إلغاء النطاق بأكمله وجميع coroutines الطفل.

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

عند العمل مع coroutineScope ، لن يتم استدعاء الدالة useValues إلا في حالة نجاح استخراج كلا القيمتين. أيضا ، إذا فشل deferred1 ، سيتم إلغاء deferred1 .

 coroutineScope { val deferred1 = async(Dispatchers.Default) { getFirstValue() } val deferred2 = async(Dispatchers.IO) { getSecondValue() } useValues(deferred1.await(), deferred2.await()) } 

يمكنك أيضًا "وضع نطاق" فصلًا بأكمله لتعيين CoroutineContext افتراضيًا له واستخدامه.

فئة المثال التي تنفذ واجهة CoroutineScope :

 open class ScopedViewModel : ViewModel(), CoroutineScope { protected val job = Job() override val coroutineContext = Dispatchers.Main+job override fun onCleared() { super.onCleared() job.cancel() } } 

تشغيل Corutin في CoroutineScope :

التشغيل الافتراضي أو مدير async يصبح الآن مدير النطاق الحالي.

 launch { val foo = withContext(Dispatchers.IO) { … } // -    CoroutineContext   … } launch(Dispatchers.Default) { // -        … } 

الإطلاق المستقل ل coroutine (خارج أي CoroutineScope):

 GlobalScope.launch(Dispatchers.Main) { // -    . … } 

يمكنك حتى تحديد نطاق تطبيق ما عن طريق تعيين المرسل Main الافتراضي:

 object AppScope : CoroutineScope by GlobalScope { override val coroutineContext = Dispatchers.Main.immediate } 

تصريحات

  • Coroutines تحد التشغيل المتداخل مع جافا
  • الحد من قابلية التحويل لتجنب الأقفال
  • تم تصميم Coroutines للانتظار ، وليس لتنظيم المواضيع
  • تجنب الإدخال / الإخراج في Dispatchers.DefaultMain ...) - هذا هو ما Dispatchers.IO
  • التدفقات تستهلك الموارد ، لذلك يتم استخدام السياقات ذات سلاسل الترابط المفردة
  • يستند Dispatchers.Default إلى ForkJoinPool ، الذي تم تقديمه في Android 5+
  • Coroutines يمكن استخدامها من خلال القنوات

التخلص من الأقفال وعمليات الاسترجاعات باستخدام القنوات

تعريف القناة من وثائق JetBrains:

قناة Channel الناحية النظرية تشبه إلى حد بعيد BlockingQueue . يتمثل الاختلاف الرئيسي في أنه لا يحظر عملية البيع ، فهو يوفر send تعليق (أو عرض غير محظور) ، وبدلاً من حظر عملية receive ، فإنه ينص على receive تعليق receive .


الجهات الفاعلة

النظر في أداة بسيطة للعمل مع القنوات: Actor .

Actor ، مرة أخرى ، يشبه إلى حد كبير Handler : نحن نعرّف سياق coroutine (أي ، الخيط الذي سنقوم فيه بتنفيذ الإجراءات) ونعمل معه بترتيب متسلسل.

الفرق ، بالطبع ، هو أن كورينز تستخدم هنا ؛ يمكنك تحديد السلطة ، ورمز تنفيذها - توقف مؤقت .

من حيث المبدأ ، سيقوم actor بإعادة توجيه أي أمر إلى قناة coroutine. إنه يضمن تنفيذ أمر ويقيد العمليات في سياقها . يساعد هذا النهج تمامًا على التخلص من synchronize المكالمات والحفاظ على جميع المواضيع مجانية!

 protected val updateActor by lazy { actor<Update>(capacity = Channel.UNLIMITED) { for (update in channel) when (update) { Refresh -> updateList() is Filter -> filter.filter(update.query) is MediaUpdate -> updateItems(update.mediaList as List<T>) is MediaAddition -> addMedia(update.media as T) is MediaListAddition -> addMedia(update.mediaList as List<T>) is MediaRemoval -> removeMedia(update.media as T) } } } //  fun filter(query: String?) = updateActor.offer(Filter(query)) //  suspend fun filter(query: String?) = updateActor.send(Filter(query)) 

في هذا المثال ، نستخدم فئات Kotlin المختومة ، ونختار الإجراء الذي يجب القيام به.

 sealed class Update object Refresh : Update() class Filter(val query: String?) : Update() class MediaAddition(val media: Media) : Update() 

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

دورة حياة أندرويد + كوروتين

يمكن أن تكون الجهات الفاعلة أيضًا مفيدة جدًا للتحكم في واجهة مستخدم Android ، وتبسيط إلغاء المهام ، ومنع التحميل الزائد للخيط الرئيسي.
دعنا ننفذ هذا وندعو job.cancel() عندما يتم تدمير النشاط.

 class MyActivity : AppCompatActivity(), CoroutineScope { protected val job = SupervisorJob() //  Job    override val coroutineContext = Dispatchers.Main.immediate+job override fun onDestroy() { super.onDestroy() job.cancel() //      } } 

تشبه فئة SupervisorJob Job العادية باستثناء أن الإلغاء يمتد فقط في اتجاه المصب.

لذلك ، لا نقوم بإلغاء جميع coroutines في Activity عندما يفشل أحدهم.

الأمور أفضل قليلاً مع وظيفة ملحق تتيح لك الوصول إلى CoroutineContext من أي View في CoroutineScope .

 val View.coroutineContext: CoroutineContext? get() = (context as? CoroutineScope)?.coroutineContext 

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

 fun View.setOnClick(action: suspend () -> Unit) { //         val scope = (context as? CoroutineScope)?: AppScope val eventActor = scope.actor<Unit>(capacity = Channel.CONFLATED) { for (event in channel) action() } //       setOnClickListener { eventActor.offer(Unit) } } 

في هذا المثال ، قمنا بتعيين Channel على Conflated بحيث تتجاهل بعض الأحداث إذا كان هناك الكثير منها. يمكنك استبداله بـ Channel.UNLIMITED إذا كنت تفضل وضع قائمة انتظار الأحداث دون فقد أي منها ، ولكن لا تزال ترغب في حماية التطبيق من أخطاء ANR.

يمكنك أيضًا الجمع بين coroutines وإطارات Lifecycle لأتمتة إلغاء المهام المتعلقة بواجهة المستخدم:

 val LifecycleOwner.untilDestroy: Job get() { val job = Job() lifecycle.addObserver(object: LifecycleObserver { @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) fun onDestroy() { job.cancel() } }) return job } //  GlobalScope.launch(Dispatchers.Main, parent = untilDestroy) { /*    ! */ } 

تبسيط الموقف مع الاسترجاعات (الجزء 1)

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

يعمل API مثل هذا:

  1. requestBrowsing(url, listener) يوزع المجلد الموجود على url .
  2. listener على onMediaAdded(media: Media) لأي ملف وسائط موجود في هذا المجلد.
  3. يتم استدعاء listener.onBrowseEnd() عند تحليل المجلد

فيما يلي وظيفة refresh القديمة في موفر المحتوى لمتصفح VLC:

 private val refreshList = mutableListOf<Media>() fun refresh() = requestBrowsing(url, refreshListener) private val refreshListener = object : EventListener{ override fun onMediaAdded(media: Media) { refreshList.add(media)) } override fun onBrowseEnd() { val list = refreshList.toMutableList() refreshList.clear() launch { dataset.value = list parseSubDirectories() } } } 

كيفية تحسينه؟

قم بإنشاء قناة ستعمل في refresh . الآن ستقوم عمليات معاودة الاتصال بالمتصفح بتوجيه الوسائط إلى هذه القناة فقط ، ثم إغلاقها.

الآن أصبحت وظيفة refresh أكثر وضوحًا. إنها تنشئ قناة ، وتدعو متصفح VLC ، ثم تشكل قائمة بملفات الوسائط وتعالجها.

بدلاً من select أو consumeEach يمكنك استخدام انتظار الوسائط ، وسوف تنقطع هذه الحلقة بمجرد إغلاق browserChannel .

 private lateinit var browserChannel : Channel<Media> override fun onMediaAdded(media: Media) { browserChannel.offer(media) } override fun onBrowseEnd() { browserChannel.close() } suspend fun refresh() { browserChannel = Channel(Channel.UNLIMITED) val refreshList = mutableListOf<Media>() requestBrowsing(url) //        for (media in browserChannel) refreshList.add(media) //   dataset.value = refreshList parseSubDirectories() } 

تبسيط الموقف من خلال عمليات الاسترجاعات (الجزء 2): التحديثية

الطريقة الثانية: نحن لا نستخدم kotlinx coroutines على الإطلاق ، لكننا نستخدم إطار coroutine الأساسي.

ترى كيف تعمل coroutines فعلا!

التفاف وظيفة retrofitSuspendCall طلب Retrofit Call لجعله وظيفة suspend .

باستخدام suspendCoroutine ندعو طريقة Call.enqueue مؤقتًا عن coroutine. سوف استدعاء رد الاتصال بهذه الطريقة استدعاء continuation.resume(response) لاستئناف coroutine مع استجابة من الخادم بمجرد تلقيها.

بعد ذلك ، نحتاج فقط إلى دمج وظائف Retrofit في retrofitSuspendCall لإرجاع نتائج الاستعلام باستخدامها.

 suspend inline fun <reified T> retrofitSuspendCall(request: () -> Call <T> ) : Response <T> = suspendCoroutine { continuation -> request.invoke().enqueue(object : Callback<T> { override fun onResponse(call: Call<T>, response: Response<T>) { continuation.resume(response) } override fun onFailure(call: Call<T>, t: Throwable) { continuation.resumeWithException(t) } }) } suspend fun browse(path: String?) = retrofitSuspendCall { ApiClient.browse(path) } //  (   Main) livedata.value = Repo.browse(path) 

وبالتالي ، يتم إجراء حظر المكالمات على الشبكة في مؤشر الترابط التحديثي المخصص ، والموقع هنا ، في انتظار استجابة من الخادم ، وليس هناك مكان لاستخدامه في التطبيق!

هذا التطبيق مستوحى من مكتبة gildor / kotlin-coroutines-retrofit .

يوجد أيضًا محول JakeWharton / retrofit2-kotlin-coroutines مع تطبيق آخر يعطي نتيجة مماثلة.

خاتمة

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

يمكنك أيضًا إنشاء قنوات باستخدام وظيفة Produce .

أخيرًا ، يعد استخدام القنوات مناسبًا لتنظيم الاتصالات بين مكونات واجهة المستخدم: يمكن للمحول نقل أحداث النقر إلى جزءه / نشاطه عبر Channel أو ، على سبيل المثال ، من خلال Actor .

Source: https://habr.com/ru/post/ar457224/


All Articles