ב-Jetpack Compose, פונקציות קומפוזביליות מחזיקות לעיתים קרובות ערך דינמי באמצעות הפונקציה remember. אפשר לעשות שימוש חוזר בערכים שנשמרו בין רה-קומפוזיציות, כמו שמוסבר במאמר ערך דינמי ו-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 היא בעלת משך החיים הקצר ביותר, והיא שוכחת את הערכים הכי מהר מבין ארבע פונקציות הממויזציה שמתוארות בדף הזה.
האפשרות הזו מתאימה במיוחד ל:
- יצירת אובייקטים של מצב פנימי, כמו מיקום גלילה או מצב אנימציה
- איך נמנעים מיצירה מחדש של אובייקט יקר בכל הרכבה מחדש
עם זאת, כדאי להימנע מ:
- אחסון קלט של משתמשים כלשהו באמצעות
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 הם אובייקטים שבדרך כלל מכילים את התקשורת בין ממשק המשתמש של האפליקציה לבין שכבות הנתונים שלה. הם מאפשרים להוציא את הלוגיקה מהפונקציות המורכבות, וכך לשפר את יכולת הבדיקה. ViewModels מנוהלים כ-singletons בתוך ViewModelStore, והם בעלי משך חיים שונה מערכים שנשמרו. ViewModel יישאר פעיל עד ש-ViewModelStore שלו ייהרס, אבל ערכים שנשמרו יוצאו משימוש כשהתוכן יוסר לצמיתות מהקומפוזיציה (לדוגמה, בשינוי הגדרה, ערך שנשמר יוצא משימוש אם ההיררכיה של ממשק המשתמש נוצרת מחדש והערך שנשמר לא נצרך אחרי שהקומפוזיציה נוצרת מחדש).
ViewModel כולל גם אינטגרציות מוכנות מראש להזרקת תלות (dependency injection) עם Dagger ו-Hilt, אינטגרציה עם SavedState ותמיכה מובנית ב-coroutines להפעלת משימות ברקע. לכן, ViewModel הוא מקום אידיאלי להפעלת משימות ברקע ולשליחת בקשות לרשת, לאינטראקציה עם מקורות נתונים אחרים בפרויקט, ולשמירה של מצב ממשק המשתמש שחיוני למשימה, כך שהוא יישמר גם אחרי שינויים בהגדרות ב-ViewModel וגם אחרי סיום התהליך.
retain מתאים במיוחד לאובייקטים שמוגבלים למקרים ספציפיים של קומפוזיציות ולא דורשים שימוש חוזר או שיתוף בין קומפוזיציות מקבילות. ViewModel הוא מקום טוב לאחסון מצב ממשק המשתמש ולביצוע משימות ברקע, ו-retain הוא מועמד טוב לאחסון אובייקטים של צינורות להעברת נתונים של ממשק המשתמש, כמו מטמון, מעקב אחר חשיפות וניתוח נתונים, תלויות ב-AndroidView ואובייקטים אחרים שמתקשרים עם מערכת ההפעלה של Android או מנהלים ספריות של צד שלישי, כמו מעבדי תשלומים או פרסום.
למשתמשים מתקדמים שמתכננים דפוסי ארכיטקטורה מותאמים אישית לאפליקציות מחוץ להמלצות של גישת פיתוח מודרנית ל-Android: אפשר גם להשתמש ב-retain כדי ליצור API פנימי שדומה ל-ViewModel. למרות שאין תמיכה מובנית ב-coroutines ובמצב שמור, אפשר להשתמש ב-retain כבלוק בנייה למחזור החיים של רכיבים דומים ל-ViewModel עם התכונות האלה. הפרטים הספציפיים של אופן התכנון של רכיב כזה לא נכללים במדריך הזה.
|
|
|
|---|---|---|
הגדרת היקף |
אין ערכים משותפים. כל ערך נשמר ומשויך לנקודה ספציפית בהיררכיית הקומפוזיציה. שמירה על אותו סוג במיקום אחר תמיד פועלת על מופע חדש. |
|
Destruction |
כשעוזבים את היררכיית ההרכבה באופן סופי |
כשמנקים או משמידים את |
פונקציות נוספות |
יכול לקבל קריאות חוזרות (callback) כשהאובייקט נמצא בהיררכיית ההרכבה או לא |
תמיכה מובנית ב- |
בבעלות |
|
|
תרחישים לדוגמה |
|
|
שילוב של retain עם rememberSaveable או rememberSerializable
לפעמים, אובייקט צריך להיות בעל משך חיים היברידי של retained וגם של rememberSaveable או rememberSerializable. יכול להיות שזה מעיד על כך שהאובייקט צריך להיות ViewModel, שיכול לתמוך במצב שמור כפי שמתואר במודול Saved State במדריך ViewModel.
אפשר להשתמש ב-retain וב-rememberSaveable או ב-rememberSerializable בו-זמנית. שילוב נכון של שני מחזורי החיים מוסיף מורכבות משמעותית.
מומלץ להשתמש בדפוס הזה כחלק מדפוסי ארכיטקטורה מתקדמים ומותאמים אישית יותר, ורק אם כל התנאים הבאים מתקיימים:
- אתם מגדירים אובייקט שמורכב משילוב של ערכים שצריך לשמור או לשמר (למשל, אובייקט שעוקב אחרי קלט של משתמש ומטמון בזיכרון שלא ניתן לכתוב לדיסק)
- המצב מוגבל ל-composable ולא מתאים ל-scoping או ל-lifespan של
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 כבר נשמרה ואי אפשר לעדכן אותה. בנוסף, אפשר לבדוק תרחישי יצירה מחדש על ידי בדיקת הקונסטרוקטורים בלי לבצע קריאה ל-Compose או לדמות יצירה מחדש של Activity.
בדוגמה המלאה (RetainAndSaveSample.kt) אפשר לראות איך אפשר להטמיע את התבנית הזו.
שימוש בזיכרון מטמון לפי מיקום ופריסות מותאמות
אפליקציות ל-Android יכולות לתמוך במגוון רחב של גורמי צורה, כולל טלפונים, מכשירים מתקפלים, טאבלטים ומחשבים. לעתים קרובות יש צורך במעבר בין גורמי הצורה האלה באפליקציות באמצעות פריסות דינמיות. לדוגמה, אפליקציה שפועלת בטאבלט עשויה להציג תצוגה של רשימה עם שני עמודות של פרטים, אבל כשהיא מוצגת במסך קטן יותר של טלפון, היא עשויה לנווט בין רשימה לדף פרטים.
מכיוון שהערכים שנשמרו ונשמרו נשמרים בזיכרון במטמון לפי מיקום, הם משמשים שוב רק אם הם מופיעים באותה נקודה בהיררכיית הקומפוזיציה. כשהפריסות מותאמות לגורמי צורה שונים, הן עשויות לשנות את המבנה של היררכיית הקומפוזיציה ולהוביל לכך שערכים יישכחו.
במקרה של רכיבים מוכנים לשימוש כמו ListDetailPaneScaffold ו-NavDisplay
(מ-Jetpack Navigation 3), אין בעיה כזו והמצב יישמר גם אחרי שינויים בפריסה. כדי לוודא שהמצב לא מושפע משינויים בפריסה של רכיבים מותאמים אישית שמותאמים לגורמי צורה, צריך לבצע אחת מהפעולות הבאות:
- חשוב לוודא שקומפוזיציות עם מצב תמיד נקראות באותו מקום בהיררכיית הקומפוזיציה. כדי להטמיע פריסות דינמיות, משנים את לוגיקת הפריסה במקום להעביר אובייקטים בהיררכיית הקומפוזיציה.
- אפשר להשתמש ב-
MovableContentכדי להעביר רכיבים קומפוזביליים עם מצב בצורה חלקה. מקרים שלMovableContentיכולים להעביר ערכים שנשמרו ומוחזרו מהמיקומים הישנים למיקומים החדשים.
זכירת פונקציות של הגדרות היצרן
ממשקי משתמש ב-Compose מורכבים מפונקציות שאפשר להרכיב, אבל הרבה אובייקטים משתתפים ביצירה ובארגון של קומפוזיציה. הדוגמה הנפוצה ביותר לכך היא אובייקטים מורכבים של רכיבים קומפוזביליים שמגדירים את הסטטוס שלהם, כמו LazyList, שמקבל LazyListState.
כשמגדירים אובייקטים שמתמקדים ב-Compose, מומלץ ליצור פונקציה remember כדי להגדיר את התנהגות הזיכרון הרצויה, כולל משך החיים וקלט המפתח. כך הצרכנים של המצב יכולים ליצור בביטחון מופעים בהיררכיית הקומפוזיציה שישרדו ויבוטלו כמצופה. כשמגדירים פונקציית factory שאפשר להרכיב, צריך לפעול לפי ההנחיות הבאות:
- מוסיפים את הקידומת
rememberלשם הפונקציה. אפשרות נוספת: אם ההטמעה של הפונקציה תלויה באובייקטretainedוממשק ה-API לעולם לא יסתמך על וריאציה אחרת שלremember, אפשר להשתמש בקידומתretainבמקום זאת. - משתמשים ב-
rememberSaveableאו ב-rememberSerializableאם בוחרים בשימור מצב ואפשר לכתוב הטמעה נכונה שלSaver. - אל תנסו להימנע מתופעות לוואי או לאתחל ערכים על סמך
CompositionLocals שאולי לא רלוונטיים לשימוש. חשוב לזכור שהמקום שבו נוצר הסטייט לא בהכרח יהיה המקום שבו הוא ייצרך.
@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) } ) }