שיפור ביצועי האפליקציה באמצעות קורוטינים ב-Kotlin

קורוטינות של Kotlin מאפשרות לכם לכתוב קוד אסינכרוני נקי ופשוט, ששומר על היענות האפליקציה תוך ניהול משימות ארוכות כמו קריאות לרשת או פעולות בדיסק.

בנושא הזה מוסבר בפירוט על קורוטינות ב-Android. אם אתם לא מכירים את המושג 'שגרות משנה', מומלץ לקרוא את המאמר שגרות משנה ב-Kotlin ב-Android לפני שתמשיכו לקרוא את הנושא הזה.

ניהול משימות לטווח ארוך

קורוטינות מבוססות על פונקציות רגילות, אבל הן כוללות שתי פעולות נוספות לטיפול במשימות ארוכות טווח. בנוסף ל-invoke (או ל-call) ול-return,‏ קורוטינות מוסיפות את suspend ואת resume:

  • ‫suspend pause משהה את ההרצה של הקורוטינה הנוכחית ושומר את כל המשתנים המקומיים.
  • ‫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() עדיין פועל בשרשור הראשי, אבל הוא משעה את שגרת המשך (coroutine) לפני שהוא מתחיל את בקשה לאחזור מהרשת. כשבקשה לאחזור מהרשת מסתיימת, get מפעיל מחדש את שגרת ההמשך (coroutine) שהושעתה במקום להשתמש בקריאה חוזרת (callback) כדי להודיע ל-Thread הראשי.

ב-Kotlin נעשה שימוש במסגרת סטאק כדי לנהל את הפונקציה שפועלת ואת המשתנים המקומיים. כשמשעים קורוטינה, מסגרת המחסנית הנוכחית מועתקת ונשמרת למועד מאוחר יותר. כשממשיכים, מסגרת סטאק מועתקת בחזרה מהמקום שבו היא נשמרה, והפונקציה מתחילה לפעול שוב. למרות שהקוד עשוי להיראות כמו בקשת חסימה רגילה, רציפה, שגרת המשך (coroutine) מבטיחה שבקשה לאחזור מהרשת לא תחסום את ה-Thread הראשי.

שימוש ב-coroutines כדי להבטיח בטיחות בשרשור הראשי

ב-coroutines של Kotlin נעשה שימוש בdispatchers כדי לקבוע באילו threads נעשה שימוש להרצת coroutine. כדי להריץ קוד מחוץ ל-thread הראשי, אפשר להגדיר ל-coroutines של Kotlin לבצע עבודה ב-dispatcher של Default או של IO. ב-Kotlin, כל הקורוטינות חייבות לפעול ב-dispatcher, גם כשהן פועלות ב-thread הראשי. קורוטינות יכולות להשהות את עצמן, וה-Dispatcher אחראי להפעיל אותן מחדש.

כדי לציין איפה שגרות המשנה צריכות לפעול, Kotlin מספקת שלושה רכיבי dispatcher שאפשר להשתמש בהם:

  • ‫Dispatchers.Main – משתמשים ב-dispatcher הזה כדי להריץ שגרת המשך (coroutine) ב-thread הראשי של Android. השימוש באפשרות הזו צריך להיות מוגבל לאינטראקציה עם ממשק המשתמש ולביצוע עבודות מהירות. לדוגמה: קריאה לפונקציות suspend, הפעלת פעולות של מסגרת ממשק המשתמש של Android ועדכון אובייקטים של LiveData.
  • ‫Dispatchers.IO – ה-dispatcher הזה מותאם לביצוע קלט/פלט של דיסק או רשת מחוץ ל-thread הראשי. לדוגמה, שימוש ברכיב Room, קריאה מקבצים או כתיבה לקבצים והרצת פעולות ברשת.
  • ‫Dispatchers.Default – ה-dispatcher הזה מותאם לביצוע עבודה שדורשת הרבה משאבי CPU מחוץ ל-thread הראשי. דוגמאות לתרחישי שימוש: מיון רשימה וניתוח של JSON.

בהמשך לדוגמה הקודמת, אפשר להשתמש ב-dispatchers כדי להגדיר מחדש את הפונקציה get. בתוך הגוף של get, קוראים ל-withContext(Dispatchers.IO) כדי ליצור בלוק שפועל במאגר השרשורים של IO. כל קוד שמוסיפים לבלוק הזה מופעל תמיד דרך רכיב IO של המערכת. מכיוון ש-withContext היא בעצמה פונקציית השעיה, הפונקציה get היא גם פונקציית השעיה.

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() מאפשרת לכם לשלוט במאגר השרשורים של כל שורת קוד בלי להוסיף קריאות חוזרות (callbacks), ולכן אפשר להשתמש בה בפונקציות קטנות מאוד כמו קריאה ממסד נתונים או ביצוע בקשה לאחזור מהרשת. מומלץ להשתמש ב-withContext() כדי לוודא שכל פונקציה היא main-safe, כלומר שאפשר לקרוא לפונקציה מה-main thread. כך, המתקשר לא צריך לחשוב באיזה שרשור צריך להשתמש כדי להפעיל את הפונקציה.

