איפה להעלות את המצב

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

שיטה מומלצת

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

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

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

סוגים של מצב ממשק משתמש ולוגיקת ממשק משתמש

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

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

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

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

לוגיקה

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

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

הלוגיקה של ממשק המשתמש

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

בהמשך מפורט תיאור של שני הפתרונות והסבר מתי כדאי להשתמש בכל אחד מהם.

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

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

אין צורך בהעלאת הרמה של מצב (state hoisting)

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

@Composable
fun ChatBubble(
    message: Message
) {
    var showDetails by rememberSaveable { mutableStateOf(false) } // Define the UI element expanded state

    Text(
        text = AnnotatedString(message.content),
        modifier = Modifier.clickable {
            showDetails = !showDetails // Apply UI logic
        }
    )

    if (showDetails) {
        Text(message.timestamp)
    }
}

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

הרמה ברכיבים קומפוזביליים

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

בדוגמה הבאה מוצגת אפליקציית צ'אט שמטמיעה שני רכיבי פונקציונליות:

  • הלחצן JumpToBottom גולל את רשימת ההודעות לתחתית. הכפתור מבצע לוגיקה של ממשק המשתמש במצב הרשימה.
  • הרשימה MessagesList מתגללת לחלק התחתון אחרי שהמשתמש שולח הודעות חדשות. הקומפוננטה UserInput מבצעת לוגיקה של ממשק משתמש בסטטוס של הרשימה.
אפליקציית צ'אט עם לחצן 'מעבר לתחתית' וגלילה לתחתית בהודעות חדשות
איור 1. אפליקציית צ'אט עם לחצן JumpToBottom וגלילה לתחתית בהודעות חדשות

ההיררכיה של הרכיבים היא כזו:

עץ של צ'אט עם אפשרויות הרכבה
איור 2. עץ של רכיבים שאפשר להרכיב בצ'אט

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

העברת מצב LazyColumn מ-LazyColumn אל ConversationScreen
איור 3. העברת מצב LazyColumn מה-LazyColumn אל ConversationScreen

אז אלה הפונקציות הניתנות להרכבה:

עץ של רכיבים שאפשר להרכיב מהם צ'אט עם LazyListState שהועבר אל ConversationScreen
איור 4. עץ של רכיבים שאפשר להוסיף לצ'אט עם LazyListState שהועבר אל ConversationScreen

הקוד הוא:

@Composable
private fun ConversationScreen(/*...*/) {
    val scope = rememberCoroutineScope()

    val lazyListState = rememberLazyListState() // State hoisted to the ConversationScreen

    MessagesList(messages, lazyListState) // Reuse same state in MessageList

    UserInput(
        onMessageSent = { // Apply UI logic to lazyListState
            scope.launch {
                lazyListState.scrollToItem(0)
            }
        },
    )
}

@Composable
private fun MessagesList(
    messages: List<Message>,
    lazyListState: LazyListState = rememberLazyListState() // LazyListState has a default value
) {

    LazyColumn(
        state = lazyListState // Pass hoisted state to LazyColumn
    ) {
        items(messages, key = { message -> message.id }) { item ->
            Message(/*...*/)
        }
    }

    val scope = rememberCoroutineScope()

    JumpToBottom(onClicked = {
        scope.launch {
            lazyListState.scrollToItem(0) // UI logic being applied to lazyListState
        }
    })
}

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

שימו לב שהערך lazyListState מוגדר בשיטה MessagesList, עם ערך ברירת המחדל rememberLazyListState(). זו תבנית נפוצה ב-Compose. הוא מאפשר שימוש חוזר וגמיש יותר ברכיבים הניתנים להרכבה. אחר כך תוכלו להשתמש ב-composable בחלקים שונים של האפליקציה, שאולי לא צריכים לשלוט במצב. בדרך כלל זה קורה כשבודקים או מציגים בתצוגה מקדימה רכיב שאפשר להרכיב. כך בדיוק LazyColumn מגדיר את המצב שלו.

האב הקדמון המשותף הנמוך ביותר של LazyListState הוא ConversationScreen
איור 5. האב הקדמון המשותף הנמוך ביותר של LazyListState הוא ConversationScreen

מחזיק מצב פשוט בתור בעלים של מצב

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

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

המחלקות הפשוטות האלה נוצרות ונשמרות ב-Composition. הם פועלים לפי מחזור החיים של רכיב שאפשר להרכיב, ולכן הם יכולים לקבל סוגים שסופקו על ידי ספריית Compose, כמו rememberNavController() או rememberLazyListState().

דוגמה לכך היא המחלקה LazyListState plain state holder, שמוטמעת ב-Compose כדי לשלוט במורכבות של ממשק המשתמש של LazyColumn או LazyRow.

// LazyListState.kt

@Stable
class LazyListState constructor(
    firstVisibleItemIndex: Int = 0,
    firstVisibleItemScrollOffset: Int = 0
) : ScrollableState {
    /**
     *   The holder class for the current scroll position.
     */
    private val scrollPosition = LazyListScrollPosition(
        firstVisibleItemIndex, firstVisibleItemScrollOffset
    )

    suspend fun scrollToItem(/*...*/) { /*...*/ }

    override suspend fun scroll() { /*...*/ }

    suspend fun animateScrollToItem() { /*...*/ }
}

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

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

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

