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

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

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

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

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

מצב וקומפוזיציה

‫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) }

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

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

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

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

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

  • Flow: collectAsStateWithLifecycle()

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

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

Kotlin

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

מגניב

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

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

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

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

  • LiveData: observeAsState()

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

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

Kotlin

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

מגניב

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

Kotlin

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

מגניב

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

Kotlin

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

מגניב

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

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

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

רכיב קומפוזבילי בלי שמירת מצב הוא רכיב קומפוזבילי שלא מכיל ערך דינמי כלשהו. דרך נפוצה להשיג מצב ללא סטייט היא באמצעות העלאת הרמה של מצב (state hoisting).

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

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

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

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

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

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

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

  1. צריך להעביר את המצב לפחות להורה המשותף הנמוך ביותר של כל ה-composables שמשתמשים במצב (קריאה).
  2. צריך להעביר את המצב לפחות לרמה הגבוהה ביותר שבה אפשר לשנות אותו (כתיבה).
  3. אם שני מצבים משתנים בתגובה לאותם אירועים, צריך להעביר אותם יחד.

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

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

שחזור מצב ב-Compose

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

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

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

Parcelize

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

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

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

var name by remember { mutableStateOf("") }

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

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

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

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

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

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

מידע נוסף

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

דוגמאות

Codelabs

סרטונים

בלוגים