בדוגמה הקודמת, הפונקציה fetchDocs() מופעלת בשרשור הראשי, אבל היא יכולה לקרוא בבטחה לפונקציה get, שמבצעת בקשה לאחזור מהרשת ברקע. מכיוון שקורוטינות תומכות ב-suspend וב-resume, הקורוטינה בשרשור הראשי ממשיכה עם התוצאה get ברגע שהבלוק withContext מסתיים.

ביצועים של withContext()‎

‫withContext() לא מוסיף תקורה נוספת בהשוואה להטמעה מקבילה שמבוססת על קריאה חוזרת (callback). בנוסף, במקרים מסוימים אפשר לבצע אופטימיזציה של withContext() שיחות מעבר להטמעה מקבילה שמבוססת על קריאה חוזרת. לדוגמה, אם פונקציה מבצעת עשר קריאות לרשת, אפשר להגדיר ב-Kotlin מעבר בין השרשורים רק פעם אחת באמצעות withContext() חיצוני. אז, למרות שספריית הרשת משתמשת ב-withContext() כמה פעמים, היא נשארת באותו רכיב לשליחת בקשות ונמנעת מהחלפת שרשורים. בנוסף, Kotlin מבצעת אופטימיזציה למעבר בין Dispatchers.Default לבין Dispatchers.IO כדי למנוע מעברים בין שרשורים, בכל הזדמנות שאפשר.

הפעלת שגרת המשך (coroutine)

יש שתי דרכים להפעיל קורוטינות:

  • ‫launch מתחיל שגרת המשך חדשה ולא מחזיר את התוצאה לפונקציית קריאה חוזרת (caller). אפשר להשתמש ב-launch כדי להתחיל כל עבודה שנחשבת ל'הפעל וסיימנו'.
  • ‫async מתחיל שגרת המשך (coroutine) חדשה ומאפשר להחזיר תוצאה באמצעות פונקציית השעיה בשם await.

בדרך כלל, צריך להפעיל launch שגרת המשך חדשה מפונקציה רגילה, כי פונקציה רגילה לא יכולה להפעיל await. משתמשים ב-async רק בתוך שגרת המשך (coroutine) אחרת או בתוך פונקציית השהיה, כשמבצעים פירוק מקביל.

פירוק מקביל

כל הקורוטינות שמופעלות בתוך פונקציית suspend חייבות להיפסק כשהפונקציה הזו מחזירה ערך, ולכן כנראה תצטרכו לוודא שהקורוטינות האלה מסתיימות לפני שהפונקציה מחזירה ערך. באמצעות structured concurrency ב-Kotlin, אפשר להגדיר 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(), ה-builder coroutineScope לא היה מפעיל מחדש את שגרת ההמשך (coroutine) שקראה ל-fetchTwoDocs עד שכל שגרות ההמשך (coroutine) החדשות היו מסיימות את הפעולה.

בנוסף, coroutineScope מאתר חריגים שמוחזרים על ידי הקורוטינות ומנתב אותם בחזרה למתקשר.

מידע נוסף על פירוק מקביל זמין במאמר בנושא Composing suspending functions (הרכבת פונקציות להשהיה).

מושגים שקשורים לשגרות משנה

CoroutineScope

CoroutineScope עוקב אחרי כל שגרת המשך (coroutine) שהוא יוצר באמצעות launch או async. אפשר לבטל את העבודה השוטפת (כלומר, את הקורוטינות הפועלות) על ידי קריאה ל-scope.cancel() בכל שלב. ב-Android, חלק מספריות KTX מספקות CoroutineScope משלהן עבור מחלקות מסוימות של מחזור חיים. לדוגמה, ViewModel כולל viewModelScope, ו-Lifecycle כולל lifecycleScope. עם זאת, בניגוד ל-dispatcher, ‏ CoroutineScope לא מפעיל את הקורוטינות.

השם viewModelScope משמש גם בדוגמאות שמופיעות במאמר Background threading on Android with Coroutines. עם זאת, אם אתם צריכים ליצור CoroutineScope משלכם כדי לשלוט במחזור החיים של קורוטינות בשכבה מסוימת באפליקציה, אתם יכולים ליצור אותו באופן הבא:

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()
    }
}

היקף שבוטל לא יכול ליצור עוד שגרות המשך (coroutine). לכן, צריך לבצע קריאה ל-scope.cancel() רק כשהמחלקה ששולטת במחזור החיים שלה מושמדת. כשמשתמשים ב-viewModelScope, המחלקה ViewModel מבטלת את ההיקף באופן אוטומטי בשיטה onCleared() של ViewModel.

משימה

‫Job הוא נקודת אחיזה לשגרת המשך (coroutine). כל קורוטינה שיוצרים באמצעות launch או async מחזירה מופע של Job שמזהה באופן ייחודי את הקורוטינה ומנהל את מחזור החיים שלה. אפשר גם להעביר 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 מגדיר את ההתנהגות של שגרת המשך (coroutine) באמצעות קבוצת הרכיבים הבאה:

  • ‫Job: שולט במחזור החיים של הקורוטינה.
  • ‫CoroutineDispatcher: העברת עבודה לשרשור המתאים.
  • ‫CoroutineName: השם של שגרת המשך (coroutine), שימושי לניפוי באגים.
  • ‫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)
        }
    }
}

מקורות מידע נוספים על קורוטינות

מקורות מידע נוספים על קורוטינות: