מדינה ו-Jetpack פיתוח נייטיב

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

כל האפליקציות ל-Android מציגות את המצב למשתמש. דוגמאות למצבים באפליקציות ל-Android:

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

בעזרת 'Jetpack פיתוח נייטיב' תוכלו לדעת איך לאחסן את המצב באפליקציה ל-Android ולהשתמש בו בצורה ברורה. המדריך הזה מתמקד בחיבור בין מדינות (States) לתכנים קומפוזביליים, ובממשקי API ש-Jetpack פיתוח נייטיב מציע לעבוד עם מצב בקלות רבה יותר.

מצב ויצירה

Compose הוא פונקציונלי, ולכן הדרך היחידה לעדכן אותו היא להפעיל את אותו composable עם ארגומנטים חדשים. הארגומנטים האלה מייצגים את המצב של ממשק המשתמש. בכל פעם שמדינה מתעדכנת, מתבצע הרכבה מחדש. כתוצאה מכך, פריטים כמו TextField לא מתעדכנים באופן אוטומטי כמו שהם מתעדכנים בתצוגות מבוססות-XML אופרטיביות. כדי שהמצב החדש יתעדכן בהתאם, צריך לציין אותו במפורש ב-composable.

@Composable
private fun HelloContent() {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello!",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.bodyMedium
        )
        OutlinedTextField(
            value = "",
            onValueChange = { },
            label = { Text("Name") }
        )
    }
}

אם מריצים את הקוד הזה ומנסים להזין טקסט, לא קורה כלום. הסיבה לכך היא שה-TextField לא מתעדכן בעצמו – הוא מתעדכן כשהפרמטר value שלו משתנה. הסיבה לכך היא האופן שבו פועלים הרכבת קובצי Compose ושינוי שלהם.

למידע נוסף על הרכבה ראשונית ועל הרכבה מחדש, קראו את המאמר חשיבה ב-Compose.

מצב ברכיבים קומפוזביליים

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

mutableStateOf יוצר MutableState<T> קריא, שהוא טיפוס ניתן למדידה ומשולב בסביבת זמן הריצה של הכתיבה.

interface MutableState<T> : State<T> {
    override var value: T
}

שינויים ב-value מתזמנים מחדש של פונקציות קומפוזביליות שקוראות value.

יש שלוש דרכים להצהיר על אובייקט MutableState ב-composable:

  • val mutableState = remember { mutableStateOf(default) }
  • var value by remember { mutableStateOf(default) }
  • val (value, setValue) = remember { mutableStateOf(default) }

ההצהרות האלה הן מקבילות, והן מסופקות כ-תחביר sugar לשימושים שונים של מצב. כדאי לבחור את האפשרות שמניבה את הקוד הכי קל לקריאה ברכיב ה-Composable שאתם כותבים.

התחביר להענקת גישה ב-by מחייב את פעולות הייבוא הבאות:

import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

אפשר להשתמש בערך שנשמר כפרמטר לרכיבים מורכבים אחרים, או אפילו כלוגיקה בהצהרות כדי לשנות את הרכיבים המורכבים שיוצגו. לדוגמה, אם אתם לא רוצים להציג את הודאת הפתיחה אם השם ריק, תוכלו להשתמש במצב בהצהרה if:

@Composable
fun HelloContent() {
    Column(modifier = Modifier.padding(16.dp)) {
        var name by remember { mutableStateOf("") }
        if (name.isNotEmpty()) {
            Text(
                text = "Hello, $name!",
                modifier = Modifier.padding(bottom = 8.dp),
                style = MaterialTheme.typography.bodyMedium
            )
        }
        OutlinedTextField(
            value = name,
            onValueChange = { name = it },
            label = { Text("Name") }
        )
    }
}

המשתנה remember עוזר לשמור את המצב במהלך יצירת קומפוזיציות מחדש, אבל המצב לא נשמר במהלך שינויים בהגדרות. לשם כך, צריך להשתמש ב-rememberSaveable. rememberSaveable שומר באופן אוטומטי כל ערך שאפשר לשמור ב-Bundle. לערכים אחרים, אפשר להעביר אובייקט שומר מותאם אישית.

סוגים אחרים של מצבים נתמכים

כדי לכתוב את המצב לא צריך להשתמש ב-MutableState<T> כדי לשמור על המצב, כי הוא תומך בסוגים אחרים של מקורות גלויים. לפני שקוראים סוג אחר של משתנה שניתן לצפות בו ב-Compose, צריך להמיר אותו ל-State<T> כדי שרכיבי Compose יוכלו לבצע קומפוזיציה מחדש באופן אוטומטי כשהמצב משתנה.

Compose מגיע עם פונקציות ליצירת State<T> מטיפים נפוצים של observable שנעשה בהם שימוש באפליקציות ל-Android. לפני שמשתמשים בשילובים האלה, צריך להוסיף את פריטי המידע שנוצרו בתהליך הפיתוח(Artifact) הרלוונטיים:

  • Flow: collectAsStateWithLifecycle()

    collectAsStateWithLifecycle() אוסף ערכים מ-Flow באופן שמתחשב במחזור החיים, וכך מאפשר לאפליקציה לחסוך במשאבי האפליקציה. הוא מייצג את הערך האחרון שהופלט מ-Compose‏ State. כדאי להשתמש ב-API הזה כשיטה המומלצת לאיסוף תהליכים באפליקציות ל-Android.

    התלות הבאה נדרשת בקובץ build.gradle (היא צריכה להיות בגרסה 2.6.0-beta01 ואילך):

Kotlin

dependencies {
      ...
      implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
}

Groovy

dependencies {
      ...
      implementation "androidx.lifecycle:lifecycle-runtime-compose:2.8.7"
}
  • Flow: collectAsState()

    collectAsState דומה ל-collectAsStateWithLifecycle, כי גם היא אוספת ערכים מ-Flow ומעבירה אותם ל-Compose‏ State.

    משתמשים ב-collectAsState לקוד שלא תלוי בפלטפורמה במקום ב-collectAsStateWithLifecycle, שמוגבל ל-Android בלבד.

    לא נדרשים יחסי תלות נוספים בשביל collectAsState כי הוא זמין ב-compose-runtime.

  • LiveData: observeAsState()

    observeAsState() מתחיל לצפות בLiveData הזה ומייצג את הערכים שלו באמצעות State.

    בקובץ build.gradle נדרשת מידת התלות הבאה:

Kotlin

dependencies {
      ...
      implementation("androidx.compose.runtime:runtime-livedata:1.7.5")
}

Groovy

dependencies {
      ...
      implementation "androidx.compose.runtime:runtime-livedata:1.7.5"
}

Kotlin

dependencies {
      ...
      implementation("androidx.compose.runtime:runtime-rxjava2:1.7.5")
}

Groovy

dependencies {
      ...
      implementation "androidx.compose.runtime:runtime-rxjava2:1.7.5"
}

Kotlin

dependencies {
      ...
      implementation("androidx.compose.runtime:runtime-rxjava3:1.7.5")
}

Groovy

dependencies {
      ...
      implementation "androidx.compose.runtime:runtime-rxjava3:1.7.5"
}

עם שמירת מצב לעומת ללא שמירת מצב

תוכן קומפוזבילי שמשתמש ב-remember לאחסון אובייקט יוצר מצב פנימי, שהופך את התוכן הקומפוזבילי למצב. HelloContent הוא דוגמה לרכיב מורכב עם מצב (stateful) כי הוא שומר ומשנה את המצב שלו (name) באופן פנימי. האפשרות הזו יכולה להיות שימושית במצבים שבהם מבצע הקריאה לא צריך לשלוט במצב, ויכול להשתמש בו בלי לנהל אותו בעצמו. עם זאת, רכיבים מורכבים עם מצב פנימי נוטים להיות פחות ניתנים לשימוש חוזר וקשה יותר לבדוק אותם.

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

כשאתם מפתחים רכיבים מורכבים לשימוש חוזר, לעיתים קרובות אתם רוצים לחשוף גם גרסה עם שמירת מצב וגם גרסה ללא שמירת מצב של אותו רכיב מורכב. הגרסה עם מצב (stateful) נוחה למבצעי קריאה שלא אכפת להם מהמצב, והגרסה ללא מצב (stateless) נחוצה למבצעי קריאה שצריכים לשלוט במצב או להעביר אותו.

העלאת הרמה של מצב (state hoisting)

העלאת המצב ב-Compose היא דפוס של העברת המצב למבצע הקריאה של ה-composable כדי להפוך את ה-composable ללא מצב. התבנית הכללית להעברת המצב לחלק העליון של הקוד ב-Jetpack Compose היא להחליף את משתנה המצב בשני פרמטרים:

  • value: T: הערך הנוכחי שרוצים להציג
  • onValueChange: (T) -> Unit: אירוע שמבקש לשנות את הערך, כאשר T הוא הערך החדש המוצע

עם זאת, אתם לא מוגבלים ל-onValueChange. אם אירועים ספציפיים יותר מתאימים לתוכן הקומפוזיציה, צריך להגדיר אותם באמצעות פונקציות lambda.

למצבים שמאוחזרים בצורה הזו יש כמה מאפיינים חשובים:

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

בדוגמה, מחלצים את name ואת onValueChange מ-HelloContent ומעבירים אותם למעלה בעץ, ל-HelloScreen composable שמפעיל את HelloContent.

@Composable
fun HelloScreen() {
    var name by rememberSaveable { mutableStateOf("") }

    HelloContent(name = name, onNameChange = { name = it })
}

@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello, $name",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.bodyMedium
        )
        OutlinedTextField(value = name, onValueChange = onNameChange, label = { Text("Name") })
    }
}

כשמגדירים את המצב מחוץ ל-HelloContent, קל יותר להבין את הרכיב הניתן לקיבוץ, לעשות בו שימוש חוזר במצבים שונים ולבדוק אותו. HelloContent מנותקת מהאופן שבו המצב שלה מאוחסן. המשמעות של ביטול הצמדה היא שאם משנים או מחליפים את HelloScreen, אין צורך לשנות את אופן ההטמעה של HelloContent.

התבנית שבה המצב יורד והאירועים עולים נקראת זרימת נתונים חד-כיוונית. במקרה הזה, המדינה יורדת מ-HelloScreen ל-HelloContent והאירועים עולים מ-HelloContent עד HelloScreen. כשפועלים לפי זרימת נתונים חד-כיוונית, אפשר לנתק בין רכיבי ה-Composable שמוצגים במצב בממשק המשתמש לבין החלקים באפליקציה שמאחסנים את המצב ומשנים אותו.

מידע נוסף זמין בדף איפה להעלות את המצב.

שחזור המצב ב-Compose

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

דרכים לאחסון מצב

כל סוגי הנתונים שנוספים ל-Bundle נשמרים באופן אוטומטי. אם אתם רוצים לשמור משהו שלא ניתן להוסיף ל-Bundle, יש כמה אפשרויות.

חלוקה למקטעים

הפתרון הפשוט ביותר הוא להוסיף את ההערה @Parcelize לאובייקט. האובייקט הופך לניתן לחלוקה, וניתן לקבץ אותו בחבילה. לדוגמה, הקוד יוצר סוג נתונים City שניתן למגרש ושומר אותו במדינה.

@Parcelize
data class City(val name: String, val country: String) : Parcelable

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

MapSaver

אם מסיבה כלשהי @Parcelize לא מתאים, אפשר להשתמש ב-mapSaver כדי להגדיר כלל משלכם להמרת אובייקט לקבוצת ערכים שהמערכת יכולה לשמור ב-Bundle.

data class City(val name: String, val country: String)

val CitySaver = run {
    val nameKey = "Name"
    val countryKey = "Country"
    mapSaver(
        save = { mapOf(nameKey to it.name, countryKey to it.country) },
        restore = { City(it[nameKey] as String, it[countryKey] as String) }
    )
}

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

שומר רשימה

כדי שלא תצטרכו להגדיר את המפתחות למפה, תוכלו גם להשתמש ב-listSaver ולהשתמש באינדיקטורים שלו כמפתחות:

data class City(val name: String, val country: String)

val CitySaver = listSaver<City, Any>(
    save = { listOf(it.name, it.country) },
    restore = { City(it[0] as String, it[1] as String) }
)

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

מאגרי המצבים ב-Compose

אפשר לנהל העברת מצב פשוטה בפונקציות הניתנות ליצירה עצמה. עם זאת, אם כמות המצבים שצריך לעקוב אחריהם גדלה, או אם צריך לבצע לוגיקה בפונקציות שניתנות ליצירה, מומלץ להעביר את האחריות על הלוגיקה והמצב לכיתות אחרות: מחזקי מצב.

מידע נוסף זמין במסמכי התיעוד בנושא העלאת מצב ב-Compose, או באופן כללי יותר, בדף מאגרי מצב ומצב ממשק המשתמש במדריך הארכיטקטורה.

הפעלה מחדש של חישובי הזיכרון כשהמקשים משתנים

לעיתים קרובות משתמשים ב-API של remember יחד עם MutableState:

var name by remember { mutableStateOf("") }

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

באופן כללי, remember מקבל פרמטר lambda‏ calculation. כשהפונקציה remember מופעלת בפעם הראשונה, היא מפעילה את הפונקציה הלוגרית calculation ושומרת את התוצאה שלה. במהלך הרכבת מחדש, remember מחזיר את הערך האחרון שנשמר.

מלבד שמירת המצב במטמון, אפשר גם להשתמש ב-remember כדי לאחסן כל אובייקט או תוצאה של פעולה בהרכבה, שהפעלתם או החישוב שלהם יקרים. יכול להיות שלא תרצו לחזור על החישוב הזה בכל פעם שתבצעו יצירת קובץ מחדש. לדוגמה, אפשר ליצור את האובייקט ShaderBrush הזה, שזו פעולה יקרה:

