משך החיים של מצבים ב-Compose

ב-Jetpack Compose, פונקציות composable מחזיקות לעיתים קרובות מצב באמצעות הפונקציה remember. אפשר לעשות שימוש חוזר בערכים שנשמרים בין קומפוזיציות, כמו שמוסבר במאמר מצב ו-Jetpack פיתוח נייטיב.

remember משמש ככלי לשמירת ערכים בין קומפוזיציות, אבל לעיתים קרובות המצב צריך להתקיים מעבר לזמן החיים של קומפוזיציה. בדף הזה מוסבר ההבדל בין ממשקי ה-API‏ remember,‏ retain,‏ rememberSaveable ו-rememberSerializable, מתי כדאי לבחור כל אחד מהם ומהן שיטות העבודה המומלצות לניהול ערכים שנשמרים ונשארים ב-Compose.

בחירת משך החיים הנכון

ב-Compose יש כמה פונקציות שאפשר להשתמש בהן כדי לשמור את המצב בין קומפוזיציות ומחוצה להן: remember,‏ retain,‏ rememberSaveable ו-rememberSerializable. הפונקציות האלה שונות זו מזו במשך החיים שלהן ובסמנטיקה שלהן, וכל אחת מהן מתאימה לאחסון של סוגים ספציפיים של מצב. ההבדלים מפורטים בטבלה הבאה:

remember

retain

rememberSaveable, rememberSerializable

האם הערכים נשמרים אחרי שינוי המיקום של הרכיבים?

הערכים נשמרים גם אחרי יצירה מחדש של הפעילות?

תמיד יוחזר אותו מופע (===)

יוחזר אובייקט שווה ערך (==), יכול להיות שזה יהיה עותק שעבר דה-סריאליזציה

הערכים נשמרים גם אחרי שהתהליך מושבת?

סוגי נתונים נתמכים

הכול

אסור להפנות לאובייקטים שיימחקו אם הפעילות תיהרס

חייב להיות ניתן לסדר את הנתונים בסדר עולה
(באמצעות Saver בהתאמה אישית או באמצעות kotlinx.serialization)

תרחישים לדוגמה

  • אובייקטים שההיקף שלהם מוגבל להרכבה
  • אובייקטים של הגדרות אישיות לרכיבים הניתנים להרכבה
  • מצב שאפשר ליצור מחדש בלי לפגוע בנאמנות של ממשק המשתמש
  • קובצי מטמון
  • אובייקטים עם משך חיים ארוך או אובייקטים מסוג 'ניהול'
  • קלט של משתמשים
  • מצב שלא ניתן ליצור מחדש על ידי האפליקציה, כולל קלט של שדה טקסט, מצב גלילה, מתגים וכו'.

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 עם התכונות האלה. הפרטים הספציפיים של אופן התכנון של רכיב כזה לא נכללים במדריך הזה.

retain

ViewModel

הגדרת היקף

אין ערכים משותפים. כל ערך נשמר ומשויך לנקודה ספציפית בהיררכיית ההרכב. שמירה על אותו סוג במיקום אחר תמיד פועלת על מופע חדש.

ViewModel הם סינגלטונים בתוך ViewModelStore

Destruction

כשעוזבים את היררכיית ההרכבה באופן סופי

כשמנקים או משמידים את ViewModelStore

פונקציונליות נוספת

יכול לקבל קריאות חוזרות (callback) כשהאובייקט נמצא בהיררכיית ההרכבה או לא

coroutineScope מובנה, תמיכה ב-SavedStateHandle, אפשר להוסיף אותו באמצעות Hilt

בבעלות

RetainedValuesStore

ViewModelStore

תרחישים לדוגמה

  • שמירת ערכים ספציפיים לממשק המשתמש באופן מקומי במופעים נפרדים של רכיבים הניתנים להרכבה
  • מעקב אחרי חשיפות, יכול להיות דרך RetainedEffect
  • אבן בניין להגדרת רכיב ארכיטקטורה מותאם אישית מסוג ViewModel
  • חילוץ אינטראקציות בין שכבות ממשק המשתמש ושכבות הנתונים למחלקה נפרדת, גם לארגון הקוד וגם לבדיקה
  • המרת Flow לאובייקטים מסוג State וקריאה לפונקציות השהיה שלא אמורות להיות מופרעות על ידי שינויים בהגדרות
  • שיתוף מצבים באזורים גדולים בממשק המשתמש, כמו מסכים שלמים
  • יכולת פעולה הדדית עם View

שילוב של 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) }
    )
}