ב-Jetpack Compose, פונקציות composable מחזיקות לעיתים קרובות מצב באמצעות הפונקציה remember. אפשר לעשות שימוש חוזר בערכים שנשמרים בין קומפוזיציות, כמו שמוסבר במאמר מצב ו-Jetpack פיתוח נייטיב.
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 הם אובייקטים שבדרך כלל מכילים את התקשורת בין ממשק המשתמש של האפליקציה לבין שכבות הנתונים שלה. הם מאפשרים להוציא את הלוגיקה מהפונקציות המורכבות, מה שמשפר את יכולת הבדיקה. ViewModel מנוהלים כ-singletons בתוך ViewModelStore, והם בעלי משך חיים שונה מערכים שנשמרו. ViewModel נשאר פעיל עד ש-ViewModelStore שלו מושמד, אבל ערכים שנשמרו מוצאים משימוש כשהתוכן מוסר לצמיתות מהקומפוזיציה (לדוגמה, בשינוי הגדרה, ערך שנשמר מוצא משימוש אם ההיררכיה של ממשק המשתמש נוצרת מחדש והערך שנשמר לא נצרך אחרי שהקומפוזיציה נוצרת מחדש).
ViewModel כולל גם אינטגרציות מוכנות מראש להזרקת תלות (dependency injection) עם Dagger ו-Hilt, אינטגרציה עם SavedState ותמיכה מובנית ב-coroutines להפעלת משימות ברקע. לכן, ViewModel הוא המקום האידיאלי להפעלת משימות ברקע ולשליחת בקשות לרשת, לאינטראקציה עם מקורות נתונים אחרים בפרויקט, ולשמירה של מצב ממשק המשתמש שחיוני למשימה, שצריך להישמר גם אחרי שינויים בהגדרות ב-ViewModel וגם אחרי סיום התהליך.
retain מתאים במיוחד לאובייקטים שמוגבלים למקרים ספציפיים של קומפוזיציות ולא דורשים שימוש חוזר או שיתוף בין קומפוזיציות מקבילות. ViewModel הוא מקום טוב לאחסון מצב ממשק המשתמש ולביצוע משימות ברקע, ו-retain הוא מועמד טוב לאחסון אובייקטים של צינורות להעברת נתונים של ממשק המשתמש, כמו מטמון, מעקב המרות וניתוח נתונים, תלויות ב-AndroidView ואובייקטים אחרים שמבצעים אינטראקציה עם מערכת ההפעלה של Android או מנהלים ספריות של צד שלישי כמו מעבדי תשלומים או פרסום.
למשתמשים מתקדמים שמעצבים דפוסי ארכיטקטורה מותאמים אישית לאפליקציות מחוץ להמלצות של Modern Android app architecture: אפשר גם להשתמש ב-retain כדי ליצור API פנימי שדומה ל-ViewModel. למרות שאין תמיכה מובנית ב-coroutines ובמצב שמור, אפשר להשתמש ב-retain כבלוק בנייה למחזור החיים של רכיבים דומים ל-ViewModel עם התכונות האלה. הפרטים הספציפיים של אופן התכנון של רכיב כזה לא נכללים במדריך הזה.
|
|
|
|---|---|---|
הגדרת היקף |
אין ערכים משותפים. כל ערך נשמר ומשויך לנקודה ספציפית בהיררכיית ההרכב. שמירה על אותו סוג במיקום אחר תמיד פועלת על מופע חדש. |
|
Destruction |
כשעוזבים את היררכיית ההרכבה באופן סופי |
כשמנקים או משמידים את |
פונקציונליות נוספת |
יכול לקבל קריאות חוזרות (callback) כשהאובייקט נמצא בהיררכיית ההרכבה או לא |
|
בבעלות |
|
|
תרחישים לדוגמה |
|
|
שילוב של retain עם rememberSaveable או rememberSerializable
לפעמים, אובייקט צריך להיות בעל משך חיים היברידי של retained וגם של rememberSaveable או rememberSerializable. יכול להיות שזה מעיד על כך שהאובייקט צריך להיות ViewModel, שיכול לתמוך במצב שמור כמו שמתואר במודול Saved State במדריך ViewModel.
אפשר להשתמש ב-retain וב-rememberSaveable או ב-rememberSerializable בו-זמנית. שילוב נכון של שני מחזורי החיים מוסיף מורכבות משמעותית.
מומלץ להשתמש בדפוס הזה כחלק מדפוסי ארכיטקטורה מתקדמים ומותאמים אישית יותר, ורק אם כל התנאים הבאים מתקיימים:
- אתם מגדירים אובייקט שמורכב משילוב של ערכים שצריך לשמור או לגבות (למשל, אובייקט שעוקב אחרי קלט של משתמש ומטמון בזיכרון שלא ניתן לכתוב לדיסק)
- המצב מוגדר בהיקף של קומפוזיציה ולא מתאים להיקף או למשך החיים של סינגלטון של
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) } ) }