val brush = remember {
    ShaderBrush(
        BitmapShader(
            ImageBitmap.imageResource(res, avatarRes).asAndroidBitmap(),
            Shader.TileMode.REPEAT,
            Shader.TileMode.REPEAT
        )
    )
}

remember מאחסן את הערך עד שהוא יוצא מה-Composition. עם זאת, יש דרך לבטל את התוקף של הערך ששמור במטמון. ה-API remember לוקח גם את הפרמטר key או keys. אם אחד מהמפתחות האלה משתנה, בפעם הבאה שהפונקציה יוצרת מחדש, הערך של remember ביטול המטמון ומבצע שוב את בלוק lambda לחישוב. המנגנון הזה מאפשר לכם לשלוט במחזור החיים של אובייקט ב-Composition. החישוב יישאר תקף עד שהנתונים המזינים ישתנו, במקום עד שהערך שנשמר יוצא מה-Composition.

בדוגמאות הבאות מוסבר איך המנגנון הזה עובד.

בקטע הקוד הזה, נוצר ShaderBrush ומשמש כצבע הרקע של תוכן קומפוזבילי מסוג Box. המכונה remember שומרת את המכונה ShaderBrush כי יקר ליצור אותה מחדש, כפי שהוסבר למעלה. הפונקציה remember מקבלת את הערך avatarRes כפרמטר key1, שהוא תמונת הרקע שנבחרה. אם avatarRes משתנה, המברשת מורכבת מחדש עם התמונה החדשה ומוחלת מחדש על Box. מצב כזה יכול לקרות כשהמשתמש בוחר תמונה אחרת לרקע מתוך בוחר תמונות.

@Composable
private fun BackgroundBanner(
    @DrawableRes avatarRes: Int,
    modifier: Modifier = Modifier,
    res: Resources = LocalContext.current.resources
) {
    val brush = remember(key1 = avatarRes) {
        ShaderBrush(
            BitmapShader(
                ImageBitmap.imageResource(res, avatarRes).asAndroidBitmap(),
                Shader.TileMode.REPEAT,
                Shader.TileMode.REPEAT
            )
        )
    }

    Box(
        modifier = modifier.background(brush)
    ) {
        /* ... */
    }
}

בקטע הקוד הבא, המצב מועבר לכיתה פשוטה של מחזיק מצב MyAppState. הוא חושף את הפונקציה rememberMyAppState כדי לאתחל מכונה של הכיתה באמצעות remember. חשיפת פונקציות כאלה כדי ליצור מכונה ששרדה קומפוזיציות מחדש היא דפוס נפוץ ב-Compose. הפונקציה rememberMyAppState מקבלת את הערך windowSizeClass, שמשמשת כפרמטר key של remember. אם הפרמטר הזה ישתנה, האפליקציה תצטרך ליצור מחדש את הכיתה של מחזיק המצב הפשוט עם הערך העדכני. זה יכול לקרות אם, למשל, המשתמש מסובב את המכשיר.

@Composable
private fun rememberMyAppState(
    windowSizeClass: WindowSizeClass
): MyAppState {
    return remember(windowSizeClass) {
        MyAppState(windowSizeClass)
    }
}

@Stable
class MyAppState(
    private val windowSizeClass: WindowSizeClass
) { /* ... */ }

Compose משתמש בהטמעה של equals בכיתה כדי להחליט אם מפתח השתנה ולבטל את התוקף של הערך המאוחסן.

אחסון מצב באמצעות מפתחות מעבר ליצירה מחדש

ה-API של rememberSaveable הוא מעטפת של remember שיכולה לאחסן נתונים ב-Bundle. ה-API הזה מאפשר למצב לשרוד לא רק לאחר יצירת מחדש, אלא גם לאחר יצירה מחדש של פעילות וסיום תהליך ביוזמת המערכת. rememberSaveable מקבל פרמטרים של input לאותו מטרה שבה remember מקבל את keys. המטמון לא תקף כשאחד מהקלטים משתנה. בפעם הבאה שהפונקציה תעבור עיבוד מחדש, rememberSaveable מריץ מחדש את בלוק הלוגריתם של החישוב.

בדוגמה הבאה, המשתנה rememberSaveable שומר את הערך של userTypedQuery עד ש-typedQuery משתנה:

var userTypedQuery by rememberSaveable(typedQuery, stateSaver = TextFieldValue.Saver) {
    mutableStateOf(
        TextFieldValue(text = typedQuery, selection = TextRange(typedQuery.length))
    )
}

מידע נוסף

למידע נוסף על State ו-Jetpack פיתוח נייטיב, תוכלו להיעזר במקורות המידע הנוספים הבאים.

דוגמיות

Codelabs

סרטונים

בלוגים