באפליקציית Compose, המקום שבו מרימים את מצב ממשק המשתמש תלוי בשאלה אם הוא נדרש על ידי הלוגיקה של ממשק המשתמש או הלוגיקה העסקית. במסמך הזה מפורטים שני התרחישים העיקריים האלה.
שיטה מומלצת
כדאי להעביר את מצב ממשק המשתמש לאב הקדמון המשותף הנמוך ביותר בין כל הרכיבים הניתנים ליצירה שקוראים וכותבים אותו. מומלץ לשמור את המצב קרוב ככל האפשר למקום שבו הוא נצרך. מבעלי המצב, חושפים לצרכנים מצב ואירועים שלא ניתנים לשינוי כדי לשנות את המצב.
האב הקדמון המשותף הנמוך ביותר יכול להיות גם מחוץ לקומפוזיציה. לדוגמה, כשמרימים מצב ב-ViewModel כי מעורבת לוגיקה עסקית.
בדף הזה מוסבר בפירוט על השיטה המומלצת הזו, ועל אזהרה שחשוב לזכור.
סוגים של מצבי ממשק משתמש ולוגיקת ממשק משתמש
בהמשך מופיעות הגדרות לסוגים של מצבי ממשק משתמש ולוגיקה שמופיעים במסמך הזה.
מצב ממשק המשתמש
מצב ממשק המשתמש הוא המאפיין שמתאר את ממשק המשתמש. יש שני סוגים של מצבי ממשק משתמש:
- מצב ממשק המשתמש של המסך הוא מה שצריך להציג במסך. לדוגמה, מחלקה מסוג
NewsUiStateיכולה להכיל את הכתבות ומידע אחר שנדרש כדי לעבד את ממשק המשתמש. המצב הזה בדרך כלל מקושר לשכבות אחרות בהיררכיה כי הוא מכיל את נתוני האפליקציה. - מצב הרכיב בממשק המשתמש מתייחס למאפיינים שקשורים לרכיבים בממשק המשתמש ומשפיעים על האופן שבו הם מוצגים. רכיב בממשק המשתמש יכול להיות מוצג או מוסתר, ויכול להיות לו גופן, גודל גופן או צבע גופן מסוים. ב-Jetpack Compose, המצב הוא חיצוני לרכיב הקומפוזבילי, ואפשר אפילו להעביר אותו מחוץ לסביבה הקרובה של הרכיב הקומפוזבילי אל הפונקציה הקומפוזבילית ששולחת את הקריאה או אל מאחסן מצב. דוגמה לכך היא
ScaffoldStateעבור הרכיב הקומפוזביליScaffold.
לוגיקה
הלוגיקה באפליקציה יכולה להיות לוגיקה עסקית או לוגיקה של ממשק המשתמש:
- לוגיקה עסקית היא ההטמעה של דרישות המוצר לנתוני האפליקציה. לדוגמה, הוספת סימנייה למאמר באפליקציה לקריאת חדשות כשהמשתמש לוחץ על הכפתור. הלוגיקה הזו לשמירת סימנייה בקובץ או במסד נתונים ממוקמת בדרך כלל בשכבות הדומיין או שכבות הנתונים. בדרך כלל, מאחסן המצב מקצה את הלוגיקה הזו לשכבות האלה על ידי קריאה ל-methods שהן חושפות.
- לוגיקת ממשק המשתמש קשורה לאופן הצגת המצב של ממשק המשתמש במסך. לדוגמה, קבלת הרמז הנכון בשורת החיפוש כשהמשתמש בחר קטגוריה, גלילה לפריט מסוים ברשימה או לוגיקת הניווט למסך מסוים כשהמשתמש לוחץ על כפתור.
הלוגיקה של ממשק המשתמש
כשלוגיקת ממשק המשתמש צריכה לקרוא או לכתוב מצב, צריך להגדיר את היקף המצב לממשק המשתמש, בהתאם למחזור החיים שלו. כדי לעשות את זה, צריך להעביר את המצב לרמה הנכונה בפונקציה קומפוזבילית. לחלופין, אפשר לעשות את זה במחלקה פשוטה של מאחסן מצב, שגם היא מוגדרת ברמת מחזור החיים של ממשק המשתמש.
בהמשך מתוארים שני הפתרונות ומוסבר מתי להשתמש בכל אחד מהם.
רכיבים קומפוזביליים כבעלי מצב
אם הלוגיקה והמצב של רכיב בממשק המשתמש פשוטים, כדאי להשתמש ברכיבים קומפוזביליים. אתם יכולים להשאיר את ערך דינמי פנימי ל-פונקציה קומפוזבילית או להעביר אותו למעלה לפי הצורך.
אין צורך בהעלאת הרמה של מצב (state hoisting)
לא תמיד צריך להעביר את המצב למעלה. אפשר לשמור את המצב פנימי ברכיב קומפוזבילי כשאין רכיב קומפוזבילי אחר שצריך לשלוט בו. בקטע הקוד הזה, יש רכיב Composable שמתרחב ומתכווץ בלחיצה:
@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 הוא המצב הפנימי של הרכיב הזה בממשק המשתמש. הוא נקרא ומשתנה רק ברכיב הקומפוזבילי הזה, והלוגיקה שחלה עליו פשוטה מאוד.
לכן, במקרה הזה, הרמת המצב לא תועיל הרבה, ואפשר להשאיר אותו פנימי. כך הפונקציה הקומפוזבילית הופכת לבעלים ולמקור הקובע של הערך הדינמי המורחב.
הנפה בתוך רכיבים קומפוזביליים
אם אתם צריכים לשתף את המצב של רכיב בממשק המשתמש עם רכיבים קומפוזביליים אחרים ולהחיל עליו לוגיקה של ממשק המשתמש במקומות שונים, אתם יכולים להעביר אותו למקום גבוה יותר בהיררכיה של ממשק המשתמש. כך גם קל יותר לעשות שימוש חוזר ברכיבים הניתנים ליצירה ולבדוק אותם.
בדוגמה הבאה מוצגת אפליקציית צ'אט שמטמיעה שתי פונקציות:
- הלחצן
JumpToBottomגולל את רשימת ההודעות לתחתית. הכפתור מבצע לוגיקה של ממשק המשתמש במצב הרשימה. - הרשימה
MessagesListנגללת לתחתית אחרי שהמשתמש שולח הודעות חדשות. UserInput מפעיל לוגיקה של ממשק המשתמש על מצב הרשימה.
JumpToBottom וגלילה לתחתית המסך כשמתקבלות הודעות חדשותההיררכיה של ה-composable היא:
המצב LazyColumn מועבר למסך השיחה כדי שהאפליקציה תוכל לבצע לוגיקה של ממשק המשתמש ולקרוא את המצב מכל הרכיבים הקומפוזביליים שדורשים אותו:
LazyColumn מ-LazyColumn אל ConversationScreenאז בסופו של דבר, הפונקציות הניתנות לTex-Compose הן:
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 מוגדר ב-method MessagesList, עם ערך ברירת המחדל rememberLazyListState(). זו תבנית נפוצה ב-Compose.
הוא הופך את הפונקציות הניתנות לשילוב לגמישות יותר ולכאלה שניתן לעשות בהן שימוש חוזר. לאחר מכן תוכלו להשתמש בפונקציה הקומפוזבילית בחלקים שונים של האפליקציה, שבהם אולי לא צריך לשלוט בערך הדינמי. בדרך כלל זה קורה כשבודקים או מציגים תצוגה מקדימה של רכיב Composable. ככה בדיוק LazyColumn מגדירה את המצב שלה.
LazyListState הוא ConversationScreenמחלקה פשוטה של מאחסן מצב בתור בעלי המצב
כשתוכן קומפוזבילי מכיל לוגיקה מורכבת של ממשק המשתמש שכוללת שדה מצב אחד או יותר של רכיב בממשק המשתמש, הוא צריך להעביר את האחריות הזו למאחסני מצב, כמו מחלקה פשוטה של מאחסן מצב. כך קל יותר לבדוק את הלוגיקה של ה-composable בנפרד, והיא פחות מורכבת. הגישה הזו תומכת בעיקרון הפרדת תחומי האחריות: הפונקציה הקומפוזבילית אחראית על פליטת רכיבים בממשק המשתמש, ומאחסן המצב מכיל את הלוגיקה של ממשק המשתמש ואת ערך דינמי של הרכיבים בממשק המשתמש.
מחלקות פשוטות של מאחסני מצב מספקות פונקציות נוחות לקריאה לפונקציה הקומפוזבילית, כך שלא צריך לכתוב את הלוגיקה הזו באופן עצמאי.
המחלקות הפשוטות האלה נוצרות ונשמרות בזיכרון בקומפוזיציה. מכיוון שהם פועלים לפי מחזור החיים של ה-composable, הם יכולים לקבל סוגים שסופקו על ידי ספריית Compose, כמו rememberNavController() או rememberLazyListState().
דוגמה לכך היא המחלקה הפשוטה של מאחסן המצב LazyListState, שמוטמעת ב-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 של רכיב בממשק המשתמש הזה. הוא גם חושף שיטות לשינוי מיקום הגלילה, למשל גלילה לפריט מסוים.
כמו שאתם רואים, ככל שמוסיפים עוד תחומי אחריות ל-composable, כך עולה הצורך במאחסן מצב. תחומי האחריות יכולים להיות בלוגיקה של ממשק המשתמש, או רק בכמות המצבים שצריך לעקוב אחריהם.
דפוס נפוץ נוסף הוא שימוש במחלקה פשוטה של מאחסן מצב כדי לטפל במורכבות של פונקציות קומפוזביליות ברמה הבסיסית (root) באפליקציה. אתם יכולים להשתמש במחלקה כזו כדי להכיל מצב ברמת האפליקציה, כמו מצב הניווט וגודל המסך. תיאור מלא של הנושא הזה זמין בדף לוגיקה של ממשק המשתמש ומאחסן המצב שלו.
לוגיקה עסקית
אם רכיבים קומפוזביליים ומחלקות פשוטות של מאחסני מצבים אחראים ללוגיקה של ממשק המשתמש ולמצב של הרכיבים בממשק המשתמש, מאחסן מצבים ברמת המסך אחראי למשימות הבאות:
- מתן גישה ללוגיקה העסקית של האפליקציה, שבדרך כלל נמצאת בשכבות אחרות בהיררכיה, כמו שכבות הנתונים והעסק.
- הכנת נתוני האפליקציה להצגה במסך מסוים, שהופך למצב ממשק המשתמש של המסך.
ViewModels כבעלים של State
היתרונות של AAC ViewModels בפיתוח ל-Android הופכים אותם למתאימים כדי לספק גישה ללוגיקה העסקית ולהכין את נתוני האפליקציה להצגה במסך.
כשמרימים את מצב ממשק המשתמש ב-ViewModel, מעבירים אותו אל מחוץ לקומפוזיציה.
ViewModel מאוחסן מחוץ לקומפוזיציה.מודלים של תצוגה לא נשמרים כחלק מהקומפוזיציה. הם מסופקים על ידי ה-framework ומוגדרים ל-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) /* ... */ }
פירוט נכסים
"העברת מאפיינים" מתייחסת להעברת נתונים דרך כמה רכיבי צאצאים בתצוגת עץ למיקום שבו הם נקראים.
דוגמה אופיינית למצב שבו יכול להופיע פיצול מאפיינים ב-Compose היא כשמזריקים את מאחסן המצב ברמת המסך ברמה העליונה, ומעבירים את המצב והאירועים לרכיבים ה-composable הצאצאים. זה עלול גם ליצור עומס יתר של חתימות של פונקציות קומפוזביליות.
למרות שחשיפת אירועים כפרמטרים נפרדים של פונקציית למבדה עלולה להעמיס על החתימה של הפונקציה, היא ממקסמת את החשיפה של תחומי האחריות של הפונקציה הקומפוזבילית. אפשר לראות מה הוא עושה במבט חטוף.
עדיף להשתמש ב-Property drilling מאשר ליצור מחלקות wrapper כדי להכיל את הערך הדינמי והאירועים במקום אחד, כי זה מפחית את החשיפה של תחומי האחריות של הפונקציה הקומפוזבילית. בנוסף, אם לא משתמשים במחלקות wrapper, סביר יותר להניח שמעבירים לפונקציות ה-composable רק את הפרמטרים שהן צריכות, וזו שיטה מומלצת.
אותה שיטה מומלצת רלוונטית גם אם האירועים האלה הם אירועי ניווט. אפשר לקרוא מידע נוסף על כך במסמכי התיעוד בנושא ניווט.
אם זיהיתם בעיה בביצועים, אתם יכולים גם לבחור לדחות את קריאת המצב. מידע נוסף זמין במסמכי התיעוד בנושא ביצועים.
מצב הרכיב בממשק המשתמש
אם יש לוגיקה עסקית שצריכה לקרוא או לכתוב את המצב של רכיב בממשק המשתמש, אתם יכולים להעביר אותו למאחסן המצב ברמת המסך.
נמשיך עם הדוגמה של אפליקציית הצ'אט: האפליקציה מציגה הצעות למשתמשים בצ'אט קבוצתי כשהמשתמש מקליד @ ורמז. ההצעות האלה מגיעות משכבת הנתונים, והלוגיקה לחישוב רשימת ההצעות למשתמשים נחשבת ללוגיקה עסקית. התכונה נראית כך:
@ ורמזה-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 הוא מצב ממשק המשתמש של המסך, והוא נצרך מממשק המשתמש של Compose על ידי איסוף מ-StateFlow.
Caveat
במצבים מסוימים של רכיבים בממשק המשתמש של Compose, יכול להיות שצריך להביא בחשבון שיקולים מיוחדים כשמעלים את הרמה של המצב ל-ViewModel. לדוגמה, חלק ממאחסני המצב של רכיבים בממשק המשתמש של Compose חושפים שיטות לשינוי המצב. חלק מהן עשויות להיות פונקציות השעיה שמפעילות אנימציות. הפונקציות האלה יכולות להקפיץ חריגות אם קוראים להן מ-CoroutineScope שלא מוגדרת לו קומפוזיציה.
נניח שהתוכן של חלונית ההזזה לאפליקציות הוא דינמי, ואתם צריכים לאחזר ולרענן אותו משכבת הנתונים אחרי שהיא נסגרת. כדאי להעביר את המצב של חלונית ההזזה אל ViewModel כדי שתוכלו לקרוא גם לממשק המשתמש וגם ללוגיקה העסקית של הרכיב הזה מהבעלים של המצב.
עם זאת, קריאה ל-method close() של DrawerState באמצעות viewModelScope מ-Compose UI גורמת לחריגה בזמן ריצה מסוג IllegalStateException עם ההודעה "MonotonicFrameClock לא זמין ב-CoroutineContext” הזה.
כדי לפתור את הבעיה, צריך להשתמש ב-CoroutineScope ברמת הקומפוזיציה. הוא מספק MonotonicFrameClock ב-CoroutineContext שנדרש כדי שהפונקציות להשהיה יפעלו.
כדי לתקן את הקריסה הזו, צריך להחליף את ה-CoroutineContext של ה-coroutine ב-ViewModel לאחד שמוגדר בהיקף של הקומפוזיציה. לדוגמה:
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) }) }
מידע נוסף
במקורות המידע הבאים אפשר למצוא מידע נוסף על State ועל Jetpack Compose.
דוגמאות
Codelabs
סרטונים
מומלץ בשבילכם
- הערה: טקסט הקישור מוצג כש-JavaScript מושבת
- שמירת מצב ממשק המשתמש בפיתוח נייטיב
- רשימות ורשתות
- תכנון הארכיטקטורה של ממשק המשתמש בפיתוח נייטיב