ביישום Compose, המיקום של מצב ממשק המשתמש תלוי אם נדרש לוגיקת ממשק משתמש או לוגיקה עסקית. במסמך הזה מפורטים שני התרחישים העיקריים האלה.
שיטה מומלצת
כדאי להעלות את מצב ממשק המשתמש לאב הקדמון המשותף הנמוך ביותר בין כל הפונקציות הניתנות להרכבה שקוראות וכותבות אותו. מומלץ לשמור את הסטטוס הכי קרוב למקום שבו הוא נמצא בשימוש. בעלי המצב חושפים לצרכנים מצב ואירועים שלא ניתן לשנות כדי לשנות את המצב.
האב הקדמון המשותף יכול להיות גם מחוץ לקומפוזיציה. לדוגמה,
כשמעבירים את מצב הנתונים (state) ב-ViewModel כי יש היגיון עסקי.
בדף הזה מוסבר בפירוט על השיטה המומלצת הזו, וגם מופיעה אזהרה שכדאי לזכור.
סוגים של מצב ממשק המשתמש ולוגיקת ממשק המשתמש
בהמשך מופיעות הגדרות של סוגים של מצב ממשק משתמש ולוגיקה שמשמשים לאורך המסמך הזה.
מצב ממשק המשתמש
מצב ממשק המשתמש הוא המאפיין שמתאר את ממשק המשתמש. יש שני סוגים של מצב ממשק המשתמש:
- מצב ממשק המשתמש במסך הוא מה שצריך להציג במסך. לדוגמה,
מחלקת
NewsUiStateיכולה להכיל את מאמרי החדשות ומידע אחר שנדרש לעיבוד ממשק המשתמש. המצב הזה בדרך כלל קשור לשכבות אחרות בהיררכיה כי הוא מכיל נתוני האפליקציה. - מצב של רכיב בממשק המשתמש מתייחס למאפיינים שמוטמעים ברכיבים בממשק המשתמש ומשפיעים על אופן העיבוד שלהם. יכול להיות שרכיב בממשק המשתמש יוצג או יוסתר, ושהוא יהיה בגופן, בגודל גופן או בצבע גופן מסוימים. ב-Jetpack Compose, המצב הוא חיצוני לרכיב הקומפוזבילי, ואפשר אפילו להעביר אותו אל מחוץ לסביבה הקרובה של הרכיב הקומפוזבילי אל פונקציית הרכיב הקומפוזבילי שקוראת לו או אל מחזיק מצב. דוגמה לכך היא
ScaffoldStateעבור הרכיב הקומפוזביליScaffold.
לוגיקה
הלוגיקה באפליקציה יכולה להיות לוגיקה עסקית או לוגיקת ממשק משתמש:
- לוגיקה עסקית היא הטמעה של דרישות מוצר לגבי נתוני אפליקציה. לדוגמה, הוספת מאמר לסימנייה באפליקציה לקריאת חדשות כשמשתמש מקיש על הלחצן. הלוגיקה לשמירת סימנייה בקובץ או במסד נתונים ממוקמת בדרך כלל בשכבות של הדומיין או הנתונים. בדרך כלל, מאחסן המצב מעביר את הלוגיקה הזו לשכבות האלה על ידי הפעלת השיטות שהן חושפות.
- הלוגיקה של ממשק המשתמש קשורה לאופן הצגת מצב ממשק המשתמש במסך. לדוגמה, קבלת רמז מתאים בסרגל החיפוש כשמשתמש בוחר קטגוריה, גלילה לפריט מסוים ברשימה או לוגיקת הניווט למסך מסוים כשמשתמש לוחץ על לחצן.
הלוגיקה של ממשק המשתמש
כשלוגיקת ממשק המשתמש צריכה לקרוא או לכתוב מצב, צריך להגדיר את המצב בהיקף של ממשק המשתמש, בהתאם למחזור החיים שלו. כדי להשיג זאת, עליך להרים את הערך הדינמי ברמה הנכונה בפונקציה קומפוזבילית. אפשרות אחרת היא לעשות זאת במחזיק מצב פשוט, שגם הוא מוגבל למחזור החיים של ממשק המשתמש.
בהמשך מפורט תיאור של שני הפתרונות והסבר מתי כדאי להשתמש בכל אחד מהם.
רכיבים שאפשר להשתמש בהם חוזרת כבעלי מצב
אם המצב והלוגיקה פשוטים, כדאי להשתמש ברכיבים קומפוזביליים כדי להגדיר את הלוגיקה של ממשק המשתמש ואת המצב של רכיבי ממשק המשתמש. אפשר להשאיר את המצב פנימי לרכיב שאפשר להרכיב או להעביר אותו לפי הצורך.
אין צורך בהעלאת הרמה של מצב (state hoisting)
לא תמיד צריך להשתמש ב-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 הוא המצב הפנימי של רכיב ממשק המשתמש הזה. הקריאה והשינוי מתבצעים רק ברכיב הקומפוזבילי הזה, והלוגיקה שמוחלת עליו היא פשוטה מאוד.
לכן, במקרה הזה, העברת המצב למעלה לא תועיל במיוחד, ואפשר להשאיר אותו פנימי. כך הרכיב הזה הופך לבעלים ולמקור האמת היחיד של המצב המורחב.
הרמה בתוך רכיבים קומפוזביליים
אם אתם צריכים לשתף את מצב רכיב ממשק המשתמש עם קומפוזיציות אחרות ולהחיל עליו לוגיקה של ממשק משתמש במקומות שונים, אתם יכולים להעביר אותו למקום גבוה יותר בהיררכיית ממשק המשתמש. בנוסף, כך קל יותר להשתמש מחדש ברכיבים הניתנים להרכבה ולבדוק אותם.
בדוגמה הבאה מוצגת אפליקציית צ'אט שמטמיעה שני רכיבי פונקציונליות:
- הלחצן
JumpToBottomגורם לגלילה לתחתית רשימת ההודעות. הכפתור מבצע לוגיקה של ממשק המשתמש במצב הרשימה. - הרשימה
MessagesListמתגללת לתחתית אחרי שהמשתמש שולח הודעות חדשות. הקומפוננטה UserInput מבצעת לוגיקה של ממשק משתמש בסטטוס של הרשימה.
JumpToBottom וגלילה לתחתית בהודעות חדשותההיררכיה של הרכיבים היא כזו:
המצב LazyColumn מועבר למסך השיחה כדי שהאפליקציה תוכל לבצע לוגיקה של ממשק המשתמש ולקרוא את המצב מכל הרכיבים הקומפוזביליים שזקוקים לו:
LazyColumn מהרכיב LazyColumn אל הרכיב ConversationScreenאז בסופו של דבר, הקומפוזיציות הן:
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. אחר כך תוכלו להשתמש ב-composable בחלקים שונים של האפליקציה, שאולי לא צריכים לשלוט במצב. בדרך כלל זה קורה כשבודקים או מציגים בתצוגה מקדימה רכיב שאפשר להרכיב. כך בדיוק LazyColumn מגדיר את הסטטוס שלו.
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 של רכיב ממשק המשתמש הזה. הוא גם חושף שיטות לשינוי מיקום הגלילה, למשל גלילה לפריט נתון.
כפי שאפשר לראות, הגדלת האחריות של רכיב שאפשר להרכיב מגדילה את הצורך במאחסן מצב. האחריות יכולה להיות בלוגיקה של ממשק המשתמש, או רק בכמות המצב שצריך לעקוב אחריו.
דפוס נפוץ נוסף הוא שימוש במחלקה פשוטה של מאגר מצב כדי לטפל במורכבות של פונקציות קומפוזביליות בסיסיות באפליקציה. אפשר להשתמש במחלקה כזו כדי להכיל מצב ברמת האפליקציה, כמו מצב ניווט ושינוי גודל המסך. תיאור מלא של הנושא הזה מופיע בדף בנושא לוגיקת ממשק המשתמש ומאחסן מצב.
לוגיקה עסקית
אם רכיבי Composable ומחזיקי מצב רגילים אחראים על לוגיקת ממשק המשתמש ועל מצב רכיבי ממשק המשתמש, מחזיק מצב ברמת המסך אחראי על המשימות הבאות:
- מתן גישה ללוגיקה העסקית של האפליקציה, שבדרך כלל ממוקמת בשכבות אחרות בהיררכיה, כמו השכבה העסקית ושכבת הנתונים.
- הכנת נתוני האפליקציה להצגה במסך מסוים, שיהפוך למצב ממשק המשתמש של המסך.
ViewModels כבעלי מצב
היתרונות של AAC ViewModels בפיתוח ל-Android הופכים אותם למתאימים למתן גישה ללוגיקה העסקית ולהכנת נתוני האפליקציה להצגה על המסך.
כשמעבירים את מצב ממשק המשתמש ב-ViewModel, מעבירים אותו מחוץ ל-Composition.
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) { /* ... */ } }
רכיבי Composable צורכים את מצב ממשק המשתמש של המסך שמועבר ב-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 היא כשמזריקים את מאחסן המצב ברמת המסך ברמה העליונה ומעבירים ערך דינמי ואירועים לפונקציות הקומפוזביליות של צאצאים. בנוסף, יכול להיות שייווצר עומס יתר של חתימות של פונקציות שאפשר להרכיב.
למרות שחשיפת אירועים כפרמטרים נפרדים של lambda עלולה להעמיס על חתימת הפונקציה, היא ממקסמת את הנראות של האחריות של הפונקציה הקומפוזבילית. אפשר לראות מה הוא עושה במבט חטוף.
עדיף להשתמש בטכניקת drill-down בנכס במקום ליצור מחלקות wrapper כדי לאגד מצב ואירועים במקום אחד, כי כך מצמצמים את הגישה לאחריות של רכיבים שניתנים להרכבה. בנוסף, אם לא משתמשים במחלקות wrapper, יש סיכוי גבוה יותר להעביר לרכיבים הניתנים להרכבה רק את הפרמטרים שהם צריכים, וזה נוהל מומלץ.
אותה שיטה מומלצת חלה גם אם האירועים האלה הם אירועי מעבר. אפשר לקרוא מידע נוסף על כך במסמכי המעבר.
אם זיהיתם בעיה בביצועים, אתם יכולים גם לדחות את קריאת המצב. מידע נוסף זמין במאמרי העזרה בנושא ביצועים.
מצב של רכיב בממשק המשתמש
אפשר להעביר את מצב רכיב ממשק המשתמש למאגר המצבים ברמת המסך אם יש לוגיקה עסקית שצריכה לקרוא או לכתוב אותו.
נמשיך עם הדוגמה של אפליקציית צ'אט. האפליקציה מציגה הצעות למשתמשים בצ'אט קבוצתי כשהמשתמש מקליד @ ורמז. ההצעות האלה מגיעות משכבת הנתונים, והלוגיקה לחישוב רשימת הצעות למשתמשים נחשבת ללוגיקה עסקית. כך נראית התכונה:
@ ורמזהקוד 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.
הערה
במקרים מסוימים, כדי להעביר את המצב של רכיב בממשק המשתמש של 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 Compose זמין במקורות המידע הבאים.
דוגמאות
Codelabs
סרטונים
מומלץ בשבילכם
- הערה: טקסט הקישור מוצג כש-JavaScript מושבת
- שמירת מצב ממשק המשתמש בפיתוח נייטיב
- רשימות ורשתות
- תכנון הארכיטקטורה של ממשק המשתמש ב-Compose