לוגיקה עסקית

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

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

‫ViewModels כבעלי מצב

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

כשמעבירים את מצב ממשק המשתמש ב-ViewModel, מעבירים אותו מחוץ ל-Composition.

הסטייט שמועבר ל-ViewModel מאוחסן מחוץ ל-Composition.
איור 6. המצב מועבר אל ViewModel ומאוחסן מחוץ לרכיב Composition.

‫ViewModels לא מאוחסנים כחלק מה-Composition. הם מסופקים על ידי מסגרת העבודה והם מוגבלים לViewModelStoreOwner שיכול להיות Activity,‏ Fragment,‏ תרשים ניווט או יעד של תרשים ניווט. מידע נוסף על היקפי ViewModel זמין במאמרי העזרה.

במקרה כזה, ViewModel הוא מקור האמת והאב הקדמון המשותף הנמוך ביותר למצב ממשק המשתמש.

מצב ממשק המשתמש במסך

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

כדאי לחשוב על ConversationViewModel של אפליקציית צ'אט ואיך היא חושפת את מצב ממשק המשתמש של המסך ואת האירועים כדי לשנות אותו:

class ConversationViewModel(
    channelId: String,
    messagesRepository: MessagesRepository
) : ViewModel() {

    val messages = messagesRepository
        .getLatestMessages(channelId)
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = emptyList()
        )

    // Business logic
    fun sendMessage(message: Message) { /* ... */ }
}

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

הדוגמה הבאה היא של ViewModel שמשמשת בקומפוזיציה ברמת המסך. במקרה הזה, רכיב ה-Composable‏ ConversationScreen() צורך את מצב ממשק המשתמש של המסך שהועבר אל ViewModel:

@Composable
private fun ConversationScreen(
    conversationViewModel: ConversationViewModel = viewModel()
) {

    val messages by conversationViewModel.messages.collectAsStateWithLifecycle()

    ConversationScreen(
        messages = messages,
        onSendMessage = { message: Message -> conversationViewModel.sendMessage(message) }
    )
}

@Composable
private fun ConversationScreen(
    messages: List<Message>,
    onSendMessage: (Message) -> Unit
) {

    MessagesList(messages, onSendMessage)
    /* ... */
}

קידוח בנכס

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

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

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

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

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

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

מצב רכיב בממשק המשתמש

אפשר להעלות את מצב רכיב ממשק המשתמש למאגר המצבים ברמת המסך אם יש לוגיקה עסקית שצריכה לקרוא או לכתוב אותו.

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

תכונה שמציגה הצעות למשתמשים בצ&#39;אט קבוצתי כשהמשתמש מקליד@ורמז
איור 7. תכונה שמציגה הצעות למשתמשים בצ'אט קבוצתי כשהמשתמש מקליד @ ורמז

היישום של התכונה ViewModel ייראה כך:

class ConversationViewModel(/*...*/) : ViewModel() {

    // Hoisted state
    var inputMessage by mutableStateOf("")
        private set

    val suggestions: StateFlow<List<Suggestion>> =
        snapshotFlow { inputMessage }
            .filter { hasSocialHandleHint(it) }
            .mapLatest { getHandle(it) }
            .mapLatest { repository.getSuggestions(it) }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = emptyList()
            )

    fun updateInput(newInput: String) {
        inputMessage = newInput
    }
}

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

suggestions הוא מצב ממשק המשתמש של המסך, והוא נצרך מממשק המשתמש של פיתוח נייטיב על ידי איסוף מ-StateFlow.

הערה

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

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

עם זאת, קריאה לשיטה close() של DrawerState באמצעות viewModelScope מממשק המשתמש של Compose גורמת לחריגה בזמן ריצה מסוג IllegalStateException עם ההודעה 'MonotonicFrameClock לא זמין ב-CoroutineContext” הזה'.

כדי לפתור את הבעיה, צריך להשתמש בCoroutineScope שמוגדר בהיקף של היצירה. הוא מספק MonotonicFrameClock ב-CoroutineContext שנדרש כדי שפונקציות ההשהיה יפעלו.

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

class ConversationViewModel(/*...*/) : ViewModel() {

    val drawerState = DrawerState(initialValue = DrawerValue.Closed)

    private val _drawerContent = MutableStateFlow(DrawerContent.Empty)
    val drawerContent: StateFlow<DrawerContent> = _drawerContent.asStateFlow()

    fun closeDrawer(uiScope: CoroutineScope) {
        viewModelScope.launch {
            withContext(uiScope.coroutineContext) { // Use instead of the default context
                drawerState.close()
            }
            // Fetch drawer content and update state
            _drawerContent.update { content }
        }
    }
}

// in Compose
@Composable
private fun ConversationScreen(
    conversationViewModel: ConversationViewModel = viewModel()
) {
    val scope = rememberCoroutineScope()

    ConversationScreen(onCloseDrawer = { conversationViewModel.closeDrawer(uiScope = scope) })
}

מידע נוסף

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

דוגמאות

Codelabs

סרטונים