בעלי מצב ומצב ממשק המשתמש

במדריך בנושא שכבת ממשק המשתמש מוסבר על זרימת נתונים חד-כיוונית (UDF) כאמצעי ליצירה ולניהול של מצב ממשק המשתמש בשכבת ממשק המשתמש.

הנתונים זורמים בכיוון אחד משכבת הנתונים לממשק המשתמש.
איור 1. זרימת נתונים חד-כיוונית.

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

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

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

רכיבים של צינור העיבוד לייצור מצבים לממשקי משתמש

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

מצב ממשק המשתמש

מצב ממשק המשתמש הוא המאפיין שמתאר את ממשק המשתמש. יש שני סוגים של מצב ממשק משתמש:

  • מצב ממשק המשתמש במסך הוא מה שצריך להציג במסך. לדוגמה, מחלקה NewsUiState יכולה להכיל את מאמרי החדשות ומידע אחר שנדרש כדי להציג את ממשק המשתמש. המצב הזה בדרך כלל קשור לשכבות אחרות בהיררכיה כי הוא מכיל נתונים של אפליקציות.
  • מצב של רכיב בממשק המשתמש מתייחס למאפיינים שמוטמעים ברכיבים בממשק המשתמש ומשפיעים על אופן העיבוד שלהם. יכול להיות שרכיב בממשק המשתמש יוצג או יוסתר, ויהיה לו גופן, גודל גופן או צבע גופן מסוימים. ב-Android Views, הרכיב View מנהל את המצב הזה בעצמו כי הוא מוגדר כבעל מצב, ומציג שיטות לשינוי המצב או לשליפת נתונים לגביו. דוגמה לכך היא השיטות get ו-set של המחלקה TextView עבור הטקסט שלה. ב-Jetpack Compose, המצב הוא חיצוני לרכיב הניתן להרכבה, ואפשר אפילו להעביר אותו מחוץ לסביבה הקרובה של הרכיב הניתן להרכבה אל פונקציית הרכיב הניתן להרכבה שקוראת לו או אל מחזיק המצב. דוגמה: ScaffoldState עבור הרכיב Scaffold.

לוגיקה

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

הלוגיקה יוצרת מצב של ממשק המשתמש
איור 2. לוגיקה בתור הגורם שמייצר מצבים לממשק המשתמש.

הלוגיקה באפליקציה יכולה להיות לוגיקה עסקית או לוגיקת ממשק משתמש:

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

מחזור החיים של Android וסוגי המצבים והלוגיקה של ממשק המשתמש

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

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

הטבלה הבאה מסכמת את האפשרויות שצוינו למעלה:

UI Lifecycle independent תלוי במחזור החיים של ממשק המשתמש
לוגיקה עסקית הלוגיקה של ממשק המשתמש
מצב ממשק המשתמש במסך

צינור העיבוד של מצבי ממשק המשתמש

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

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

  • מצב ממשק המשתמש נוצר ומנוהל על ידי ממשק המשתמש עצמו. לדוגמה, מונה בסיסי פשוט לשימוש חוזר:

    @Composable
    fun Counter() {
        // The UI state is managed by the UI itself
        var count by remember { mutableStateOf(0) }
        Row {
            Button(onClick = { ++count }) {
                Text(text = "Increment")
            }
            Button(onClick = { --count }) {
                Text(text = "Decrement")
            }
        }
    }
    
  • לוגיקה של ממשק המשתמש ← ממשק משתמש. לדוגמה, הצגה או הסתרה של לחצן שמאפשר למשתמש לעבור לראש הרשימה.

    @Composable
    fun ContactsList(contacts: List<Contact>) {
        val listState = rememberLazyListState()
        val isAtTopOfList by remember {
            derivedStateOf {
                listState.firstVisibleItemIndex < 3
            }
        }
    
        // Create the LazyColumn with the lazyListState
        ...
    
        // Show or hide the button (UI logic) based on the list scroll position
        AnimatedVisibility(visible = !isAtTopOfList) {
            ScrollToTopButton()
        }
    }
    
  • לוגיקה עסקית ← ממשק משתמש. רכיב בממשק המשתמש שבו מוצגת התמונה של המשתמש הנוכחי במסך.

    @Composable
    fun UserProfileScreen(viewModel: UserProfileViewModel = hiltViewModel()) {
        // Read screen UI state from the business logic state holder
        val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    
        // Call on the UserAvatar Composable to display the photo
        UserAvatar(picture = uiState.profilePicture)
    }
    
  • לוגיקה עסקית ← לוגיקה של ממשק המשתמש ← ממשק המשתמש. רכיב בממשק המשתמש שניתן לגלול בו כדי להציג את המידע הנכון במסך עבור מצב מסוים של ממשק המשתמש.

    @Composable
    fun ContactsList(viewModel: ContactsViewModel = hiltViewModel()) {
        // Read screen UI state from the business logic state holder
        val uiState by viewModel.uiState.collectAsStateWithLifecycle()
        val contacts = uiState.contacts
        val deepLinkedContact = uiState.deepLinkedContact
    
        val listState = rememberLazyListState()
    
        // Create the LazyColumn with the lazyListState
        ...
    
        // Perform UI logic that depends on information from business logic
        if (deepLinkedContact != null && contacts.isNotEmpty()) {
            LaunchedEffect(listState, deepLinkedContact, contacts) {
                val deepLinkedContactIndex = contacts.indexOf(deepLinkedContact)
                if (deepLinkedContactIndex >= 0) {
                  // Scroll to deep linked item
                  listState.animateScrollToItem(deepLinkedContactIndex)
                }
            }
        }
    }
    

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

הנתונים זורמים משכבת הפקת הנתונים אל ממשק המשתמש
איור 3. החלת לוגיקה בשכבת ממשק המשתמש.

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

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

היתרונות של השיטה הזו:

  • ממשקי משתמש פשוטים: ממשק המשתמש רק קושר את המצב שלו.
  • יכולת תחזוקה: אפשר לחזור על הלוגיקה שמוגדרת ב-State Holder בלי לשנות את ממשק המשתמש עצמו.
  • אפשרות בדיקה: אפשר לבדוק את ממשק המשתמש ואת הלוגיקה של ייצור המצבים שלו בנפרד.
  • קריאות: מי שקורא את הקוד יכול לראות בבירור את ההבדלים בין קוד ההצגה של ממשק המשתמש לבין קוד הייצור של מצב ממשק המשתמש.

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

סוגים של בעלי סטטוס

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

  • האובייקט שמחזיק את מצב הלוגיקה העסקית.
  • מאחסן המצב של הלוגיקה של ממשק המשתמש.

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

לוגיקה עסקית ובעל המצב שלה

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

נכס פרטים
ייצור מצבים לממשק המשתמש האחריות על יצירת מצב ממשק המשתמש עבור ממשקי המשתמש שלהם מוטלת על מחזיקי המצב של הלוגיקה העסקית. מצב ממשק המשתמש הזה הוא לרוב תוצאה של עיבוד אירועי משתמשים וקריאת נתונים משכבות הדומיין והנתונים.
הנתונים נשמרים באמצעות יצירה מחדש של פעילות מחזיקי מצב של לוגיקה עסקית שומרים על המצב שלהם ועל צינורות העיבוד של המצב במהלך Activity יצירה מחדש, וכך עוזרים לספק חוויית משתמש חלקה. במקרים שבהם אי אפשר לשמור את הנתונים של האובייקט ששומר את המצב והוא נוצר מחדש (בדרך כלל אחרי סיום התהליך), האובייקט ששומר את המצב צריך להיות מסוגל ליצור מחדש בקלות את המצב האחרון שלו כדי להבטיח חוויית משתמש עקבית.
Possess long lived state לרוב משתמשים ב-state holders של לוגיקה עסקית כדי לנהל את הסטטוס של יעדי ניווט. כתוצאה מכך, הם לרוב שומרים את המצב שלהם גם אחרי שינויים בניווט, עד שהם מוסרים מתרשים הניווט.
ייחודית לממשק המשתמש שלה ואי אפשר לעשות בה שימוש חוזר בדרך כלל, מחזיקי מצב של לוגיקה עסקית יוצרים מצב עבור פונקציה מסוימת של אפליקציה, לדוגמה TaskEditViewModel או TaskListViewModel, ולכן הם רלוונטיים רק לפונקציה הזו של האפליקציה. אותו מאגר נתונים יכול לתמוך בפונקציות האלה של האפליקציה במגוון גורמים שמשפיעים על הצורה. לדוגמה, גרסאות של האפליקציה לנייד, לטלוויזיה ולטאבלט עשויות לעשות שימוש חוזר באותו מחזיק מצב של לוגיקה עסקית.

