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

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

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

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

‫Jetpack Compose עוזר לכם להגדיר במפורש איפה ואיך אתם מאחסנים ומשתמשים ב-state באפליקציית Android. המדריך הזה מתמקד בקשר בין state לבין composables, ובממשקי ה-API ש-Jetpack Compose מציע כדי לעבוד עם state בצורה קלה יותר.

מצב והרכב

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

מידע נוסף על הרכבה ראשונית והרכבה מחדש זמין במאמר Thinking in Compose.

מצב ברכיבים שאפשר להרכיב

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

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

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

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

יש שלוש דרכים להצהיר על אובייקט MutableState בקומפוזיציה:

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

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

התחביר של by delegate מחייב את הייבואים הבאים:

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. לערכים אחרים, אפשר להעביר אובייקט מותאם אישית של שמירה.

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

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

‫Compose כולל פונקציות ליצירת State<T> מסוגים נפוצים של אובייקטים שאפשר לצפות בהם, שמשמשים באפליקציות ל-Android. לפני שמשתמשים בשילובים האלה, צריך להוסיף את הארטיפקטים המתאימים, כמו שמתואר בהמשך:

  • 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")
}

מגניב

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.8.1")
}

מגניב

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

Kotlin

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

מגניב

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

Kotlin

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

מגניב

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

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

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

קומפוזבל חסר מצב הוא קומפוזבל שלא מחזיק מצב. דרך קלה להשיג מצב חסר סטטוס היא באמצעות העברת מצב.

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

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

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

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

עם זאת, אתם לא מוגבלים ל-onValueChange. אם יש אירועים ספציפיים יותר שמתאימים לקומפוזבל, צריך להגדיר אותם באמצעות ביטויי למבדה.

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

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

בדוגמה, מחלצים את name ואת onValueChange מתוך HelloContent ומעבירים אותם למעלה בעץ אל רכיב HelloScreen שאפשר להרכיב שקורא ל-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

הפתרון הכי פשוט הוא להוסיף את ההערה @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

כדי להימנע מהצורך להגדיר את המפתחות למיפוי, אפשר גם להשתמש ב-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

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

מידע נוסף זמין במאמרי העזרה בנושא state hoisting ב-Compose או בדף State holders and UI State במדריך לארכיטקטורה.

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

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

var name by remember { mutableStateOf("") }

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

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

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

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

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

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

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

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

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

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

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

מידע נוסף

מידע נוסף על מצב ועל Jetpack Compose זמין במקורות המידע הנוספים הבאים.

טעימות

Codelabs

סרטונים

בלוגים