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

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

בנושא הזה מפורט מידע מפורט על קורוטין ב-Android. אם אתם לא מכירים את קורוטינים, חשוב לקרוא לפני קריאת הנושא, קורוטין של קוטלין ב-Android.

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

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

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

שימוש בקורוטינים לשמירה על הבטיחות העיקרית

קורוטינים של Kotlin משתמשים במשגרים כדי לקבוע אילו שרשורים משמשים ביצוע קורוטין. כדי להריץ קוד מחוץ ל-thread הראשי, אפשר לומר ל-Kotlin שגרים (coroutines) לביצוע עבודה בשולחנות ברירת המחדל או ב-IO. לחשבון Kotlin, כל הקורוטינים חייבים לרוץ בסדרנים, גם כשהם פועלים בשרשור הראשי. קורוטינים יכולים להשעות את עצמם, והשולח באחריות לחדש את השימוש בהם.

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

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

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

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

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

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

הפעלת קורוטין

יש שתי דרכים להתחיל לקבל קורוטינים:

  • launch מתחיל קורוטין חדש ולא מחזיר את התוצאה למתקשר. כלשהו יצירה שנחשבת ל"אש ושכח" אפשר להתחיל להשתמש ב-launch.
  • async מתחיל קורוטין חדש ומאפשר לך להחזיר תוצאה עם השעיה בפונקציה await.

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

פירוק מקביל

יש להפסיק את כל הקורוטינים שהתחילו בתוך פונקציה suspend כאשר הפונקציה מחזירה, אז סביר להניח שתצטרכו להבטיח שהקורוטין לסיים לפני החזרה. באמצעות בו-זמניות מובנה ב-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 לא ממשיך את התהליך של קורוטין fetchTwoDocs עד לסיום כל הקורוטינים החדשים.

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

למידע נוסף על פירוק מקביל ראו כתיבת פונקציות השעיה.

מושגי קורוטינה

היקף קואוטין

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

viewModelScope משמש גם בדוגמאות שמופיעות ב- הפעלת שרשורים ברקע ב-Android עם Coroutine. עם זאת, אם אתם צריכים ליצור 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()
    }
}

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

משרה

Job הוא כינוי של קורוטין. כל קוקטייל שיוצרים באמצעות 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 (...) {
            // Cancel the coroutine started above, this doesn't affect the scope
            // this coroutine was launched in
            job.cancel()
        }
    }
}

CoroutineContext

CoroutineContext מגדיר את ההתנהגות של קורוטין באמצעות קבוצת הרכיבים הבאה:

בשביל קורוטינים חדשים שנוצרו במסגרת היקף, מופע 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)
        }
    }
}

משאבים נוספים בנושא קורוטינים

למשאבים נוספים בנושא קורוטינים, ראו את הקישורים הבאים: