תופעת לוואי היא שינוי במצב האפליקציה שמתרחש מחוץ להיקף של פונקציה שניתנת להרכבה. בגלל מחזור החיים של פונקציות Composable ומאפיינים כמו recomposition בלתי צפוי, ביצוע recomposition של פונקציות Composable בסדרים שונים או ביצוע recomposition שאפשר לבטל, פונקציות Composable צריכות להיות ללא תופעות לוואי.
עם זאת, לפעמים יש צורך בתופעות לוואי, למשל כדי להפעיל אירוע חד-פעמי כמו הצגת חטיף או מעבר למסך אחר בהינתן תנאי מצב מסוים. צריך להפעיל את הפעולות האלה בסביבה מבוקרת שמודעת למחזור החיים של הרכיב. בדף הזה נסביר על ממשקי ה-API השונים של תופעות הלוואי ש-Jetpack Compose מציע.
תרחישים לדוגמה של מצב והשפעה
כמו שמוסבר במסמכי התיעוד בנושא Thinking in Compose, רכיבי Composable לא אמורים להכיל תופעות לוואי. כשצריך לבצע שינויים במצב האפליקציה (כפי שמתואר במסמך ניהול מצב), צריך להשתמש בממשקי ה-API של Effect כדי שהתופעות לוואי האלה יבוצעו בצורה צפויה.
בגלל האפשרויות השונות שאפקטים פותחים בכלי הכתיבה, קל להשתמש בהם יותר מדי. חשוב לוודא שהעבודה שאתם מבצעים בהם קשורה לממשק המשתמש ולא פוגעת בזרימת נתונים חד-כיוונית, כפי שמוסבר במסמכי הניהול של מצב.
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
: קבלת היקף שכולל את הקומפוזיציה כדי להפעיל קורוטינה מחוץ לקומפוזיציה
מכיוון ש-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 */ }
כדי ליצור אפקט שתואם למחזור החיים של האתר שקורא ל-API, מעבירים קבוע שלא משתנה אף פעם כמו Unit
או true
כפרמטר. בדוגמת הקוד שלמעלה נעשה שימוש ב-LaunchedEffect(true)
. כדי לוודא ש-onTimeout
lambda תמיד מכיל את הערך האחרון ש-LandingScreen
הורכב מחדש, צריך להוסיף את הפונקציה rememberUpdatedState
ל-onTimeout
.
הערכים State
, currentOnTimeout
שמוחזרים בקוד צריכים לשמש באפקט.
DisposableEffect
: אפקטים שנדרש ניקוי
כדי לנקות תופעות לוואי שמתרחשות אחרי שינוי המקשים או אם רכיב ה-composable יוצא מה-Composition, משתמשים ב-DisposableEffect
.
אם המפתחות DisposableEffect
משתנים, הרכיב שאפשר להרכיב צריך לשחרר (לבצע ניקוי) את האפקט הנוכחי שלו, ולאפס על ידי קריאה חוזרת לאפקט.
לדוגמה, יכול להיות שתרצו לשלוח אירועים של Analytics על סמך אירועים של 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) תציג שגיאה בזמן הבנייה.
SideEffect
: publish Compose state to non-Compose code
כדי לשתף את מצב ה-Compose עם אובייקטים שלא מנוהלים על ידי Compose, משתמשים ב-composable 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
: המרה של מצב שאינו מצב כתיבה למצב כתיבה
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, recomposition מתרחש בכל פעם שאובייקט מצב שנצפה או קלט שאפשר להרכיב משתנה. יכול להיות שאובייקט מצב או קלט משתנים בתדירות גבוהה יותר מהתדירות שבה ממשק המשתמש צריך להתעדכן בפועל, מה שמוביל להרכבה מחדש מיותרת.
כדאי להשתמש בפונקציה derivedStateOf
כשמקורות הקלט של רכיב ה-Composable משתנים בתדירות גבוהה יותר מהתדירות שבה צריך ליצור אותו מחדש. לרוב זה קורה כשמשהו משתנה לעיתים קרובות, כמו מיקום גלילה, אבל הרכיב שניתן להרכבה צריך להגיב לשינוי רק כשהוא חוצה סף מסוים. 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
.
שימוש לא נכון
טעות נפוצה היא להניח שאם משלבים שני אובייקטים של מצב 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 ל-Flows
משתמשים ב-snapshotFlow
כדי להמיר אובייקטים של State<T>
ל-Flow קר. snapshotFlow
מפעיל את הבלוק שלו כשהוא נאסף ופולט את התוצאה של האובייקטים State
שנקראו בו. כשמשנים אחד מאובייקטי State
שנקראים בתוך הבלוק snapshotFlow
, אם הערך החדש לא שווה לערך הקודם שמועבר (ההתנהגות הזו דומה לזו של Flow.distinctUntilChanged
), הערך החדש מועבר ל-Collector.
בדוגמה הבאה מוצגת תופעת לוואי שמתעדת את הפעולה של גלילה של המשתמש מעבר לפריט הראשון ברשימה אל 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 }
בגלל הדקויות של ההתנהגות הזו, יכולות להתרחש בעיות אם הפרמטרים שמשמשים להפעלה מחדש של האפקט לא מתאימים:
- הפעלה מחדש של אפקטים בתדירות נמוכה מדי עלולה לגרום לבאגים באפליקציה.
- הפעלה מחדש של אפקטים יותר מדי פעמים עלולה להיות לא יעילה.
ככלל, משתנים שניתנים לשינוי ומשתנים שלא ניתנים לשינוי שמשמשים בבלוק האפקטים של הקוד צריכים להתווסף כפרמטרים לפונקציה הניתנת להרכבה של האפקט. בנוסף לאלה,
אפשר להוסיף עוד פרמטרים כדי לאלץ הפעלה מחדש של האפקט. אם שינוי של משתנה לא אמור לגרום להפעלה מחדש של האפקט, צריך להוסיף את המשתנה בין התגים 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
כי הערך שלהם לא משתנה אף פעם ב-Composition בגלל השימוש ב-rememberUpdatedState
.DisposableEffect
אם לא מעבירים את lifecycleOwner
כפרמטר והוא משתנה, HomeScreen
מורכב מחדש, אבל DisposableEffect
לא מושבת ולא מופעל מחדש. הבעיה היא שמשתמשים ב-lifecycleOwner
הלא נכון מהשלב הזה ואילך.
קבועים כמפתחות
אפשר להשתמש בקבוע כמו true
כמפתח אפקט כדי להגדיר אותו כך שיפעל בהתאם למחזור החיים של האתר שבו מתבצעת השיחה. יש תרחישי שימוש תקפים, כמו הדוגמה של LaunchedEffect
שמוצגת למעלה. עם זאת, לפני שעושים את זה, כדאי לחשוב פעמיים ולוודא שזה מה שרוצים.
מומלץ עבורך
- הערה: טקסט הקישור מוצג כש-JavaScript מושבת
- State ו-Jetpack פיתוח נייטיב
- Kotlin ל-Jetpack פיתוח נייטיב
- שימוש בתצוגות בכתיבה