عملکرد برنامه را با کوروتین های Kotlin بهبود بخشید

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

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

مدیریت وظایف طولانی مدت

کوروتین‌ها با اضافه کردن دو عملیات برای مدیریت وظایف طولانی مدت، بر اساس توابع معمولی ساخته می‌شوند. کوروتین‌ها علاوه بر invoke (یا call ) و return ، suspend و resume نیز اضافه می‌کنند:

  • suspend اجرای کوروتین فعلی را متوقف می‌کند و تمام متغیرهای محلی را ذخیره می‌کند.
  • اجرای یک کوروتین معلق شده را از جایی که معلق شده بود، ادامه می‌دهد resume .

شما می‌توانید توابع suspend را فقط از توابع suspend دیگر یا با استفاده از یک سازنده‌ی کوروتین مانند launch برای شروع یک کوروتین جدید فراخوانی کنید.

مثال زیر یک پیاده‌سازی ساده‌ی کوروتین را برای یک وظیفه‌ی فرضیِ طولانی‌مدت نشان می‌دهد:

suspend fun fetchDocs() { // Dispatchers.Main
    val result = get("https://developer.android.com") // Dispatchers.IO for `get`
    show(result) // Dispatchers.Main
}

suspend fun get(url: String) = withContext(Dispatchers.IO) {
}

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

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

استفاده از کوروتین‌ها برای ایمنی اصلی

کوروتین‌های کاتلین از dispatchers برای تعیین اینکه کدام threadها برای اجرای کوروتین استفاده می‌شوند، استفاده می‌کنند. برای اجرای کد در خارج از thread اصلی، می‌توانید به کوروتین‌های کاتلین بگویید که روی Default dispatcher یا IO dispatcher کار کنند. در کاتلین، همه کوروتین‌ها باید در یک dispatcher اجرا شوند، حتی زمانی که روی thread اصلی اجرا می‌شوند. کوروتین‌ها می‌توانند خود را به حالت تعلیق درآورند و dispatcher مسئول از سرگیری آنها است.

برای مشخص کردن محل اجرای کوروتین‌ها، کاتلین سه dispatcher ارائه می‌دهد که می‌توانید از آنها استفاده کنید:

  • Dispatchers.Main - از این dispatcher برای اجرای یک کوروتین روی نخ اصلی اندروید استفاده کنید. این فقط باید برای تعامل با رابط کاربری و انجام کارهای سریع استفاده شود. مثال‌ها شامل فراخوانی توابع suspend ، اجرای عملیات چارچوب رابط کاربری اندروید و به‌روزرسانی اشیاء LiveData است.
  • Dispatchers.IO - این dispatcher برای انجام ورودی/خروجی دیسک یا شبکه خارج از thread اصلی بهینه شده است. مثال‌هایی از آن شامل استفاده از کامپوننت Room ، خواندن از یا نوشتن در فایل‌ها و اجرای هرگونه عملیات شبکه است.
  • Dispatchers.Default - این dispatcher برای انجام کارهای فشرده CPU در خارج از thread اصلی بهینه شده است. موارد استفاده مثال شامل مرتب‌سازی یک لیست و تجزیه JSON است.

در ادامه مثال قبلی، می‌توانید از dispatchers برای تعریف مجدد تابع get استفاده کنید. در داخل بدنه get ، withContext(Dispatchers.IO) را فراخوانی کنید تا بلوکی ایجاد شود که روی استخر نخ IO اجرا شود. هر کدی که داخل آن بلوک قرار دهید، همیشه از طریق dispatcher IO اجرا می‌شود. از آنجایی که withContext خود یک تابع suspend است، تابع get نیز یک تابع suspend است.

suspend fun fetchDocs() { // Dispatchers.Main
    val result = get("developer.android.com") // Dispatchers.Main
    show(result) // Dispatchers.Main
}

suspend fun get(url: String) = // Dispatchers.Main
    withContext(Dispatchers.IO) { // Dispatchers.IO (main-safety block)
        /* perform network IO here */ // Dispatchers.IO (main-safety block)
    } // Dispatchers.Main

با استفاده از کوروتین‌ها، می‌توانید نخ‌ها را با کنترل دقیق ارسال کنید. از آنجا که withContext() به شما امکان می‌دهد بدون معرفی callbackها، مجموعه نخ‌های هر خط کد را کنترل کنید، می‌توانید آن را برای توابع بسیار کوچک مانند خواندن از پایگاه داده یا انجام درخواست شبکه اعمال کنید. یک روش خوب، استفاده از withContext() است تا مطمئن شوید که هر تابع main-safe است، به این معنی که می‌توانید تابع را از نخ اصلی فراخوانی کنید. به این ترتیب، فراخواننده هرگز نیازی به فکر کردن در مورد اینکه کدام نخ باید برای اجرای تابع استفاده شود، ندارد.

