תופעות לוואי

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

עם זאת, לפעמים נדרשות תופעות לוואי, למשל, כדי להפעיל אירוע חד-פעמי, כמו הצגת סרגל הצד או מעבר למסך אחר בתנאי של מצב מסוים. צריך להפעיל את הפעולות האלה בסביבה מבוקרת שמכירה את מחזור החיים של הרכיב הניתן לקישור. בדף הזה נסביר על ממשקי ה-API השונים של תופעות לוואי ש-Jetpack Compose מציע.

תרחישים לדוגמה של מצבים והשפעה

כפי שמתואר במסמכי העזרה של Thinking in Compose, רכיבים שניתנים ליצירה צריכים להיות ללא תופעות לוואי. כשצריך לבצע שינויים במצב של האפליקציה (כפי שמתואר במסמך ניהול המצב), צריך להשתמש ב-Effect API כדי שהתוצאות הלוואי יבוצעו באופן צפוי.

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

LaunchedEffect: הפעלת פונקציות השהיה בהיקף של פונקציה הניתנת להגדרה

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

לדוגמה, הנה אנימציה של ערך האלפא מהבהב עם השהיה שניתן להגדיר:

// Allow the pulse rate to be configured, so it can be sped up if the user is running
// out of time
var pulseRateMs by remember { mutableStateOf(3000L) }
val alpha = remember { Animatable(1f) }
LaunchedEffect(pulseRateMs) { // Restart the effect when the pulse rate changes
    while (isActive) {
        delay(pulseRateMs) // Pulse the alpha every pulseRateMs to alert the user
        alpha.animateTo(0f)
        alpha.animateTo(1f)
    }
}

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

rememberCoroutineScope: קבלת היקף שמודע להרכבה כדי להפעיל פונקציית coroutine מחוץ ל-composable

מכיוון ש-LaunchedEffect היא פונקציה שניתנת ליצירה, אפשר להשתמש בה רק בתוך פונקציות אחרות שניתנות ליצירה. כדי להפעיל קורוטין מחוץ ל-composable, אבל כך שהוא יבוטל באופן אוטומטי ברגע שהוא יוצא מההרכב, משתמשים ב-rememberCoroutineScope. אפשר גם להשתמש ב-rememberCoroutineScope בכל פעם שצריך לשלוט במחזור החיים של קואוטין אחד או יותר באופן ידני, לדוגמה, לבטל אנימציה כשמתרחש אירוע משתמש.

rememberCoroutineScope היא פונקציה קומפוזבילית שמחזירה CoroutineScope שקשורה לנקודה של היצירה שבה היא נקראת. ההיקף יבוטל כשהשיחה תצא מהעריכה.

בהמשך לדוגמה הקודמת, תוכלו להשתמש בקוד הזה כדי להציג Snackbar כשהמשתמש מקיש על Button:

@Composable
fun MoviesScreen(snackbarHostState: SnackbarHostState) {

    // Creates a CoroutineScope bound to the MoviesScreen's lifecycle
    val scope = rememberCoroutineScope()

    Scaffold(
        snackbarHost = {
            SnackbarHost(hostState = snackbarHostState)
        }
    ) { contentPadding ->
        Column(Modifier.padding(contentPadding)) {
            Button(
                onClick = {
                    // Create a new coroutine in the event handler to show a snackbar
                    scope.launch {
                        snackbarHostState.showSnackbar("Something happened!")
                    }
                }
            ) {
                Text("Press me")
            }
        }
    }
}

rememberUpdatedState: הפניה לערך באפקט שלא צריך להתחיל מחדש אם הערך משתנה

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

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

@Composable
fun LandingScreen(onTimeout: () -> Unit) {

    // This will always refer to the latest onTimeout function that
    // LandingScreen was recomposed with
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    // Create an effect that matches the lifecycle of LandingScreen.
    // If LandingScreen recomposes, the delay shouldn't start again.
    LaunchedEffect(true) {
        delay(SplashWaitTimeMillis)
        currentOnTimeout()
    }

    /* Landing screen content */
}

כדי ליצור אפקט שתואם למחזור החיים של אתר הקריאה, מעבירים פרמטר של קבוע שלא משתנה, כמו Unit או true. בקוד שלמעלה נעשה שימוש ב-LaunchedEffect(true). כדי לוודא ש-onTimeout lambda תמיד מכיל את הערך העדכני ביותר שאיתו LandingScreen הורכב מחדש, צריך לתחום את onTimeout בפונקציה rememberUpdatedState. צריך להשתמש ב-State, ‏ currentOnTimeout שהוחזר בקוד, כדי ליצור את האפקט.

DisposableEffect: אפקטים שדורשים ניקוי

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

לדוגמה, יכול להיות שתרצו לשלוח אירועי ניתוח נתונים על סמך אירועי Lifecycle באמצעות LifecycleObserver. כדי להאזין לאירועים האלה ב-Compose, משתמשים ב-DisposableEffect כדי לרשום ולבטל את הרישום של הצופה לפי הצורך.

@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit, // Send the 'started' analytics event
    onStop: () -> Unit // Send the 'stopped' analytics event
) {
    // Safely update the current lambdas when a new one is provided
    val currentOnStart by rememberUpdatedState(onStart)
    val currentOnStop by rememberUpdatedState(onStop)

    // If `lifecycleOwner` changes, dispose and reset the effect
    DisposableEffect(lifecycleOwner) {
        // Create an observer that triggers our remembered callbacks
        // for sending analytics events
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_START) {
                currentOnStart()
            } else if (event == Lifecycle.Event.ON_STOP) {
                currentOnStop()
            }
        }

        // Add the observer to the lifecycle
        lifecycleOwner.lifecycle.addObserver(observer)

        // When the effect leaves the Composition, remove the observer
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }

    /* Home screen content */
}

בקוד שלמעלה, האפקט יוסיף את הערך observer לערך lifecycleOwner. אם lifecycleOwner ישתנה, האפקט יושמט ותתחיל מחדש עם ה-lifecycleOwner החדש.

DisposableEffect חייב לכלול תנאי onDispose כטענת הקצה בבלוק הקוד שלו. אחרת, סביבת הפיתוח המשולבת (IDE) תציג שגיאה בזמן build.

SideEffect: פרסום המצב של Compose בקוד שאינו של Compose

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

לדוגמה, ייתכן שספריית הניתוח תאפשר לכם לפלח את אוכלוסיית המשתמשים על ידי צירוף מטא-נתונים מותאמים אישית ('מאפייני משתמש' בדוגמה הזו) לכל האירועים הבאים בניתוח. כדי להעביר לספריית ניתוח הנתונים את סוג המשתמש של המשתמש הנוכחי, אפשר להשתמש ב-SideEffect כדי לעדכן את הערך שלו.

@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
    val analytics: FirebaseAnalytics = remember {
        FirebaseAnalytics()
    }

    // On every successful composition, update FirebaseAnalytics with
    // the userType from the current User, ensuring that future analytics
    // events have this metadata attached
    SideEffect {
        analytics.setUserProperty("userType", user.userType)
    }
    return analytics
}

produceState: המרת מצב שאינו Compose למצב Compose

produceState מפעילה פונקציית קורוטין ברמת הקומפוזיציה, שיכולה לדחוף ערכים ל-State המוחזר. אפשר להשתמש בו כדי להמיר מצב שאינו של Compose למצב של Compose. לדוגמה, אפשר להוסיף ל-Composition מצב חיצוני שמבוסס על מינויים, כמו Flow, ‏ LiveData או RxJava.

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

למרות ש-produceState יוצרת קורוטין, אפשר להשתמש בה גם כדי לצפות במקורות נתונים שלא מושהים. כדי להסיר את המינוי למקור הזה, משתמשים בפונקציה awaitDispose.

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

@Composable
fun loadNetworkImage(
    url: String,
    imageRepository: ImageRepository = ImageRepository()
): State<Result<Image>> {
    // Creates a State<T> with Result.Loading as initial value
    // If either `url` or `imageRepository` changes, the running producer
    // will cancel and will be re-launched with the new inputs.
    return produceState<Result<Image>>(initialValue = Result.Loading, url, imageRepository) {
        // In a coroutine, can make suspend calls
        val image = imageRepository.load(url)

        // Update State with either an Error or Success result.
        // This will trigger a recomposition where this State is read
        value = if (image == null) {
            Result.Error
        } else {
            Result.Success(image)
        }
    }
}

derivedStateOf: המרת אובייקט מצב אחד או יותר למצב אחר

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

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

שימוש נכון

קטע הקוד הבא מציג תרחיש לדוגמה לשימוש ב-derivedStateOf:

@Composable
// When the messages parameter changes, the MessageList
// composable recomposes. derivedStateOf does not
// affect this recomposition.
fun MessageList(messages: List<Message>) {
    Box {
        val listState = rememberLazyListState()

        LazyColumn(state = listState) {
            // ...
        }

        // Show the button if the first visible item is past
        // the first item. We use a remembered derived state to
        // minimize unnecessary compositions
        val showButton by remember {
            derivedStateOf {
                listState.firstVisibleItemIndex > 0
            }
        }

        AnimatedVisibility(visible = showButton) {
            ScrollToTopButton()
        }
    }
}

בקטע הקוד הזה, firstVisibleItemIndex משתנה בכל פעם שהפריט הגלוי הראשון משתנה. בזמן הגלילה, הערך הופך ל-0, 1, 2, 3, 4, 5 וכו'. עם זאת, הרכבה מחדש צריכה להתרחש רק אם הערך גדול מ-0. חוסר ההתאמה בתדירות העדכונים מצביע על כך שזהו תרחיש לדוגמה מתאים ל-derivedStateOf.

שימוש שגוי

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

// DO NOT USE. Incorrect usage of derivedStateOf.
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }

val fullNameBad by remember { derivedStateOf { "$firstName $lastName" } } // This is bad!!!
val fullNameCorrect = "$firstName $lastName" // This is correct

בקטע הקוד הזה, fullName צריך להתעדכן בתדירות זהה לזו של firstName ו-lastName. לכן, לא מתבצעת יצירת קומפוזיציה מחדש מיותרת ואין צורך להשתמש ב-derivedStateOf.

snapshotFlow: המרת המצב של Compose ל-Flows

משתמשים ב-snapshotFlow כדי להמיר אובייקטים מסוג State<T> ל-Flow קר. snapshotFlow מפעיל את הבלוק שלו כשהוא נאסף ומפיק את התוצאה של אובייקטי State שנקראים בו. כשאחד מהאובייקטים של State שנקראו בתוך הבלוק snapshotFlow עובר שינוי, ה-Flow יפיק את הערך החדש לאוסף שלו אם הערך החדש לא שווה לערך הקודם שהופיק (ההתנהגות הזו דומה לזו של Flow.distinctUntilChanged).

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

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .map { index -> index > 0 }
        .distinctUntilChanged()
        .filter { it == true }
        .collect {
            MyAnalyticsService.sendScrolledPastFirstItemEvent()
        }
}

בקוד שלמעלה, הערך listState.firstVisibleItemIndex מומר ל-Flow שאפשר להשתמש בו עם אופרטורים של Flow.

אפקטים להפעלה מחדש

חלק מהאפקטים בכתיבה, כמו LaunchedEffect, produceState או DisposableEffect, משתמשים במספר משתנה של ארגומנטים ומפתחות, שמאפשרים לבטל את האפקט שרץ ולהתחיל אפקט חדש עם המפתחות החדשים.

המבנה הטיפוסי של ממשקי ה-API האלה הוא:

EffectName(restartIfThisKeyChanges, orThisKey, orThisKey, ...) { block }

בגלל המורכבות של ההתנהגות הזו, יכולות להתרחש בעיות אם הפרמטרים המשמשים להפעלה מחדש של האפקט לא נכונים:

  • הפעלה מחדש של אפקטים בתדירות נמוכה מהנדרש עלולה לגרום לבאגים באפליקציה.
  • הפעלה מחדש של אפקטים יותר מהנדרש עלולה להיות לא יעילה.

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

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

@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit, // Send the 'started' analytics event
    onStop: () -> Unit // Send the 'stopped' analytics event
) {
    // These values never change in Composition
    val currentOnStart by rememberUpdatedState(onStart)
    val currentOnStop by rememberUpdatedState(onStop)

    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            /* ... */
        }

        lifecycleOwner.lifecycle.addObserver(observer)
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }
}

אין צורך ב-currentOnStart וב-currentOnStop כמפתחות DisposableEffect, כי הערך שלהם אף פעם לא משתנה ב-Composition בגלל השימוש ב-rememberUpdatedState. אם לא מעבירים את lifecycleOwner כפרמטר והוא משתנה, HomeScreen יעבור קומפוזיציה מחדש, אבל ה-DisposableEffect לא ינוקה ויופעל מחדש. זה גורם לבעיות כי המערכת משתמשת ב-lifecycleOwner הלא נכון מהרגע הזה ואילך.

קבועים כמפתחות

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