ב-Jetpack Compose, פונקציות קומפוזביליות בדרך כלל שומרות את המצב באמצעות הפונקציה remember. אפשר לעשות שימוש חוזר בערכים שנשמרים בזיכרון בקומפוזיציות חוזרות, כמו שמוסבר במאמר State ו-Jetpack Compose.
אמנם remember משמש ככלי לשמירת ערכים בין קומפוזיציות חוזרות, אבל לעיתים קרובות המצב צריך להתקיים מעבר למשך החיים של הקומפוזיציה. בדף הזה מוסבר ההבדל בין ממשקי ה-API remember, retain, rememberSaveable ו-rememberSerializable, מתי לבחור בכל אחד מהם ומהן השיטות המומלצות לניהול ערכים שנשמרו בזיכרון ובמצב ב-Compose.
בחירת תוחלת החיים הנכונה
ב-Compose, יש כמה פונקציות שאפשר להשתמש בהן כדי לשמור את המצב בקומפוזיציות שונות ומעבר לכך: remember, retain, rememberSaveable ו-rememberSerializable. הפונקציות האלה שונות מבחינת משך החיים והסמנטיקה שלהן, וכל אחת מהן מתאימה לאחסון סוגים ספציפיים של מצבים. ההבדלים מפורטים בטבלה הבאה:
|
|
|
|
|---|---|---|---|
הערכים נשמרים אחרי הרכבה מחדש? |
✅ |
✅ |
✅ |
האם הערכים נשמרים אחרי יצירה מחדש של פעילות? |
❌ |
✅ תמיד יוחזר אותו מופע ( |
✅ יוחזר אובייקט שווה ערך ( |
הערכים נשמרים אחרי השבתת התהליך? |
❌ |
❌ |
✅ |
סוגי נתונים נתמכים |
הכול |
אסור להפנות לאובייקטים שעלולים לדלוף אם הפעילות תושמד |
חייב להיות ניתן לסריאליזציה |
תרחישים לדוגמה |
|
|
|
remember
remember היא הדרך הנפוצה ביותר לאחסן מצב ב-Compose. כשמפעילים את remember בפעם הראשונה, החישוב שצוין מבוצע ונשמר בזיכרון, כלומר הוא מאוחסן על ידי Compose לשימוש חוזר עתידי על ידי הרכיב הקומפוזבילי. כשפונקציה קומפוזבילית מבצעת הרכבה מחדש, היא מריצה שוב את הקוד שלה, אבל כל הקריאות ל-remember מחזירות את הערכים שלהן מההרכבה הקודמת במקום להריץ שוב את החישוב.
לכל מופע של פונקציה קומפוזבילית יש קבוצה משלו של ערכים שנשמרו, שנקראת שמירת מיקום בזיכרון. כשערכים שנשמרו בזיכרון נשמרים לשימוש בקומפוזיציות חוזרות, הם קשורים למיקום שלהם בהיררכיית הקומפוזיציה. אם משתמשים בפונקציה קומפוזבילית במיקומים שונים, לכל מופע בהיררכיית הקומפוזיציה יש קבוצה משלו של ערכים שנשמרו.
כשערך שנשמר כבר לא בשימוש, הוא נשכח והרשומה שלו נמחקת. ערכים שנשמרו בזיכרון נשכחים כשהם מוסרים מההיררכיה של הקומפוזיציה (כולל כשערך מוסר ומתווסף מחדש כדי לעבור למיקום אחר בלי להשתמש ב-composable key או ב-MovableContent), או כשהם נקראים עם פרמטרים שונים של key.
מבין האפשרויות הזמינות, לפונקציה remember יש את משך החיים הקצר ביותר, והיא שוכחת ערכים הכי מוקדם מבין ארבע פונקציות ה-memoization שמתוארות בדף הזה.
לכן, הוא מתאים במיוחד למקרים הבאים:
- יצירת אובייקטים של מצב פנימי, כמו מיקום הגלילה או מצב האנימציה
- איך להימנע מיצירה מחדש של אובייקטים יקרים בכל הרכבה מחדש
עם זאת, כדאי להימנע מהפעולות הבאות:
- אחסון של קלט משתמש באמצעות
remember, כי אובייקטים שנשמרים בזיכרון נמחקים כשיש שינויים בהגדרות של Activity וכשהמערכת משביתה תהליכים.
rememberSaveable וגם rememberSerializable
rememberSaveable ו-rememberSerializable מבוססים על remember. יש להן את משך החיים הארוך ביותר מבין פונקציות ה-memoization שמתוארות במדריך הזה.
בנוסף לשמירת אובייקטים בזיכרון לפי מיקום בין הרכבות מחדש, היא יכולה גם לשמור ערכים כדי שיהיה אפשר לשחזר אותם בין יצירות מחדש של פעילויות, כולל משינויים בהגדרות ומהשבתת תהליכים (כשהמערכת משביתה את התהליך של האפליקציה כשהיא פועלת ברקע, בדרך כלל כדי לפנות זיכרון לאפליקציות שפועלות בחזית או אם המשתמש מבטל את ההרשאות של האפליקציה בזמן שהיא פועלת).
rememberSerializable פועל באותו אופן כמו rememberSaveable, אבל תומך באופן אוטומטי בסוגים מורכבים קבועים שניתן לסדר באמצעות הספרייה kotlinx.serialization. בוחרים באפשרות rememberSerializable אם הסוג מסומן (או יכול להיות מסומן) ב-@Serializable, ובאפשרות rememberSaveable בכל המקרים האחרים.
לכן, גם rememberSaveable וגם rememberSerializable הם מועמדים מושלמים לאחסון מצב שמשויך לקלט של המשתמש, כולל ערך של שדה טקסט, מיקום גלילה, מצבי החלפה וכו'. כדאי לשמור את המצב הזה כדי לוודא שהמשתמש אף פעם לא יאבד את המיקום שלו. באופן כללי, כדאי להשתמש ב-rememberSaveable או ב-rememberSerializable כדי לשמור בזיכרון כל מצב שהאפליקציה לא יכולה לאחזר ממקור נתונים קבוע אחר, כמו מסד נתונים.
שימו לב ש-rememberSaveable ו-rememberSerializable שומרים את הערכים שנשמרו בזיכרון על ידי הפיכתם לסדרה ב-Bundle. יש לכך שתי השלכות:
- הערכים שאתם שומרים בזיכרון צריכים להיות מיוצגים על ידי אחד או יותר מסוגי הנתונים הבאים: פרימיטיביים (כולל
Int,Long,Float,Double),Stringאו מערכים של כל אחד מהסוגים האלה. - כשערך שנשמר משוחזר, הוא יהיה מופע חדש ששווה ל-(
==), אבל לא אותו קובץ עזר (===) שבו הקומפוזיציה השתמשה קודם.
כדי לאחסן סוגי נתונים מורכבים יותר בלי להשתמש ב-kotlinx.serialization, אפשר להטמיע Saver בהתאמה אישית כדי לבצע סריאליזציה ודה-סריאליזציה של האובייקט לסוגי נתונים נתמכים. חשוב לדעת ש-Compose מזהה סוגי נתונים נפוצים כמו State, List, Map, Set וכו' באופן מיידי, וממיר אותם באופן אוטומטי לסוגים נתמכים. הדוגמה הבאה היא של Saver למחלקה Size. כדי להטמיע את זה, אורזים את כל המאפיינים של Size ברשימה באמצעות listSaver.
data class Size(val x: Int, val y: Int) { object Saver : androidx.compose.runtime.saveable.Saver<Size, Any> by listSaver( save = { listOf(it.x, it.y) }, restore = { Size(it[0], it[1]) } ) } @Composable fun rememberSize(x: Int, y: Int) { rememberSaveable(x, y, saver = Size.Saver) { Size(x, y) } }
retain
ה-API של retain נמצא בין remember לבין rememberSaveable/rememberSerializable מבחינת משך הזמן שבו הוא שומר את הערכים שלו בזיכרון. השם שונה כי גם מחזור החיים של ערכים שנשמרו שונה מזה של ערכים שנזכרו.
כשערך נשמר, הוא גם נשמר בזיכרון לפי מיקום וגם נשמר במבנה נתונים משני עם משך חיים נפרד שקשור למשך החיים של האפליקציה. ערך שמור יכול לשרוד שינויים בהגדרות בלי לעבור סריאליזציה, אבל הוא לא יכול לשרוד השבתת תהליך. אם לא משתמשים בערך אחרי שיוצרים מחדש את היררכיית הקומפוזיציה, הערך שנשמר יוצא משימוש (המקבילה של retain ל"נשכח").
בתמורה למחזור החיים הקצר יותר מ-rememberSaveable, retain יכול לשמור ערכים שלא ניתן להעביר לסדרה, כמו ביטויי למדה, תהליכים ואובייקטים גדולים כמו מפות סיביות. לדוגמה, אתם יכולים להשתמש ב-retain כדי לנהל נגן מדיה (כמו ExoPlayer) כדי למנוע הפרעות בהפעלת המדיה במהלך שינוי בהגדרות האישיות.
@Composable fun MediaPlayer() { // Use the application context to avoid a memory leak val applicationContext = LocalContext.current.applicationContext val exoPlayer = retain { ExoPlayer.Builder(applicationContext).apply { /* ... */ }.build() } // ... }
retain מול ViewModel
בבסיס, גם retain וגם ViewModel מציעים פונקציונליות דומה ביכולת הנפוצה ביותר שלהם לשמור על מופעי אובייקטים בשינויי הגדרות. הבחירה בין retain לבין ViewModel תלויה בסוג הערך שאתם שומרים, בהיקף שלו ובצורך בפונקציונליות נוספת.
ViewModel הם אובייקטים שבדרך כלל כוללים את התקשורת בין ממשק המשתמש של האפליקציה לבין שכבות הנתונים. הם מאפשרים להעביר את הלוגיקה מחוץ לפונקציות הקומפוזביליות, וכך לשפר את יכולת הבדיקה. ה-ViewModel מנוהלים כ-singletons בתוך ViewModelStore, ויש להם משך חיים שונה מזה של ערכים שנשמרים. ה-ViewModel יישאר פעיל עד שה-ViewModelStore שלו יושמד, אבל ערכים שנשמרו יצאו משימוש כשהתוכן יוסר באופן סופי מהקומפוזיציה (לדוגמה, במקרה של שינוי בהגדרות, ערך שנשמר יצא משימוש אם היררכיית ממשק המשתמש נוצרה מחדש והערך שנשמר לא נוצל אחרי שהקומפוזיציה נוצרה מחדש).
ViewModel כולל גם שילובים מוכנים לשימוש להזרקת תלויות עם Dagger ו-Hilt, שילוב עם SavedState ותמיכה מובנית בשגרות המשך (coroutine) להפעלת משימות ברקע. לכן ViewModel הוא מקום אידיאלי להפעלת משימות ברקע ובקשות לאחזור מהרשת, לאינטראקציה עם מקורות נתונים אחרים בפרויקט, ובאופן אופציונלי, לתפיסה ולשמירה של מצב ממשק משתמש קריטי למשימה שצריך לשמור גם בשינויים בהגדרות ב-ViewModel וגם בהשבתת תהליך.
הפונקציה retain מתאימה במיוחד לאובייקטים שמוגדרים למופעים ספציפיים של פונקציות קומפוזביליות, ולא צריך לעשות בהם שימוש חוזר או לשתף אותם בין פונקציות קומפוזביליות אחיות. ViewModel הוא מקום טוב לאחסון מצב ממשק המשתמש ולביצוע משימות ברקע, ו-retain הוא מועמד טוב לאחסון אובייקטים של ממשק המשתמש כמו מטמונים, מעקב חשיפות וניתוח נתונים, תלויות ב-AndroidView ואובייקטים אחרים שמקיימים אינטראקציה עם Android OS או מנהלים ספריות של צד שלישי כמו ספקי שירותי תשלומים או פרסום.
למשתמשים מתקדמים שמתכננים דפוסים של ארכיטקטורת אפליקציות בהתאמה אישית, מחוץ להמלצות של ארכיטקטורת האפליקציות המודרנית ל-Android: אפשר להשתמש ב-retain גם כדי ליצור API פנימי שדומה ל-ViewModel. למרות שאין תמיכה מובנית ב-coroutines ובמצב שמור, retain יכול לשמש כאבן בניין למחזור החיים של רכיבים דמויי ViewModel עם התכונות האלה. המדריך הזה לא כולל הסבר מפורט על תכנון רכיב כזה.
|
|
|
|---|---|---|
היקף |
אין ערכים משותפים. כל ערך נשמר בנקודה ספציפית בהיררכיית הקומפוזיציה ומשויך אליה. אם שומרים את אותו סוג במיקום אחר, תמיד נוצר מופע חדש. |
|
הרס |
כשיוצאים מההיררכיה של הקומפוזיציה באופן סופי |
כשהמכשיר |
פונקציונליות נוספת |
יכול לקבל קריאות חוזרות (callbacks) כשהאובייקט נמצא בהיררכיית הקומפוזיציה או לא |
|
בבעלות |
|
|
תרחישי שימוש |
|
|
שילוב של retain ו-rememberSaveable או rememberSerializable
לפעמים לאובייקט צריך להיות משך חיים משולב של retained וגם של rememberSaveable או rememberSerializable. יכול להיות שזה סימן לכך שהאובייקט צריך להיות ViewModel, שיכול לתמוך במצב שמור כמו שמתואר במדריך למודול Saved State של ViewModel.
אפשר להשתמש ב-retain וב-rememberSaveable או ב-rememberSerializable בו-זמנית. שילוב נכון של שני מחזורי החיים מוסיף מורכבות משמעותית.
מומלץ להשתמש בתבנית הזו כחלק מתבניות ארכיטקטורה מתקדמות ומותאמות אישית, ורק אם כל התנאים הבאים מתקיימים:
- אתם מגדירים אובייקט שמורכב משילוב של ערכים שצריך לשמור (למשל, אובייקט שעוקב אחרי קלט של משתמש ומטמון בזיכרון שאי אפשר לכתוב בדיסק)
- הערך הדינמי שלך מוגדר לפונקציה קומפוזבילית ולא מתאים להיקף או למשך החיים של ה-singleton של
ViewModel
במקרים כאלה, מומלץ לפצל את המחלקה לשלושה חלקים: הנתונים השמורים, הנתונים שנשמרו ו "מתווך" אובייקט שאין לו מצב משלו ומעביר את הנתונים לאובייקטים השמורים כדי לעדכן את המצב בהתאם. התבנית הזו נראית ככה:
@Composable fun rememberAndRetain(): CombinedRememberRetained { val saveData = rememberSerializable(serializer = serializer<ExtractedSaveData>()) { ExtractedSaveData() } val retainData = retain { ExtractedRetainData() } return remember(saveData, retainData) { CombinedRememberRetained(saveData, retainData) } } @Serializable data class ExtractedSaveData( // All values that should persist process death should be managed by this class. var savedData: AnotherSerializableType = defaultValue() ) class ExtractedRetainData { // All values that should be retained should appear in this class. // It's possible to manage a CoroutineScope using RetainObserver. // See the full sample for details. var retainedData = Any() } class CombinedRememberRetained( private val saveData: ExtractedSaveData, private val retainData: ExtractedRetainData, ) { fun doAction() { // Manipulate the retained and saved state as needed. } }
כשמפרידים את המצב לפי משך החיים, ההפרדה בין תחומי האחריות והאחסון הופכת למפורשת מאוד. העובדה שאי אפשר לשנות את נתוני השמירה באמצעות נתוני השמירה היא מכוונת, כי זה מונע תרחיש שבו מנסים לעדכן את נתוני השמירה כשהחבילה של savedInstanceState כבר נלכדה ואי אפשר לעדכן אותה. הוא גם מאפשר לבדוק תרחישים של יצירה מחדש על ידי בדיקת ה-constructors בלי לקרוא ל-Compose או לדמות יצירה מחדש של Activity.
בRetainAndSaveSample.kt מופיעה דוגמה מלאה שממחישה איך אפשר להטמיע את התבנית הזו.
שמירת תוצאות לפי מיקום ופריסות מותאמות
אפליקציות ל-Android יכולות לתמוך בהרבה גורמי צורה, כולל טלפונים, מכשירים מתקפלים, טאבלטים ומחשבים. אפליקציות צריכות לעבור לעיתים קרובות בין הגורמים האלה באמצעות פריסות מותאמות. לדוגמה, אפליקציה שפועלת בטאבלט יכולה להציג תצוגה של רשימה ופרטים בשתי עמודות, אבל לעבור בין דף רשימה לדף פרטים כשהיא מוצגת במסך קטן יותר של טלפון.
מכיוון שהערכים שנשמרים בזיכרון ובמצב נשמרים לפי מיקום, הם משמשים שוב רק אם הם מופיעים באותה נקודה בהיררכיית הקומפוזיציה. כשמשנים את הפריסות כך שיתאימו לגורמי צורה שונים, יכול להיות שהמבנה של היררכיית הקומפוזיציה ישתנה וערכים יישכחו.
ברכיבים מוכנים לשימוש כמו ListDetailPaneScaffold ו-NavDisplay (מ-Jetpack Navigation 3), זו לא בעיה והמצב יישמר גם אם הפריסה תשתנה. ברכיבים מותאמים אישית שמתאימים את עצמם לגורמי צורה, צריך לוודא שהמצב לא מושפע משינויים בפריסה. לשם כך, צריך לבצע אחת מהפעולות הבאות:
- חשוב לוודא שרכיבי ה-stateful composable תמיד נקראים באותו מקום בהיררכיית הקומפוזיציה. כדי להטמיע פריסות דינמיות, משנים את הלוגיקה של הפריסה במקום להעביר אובייקטים בהיררכיית הקומפוזיציה.
- משתמשים ב-
MovableContentכדי להעביר רכיבים קומפוזביליים עם שמירת מצב בצורה חלקה. מופעים שלMovableContentיכולים להעביר ערכים שנשמרו ונזכרו מהמיקומים הישנים שלהם למיקומים החדשים.
זכירת פונקציות של מפעלים
למרות שממשקי משתמש ב-Compose מורכבים מפונקציות קומפוזביליות, הרבה אובייקטים נכנסים ליצירה ולארגון של קומפוזיציה. הדוגמה הנפוצה ביותר לכך היא אובייקטים מורכבים שניתנים לשילוב ומגדירים את המצב שלהם, כמו LazyList, שמקבל LazyListState.
כשמגדירים אובייקטים שמתמקדים ב-Compose, מומלץ ליצור פונקציית remember כדי להגדיר את התנהגות הזיכרון הרצויה, כולל משך החיים וקלט המקשים. כך הצרכנים של המצב יכולים ליצור בביטחון מופעים בהיררכיית הקומפוזיציה שישרדו ויבוטלו כצפוי. כשמגדירים פונקציית מפעל קומפוזבילית, צריך לפעול לפי ההנחיות הבאות:
- מוסיפים את הקידומת
rememberלשם הפונקציה. אם הטמעת הפונקציה תלויה באובייקטretained, וה-API אף פעם לא יתפתח כך שיתבסס על וריאציה אחרת שלremember, אפשר להשתמש במקום זאת בקידומתretain. - אם בחרתם לשמור את המצב, השתמשו ב-
rememberSaveableאו ב-rememberSerializable, ואם אפשר, כתבו הטמעה נכונה שלSaver. - הימנעו מתופעות לוואי או מערכי אתחול שמבוססים על
CompositionLocalשלא רלוונטיים לשימוש. חשוב לזכור שהמקום שבו המצב נוצר הוא לא בהכרח המקום שבו הוא נצרך.
@Composable fun rememberImageState( imageUri: String, initialZoom: Float = 1f, initialPanX: Int = 0, initialPanY: Int = 0 ): ImageState { return rememberSaveable(imageUri, saver = ImageState.Saver) { ImageState( imageUri, initialZoom, initialPanX, initialPanY ) } } data class ImageState( val imageUri: String, val zoom: Float, val panX: Int, val panY: Int ) { object Saver : androidx.compose.runtime.saveable.Saver<ImageState, Any> by listSaver( save = { listOf(it.imageUri, it.zoom, it.panX, it.panY) }, restore = { ImageState(it[0] as String, it[1] as Float, it[2] as Int, it[3] as Int) } ) }