در مثال قبلی، fetchDocs() روی نخ اصلی اجرا می‌شود؛ با این حال، می‌تواند با خیال راحت get فراخوانی کند که یک درخواست شبکه را در پس‌زمینه اجرا می‌کند. از آنجا که کوروتین‌ها suspend و resume پشتیبانی می‌کنند، کوروتین روی نخ اصلی به محض اتمام بلوک withContext با نتیجه get از سر گرفته می‌شود.

عملکرد withContext()

withContext() در مقایسه با پیاده‌سازی معادل مبتنی بر فراخوانی، سربار اضافی اضافه نمی‌کند. علاوه بر این، در برخی موقعیت‌ها می‌توان فراخوانی‌های withContext() را فراتر از پیاده‌سازی معادل مبتنی بر فراخوانی، بهینه‌سازی کرد. برای مثال، اگر یک تابع ده فراخوانی به یک شبکه انجام دهد، می‌توانید با استفاده از withContext() بیرونی به کاتلین بگویید که فقط یک بار رشته‌ها را تغییر دهد. سپس، حتی اگر کتابخانه شبکه چندین بار withContext() استفاده کند، در همان dispatcher باقی می‌ماند و از تغییر رشته‌ها جلوگیری می‌کند. علاوه بر این، کاتلین تغییر بین Dispatchers.Default و Dispatchers.IO را بهینه می‌کند تا در صورت امکان از تغییر رشته‌ها جلوگیری شود.

شروع یک کوروتین

شما می‌توانید کوروتین‌ها را به یکی از دو روش زیر شروع کنید:

  • launch یک کوروتین جدید را شروع می‌کند و نتیجه را به فراخواننده برنمی‌گرداند. هر کاری که به عنوان "fire and forget" در نظر گرفته شود، می‌تواند با استفاده از launch آغاز شود.
  • async یک کوروتین جدید را شروع می‌کند و به شما امکان می‌دهد با یک تابع suspend به نام await ، نتیجه را برگردانید.

معمولاً، شما باید یک کوروتین جدید را از یک تابع معمولی launch ، زیرا یک تابع معمولی نمی‌تواند await فراخوانی کند. فقط زمانی که درون یک کوروتین دیگر هستید یا زمانی که درون یک تابع suspend هستید و تجزیه موازی انجام می‌دهید، async استفاده کنید.

تجزیه موازی

تمام کوروتین‌هایی که درون یک تابع suspend شروع می‌شوند، باید هنگام بازگشت آن تابع متوقف شوند، بنابراین احتمالاً باید تضمین کنید که آن کوروتین‌ها قبل از بازگشت به پایان می‌رسند. با همزمانی ساختاریافته در کاتلین، می‌توانید یک coroutineScope تعریف کنید که یک یا چند کوروتین را شروع کند. سپس، با استفاده از await() (برای یک کوروتین واحد) یا awaitAll() (برای چندین کوروتین)، می‌توانید تضمین کنید که این کوروتین‌ها قبل از بازگشت از تابع به پایان می‌رسند.

به عنوان مثال، بیایید یک coroutineScope تعریف کنیم که دو سند را به صورت ناهمگام دریافت می‌کند. با فراخوانی await() روی هر ارجاع معوق، تضمین می‌کنیم که هر دو عملیات async قبل از بازگرداندن یک مقدار به پایان برسند:

suspend fun fetchTwoDocs() =
    coroutineScope {
        val deferredOne = async { fetchDoc(1) }
        val deferredTwo = async { fetchDoc(2) }
        deferredOne.await()
        deferredTwo.await()
    }

همچنین می‌توانید از awaitAll() روی مجموعه‌ها استفاده کنید، همانطور که در مثال زیر نشان داده شده است:

suspend fun fetchTwoDocs() = // called on any Dispatcher (any thread, possibly Main)
    coroutineScope {
        val deferreds = listOf( // fetch two docs at the same time
            async { fetchDoc(1) }, // async returns a result for the first doc
            async { fetchDoc(2) } // async returns a result for the second doc
        )
        deferreds.awaitAll() // use awaitAll to wait for both network requests
    }

اگرچه fetchTwoDocs() کوروتین‌های جدید را با async اجرا می‌کند، اما این تابع awaitAll() برای منتظر ماندن تا پایان اجرای کوروتین‌های اجرا شده قبل از بازگشت استفاده می‌کند. با این حال، توجه داشته باشید که حتی اگر awaitAll() را فراخوانی نکرده باشیم، سازنده coroutineScope کوروتینی را که fetchTwoDocs را فراخوانی کرده است، تا زمانی که تمام کوروتین‌های جدید تکمیل نشده باشند، از سر نمی‌گیرد.

