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

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

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

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

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

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

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

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

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

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

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

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

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

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

סרטונים

בלוגים