קורוטינות של Kotlin מאפשרות לכתוב קוד אסינכרוני פשוט ונקי, ששומר על היענות האפליקציה תוך ניהול משימות ארוכות כמו קריאות לרשת או פעולות בדיסק.
בנושא הזה מוסבר בפירוט על קורוטינות ב-Android. אם אתם לא מכירים את המושג 'שגרות משנה', מומלץ לקרוא את המאמר שגרות משנה ב-Kotlin ב-Android לפני שתמשיכו לקרוא את הנושא הזה.
ניהול משימות לטווח ארוך
קורוטינות מבוססות על פונקציות רגילות, אבל הן כוללות שתי פעולות נוספות לטיפול במשימות ארוכות טווח. בנוסף ל-invoke (או ל-call) ול-return, coroutines מוסיפים את suspend ואת resume:
-
suspendpause משהה את ההרצה של הקורוטינה הנוכחית, ושומר את כל המשתנים המקומיים. -
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 מפעיל מחדש את הקורוטינה שהושעתה במקום להשתמש בקריאה חוזרת כדי להודיע על כך ל-thread הראשי.
ב-Kotlin נעשה שימוש במסגרת מחסנית כדי לנהל את הפונקציה שפועלת, יחד עם משתנים מקומיים. כשמשעים קורוטינה, מסגרת המחסנית הנוכחית מועתקת ונשמרת למועד מאוחר יותר. כשממשיכים, מסגרת המחסנית מועתקת בחזרה מהמקום שבו היא נשמרה, והפונקציה מתחילה לפעול שוב. למרות שהקוד עשוי להיראות כמו בקשת חסימה רגילה, קורוטינה מבטיחה שבקשת הרשת לא תחסום את ה-thread הראשי.
שימוש בקורוטינות כדי להבטיח בטיחות בשרשור הראשי
ב-coroutines של Kotlin נעשה שימוש בdispatchers כדי לקבוע באילו threads נעשה שימוש להרצת coroutine. כדי להריץ קוד מחוץ ל-thread הראשי, אפשר להגדיר ל-coroutines של Kotlin לבצע עבודה ב-dispatcher של Default או של IO. ב-Kotlin, כל הקורוטינות חייבות לפעול ב-dispatcher, גם כשהן פועלות ב-thread הראשי. קורוטינות יכולות להשהות את עצמן, וה-dispatcher אחראי להפעיל אותן מחדש.
כדי לציין איפה שגרות המשנה צריכות לפעול, Kotlin מספקת שלושה רכיבי dispatcher שאפשר להשתמש בהם:
- Dispatchers.Main – משתמשים ב-dispatcher הזה כדי להריץ קורוטינה ב-thread הראשי של Android. האפשרות הזו מתאימה רק לאינטראקציה עם ממשק המשתמש ולביצוע משימות מהירות. לדוגמה, קריאה לפונקציות
suspend, הפעלת פעולות של Android UI framework ועדכון אובייקטים של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 כדי למנוע מעברים בין שרשורים, ככל האפשר.
הפעלת קורוטינה
אפשר להפעיל קורוטינות באחת משתי דרכים:
-
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(), הפונקציה היוצרת coroutineScope לא הייתה מפעילה מחדש את הקורוטינה שקראה ל-fetchTwoDocs עד שכל הקורוטינות החדשות היו מסיימות את הפעולה.
בנוסף, coroutineScope מאתר חריגים שמוחזרים על ידי הקורוטינות ומנתב אותם בחזרה למתקשר.
מידע נוסף על פירוק מקביל זמין במאמר בנושא Composing suspending functions (הרכבת פונקציות להשהיה).
מושגים שקשורים לשגרות משנה
CoroutineScope
מחלקת CoroutineScope
עוקבת אחרי כל קורוטינה שהיא יוצרת באמצעות 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() } }
ב-scope שבוטלה הפעולה אי אפשר ליצור עוד קורוטינות. לכן, צריך לבצע קריאה ל-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 (condition) { // Cancel the coroutine started above, this doesn't affect the scope // this coroutine was launched in job.cancel() } } }
CoroutineContext
CoroutineContext
מגדיר את ההתנהגות של קורוטינה באמצעות קבוצת הרכיבים הבאה:
-
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) } } }
מקורות מידע נוספים על קורוטינות
מקורות מידע נוספים על קורוטינות:
- שגרות משנה (coroutines) ב-Kotlin ב-Android
- מקורות מידע נוספים על שגרות משנה (coroutines) ועל Flow ב-Kotlin