לדוגמה, נניח שרוצים להגיע ליעד הניווט של המחבר באפליקציה Now in Android:

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

ה-AuthorViewModel משמש כמאגר של מצב הלוגיקה העסקית, ובמקרה הזה הוא יוצר את מצב ממשק המשתמש:

@HiltViewModel
class AuthorViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val authorsRepository: AuthorsRepository,
    newsRepository: NewsRepository
) : ViewModel() {

    val uiState: StateFlow<AuthorScreenUiState> = 

    // Business logic
    fun followAuthor(followed: Boolean) {
      
    }
}

שימו לב של-AuthorViewModel יש את המאפיינים שצוינו קודם:

נכס פרטים
התוצאה היא AuthorScreenUiState ה-AuthorViewModel קורא נתונים מ-AuthorsRepository ומ-NewsRepository ומשתמש בנתונים האלה כדי ליצור AuthorScreenUiState. הוא גם מחיל לוגיקה עסקית כשהמשתמש רוצה לעקוב אחרי Author או לבטל את המעקב אחריו, על ידי העברה אל AuthorsRepository.
יש גישה לשכבת הנתונים מועבר אליו מופע של AuthorsRepository ושל NewsRepository בקונסטרוקטור שלו, כדי שיוכל להטמיע את הלוגיקה העסקית של מעקב אחרי Author.
ההגדרה נשמרת גם אחרי יצירה מחדש של Activity מכיוון שהיא מיושמת באמצעות ViewModel, היא תישמר גם אחרי יצירה מהירה של Activity. במקרה של סיום התהליך, אפשר לקרוא את האובייקט SavedStateHandle כדי לקבל את כמות המידע המינימלית שנדרשת לשחזור מצב ממשק המשתמש משכבת הנתונים.
Possesses long lived state ה-ViewModel מוגדר לגרף הניווט, ולכן אלא אם יעד המחבר יוסר מגרף הניווט, מצב ממשק המשתמש ב-uiState StateFlow יישאר בזיכרון. השימוש ב-StateFlow מוסיף גם את היתרון של יישום עצל של הלוגיקה העסקית שמייצרת את המצב, כי המצב נוצר רק אם יש אוסף של מצב ממשק המשתמש.
ייחודי לממשק המשתמש שלו ההגדרה AuthorViewModel רלוונטית רק ליעד הניווט של המחבר, ואי אפשר להשתמש בה שוב בשום מקום אחר. אם יש לוגיקה עסקית שנעשה בה שימוש חוזר ביעדי ניווט שונים, צריך להגדיר את הלוגיקה העסקית הזו כרכיב עם היקף של שכבת נתונים או שכבת דומיין.

ה-ViewModel כמאגר של מצב הלוגיקה העסקית

היתרונות של ViewModels בפיתוח ל-Android הופכים אותם למתאימים למתן גישה ללוגיקה העסקית ולהכנת נתוני האפליקציה להצגה במסך. ההטבות האלו כוללות:

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

לוגיקת ממשק המשתמש ומאחסן המצב שלה

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

  • יוצר מצב של ממשק משתמש ומנהל את המצב של רכיבים בממשק המשתמש.
  • לא שורד יצירה מחדש של Activity: מחזיקי מצב שמארחים בלוגיקת ממשק המשתמש תלויים לעיתים קרובות במקורות נתונים מממשק המשתמש עצמו, וניסיון לשמור את המידע הזה בשינויים בתצורה גורם לרוב לדליפת זיכרון. אם בעלי מצב צריכים שהנתונים יישמרו גם אחרי שינויים בהגדרות, הם צריכים להעביר את הטיפול לרכיב אחר שמתאים יותר לשמירה אחרי יצירה מחדש.Activity לדוגמה, ב-Jetpack Compose, מצבים של רכיבי ממשק משתמש שניתנים להרכבה שנוצרו באמצעות פונקציות remembered לרוב מועברים ל-rememberSaveable כדי לשמור על המצב במהלך יצירה מחדש של Activity. דוגמאות לפונקציות כאלה כוללות rememberScaffoldState() ו-rememberLazyListState().
  • יש הפניות למקורות נתונים בהיקף ממשק המשתמש: אפשר להפנות למקורות נתונים כמו ממשקי API של מחזור החיים ומשאבים ולקרוא אותם בצורה בטוחה, כי למחזיק מצב הלוגיקה של ממשק המשתמש יש את אותו מחזור חיים כמו לממשק המשתמש.
  • אפשר להשתמש בו שוב בכמה ממשקי משתמש: אפשר להשתמש שוב במופעים שונים של אותו מחזיק מצב לוגי של ממשק משתמש בחלקים שונים של האפליקציה. לדוגמה, אפשר להשתמש במחזיק מצב לניהול אירועי קלט של משתמשים בקבוצת צ'יפים בדף חיפוש לצ'יפים של מסננים, וגם בשדה 'אל' של נמעני אימייל.

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

הדוגמה הבאה מתוך הדוגמה Now in Android ממחישה את מה שצוין למעלה:

ב-Now in Android נעשה שימוש במאחסן מצבים של מחלקה פשוטה לניהול הלוגיקה של ממשק המשתמש
איור 5. אפליקציית הדוגמה Now in Android.

בדוגמה Now in Android מוצג סרגל אפליקציות תחתון או פס ניווט לניווט, בהתאם לגודל המסך של המכשיר. במסכים קטנים יותר משתמשים בסרגל האפליקציות התחתון, ובמסכים גדולים יותר משתמשים בסרגל הניווט.

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

@Stable
class NiaAppState(
    val navController: NavHostController,
    val windowSizeClass: WindowSizeClass
) {

    // UI logic
    val shouldShowBottomBar: Boolean
        get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact ||
            windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact

    // UI logic
    val shouldShowNavRail: Boolean
        get() = !shouldShowBottomBar

   // UI State
    val currentDestination: NavDestination?
        @Composable get() = navController
            .currentBackStackEntryAsState().value?.destination

    // UI logic
    fun navigate(destination: NiaNavigationDestination, route: String? = null) { /* ... */ }

     /* ... */
}

בדוגמה שלמעלה, הפרטים הבאים לגבי NiaAppState בולטים:

  • לא שורד יצירה מחדש של Activity: NiaAppState הוא remembered ב-Composition על ידי יצירה שלו באמצעות פונקציה Composable‏ rememberNiaAppState בהתאם למוסכמות השמות של Compose. אחרי שיוצרים מחדש את Activity, המופע הקודם אובד ונוצר מופע חדש עם כל התלויות שלו, שמתאים להגדרה החדשה של Activity שנוצר מחדש. יכול להיות שהתלות הזו חדשה או שהיא שוחזרה מההגדרה הקודמת. לדוגמה, rememberNavController() משמש בבונה NiaAppState והוא מעביר את הפעולה אל rememberSaveable כדי לשמור על המצב במהלך יצירה מחדש של Activity.
  • כולל הפניות למקורות נתונים בהיקף ממשק המשתמש: אפשר להשתמש ב-NiaAppState כדי להפנות ל-navigationController, ל-Resources ולסוגים דומים אחרים בהיקף מחזור החיים, כי הם חולקים את אותו היקף מחזור חיים.

בחירה בין ViewModel לבין מחלקה רגילה לניהול מצב

מהקטעים הקודמים, הבחירה בין ViewModel לבין מחזיק מצב של מחלקה רגילה תלויה בלוגיקה שמוחלת על מצב ממשק המשתמש ובמקורות הנתונים שהלוגיקה פועלת עליהם.

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

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

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

אפשר לשלב בין מאחסני מצב

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

  • מאחסן מצבים של לוגיקה של ממשק משתמש יכול להיות תלוי במאחסן מצבים אחר של לוגיקה של ממשק משתמש.
  • מאחסן מצבים ברמת המסך יכול להיות תלוי במאחסן מצבים של לוגיקת ממשק המשתמש.

בקטע הקוד הבא מוצגות התלויות של Compose's DrawerState במחזיק מצב פנימי אחר, SwipeableState, ואיך מחזיק מצב של לוגיקת ממשק המשתמש של אפליקציה יכול להיות תלוי ב-DrawerState:

@Stable
class DrawerState(/* ... */) {
  internal val swipeableState = SwipeableState(/* ... */)
  // ...
}

@Stable
class MyAppState(
  private val drawerState: DrawerState,
  private val navController: NavHostController
) { /* ... */ }

@Composable
fun rememberMyAppState(
  drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed),
  navController: NavHostController = rememberNavController()
): MyAppState = remember(drawerState, navController) {
  MyAppState(drawerState, navController)
}

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

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

class MyScreenViewModel(/* ... */) {
  val uiState: StateFlow<MyScreenUiState> = /* ... */
  fun doSomething() { /* ... */ }
  fun doAnotherThing() { /* ... */ }
  // ...
}

@Stable
class MyScreenState(
  // DO NOT pass a ViewModel instance to a plain state holder class
  // private val viewModel: MyScreenViewModel,

  // Instead, pass only what it needs as a dependency
  private val someState: StateFlow<SomeState>,
  private val doSomething: () -> Unit,

  // Other UI-scoped types
  private val scaffoldState: ScaffoldState
) {
  /* ... */
}

@Composable
fun rememberMyScreenState(
  someState: StateFlow<SomeState>,
  doSomething: () -> Unit,
  scaffoldState: ScaffoldState = rememberScaffoldState()
): MyScreenState = remember(someState, doSomething, scaffoldState) {
  MyScreenState(someState, doSomething, scaffoldState)
}

@Composable
fun MyScreen(
  modifier: Modifier = Modifier,
  viewModel: MyScreenViewModel = viewModel(),
  state: MyScreenState = rememberMyScreenState(
    someState = viewModel.uiState.map { it.toSomeState() },
    doSomething = viewModel::doSomething
  ),
  // ...
) {
  /* ... */
}

התרשים הבא מייצג את התלות בין ממשק המשתמש לבין מחזיקי מצב שונים בקטע הקוד הקודם:

ממשק המשתמש תלוי במאחסן המצב של לוגיקת ממשק המשתמש ובמאחסן המצב ברמת המסך
איור 7. ממשק המשתמש בהתאם למאחסני מצבים שונים. החיצים מסמלים יחסי תלות.

טעימות

בדוגמאות הבאות של Google אפשר לראות איך משתמשים ב-state holders בשכבת ממשק המשתמש. כדאי לעיין בהם כדי לראות את ההנחיות האלה בפועל: