Coroutine در کاتلین — راهنمای مقدماتی
Coroutine-ها اساساً به نخهای (Threads) سبکوزن گفته میشوند. با استفاده از Coroutine-ها میتوانیم کدهای غیر مسدودکننده و ناهمگامی به روش ترتیبی بنویسیم. در این مقاله توضیحاتی مقدماتی در خصوص Coroutine در کاتلین ارائه خواهیم کرد.
چگونه Coroutine کاتلین را در اندروید ایمپورت کنیم؟
بر اساس ریپازیتوری گیتهاب کوروتین کاتلین، برای ایمپورت کردن آن باید دو کتابخانه kotlinx-coroutines-core و kotlinx-coroutines-android را ایمپورت کنیم. کتابخانه kotlinx-coroutines-android از نخ اصلی اندروید درست مانند کتابخانه io.reactivex.rxjava2:rxandroid برای RxJava پشتیبانی میکند. همچنین با بهرهگیری از آن مطمئن خواهیم بود که استثناهای گیر نیفتاده میتوانند پیش از آن که اپلیکیشن اندروید را از کار بیندازند لاگ شوند. به علاوه اگر از RxJava در پروژه خود استفاده میکنید، برای استفاده از Coroutine-ها به همراه RxJava باید از kotlinx-coroutines-rx2 بهره بگیرید. این کتابخانه به انتقال RxJava به Coroutine-ها کمک میکند.
با افزودن کد زیر به app/build.gradle میتوانید آنها را در پروژه ایمپورت کنید:
1implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.2'
2implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.2'
3implementation "org.jetbrains.kotlinx:kotlinx-coroutines-rx2:1.3.2"
آخرین نسخه کاتلین را نیز به root/build.gradle اضافه کنید:
1buildscript {
2 ext.kotlin_version = '1.3.50'
3 repositories {
4 jcenter()
5 ...
6 }
7 ...
8}
بدین ترتیب کار تنظیمات اولیه به پایان میرسد. اینک به موضوع اصلی یعنی آشنایی با Coroutine-ها میپردازیم.
مقدمات Coroutine-ها
ابتدا باید ببینیم که Coroutine-ها اساساً به چه شکلی هستند:
1CoroutineScope(Dispatchers.Main + Job()).launch {
2 val user = fetchUser() // A suspending function running in the I/O thread.
3 updateUser(user) // Updates UI in the main thread.
4}
5
6private suspend fun fetchUser(): User = withContext(Dispatchers.IO) {
7 // Fetches the data from server and returns user data.
8}
در کد فوق دادهها را در نخ پسزمینه از سرور واکشی کرده و سپس UI را در نخ اصلی بهروزرسانی میکنیم.
تابعهای تعلیقی
در Coroutine-های کاتلین یک تابع خاص به نام suspending function وجود دارد که میتوان با کلیدواژه suspend آن را اعلان کرد.
تابعهای تعلیقی میتوانند اجرای یک Coroutine را تعلیق کنند. یعنی Coroutine تا زمانی که تابع تعلیقی از سر گرفته نشده است، صبر میکند. در این مقاله به توضیح مقدمات Coroutine-ها میپردازیم و در بخش بعدی این سری مقالات در مورد تابعهای تعلیقی بیشتر صحبت خواهیم کرد.
قطعه کد فوق را میتوان به چهار بخش تقسیم کرد:
دامنه کوروتین (CoroutineScope)
CoroutineScope دامنه یک کوروتین را تعریف میکند. هر سازنده کوروتین یک بسط روی CoroutineScope است و از coroutineContext آن برای انتشار خودکار عناصر چارچوب و لغو آن ارثبری میکند.
همه کوروتینها درون یک CoroutineScope اجرا میشوند که یک CoroutineContext به عنوان پارامتر میگیرد. چندین دامنه وجود دارد که میتوان مورد استفاده قرار داد:
CoroutineScope
این دامنه با CoroutineContext سفارشی ساخته میشود. برای نمونه برای تعریف یک thread ،parent job و exception handler مورد استفاده قرار میگیرد.
1CoroutineScope(Dispatchers.Main + job + exceptionHandler).launch {
2 ...
3}
MainScope
دامنه اصلی کامپوننتهای UI را ایجاد میکند. این دامنه روی نخ اصلی با SupervisorJob() اجرا میشود، یعنی شکست یکی از کارهای فرزند آن تأثیری روی موارد دیگر نخواهد داشت.
1public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)
GlobalScope
دامنهای است که به هیچ job محدود نمیشود. از آن برای اجرای کوروتینهای سطح بالا استفاده میشود که روی کل چرخه عمر اپلیکیشن عمل میکنند و به صورت دائمی لغو نمیشوند.
CoroutineContext
کوروتین همواره در یک چارچوب اجرا میشود که به وسیله مقدار نوع CoroutineContext بازنمایی میشود. CoroutineContext به مجموعهای از عناصر گفته میشود که برای تعریف یک سیاست «نخبندی» (threading)، مدیریت استثنا، کنترل چرخه عمر کوروتین و مواردی از این دست، مورد استفاده قرار میگیرند. میتوانیم از عملگر plus برای ترکیب عناصر CoroutineContext استفاده کنیم.
سه چارچوب مهم کوروتین وجود دارند که شامل Dispatchers ،CoroutineExceptionHandler و Job هستند و آنها را در ادامه به تفصیل توضیح میدهیم.
Dispatchers
این چارچوب تعریف میکند که کدام نخ باید کوروتین را اجرا کند. یک کوروتین میتواند هر زمان با استفاده از ()withContext بین Dispatchers سوئیچ کند.
- Dispatchers.Default: از یک استخر پسزمینه مشترک از نخها استفاده میکند. بیشینه تعداد نخهای مورد استفاده از سوی این Dispatcher به صورت پیشفرض برابر با تعداد هستههای CPU است، اما دست کم دو مورد وجود دارد. نخ میتواند مانند Thread[DefaultDispatcher-worker-2,5,main] باشد.
- Dispatchers.IO: نخها را با Dispatchers.Default به اشتراک میگذارد، اما تعداد نخها محدود به مقدار گزینه kotlinx.coroutines.io.parallelism است. مقدار پیشفرض این محدودیت برابر با 64 نخ یا تعداد هستهها (هر کدام که بزرگتر باشد) است. نخ میتواند مانند Thread[DefaultDispatcher-worker-1,5,main] باشد و از این نظر مشابه Dispatchers.Default است.
- Dispatchers.Main: معادل نخ اصلی اندروید است. نخ میتواند مانند Thread[main,5,main] باشد.
- Dispatchers.Unconfined: یک دیسپچر کوروتین است که به هیچ نخ خاصی محدود نشده است. کوروتین ابتدا در نخ جاری اجرا میشود و میتواند هر زمان که نخ از سوی تابع تعلیقی متناظر مورد استفاده قرار گرفت، ازسرگیری شود.
1CoroutineScope(Dispatchers.Unconfined).launch {
2 // Writes code here running on Main thread.
3
4 delay(1_000)
5 // Writes code here running on `kotlinx.coroutines.DefaultExecutor`.
6
7 withContext(Dispatchers.IO) { ... }
8 // Writes code running on I/O thread.
9
10 withContext(Dispatchers.Main) { ... }
11 // Writes code running on Main thread.
12}
CoroutineExceptionHandler
CoroutineExceptionHandler استثناهای گیر نیفتاده را مدیریت میکند. به طور معمول استثناهای گیر نیفتاده میتوانند تنها در نتیجه عملکرد کوروتینهای ایجاد شده با استفاده از launch builder باشند. هر کوروتین که با استفاده از async ایجاد شده باشد، همواره همه استثناهای خود را مدیریت میکند و آنها را در شیء Deferred حاصل نمایش میدهد.
مثال 1
در این مثال نمیتوانیم IOException() را خارج از try-catch به دام بیندازیم. از طرفی هم نمیتوانیم کل CoroutineScope را درون یک try-catch قرار دهیم و از این رو اپلیکیشن مداوماً از کار میافتد.
1try {
2 CoroutineScope(Dispatchers.Main).launch {
3 doSomething()
4 }
5} catch (e: IOException) {
6 // Cannot catch IOException() here.
7 Log.d("demo", "try-catch: $e")
8}
9
10private suspend fun doSomething() {
11 delay(1_000)
12 throw IOException()
13}
مثال 2
در کد زیر ()IOException با CoroutineExceptionHandler به دام میافتد. اگر استثنا چیزی به جز ()CancellationException و برای نمونه یک ()IOException باشد به CoroutineExceptionHandler ارسال میشود.
1// Handles coroutine exception here.
2val handler = CoroutineExceptionHandler { _, throwable ->
3 Log.d("demo", "handler: $throwable") // Prints "handler: java.io.IOException"
4}
5
6CoroutineScope(Dispatchers.Main + handler).launch {
7 doSomething()
8}
9
10private suspend fun doSomething() {
11 delay(1_000)
12 throw IOException()
13}
مثال 3
در کد زیر ()CancellationException نادیده گرفته میشود. اگر استثنا CancellationException باشد، در این صورت نادیده گرفته میشود، زیرا مکانیسم مورد نظر کوروتین اجرایی را لغو میکند و این استثنا به CoroutineExceptionHandler ارسال نخواهد شد.
1// Handles coroutine exception here.
2val handler = CoroutineExceptionHandler { _, throwable ->
3 // Won't print the log because the exception is "CancellationException()".
4 Log.d("demo", "handler: $throwable")
5}
6
7CoroutineScope(Dispatchers.Main + handler).launch {
8 doSomething()
9}
10
11private suspend fun doSomething() {
12 delay(1_000)
13 throw CancellationException()
14}
مثال 4
در کد زیر از invokeOnCompletion برای دریافت همه اطلاعات استثناها استفاده شده است. ()CancellationException به CoroutineExceptionHandler ارسال نمیشود. اگر میخواهید برخی اطلاعات را پس از رخ دادن استثنا پرینت کنید، میتوانید از invokeOnCompletion بهره بگیرید.
1val job = CoroutineScope(Dispatchers.Main).launch {
2 doSomething()
3}
4
5job.invokeOnCompletion {
6 val error = it ?: return@invokeOnCompletion
7 // Prints "invokeOnCompletion: java.util.concurrent.CancellationException".
8 Log.d("demo", "invokeOnCompletion: $error")
9 }
10}
11
12private suspend fun doSomething() {
13 delay(1_000)
14 throw CancellationException()
15}
Job
چارچوب Job چرخه عمر کوروتین را کنترل میکند. هر Job دارای حالتهای زیر است:
برای دانستن حالت جاری یک Job میتوانیم از Job.isActive استفاده کنیم. گردش تغییر حالتها نیز به صورت زیر است:
- یک job در زمان فعالیت کوروتین فعال محسوب میشود.
- شکست job با بروز استثنا موجب لغو شدن آن میشود. یک job میتواند هر زمان با تابع Cancel لغو شود. این تابع، گذار بیدرنگ به حالت cancelling را الزام میکند.
- Job زمانی لغو میشود که اجرای کار خود را به پایان ببرد.
- Job والد برای همه فرزندان خود در یکی از حالتهای completing یا cancelling میماند تا این که کار خود را به پایان ببرند. توجه کنید که حالت completing کاملاً در داخل job قرار دارد. از دید یک ناظر بیرونی یک job در حالت completing همچنان فعال است، در حالی که از دید درونی منتشر فرزندانش است.
سلسله مراتب والد-فرزند
پس از آشنایی با حالتهای کوروتینها اینک باید طرز کار سلسله مراتب والد-فرزند را بشناسیم. فرض کنید میخواهیم کدی مانند زیر بنویسیم:
1val parentJob = Job()
2val childJob1 = CoroutineScope(parentJob).launch {
3 val childJob2 = launch { ... }
4 val childJob3 = launch { ... }
5}
در این حالت، سلسلهمراتب والد-فرزند باید مانند زیر باشد:
میتوانیم job والد را در زمان اجرا به صورت زیر تغییر دهیم:
1val parentJob1 = Job()
2val parentJob2 = Job()
3val childJob1 = CoroutineScope(parentJob1).launch {
4 val childJob2 = launch { ... }
5 val childJob3 = launch(parentJob2) { ... }
6}
در این حالت سلسلهمراتب والد-فرزند به صورت زیر خواهد بود:
بر اساس دانش فوق، مفاهیم مهم زیادی وجود دارند که برای کار با یک job باید بدانیم.
- لغو یک والد منجر به لغو شدن بیدرنگ همه فرزندانش میشود.
1val parentJob = Job()
2CoroutineScope(Dispatchers.Main + parentJob).launch {
3 val childJob = launch {
4 delay(5_000)
5
6 // This function won't be executed because its parentJob is
7 // already cancelled after 1 sec.
8 canNOTBeExcecuted()
9 }
10 launch {
11 delay(1_000)
12 parentJob.cancel() // Cancels parent job after 1 sec.
13 }
14}
- شکست یا لغو شدن یک فرزند به دلیل وجود استثنایی به غیر از CancellationException موجب لغو بیدرنگ والد و دیگر فرزندان میشود. اما اگر استثنا CancellationException باشد، job-های دیگر که تحت کنترل آن job نیستند، تحت تأثیر قرار نمیگیرند.
اگر یک CancellationException صادر کنید، تنها job زیر childJob1 لغو خواهد شد.
1val parentJob = Job()
2CoroutineScope(Dispatchers.Main + parentJob).launch {
3 val childJob1 = launch {
4 val childOfChildJob1 = launch {
5 delay(2_000)
6 // This function won't be executed since childJob1 is cancelled.
7 canNOTBeExecuted()
8 }
9 delay(1_000)
10
11 // Cancel childJob1.
12 cancel()
13 }
14
15 val childJob2 = launch {
16 delay(2_000)
17 canDoSomethinghHere()
18 }
19
20 delay(3_000)
21 canDoSomethinghHere()
22}
اگر IOException را در یکی از job-های فرزند صادر کنیم، همه job-های مرتبط لغو خواهند شد:
1val parentJob = Job()
2val handler = CoroutineExceptionHandler { _, throwable ->
3 Log.d("demo", "handler: $throwable") // Prints "handler: java.io.IOException"
4}
5
6CoroutineScope(Dispatchers.Main + parentJob + handler).launch {
7 val childJob1 = launch {
8 delay(1_000)
9 // Throws any exception "other than CancellationException" after 1 sec.
10 throw IOException()
11 }
12
13 val childJob2 = launch {
14 delay(2_000)
15 // The other child job: this function won't be executed.
16 canNOTBExecuted()
17 }
18
19 delay(3_000)
20 // Parent job: this function won't be executed.
21 canNOTBExecuted()
22}
- ()cancelChildren – یک والد میتواند فرزندان خود یعنی همه فرزندان خود را به صورت بازگشتی و بدون لغو کردن خود، لغو کند. توجه کنید که اگر یک job لغو شود، نمیتواند به عنوان یک job والد برای اجرای مجدد کوروتین مورد استفاده قرار گیرد.
اگر از ()Job.cancel استفاده کنیم، job والد شروع به لغو شدن میکند، یعنی وارد حالت Cancelling میشود. پس از این همه job-های فرزند لغو شدند، job والد نیز خود را لغو میکند.
1val parentJob = Job()
2val childJob = CoroutineScope(Dispatchers.Main + parentJob).launch {
3 delay(1_000)
4
5 // This function won't be executed because its parent is cancelled.
6 canNOTBeExecuted()
7}
8
9parentJob.cancel()
10
11// Prints "JobImpl{Cancelling}@199d143", parent job status becomes "cancelling".
12// And will be "cancelled" after all the child job is cancelled.
13Log.d("demo", "$parentJob")
اگر از ()Job.cancelChildren استفاده کنیم، job والد همچنان active خواهد بود و همچنان میتوانیم از آن برای اجرای کوروتینهای دیگر بهره بگیریم:
1val parentJob = Job()
2val childJob = CoroutineScope(Dispatchers.Main + parentJob).launch {
3 delay(1_000)
4
5 // This function won't be executed because its parent job is cancelled.
6 canNOTBeExecuted()
7}
8
9// Only children are cancelled, the parent job won't be cancelled.
10parentJob.cancelChildren()
11
12// Prints "JobImpl{Active}@199d143", parent job is still active.
13Log.d("demo", "$parentJob")
14
15val childJob2 = CoroutineScope(Dispatchers.Main + parentJob).launch {
16 delay(1_000)
17
18 // Since the parent job is still active, we could use it to run child job 2.
19 canDoSomethingHere()
20}
SupervisorJob یا Job
فرزندان یک job ناظر میتوانند مستقل از همدیگر شکست بخورند. چنان که پیشتر گفتیم، اگر از یک ()job ساده به عنوان job والد استفاده کنیم، در صورت شکست یکی از job-های فرزند، همه فرزندان لغو میشوند:
1val parentJob = Job()
2val handler = CoroutineExceptionHandler { _, _ -> }
3val scope = CoroutineScope(Dispatchers.Default + parentJob + handler)
4val childJob1 = scope.launch {
5 delay(1_000)
6 // ChildJob1 fails with the IOException().
7 throw IOException()
8}
9
10val childJob2 = scope.launch {
11 delay(2_000)
12 // This line won't be executed due to childJob1 failure.
13 canNOTBeExecuted()
14}
اگر از ()SupervisorJob به عنوان job والد استفاده کنیم، شکست یک job فرزند تأثیری روی job-های فرزند دیگر نمیگذارد:
1val parentJob = SupervisorJob()
2val handler = CoroutineExceptionHandler { _, _ -> }
3val scope = CoroutineScope(Dispatchers.Default + parentJob + handler)
4val childJob1 = scope.launch {
5 delay(1_000)
6 // ChildJob1 fails with the IOException().
7 throw IOException()
8}
9
10val childJob2 = scope.launch {
11 delay(2_000)
12 // Since we use SupervisorJob() as parent job, the failure of
13 // childJob1 won't affect other child jobs. This function will be
14 // executed.
15 canDoSomethinghHere()
16}
سازنده کوروتین
سازندگان مختلفی برای کوروتینها وجود دارند که در ادامه هر کدام از آنها را به تفصیل شرح میدهیم.
- Launch – یک کوروتین جدید را بدون مسدودسازی نخ جاری اجرا میکند و ارجاعی به کوروتین به شکل یک job بازگشت میدهد.
- async and await – سازنده کوروتین async به صورت یک اکستنشن روی CoroutineScope تعریف میشود. این سازنده یک کوروتین ایجاد کرده و نتیجه آتی آن را به صورت یک پیادهسازی از Deferred بازگشت میدهد که یک آیتم لغو پذیر غیر مسدودساز است. در واقع یک job با نتیجه محسوب میشود.
Async به همراه await استفاده میشود؛ await برای تکمیل این مقدار بدون مسدودسازی نخ استفاده میشود و زمانی که محاسبه deferred تکمیل شود، از سر گرفته میشود و مقدار حاصل را بازگشت میدهد و یا در صورت لغو شدن deferred، استثنای متناظر را باز میگرداند.
کد زیر یک فراخوانی ترتیبی از دو تابع تعلیقی را نشان میدهد. در این کد برخی وظایف زمانبر را اجرا کردهایم که در هر دو مورد ()fetchDataFromServerOne و ()fetchDataFromServerTwo یک ثانیه طول میکشد. سپس آنها را در سازنده launch فرامیخوانیم. هزینه زمان نهایی برابر با مجموع هزینه زمانی 2 ثانیه خواهد بود.
1override fun onCreate(savedInstanceState: Bundle?) {
2 ...
3
4 val scope = MainScope()
5 scope.launch {
6 val time = measureTimeMillis {
7 val one = fetchDataFromServerOne()
8 val two = fetchDataFromServerTwo()
9 Log.d("demo", "The sum is ${one + two}")
10 }
11 Log.d("demo", "Completed in $time ms")
12 }
13}
14
15private suspend fun fetchDataFromServerOne(): Int {
16 Log.d("demo", "fetchDataFromServerOne()")
17 delay(1_000)
18 return 1
19}
20
21private suspend fun fetchDataFromServerTwo(): Int {
22 Log.d("demo", "fetchDataFromServerTwo()")
23 delay(1_000)
24 return 2
25}
لاگ به صورت زیر خواهد بود:
2019-12-09 00:00:34.547 D/demo: fetchDataFromServerOne() 2019-12-09 00:00:35.553 D/demo: fetchDataFromServerTwo() 2019-12-09 00:00:36.555 D/demo: The sum is 3 2019-12-09 00:00:36.555 D/demo: Completed in 2008 ms
هزینه زمانی برابر با مجموع زمان تأخیر و تابع تعلیقی است. این اجرا تا زمانی که ()fetchDataFromServerOne پایان نیافته تعلیق میشود و سپس ()fetchDataFromServerTwo را اجرا میکند.
اگر بخواهیم هر دو تابع را به صورت همزمان اجرا کنیم تا هزینه زمانی را کاهش دهیم، میتوانیم از await استفاده کنیم. async کاملاً شبیه به launch است. یک کوروتین دیگر آغاز میکند که به صورت همزمان با دیگر کوروتینها کار میکند و Deferred بازگشت میدهد که یک job با مقدار بازگشتی است.
1public interface Deferred<out T> : Job {
2 public suspend fun await(): T
3 ...
4}
میتوانیم ()await را روی یک مقدار Deferred برای دریافت نتیجه فراخوانی کنیم. به مثال زیر توجه کنید:
1override fun onCreate(savedInstanceState: Bundle?) {
2 ...
3
4 val scope = MainScope()
5 scope.launch {
6 val time = measureTimeMillis {
7 val one = async { fetchDataFromServerOne() }
8 val two = async { fetchDataFromServerTwo() }
9 Log.d("demo", "The sum is ${one.await() + two.await()}")
10 }
11
12 // Function one and two will run asynchrously,
13 // so the time cost will be around 1 sec only.
14 Log.d("demo", "Completed in $time ms")
15 }
16}
17
18private suspend fun fetchDataFromServerOne(): Int {
19 Log.d("demo", "fetchDataFromServerOne()")
20 delay(1_000)
21 return 1
22}
23
24private suspend fun fetchDataFromServerTwo(): Int {
25 Log.d("demo", "fetchDataFromServerTwo()")
26 Thread.sleep(1_000)
27 return 2
28}
کد فوق نتیجه زیر را لاگ میکند:
2019-12-08 23:52:01.714 D/demo: fetchDataFromServerOne() 2019-12-08 23:52:01.718 D/demo: fetchDataFromServerTwo() 2019-12-08 23:52:02.722 D/demo: The sum is 3 2019-12-08 23:52:02.722 D/demo: Completed in 1133 ms
بدنه کوروتین
کد اجرا شده در CoroutineScope شامل یک تابع معمولی یا تابع تعلیقی است که کوروتین را تا پایان اجرا تعلیق میکند؛ به این ترتیب به پایان این مقاله میرسیم. برای مطالعه بخش بعدی این مقاله روی لینک زیر کلیک کنید:
اگر این مطلب برای شما مفید بوده است، آموزشهای زیر نیز به شما پیشنهاد میشوند:
- مجموعه آموزشهای برنامهنویسی اندروید
- مجموعه آموزشهای برنامهنویسی
- آموزش مقدماتی زبان برنامه نویسی کاتلین (Kotlin) برای توسعه اندروید (Android)
- زبان برنامه نویسی کاتلین (Kotlin) — راهنمای کاربردی
- برنامه نویسی اندروید با کاتلین — راهنمای شروع به کار
==