آشنایی با Coroutine در کاتلین — از صفر تا صد

۵۲۸ بازدید
آخرین به‌روزرسانی: ۰۱ مهر ۱۴۰۲
زمان مطالعه: ۹ دقیقه
آشنایی با Coroutine در کاتلین — از صفر تا صد

تصور کنید می‌خواهید دسته‌ای از مشتریان را از روی یک لیست از سرور دریافت کنید. شما باید یک فراخوانی سرویس برای واکشی داده‌ها داشته باشید و بعد آن‌ها را در یک RecyclerView نمایش دهید. چنین سناریویی در نهایت نیاز به استفاده از کوروتین خواهد داشت. در ادامه این مقاله به بررسی Coroutine در کاتلین خواهیم پرداخت.

997696

در سناریو فوق فرض کنید تابعی به نام ()fetchCustomers دارید که در آن به صورت زیر داده‌ها را از سرور واکشی می‌کنید:

1fun fetchCustomers(){
2     val array = api.fetchCustomersList()
3     updateUI(array)
4}

اگر این کار را انجام دهید، اپلیکیشن از کار می‌افتد، زیرا در حال انجام این کار روی «نخ اصلی» (Main Thread) هستید. ممکن است روش جایگزین برای اجرای آن از طریق قرار دادن درون یک نخ دیگر بیابید. اما مشکل بعدی در زمینه مدیریت چرخه عمر خواهد بود. از این رو باید از callback-ها با اشتراک و قابلیت لغو برای حل مسئله بهره بگیرید. در نتیجه در نهایت به کد زیر می‌رسید:

1fun onDestroy(){
2    subscription1.cancel()
3    subscription2.cancel()
4    subscription3.cancel()
5    subscription4.cancel()
6}

کوروتین‌ها شما را از ورود به این جهنم callback بازمی‌دارند. ممکن است تلاش کنید این مسئله را با استفاده از disposable-ها در RxJava نیز حل کنید، اما استفاده از آن موجب افزایش پیچیدگی موارد مختلف می‌شود. RxJava همان قدر که مفید است، تولید مشکل نیز می‌کند.

بدین ترتیب در نهایت متوجه می‌شویم که چاره کار در کوروتین است که در عین ساده بودن، جامع است.

مبانی کوروتین

کوروتین‌ها چیزی به جز نخ‌های سبک نیستند که می‌توانیم روی آن‌ها وظایف مرتبط با کارهای پس‌زمینه یا تغییرات UI را بر مبنای چارچوبی که انتخاب می‌کنیم اجرا نماییم.

در ادامه تابع ()fetchCustomers فوق را با استفاده از کوروتین کاتلین می‌نویسیم:

1suspend fun fetchCustomers(){
2     val array = api.fetchCustomersList()
3     updateUI(array)
4}

برای این که نشان دهیم این تابع روی کوروتین اجرا می‌شود، از مادیفایر suspend استفاده کرده‌ایم. طرز کار آن مشابه استایل callback است، اما از کد کمتری استفاده می‌کند و «اشتراک» (Subscription) نیز ندارد. پیاده‌سازی آن آسان است و کاربردش نیز به همان سادگی است. نیازی به یادگیری هیچ چیز جدیدی به سبک اجرایی وجود ندارد. کوروتین یک رویه اجرایی async را به روشی ترتیبی عرضه می‌کند.

کاری که کوروتین انجام می‌دهد این است که وقتی شروع به اجرای یک کوروتین می‌کنید، اجرا را معلق می‌سازد و زمانی که پاسخ دریافت شد، اجرا را از جایی که تعلیق یافته بود، از سر می‌گیرد. بدین ترتیب suspend و resume جایگزین callback می‌شوند.

زیبایی کوروتین در این است که در کتابخانه‌های زیادی از قبیل Retrofit ،Room و غیره کاملاً تثبیت شده است. هم اینک می‌توانید از کوروتین‌ها در پروژه اندروید خود استفاده کنید و همه فراخوانی‌های مربوط به شبکه را که در مثال قبلی دیدیم با استفاده از Retrofit نسخه 2.6 و بالاتر ساده‌سازی کنید.

اگر به مبانی بازگردیم، امکان فراخوانی تنها یک تابع suspend از تابع suspend دیگر وجود دارد. آیا این بدان معنی است که باید یک هر تابعی را در پروژه به صورت تابع suspend بنویسیم؟ نه این کار ضرورتی ندارد.

می‌توانیم یک کوروتین جدید با استفاده از بیلدر withContext آغاز کنیم و مشخص سازیم که کوروتین باید روی کدام نخ با استفاده از Dispatchers از یک تابع معمولی اجرا شود. به مثال زیر توجه کنید:

1fun normalFunction(){
2  withContext(Dispatchers.IO){
3     // blocking calls
4    val customers = api.fetchCustomers()
5  }
6}

Dispatcher-ها

Dispatcher-ها چیزی به جز دسته‌ای از کلیدواژه نیستند و برای اعلام نخی که کوروتین باید روی آن اجرا شود به کار می‌روند. کاتلین سه نوع Dispatcher ارائه می‌کند:

  • Default – هر چیزی مانند اجرای DiffUtil که برای اجرا روی نخ اصلی نیاز به زمان زیادی داشته باشد، باید روی Dispatcher پیش‌فرض اجرا شود.
  • IO – وظایفی مانند نوشتن در یک فایل یا فراخوانی API که موجب مسدود شدن Ul می‌شوند، باید روی دیسپچر IO اجرا شوند.
  • Main – این نخ اصلی است که همه وظایف اندروید به صورت معمول روی آن اجرا می‌شوند. این نخ به صورت پیش‌فرض آغاز می‌شود و در موارد ضروری به آن دیسپچر که لازم است سوئیچ می‌کنیم.

استفاده از withContext یک فراخوانی امن main است. سپس می‌توانید کار async را هر جایی در اندروید با استفاده از withContext اجرا کنید و با استفاده از withContext نیز می‌توانید کنترل کنید که باید روی کدام نخ اجرا شود.

اصطلاحات

بدیهی است زمانی که شروع به یادگیری یک فناوری جدید می‌کنیم، برخی اصطلاحات وجود دارند که باید در مورد آن‌ها اطلاع داشته باشیم. کوروتین‌ها نیز از این قاعده مستثنا نیستند. در ادامه برخی از مواردی که دانستنشان در زمان شروع به یادگیری کوروتین مورد نیاز خواهند بود را توضیح داده‌ایم.

  • CoroutineScope – یک «دامنه» (Scope) برای کوروتین تعریف می‌کند.
  • Job - یک کار پس‌زمینه است که می‌تواند در سلسله‌مراتب والد-فرزند مرتب شود. لغو هر والد منجر به لغو بی‌درنگ همه فرزندان می‌شود و شکست یا لغو هر یک از کارهای فرزند به جز CancellationException موجب لغو والدینش خواهد شد.
  • SupervisorJob – یک Job سوپروایزر در واقع یک Job معمولی است، اما فرزندانش می‌توانند مستقل از آن و بدون لغو کردن والد، لغو شوند.
  • Suspend – این یک مادیفایر است که روی تابع‌ها استفاده می‌شود تا مشخص شود که یک تابع کوروتین هستند.
  • Launch – این یک متد در CoroutineScope است که کوروتین را بدون مسدودسازی نخ کنونی آغاز می‌کند و یک ارجاع به کوروتین به صورت یک Job بازگشت می‌دهد که می‌تواند برای توقف اجرای کوروتین لغو شود.
  • Async – یک متد در CoroutineScope است که یک کوروتین آغاز می‌کند و نتیجه نهایی را در یک پیاده‌سازی [Deferred] بازگشت می‌دهد.

ساده‌سازی وظایف پیچیده با کوروتین

از آنجا که پیاده‌سازی کوروتین بسیار ساده و درک آن آسان است، بسیاری از کتابخانه‌های پیشرفته مانند Retrofit ،Room و WorkManager هم اینک از آن پشتیبانی می‌کنند.

Retrofit

Retrofit یک کتابخانه مشهور است که برای ایجاد فراخوانی‌های شبکه استفاده می‌شود و از کلاینت OkHTTP بهره می‌گیرد. در ادامه با شیوه استفاده از Retrofit به همراه کوروتین‌ها آشنا می‌شویم و کار را با مواردی که از کتابخانه‌های دیگری مانند RxJava استفاده می‌کنیم مقایسه خواهیم کرد.

تنها چیزی که لازم است تغییر دهیم تابع‌های سرویس repo به suspend و فراخوانی آن‌ها از withContext است. ابتدا تابع‌های فراخوانی سرویس annotate را در Retrofit مانند زیر به تابع‌های suspend تغییر می‌دهیم:

1//Before
2fun fetchShowTrailerCoroutine(showId : Long):
3            Call<ShowTrailerModel>
4
5//After
6suspend fun fetchShowTrailerCoroutine(showId : Long):
7            Response<ShowTrailerModel>

اکنون آن را از ViewModel فراخوانی می‌کنیم تا داده‌ها را از سرور واکشی کنیم. در این مرحله می‌توانیم از دو الگو بهره بگیریم. یک الگو این است که تابع‌ها را در ViewModel به صورت suspend علامت‌گذاری کنیم و الگوی دیگر این است که کوروتین را با استفاده از withContext به صورتی که در مثال قبل دیدیم آغاز کنیم.

در این بخش روش استفاده از withContext را توضیح می‌دهیم، زیرا علامت‌گذاری تابع با suspend ساده‌ترین روش است، اما در ادامه شروع به استفاده از withContext برای آغاز یک کوروتین از تابع نرمال هم خواهیم کرد.

1private val viewModelJob = SupervisorJob()
2val viewmodelCoroutineScope = CoroutineScope(Dispatchers.IO + viewModelJob)
3fun fetchShowTrailer(){
4  viewmodelCoroutineScope.launch {
5          val response = withContext(Dispatchers.IO) {
6              repo.fetchShowTrailerCoroutine(getSeriesNameforTrakt(), application)
7          }
8          updateUI(response.body())
9   }
10}

در کد فوق می‌بینیم که ایجاد یک فراخوانی سرویس در اندروید با استفاده از کوروتین‌ها تا چه حد آسان است. اکنون همان سرویس را با RxJava مقایسه می‌کنیم تا ببینیم کوروتین‌ها چه کمک بزرگی به ما کرده‌اند:

1// Using Coroutines
2fun fetchShowTrailer(){
3  viewmodelCoroutineScope.launch {
4          val response = withContext(Dispatchers.IO) {
5              repo.fetchShowTrailerCoroutine(getSeriesNameforTrakt())
6          }
7          updateUI(response.body())
8   }
9}
10
11// Using RxJava
12fun fetchShowTrailer(){
13      repo.fetchShowTrailerRxJava(getSeriesNameforTrakt())
14        .observeOn(AndroidSchedulers.mainThread())
15        .subscribeOn(Schedulers.io())
16        .subscribe({ data ->
17            updateUI(response.body())
18        }, { error ->
19            
20        }).let {
21            getCompositeDisposable().add((it))
22        }
23}

تعداد خطوط کدی که با RxJava می‌نویسیم بیشتر است، اما این همه مشکل نیست. برای پیاده‌سازی RxJava باید اطلاعات بیشتری داشته باشیم، مثلاً این که بدانیم observeOn و SubscribeOn چه هستند. همچنین باید در مورد disposables اطلاعات داشته باشیم. این که چرا استفاده می‌شوند، زمانی که dispose را فراموش کنیم چه اتفاقی می‌افتد و صدها مورد دیگر را باید بدانیم.

اما زمانی که از کوروتین‌ها استفاده می‌کنیم تنها چیزی که باید بدانیم این است که Dispatchers و scope چیست. کوروتین خودش بقیه موارد را بر عهده می‌گیرد.

Room

Room یک کتابخانه Jetpack است که قابلیت اجرای عملیات پیچیده SQLite را بدون نیاز به کد boilerplate دارد. این کتابخانه یک لایه انتزاع روی SQLite ایجاد می‌کند و در زمان نوشتن کدهای قالبی صرفه‌جویی زیادی پدید می‌آورد.

در ادامه می‌بینیم که چطور کوئری های SQLite به وسیله Room و RxJava موجب می‌شود کاربردشان در کوروتین‌ها سرراست‌تر شود.

همانند زمانی که از Retrofit استفاده می‌کردیم، باید تابع‌های Dao را به صورت تابع‌های suspend درآوریم و از آن‌ها با استفاده از تابع‌های suspend یا از طریق اجرای یک دامنه کوروتین بهره بگیریم.

1@Insert(onConflict = OnConflictStrategy.REPLACE)
2suspend fun insertAll(plants: List<Show>)

اکنون آن را از viewModel فراخوانی می‌کنیم.

1private val viewModelJob = SupervisorJob()
2val viewmodelCoroutineScope = CoroutineScope(Dispatchers.IO + viewModelJob)
3fun fetchShowTrailer(){
4  viewmodelCoroutineScope.launch {
5          showsDao.insertAll(showsArray)
6          updateUI(response.body())
7   }
8}

این تنها کاری است که باید انجام دهیم. اکنون اپلیکیشن ما از کوروتین با امکانات شبکه‌بندی و پایگاه داده محلی پشتیبانی می‌کند.

WorkManager

امکان استفاده از کوروتین‌ها در WorkManager نیز وجود دارد.

سیاست لغو

لغو کردن یک کوروتین بسیار حائز اهمیت است، زیرا لغو کردن می‌تواند منابع کاربر را با اتصال‌های سوکت غیرضروری یا عملیاتی مانند خواندن یک فایل خالی کند. پیش از یادگیری مواردی در مورد روش لغو کردن یک کوروتین باید بدانیم که یک کوروتین چطور اجرا می‌شود و در صورتی که یک درخواست لغو ارسال کنیم، چه مراحلی طی خواهند شد.

ابتدا به بررسی چرخه عمر یک کوروتین می‌پردازیم:

Coroutine در کاتلین

به طور کلی یک کوروتین از یک «حالت فعال» (Active State) آغاز می‌شود، در حالی که حالت جدید تنها زمانی مشخص می‌شود که یک کوروتین به صورت lazy آغاز شود و سپس با آغاز یا اتصال به یک کوروتین به حالت فعال تبدیل شود. زمانی که یک کوروتین شروع به اجرا کند در حالت completing خواهد بود.

شاید بپرسید منظور از حالت completing چیست؟ در مثال فوق نشان دادیم که کد زیر یک دامنه کوروتین ایجاد می‌کند که در آن عملیات شبکه و پایگاه داده اجرا می‌شود:

val viewModelJob = SupervisorJob()
val viewmodelCoroutineScope = CoroutineScope(Dispatchers.IO + viewModelJob)

هر بار که تابع launch روی viewmodelCoroutineScope اجرا شود، یک job بازگشت می‌دهد. این وضعیت شبیه وضعیت والد و فرزند است. در این جا viewModelJob همان job والد است و job-هایی که با فراخوانی Launch ایجاد می‌شوند نیز Job-های فرزند هستند.

تا زمانی که همه فرزندان تکمیل نشده‌اند، یک کوروتین در حالت completing می‌ماند. زمانی که همه Job-ها در کوروتین تکمیل شدند، به یک حالت completed تبدیل می‌شود.

در ادامه سه فلگ داریم که حالت یک کوروتین را بررسی می‌کنند:

  • isActive – در حالت‌های active و complenting یک کوروتین مقدار True دارد.
  • isCompleted – به صورت پیش‌فرض False است. این مقدار زمانی که همه کارهای والد و فرزند کامل شدند به صورت True درمی‌آید.
  • isCancelled – به صورت پیش‌فرض False است. اگر هر گونه استثنایی رخ دهد یا کوروتین لغو شود، مقدار به True تغییر می‌یابد.

تا این جا با چرخه عمر یک کوروتین که با موفقیت و بدون هیچ گونه استثنا یا لغو اجرا شده است آشنا شدیم. اینک زمان آن رسیده است که با مراحلی که در زمان لغو شدن یک کوروتین چه بر اساس یک استثنا و چه به دلیل لغو عامدانه طی می‌شوند، آشنا شویم.

امکان لغو کردن یک کوروتین تنها زمانی ایجاد می‌شود که وارد حالت فعال یا completing شده باشد. زمانی که این اتفاق بیفتد، کوروتین وارد حالت canceling می‌شود و زمانی که همه job-ها لغو شوند، وارد حالت cancelled خواهد شد. برای روشن‌تر شدن موضوع به تصویر زیر نگاه کنید:

Coroutine در کاتلین

کاربرد مؤثر کوروتین‌ها

برخی Job-ها هستند که باید در زمان خروج کاربر از یک محل لغو شوند. تداوم این وظایف غیرضروری تنها موجب تحلیل رفتن منابع کاربر مانند پهنای باند، باتری و ظرفیت پردازشی می‌شود.

در این بخش به بررسی روش لغو کردن یک وظیفه یا یک سری از وظایف در زمان عدم نیاز به آن‌ها می‌پردازیم.

چنان که پیش‌تر گفتیم، زمانی که یک Launch روی یک دامنه کوروتین فراخوانی می‌شود، یک Job بازگشت خواهد یافت و اگر بخواهید آن وظیفه خاص را پایان بدهید، می‌توانید آن Job را لغو کنید.

همه ما می‌دانیم که چگونه یک Job خاص را لغو کنیم. اینک زمان آن رسیده است که تفاوت بین Job و SupervisorJob را درک کنیم.

تنها تفاوت بین یک Job و یک Job سوپروایزر این است که وقتی یک فرزند تحت Job سوپروایزر لغو می‌شود، والد یا دیگر فرزندان از آن تأثیر نمی‌پذیرند. از سوی دیگر زمانی که یک فرزند تحت Job لغو می‌شود، والد و همچنین همه فرزندان آن لغو خواهند شد.

val scope = CoroutineScope(parentJob)
val job1 = scope.launch{ ... }
val job2 = scope.launch{ ... }

می‌توانیم Job-ها را به صورت منفرد نیز در زمان عدم نیاز لغو کنیم. اگر parentJob یک Job سوپروایزر باشد، تنها آن Job لغو خواهد شد. در غیر این صورت همه Job-ها لغو می‌شوند.

در مثال فوق با شیوه لغو کردن یک Job خاص آشنا شدیم. اما اگر بخواهیم یک Job خاص لغو نشود و یا اگر نخواهیم همه Job ها را ردگیری کنیم و در عین حال در زمان نیاز همه آن‌ها را لغو کنیم، می‌توانیم تابع cancel را روی دامنه کوروتین فراخوانی کنیم به طوری که همه فرزندان زیر آن دامنه لغو شوند.

scope.cancel()
or
scope.coroutineContext.cancelChildren()

مدیریت خطا و استثنا

نکته دیگری که در بخش‌های قبل مورد اشاره قرار نگرفته است بحث مدیریت خطا و استثنا است. این یکی از وظایف اساسی در زمان تراکنش‌های مرتبط با شبکه و پایگاه داده محسوب می‌شود. برخلاف RxJava در زمان استفاده از کوروتین‌های کاتلین می‌توان این موضوع را به یک روش بسیار تمیز مدیریت کرد.

لغو جمعی

موارد isActive یا ensureActive را در Job-های خود بررسی کنید تا مطمئن شوید که Job کنونی از سوی استثنا یا عامدانه لغو نشده است.

1viewmodelCoroutineScope.launch {
2     ensureActive()
3         or
4     if(isActive){ ... }
5}

اگر Job لغو شده باشد، در این صورت این شروط برقرار نیستند.

اگر با استفاده از isActive روی هر Job راحت نیستید، می‌توانید کد را درون try/catch قرار دهید و در بلوک final هر کاری که می‌خواهید از قبیل پاک‌سازی زمان لغو شدن کوروتین را انجام دهید.

CoroutineScope لغو شده

اگر یک با Job ایجاد شده باشد و یکی از فرزندانش لغو شوند، در این صورت همه فرزندان دیگر آن نیز لغو می‌شوند که شامل Job والد نیز می‌شود. همراه با آن CoroutineScope نیز لغو می‌شود که معنی آن این است که نمی‌توان یک کوروتین جدید با آن دامنه آغاز کرد.

برای رفع این مشکل می‌توانید از SupervisorJob برای ایجاد CoroutineScope استفاده کنیم که تأثیری بر Job والد یا دامنه در زمان لغو کردن هر کدام از فرزندانش ندارد.

Try/Catch با کوروتین‌ها

برخلاف RxJava می‌توان خطاها و استثناها را از طریق قرار دادن آن‌ها در بلوک کد Try/Catch مدیریت کرد. نیازی هم به پیاده‌سازی یک بلوک خطا و بلوک موفقیت به صورت مجزا همانند کاری که در RxJava انجام می‌دادیم وجود نخواهد داشت.

runCatching

کاتلین همراه با try/catch اقدام به عرضه runCatching نیز کرده است که نتیجه‌ی بازگشت می‌دهد که اطلاعاتی در مورد خروجی همراه با حالت استثنا مانند isSucess دارد. اگر یک مورد ناموفق باشد، خصوصیتی در نتیجه exceptionOrNull وجود خواهد داشت که از طریق آن اطلاعاتی دقیقی در مورد استثنا به دست می‌آوریم. این روشی منعطف‌تر است و اینترفیس تمیزتری برای کار با استثناها و خطاها ارائه می‌کند. به مثال زیر توجه کنید:

1viewmodelCoroutineScope.launch {
2    val result = kotlin.runCatching {
3        repository.getData(inputs)
4    }
5}

به این ترتیب به پایان این مقاله با موضوع بررسی کوروتین‌ها در کاتلین می‌رسیم.

اگر این مطلب برای شما مفید بوده است، آموزش‌های زیر نیز به شما پیشنهاد می‌شوند:

==

بر اساس رای ۸ نفر
آیا این مطلب برای شما مفید بود؟
اگر بازخوردی درباره این مطلب دارید یا پرسشی دارید که بدون پاسخ مانده است، آن را از طریق بخش نظرات مطرح کنید.
منابع:
better-programming
۴ دیدگاه برای «آشنایی با Coroutine در کاتلین — از صفر تا صد»

سلام این کد بر رفع فیلتری (تعلیقی)روبیکا اثر دارد؟

عالی بود ممنون

ممنون از مطلب کاربردیتون

ممنون بابت این مقاله.
کاش WorkManager هم مثال می زدید

نظر شما چیست؟

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *