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

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

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

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

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

מצב והרכב

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

@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 מאוחסן בקומפוזיציה במהלך הקומפוזיציה הראשונית, והערך המאוחסן מוחזר במהלך רה-קומפוזיציה. אפשר להשתמש ב-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.10.0")
}

מגניב

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

    הפונקציה collectAsState דומה לפונקציה collectAsStateWithLifecycle, כי היא גם אוספת ערכים מ-Flow ומשנה אותם ל-Compose State.

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

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

  • LiveData: observeAsState()

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

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

Kotlin

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

מגניב

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

Kotlin

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

מגניב

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

Kotlin

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

מגניב

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

var name by remember { mutableStateOf("") }

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

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

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

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

סרטונים

בלוגים