תופעת לוואי היא שינוי במצב האפליקציה שמתרחש מחוץ להיקף של פונקציה שאפשר להרכיב. בגלל מחזור החיים של רכיבים קומפוזביליים ומאפיינים כמו קומפוזיציות מחדש בלתי צפויות, ביצוע קומפוזיציות מחדש של רכיבים קומפוזביליים בסדרים שונים או קומפוזיציות מחדש שאפשר לבטל, רכיבים קומפוזביליים צריכים להיות ללא תופעות לוואי.
עם זאת, לפעמים יש צורך בתופעות לוואי, למשל כדי להפעיל אירוע חד-פעמי כמו הצגת סרגל אינטראקטיבי או מעבר למסך אחר בהינתן תנאי מצב מסוים. צריך לקרוא לפעולות האלה מסביבה מבוקרת שמודעת למחזור החיים של הרכיב הקומפוזבילי. בדף הזה נסביר על ממשקי ה-API השונים של תופעות לוואי ש-Jetpack Compose מציע.
תרחישי שימוש במצב ובאפקט
כמו שמוסבר במאמר חשיבה ב-Compose, רכיבים קומפוזביליים לא אמורים ליצור תופעות לוואי. כשצריך לבצע שינויים במצב האפליקציה (כפי שמתואר במסמך ניהול מצבים), מומלץ להשתמש בממשקי ה-API של Effect כדי שהתופעות לוואי האלה יופעלו בצורה צפויה.
בגלל האפשרויות השונות שזמינות באפקטים ב-Compose, קל להשתמש בהם יותר מדי. חשוב לוודא שהעבודה שאתם מבצעים בהם קשורה לממשק המשתמש ולא פוגעת בזרימת נתונים חד-כיוונית, כמו שמוסבר במסמכי ניהול המצב.
LaunchedEffect: הפעלת פונקציות השהיה בהיקף של פונקציה קומפוזבילית
כדי לבצע עבודה במהלך מחזור החיים של רכיב קומפוזבילי ולהשתמש בפונקציות השהיה, משתמשים ברכיב הקומפוזבילי LaunchedEffect. כש-LaunchedEffect נכנס לקומפוזיציה, הוא מפעיל שגרת המשך (coroutine) עם בלוק הקוד שמועבר כפרמטר. שגרת ההמשך (coroutine) תבוטל אם 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 { mutableLongStateOf(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) מחוץ לפונקציה קומפוזבילית
LaunchedEffect היא פונקציה קומפוזבילית, ולכן אפשר להשתמש בה רק בתוך פונקציות קומפוזביליות אחרות. כדי להפעיל קורוטינה מחוץ לפונקציה קומפוזבילית, אבל בטווח שבו היא תבוטל אוטומטית כשהיא תצא מהקומפוזיציה, צריך להשתמש ב-rememberCoroutineScope. צריך להשתמש ב-rememberCoroutineScope גם כשצריך לשלוט במחזור החיים של קורוטינה אחת או יותר באופן ידני, למשל, כדי לבטל אנימציה כשמתרחש אירוע של משתמש.
rememberCoroutineScope היא פונקציה קומפוזבילית שמחזירה CoroutineScope שקשור לנקודה בקומפוזיציה שבה היא נקראת. ההיקף יבוטל כשהשיחה תצא מה-Composition.
בהמשך לדוגמה הקודמת, אפשר להשתמש בקוד הזה כדי להציג 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 מופעל מחדש כשמשתנה אחד מפרמטרי המפתח. עם זאת, במקרים מסוימים יכול להיות שתרצו לתעד ערך באפקט, שאם הוא ישתנה, לא תרצו שהאפקט יופעל מחדש. כדי לעשות זאת, צריך להשתמש ב-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: אפקטים שנדרש ניקוי
כדי לטפל בתופעות לוואי שצריך לנקות אחרי שינוי המקשים או אם רכיב ה-composable יוצא מה-Composition, משתמשים ב-DisposableEffect.
אם המפתחות DisposableEffect משתנים, הקומפוזיציה צריכה להסיר (לנקות) את האפקט הנוכחי שלה, ולאפס על ידי קריאה חוזרת לאפקט.
לדוגמה, יכול להיות שתרצו לשלוח אירועים של Analytics על סמך אירועים של Lifecycle באמצעות LifecycleObserver.
כדי להמתין ולתעד את האירועים האלה ב-Compose, משתמשים ב-DisposableEffect כדי לרשום ולבטל את הרישום של האובייקט Observer כשצריך.
@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) תוצג שגיאה בזמן הבנייה.
SideEffect: publish Compose state to non-Compose code
כדי לשתף את ערך דינמי של Compose עם אובייקטים שלא מנוהלים על ידי Compose, צריך להשתמש בפונקציה הקומפוזבילית SideEffect. השימוש ב-SideEffect מבטיח שהאפקט יופעל אחרי כל רה-קומפוזיציה מוצלחת. מצד שני, לא נכון להפעיל אפקט לפני שמובטחת רה-קומפוזיציה מוצלחת, וזה המצב כשכותבים את האפקט ישירות בפונקציה קומפוזבילית.
לדוגמה, יכול להיות שספריית ניתוח הנתונים שלכם מאפשרת לכם לפלח את אוכלוסיית המשתמשים על ידי צירוף מטא-נתונים מותאמים אישית ('מאפייני משתמש' בדוגמה הזו) לכל אירועי ניתוח הנתונים הבאים. כדי להעביר לספריית ניתוח הנתונים את סוג המשתמש הנוכחי, משתמשים ב-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
מפעיל קורוטינה בהיקף של ה-Composition שיכולה להעביר ערכים ל-State שמוחזר. אפשר להשתמש בה כדי להמיר מצב שאינו Compose למצב Compose, למשל כדי להעביר מצב חיצוני מבוסס-מינוי כמו Flow, LiveData או RxJava אל ה-Composition.
הפונקציה להפקת נתונים מופעלת כש-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 כשערכי הקלט של רכיב ה-Composable משתנים בתדירות גבוהה יותר מהתדירות שבה צריך לבצע קומפוזיציה מחדש. זה קורה בדרך כלל כשמשהו משתנה לעיתים קרובות, כמו מיקום גלילה, אבל רכיב ה-Composable צריך להגיב לשינוי רק אחרי שהוא חוצה סף מסוים. הפונקציה derivedStateOf יוצרת אובייקט חדש של מצב Compose שאפשר לעקוב אחריו, והוא מתעדכן רק כשצריך. באופן הזה, היא פועלת באופן דומה לאופרטור distinctUntilChanged() של Kotlin Flow.
שימוש נכון
בקטע הקוד הבא מוצג תרחיש שימוש מתאים בפונקציה 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.
שימוש לא נכון
טעות נפוצה היא להניח שכאשר משלבים שני אובייקטים של מצב Compose, צריך להשתמש ב-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 לתהליכים
משתמשים ב-snapshotFlow כדי להמיר אובייקטים של State<T> ל-Flow קר. snapshotFlow מפעיל את הבלוק שלו כשהוא נאסף ופולט את התוצאה של האובייקטים State שנקראו בו. כשמשנים אחד מאובייקטי State שנקראים בתוך הבלוק snapshotFlow, הפונקציה Flow תפלוט את הערך החדש אל האוסף שלה אם הערך החדש לא שווה לערך הקודם שנפלט (ההתנהגות הזו דומה להתנהגות של Flow.distinctUntilChanged).
בדוגמה הבאה מוצגת תופעת לוואי שמתעדת את הפעולה של גלילת המשתמש מעבר לפריט הראשון ברשימה אל Analytics:
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.
הפעלה מחדש של אפקטים
חלק מהאפקטים ב-Compose, כמו LaunchedEffect, produceState או DisposableEffect, מקבלים מספר משתנה של ארגומנטים, מפתחות, שמשמשים לביטול האפקט הפועל ולהתחלת אפקט חדש עם המפתחות החדשים.
הפורמט האופייני של ממשקי ה-API האלה הוא:
EffectName(restartIfThisKeyChanges, orThisKey, orThisKey, ...) { block }
בגלל הדקויות של ההתנהגות הזו, יכולות להתרחש בעיות אם הפרמטרים שמשמשים להפעלה מחדש של האפקט לא מתאימים:
- הפעלה מחדש של אפקטים בתדירות נמוכה מהנדרש עלולה לגרום לבאגים באפליקציה.
- הפעלה מחדש של אפקטים יותר מדי פעמים עלולה להיות לא יעילה.
ככלל, משתנים שניתנים לשינוי ומשתנים שלא ניתנים לשינוי שמשמשים בבלוק האפקטים של הקוד צריכים להתווסף כפרמטרים לפונקציית ה-composable של האפקט. בנוסף לאלה,
אפשר להוסיף עוד פרמטרים כדי לאלץ הפעלה מחדש של האפקט. אם שינוי של משתנה לא אמור לגרום להפעלה מחדש של האפקט, צריך להוסיף את המשתנה בין התגים 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 כמפתח אפקט כדי שהוא יפעל בהתאם למחזור החיים של האתר שקורא ל-API. יש תרחישי שימוש תקפים לכך, כמו בדוגמה LaunchedEffect שמוצגת למעלה. עם זאת, לפני שעושים זאת, כדאי לחשוב פעמיים ולוודא שזה מה שצריך.
מומלץ בשבילך
- הערה: טקסט הקישור מוצג כש-JavaScript מושבת
- מצב ו-Jetpack פיתוח נייטיב
- Kotlin ל-Jetpack Compose
- שימוש ב-Views ב-Compose