علاوه بر این، coroutineScope هر استثنایی را که کوروتین‌ها ایجاد می‌کنند، دریافت کرده و آنها را به فراخواننده (caller) هدایت می‌کند.

برای اطلاعات بیشتر در مورد تجزیه موازی، به ترکیب توابع معلق مراجعه کنید.

مفاهیم کوروتین

کوروتین‌اسکوپ

یک CoroutineScope هر کوروتینی را که با استفاده از launch یا async ایجاد می‌کند، پیگیری می‌کند. کار جاری (یعنی کوروتین‌های در حال اجرا) را می‌توان با فراخوانی scope.cancel() در هر نقطه از زمان لغو کرد. در اندروید، برخی از کتابخانه‌های KTX CoroutineScope مخصوص به خود را برای کلاس‌های چرخه عمر خاص ارائه می‌دهند. به عنوان مثال، ViewModel دارای viewModelScope و Lifecycle دارای lifecycleScope است. با این حال، برخلاف dispatcher، یک CoroutineScope کوروتین‌ها را اجرا نمی‌کند.

viewModelScope همچنین در مثال‌های موجود در Background threading در اندروید با Coroutineها استفاده می‌شود. با این حال، اگر نیاز دارید CoroutineScope خودتان را برای کنترل چرخه حیات Coroutineها در یک لایه خاص از برنامه خود ایجاد کنید، می‌توانید آن را به صورت زیر ایجاد کنید:

class ExampleClass {

    // Job and Dispatcher are combined into a CoroutineContext which
    // will be discussed shortly
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine within the scope
        scope.launch {
            // New coroutine that can call suspend functions
            fetchDocs()
        }
    }

    fun cleanUp() {
        // Cancel the scope to cancel ongoing coroutines work
        scope.cancel()
    }
}

یک scope لغو شده نمی‌تواندCoroutineهای بیشتری ایجاد کند. بنابراین، شما باید scope.cancel() را فقط زمانی فراخوانی کنید که کلاسی که چرخه حیات آن را کنترل می‌کند، در حال از بین رفتن باشد. هنگام استفاده از viewModelScope ، کلاس ViewModel به طور خودکار scope را برای شما در متد onCleared() از ViewModel لغو می‌کند.

شغل

یک Job یک دستگیره برای یک Coroutine است. هر Coroutine که با launch یا async ایجاد می‌کنید، یک نمونه Job برمی‌گرداند که به طور منحصر به فرد Coroutine را شناسایی کرده و چرخه حیات آن را مدیریت می‌کند. همچنین می‌توانید یک Job به CoroutineScope ارسال کنید تا چرخه حیات آن را بیشتر مدیریت کنید، همانطور که در مثال زیر نشان داده شده است:

class ExampleClass {
    // ...
    fun exampleMethod() {
        // Handle to the coroutine, you can control its lifecycle
        val job = scope.launch {
            // New coroutine
        }

        if (condition) {
            // Cancel the coroutine started above, this doesn't affect the scope
            // this coroutine was launched in
            job.cancel()
        }
    }
}

CoroutineContext

یک CoroutineContext رفتار یک کوروتین را با استفاده از مجموعه عناصر زیر تعریف می‌کند:

  • Job : چرخه حیات کوروتین را کنترل می‌کند.
  • CoroutineDispatcher : کار را به thread مربوطه ارسال می‌کند.
  • CoroutineName : نام کوروتین، که برای اشکال‌زدایی مفید است.
  • CoroutineExceptionHandler : استثنائات مدیریت نشده را مدیریت می‌کند.

برای کوروتین‌های جدید ایجاد شده در یک محدوده، یک نمونه جدید Job به کوروتین جدید اختصاص داده می‌شود و سایر عناصر CoroutineContext از محدوده حاوی آن به ارث می‌رسند. می‌توانید با ارسال یک CoroutineContext جدید به تابع launch یا async ، عناصر به ارث برده شده را بازنویسی کنید. توجه داشته باشید که ارسال یک Job به launch یا async هیچ تاثیری ندارد، زیرا یک نمونه جدید از Job همیشه به یک کوروتین جدید اختصاص داده می‌شود.

class ExampleClass {
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine on Dispatchers.Main as it's the scope's default
        val job1 = scope.launch {
            // New coroutine with CoroutineName = "coroutine" (default)
        }

        // Starts a new coroutine on Dispatchers.Default
        val job2 = scope.launch(Dispatchers.Default + CoroutineName("BackgroundCoroutine")) {
            // New coroutine with CoroutineName = "BackgroundCoroutine" (overridden)
        }
    }
}

منابع اضافی کوروتین

برای منابع بیشتر در مورد کوروتین‌ها، به لینک‌های زیر مراجعه کنید: