קורוטינים ב-Kotlin ב-Android

קורוטינה היא תבנית עיצוב של פעולות מקבילות שאפשר להשתמש בה ב-Android כדי לפשט קוד שמופעל באופן אסינכרוני. ‫Coroutines נוספו ל-Kotlin בגרסה 1.3 ומבוססים על מושגים מוכרים משפות אחרות.

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

תכונות

שגרות המשך (coroutines) הן הפתרון המומלץ שלנו לתכנות אסינכרוני ב-Android. התכונות הבולטות כוללות:

  • קל משקל: אפשר להריץ הרבה שגרות המשך (coroutine) ב-Thread יחיד כי יש תמיכה בהשעיה, שלא חוסמת את ה-Thread שבו שגרת המשך (coroutine) פועלת. השהיה חוסכת זיכרון בהשוואה לחסימה, ותומכת בהרבה פעולות בו-זמניות.
  • פחות דליפות זיכרון: אפשר להשתמש במקביליות מובנית כדי להריץ פעולות בהיקף מסוים.
  • תמיכה מובנית בביטול: ביטול מופץ אוטומטית בהיררכיית שגרות ההמשך (coroutine) הפועלות.
  • שילוב עם Jetpack: הרבה ספריות Jetpack כוללות תוספים שמספקים תמיכה מלאה בשגרות משנה. חלק מהספריות מספקות גם היקף משתני קורוטינה משלהן, שאפשר להשתמש בו כדי להגדיר מקביליות מובנית.

סקירה כללית של דוגמאות

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

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

ViewModel כוללת קבוצה של תוספי KTX שפועלים ישירות עם קורוטינות. התוספים האלה הם ספריית lifecycle-viewmodel-ktx והם משמשים במדריך הזה.

פרטי התלות

כדי להשתמש בקורוטינות בפרויקט Android, מוסיפים את התלות הבאה לקובץ build.gradle של האפליקציה:

מגניב

dependencies {
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
}

Kotlin

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9")
}

ביצוע בשרשור ברקע

כשמבצעים בקשה לאחזור מהרשת בשרשור הראשי, השרשור מחכה, או נחסם, עד שהוא מקבל תשובה. מכיוון שהשרשור חסום, מערכת ההפעלה לא יכולה לקרוא ל-onDraw(), מה שגורם לאפליקציה לקפוא ועלול להוביל לתיבת דו-שיח של ANR (האפליקציה לא מגיבה). כדי לשפר את חוויית המשתמש, נריץ את הפעולה הזו בשרשור ברקע.

קודם נסתכל על המחלקה Repository ונראה איך היא יוצרת את בקשה לאחזור מהרשת:

sealed class Result<out R> {
    data class Success<out T>(val data: T) : Result<T>()
    data class Error(val exception: Exception) : Result<Nothing>()
}

private const val loginUrl = "https://example.com/login"

class LoginRepository(private val responseParser: LoginResponseParser) {
    // Function that makes the network request, blocking the current thread
    fun makeLoginRequest(
        jsonBody: String
    ): Result<LoginResponse> {
        val url = URL(loginUrl)
        (url.openConnection() as? HttpURLConnection)?.run {
            requestMethod = "POST"
            setRequestProperty("Content-Type", "application/json; utf-8")
            setRequestProperty("Accept", "application/json")
            doOutput = true
            outputStream.write(jsonBody.toByteArray())
            return Result.Success(responseParser.parse(inputStream))
        }
        return Result.Error(Exception("Cannot open HttpURLConnection"))
    }
}

makeLoginRequest הוא סינכרוני וחוסם את ה-thread שקורא לו. כדי לדמות את התגובה לבקשה לאחזור מהרשת, יש לנו מחלקה משלנו, Result.

הטריגר ViewModel מפעיל את בקשה לאחזור מהרשת כשהמשתמש לוחץ, למשל, על לחצן:

class LoginViewModel(
    private val loginRepository: LoginRepository
) : ViewModel() {

    fun login(username: String, token: String) {
        val jsonBody = "{ username: \"$username\", token: \"$token\"}"
        loginRepository.makeLoginRequest(jsonBody)
    }
}

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

class LoginViewModel(
    private val loginRepository: LoginRepository
) : ViewModel() {

    fun login(username: String, token: String) {
        // Create a new coroutine to move the execution off the UI thread
        viewModelScope.launch(Dispatchers.IO) {
            val jsonBody = "{ username: \"$username\", token: \"$token\"}"
            loginRepository.makeLoginRequest(jsonBody)
        }
    }
}

ננתח את קוד הקורוטינות בפונקציה login:

  • viewModelScope הוא CoroutineScope מוגדר מראש שכלול בתוספים של ViewModel KTX. חשוב לזכור שכל שגרות ההמשך (coroutine) חייבות לפעול בהיקף. ‫CoroutineScope מנהל קורוטינה אחת או יותר שקשורות זו לזו.
  • launch היא פונקציה שיוצרת שגרת המשך (coroutine) ושולחת את ההרצה של גוף הפונקציה שלה אל ה-Dispatcher המתאים.
  • Dispatchers.IO מציין שצריך להריץ את שגרת ההמשך הזו ב-Thread ששמור לפעולות קלט/פלט (I/O).

הפונקציה login מבוצעת באופן הבא:

  • האפליקציה קוראת לפונקציה login מהשכבה View ב-thread הראשי.
  • launch יוצרת קורוטינה חדשה, ובקשת הרשת מתבצעת באופן עצמאי ב-thread ששמור לפעולות קלט/פלט.
  • בזמן ששגרת המשך (coroutine) פועלת, הפונקציה login ממשיכה לפעול ומחזירה ערך, יכול להיות שלפני שהבקשה לאחזור מהרשת מסתיימת. שימו לב: לצורך פשטות, בשלב הזה מתעלמים מתגובת הרשת.

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

בעיה אחת בדוגמה הקודמת היא שכל מה שמפעיל את makeLoginRequest צריך לזכור להעביר באופן מפורש את ההפעלה מה-thread הראשי. אבדוק איך אפשר לשנות את Repository כדי לפתור את הבעיה.

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

פונקציה נחשבת בטוחה לשימוש בשרשור הראשי אם היא לא חוסמת עדכונים של ממשק המשתמש בשרשור הראשי. הפונקציה makeLoginRequest לא בטוחה לשימוש ב-thread הראשי, כי קריאה ל-makeLoginRequest מה-thread הראשי חוסמת את ממשק המשתמש. אפשר להשתמש בפונקציה withContext() מהספרייה של קורוטינות כדי להעביר את ההפעלה של קורוטינה לשרשור אחר:

class LoginRepository(
    // ...
) {
    // ...
    suspend fun makeLoginRequest(
        jsonBody: String
    ): Result<LoginResponse> {

        // Move the execution of the coroutine to the I/O dispatcher
        return withContext(Dispatchers.IO) {
            // Blocking network request code
        }
    }
}

withContext(Dispatchers.IO) מעבירה את ההפעלה של הקורוטינה לשרשור קלט/פלט (I/O), וכך פונקציית הקריאה שלנו בטוחה לשימוש בשרשור הראשי ומאפשרת לממשק המשתמש להתעדכן לפי הצורך.

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

בדוגמה הבאה, שגרת ההמשך (coroutine) נוצרת ב-LoginViewModel. הפונקציה makeLoginRequest מעבירה את ההרצה מה-thread הראשי, ולכן אפשר להריץ את ה-Coroutine בפונקציה login ב-thread הראשי:

class LoginViewModel(
    private val loginRepository: LoginRepository
) : ViewModel() {

    fun login(username: String, token: String) {

        // Create a new coroutine on the UI thread
        viewModelScope.launch {
            val jsonBody = "{ username: \"$username\", token: \"$token\"}"

            // Make the network call and suspend execution until it finishes
            val result = loginRepository.makeLoginRequest(jsonBody)

            // Display result of the network request to the user
            when (result) {
                is Result.Success<LoginResponse> -> { /* Happy path */ }
                else -> { /* Show error in UI */ }
            }
        }
    }
}

שימו לב: עדיין צריך כאן קורוטינה, כי makeLoginRequest היא פונקציית suspend, וכל פונקציות suspend חייבות לפעול בקורוטינה.

יש כמה הבדלים בין הקוד הזה לבין הדוגמה הקודמת של login:

  • הפונקציה launch לא מקבלת פרמטר Dispatchers.IO. אם לא מעבירים Dispatcher ל-launch, כל הקורוטינות שמופעלות מ-viewModelScope פועלות בשרשור הראשי.
  • התוצאה של בקשה לאחזור מהרשת מטופלת עכשיו כדי להציג את ממשק המשתמש של ההצלחה או הכישלון.

פונקציית הכניסה מבוצעת עכשיו באופן הבא:

  • האפליקציה קוראת לפונקציה login() מהשכבה View ב-thread הראשי.
  • launch יוצרת קורוטינה חדשה בשרשור הראשי, והקורוטינה מתחילה לפעול.
  • בתוך הקורוטינה, הקריאה ל-loginRepository.makeLoginRequest() now משהה את ההרצה הנוספת של הקורוטינה עד שבלוק withContext ב-makeLoginRequest() יסיים את ההרצה.
  • אחרי שהבלוק withContext מסתיים, שגרת המשך (coroutine) ב-login() ממשיכה את ההרצה ב-thread הראשי עם התוצאה של הבקשה לאחזור מהרשת.

טיפול בחריגים

כדי לטפל בחריגים ששכבת Repository יכולה להפעיל, משתמשים בתמיכה המובנית של Kotlin בחריגים. בדוגמה הבאה אנחנו משתמשים בבלוק try-catch:

class LoginViewModel(
    private val loginRepository: LoginRepository
) : ViewModel() {

    fun login(username: String, token: String) {
        viewModelScope.launch {
            val jsonBody = "{ username: \"$username\", token: \"$token\"}"
            val result = try {
                loginRepository.makeLoginRequest(jsonBody)
            } catch (e: Exception) {
                Result.Error(Exception("Network request failed"))
            }
            when (result) {
                is Result.Success<LoginResponse> -> { /* Happy path */ }
                else -> { /* Show error in UI */ }
            }
        }
    }
}

בדוגמה הזו, כל חריגה לא צפויה שמוצגת על ידי הקריאה makeLoginRequest() מטופלת כשגיאה בממשק המשתמש.

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

למידע נוסף על שגרות המשך (coroutine) ב-Android, אפשר לעיין במאמר שיפור ביצועי האפליקציה באמצעות שגרות המשך (coroutine) ב-Kotlin.

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