ממשקי משתמש מודרניים הם בדרך כלל לא סטטיים. מצב ממשק המשתמש משתנה כשהמשתמש מקיים אינטראקציה עם ממשק המשתמש או כשהאפליקציה צריכה להציג נתונים חדשים.
במסמך הזה מפורטות הנחיות ליצירה ולניהול של מצב ממשק המשתמש. המדריך הזה נועד לעזור לכם להבין את הנקודות הבאות:
- אילו ממשקי API משמשים ליצירת מצב ממשק המשתמש. זה תלוי באופי של מקורות שינוי המצב שזמינים במחזיקי המצב, בהתאם לעקרונות של זרימת נתונים חד-כיוונית.
- איך מגדירים את היקף הייצור של מצב ממשק המשתמש כדי לשמור על משאבי המערכת.
- איך חושפים את מצב ממשק המשתמש לצריכה על ידי ממשק המשתמש.
באופן בסיסי, יצירת מצב היא יישום מצטבר של השינויים האלה במצב של ממשק המשתמש. הסטטוס תמיד קיים, והוא משתנה כתוצאה מאירועים. בטבלה הבאה מפורטים ההבדלים בין אירועים לבין מצב:
| אירועים | מדינה |
|---|---|
| חולפות, לא צפויות וקיימות לפרק זמן מוגבל. | תמיד קיים. |
| הקלט של יצירת המצב. | הפלט של ייצור המצב. |
| התוצאה של ממשק המשתמש או מקורות אחרים. | הנתונים מוצגים בממשק המשתמש. |
אפשר לזכור את זה בעזרת המשפט הבא: המצב הוא; האירועים קורים. הדיאגרמה הבאה עוזרת להמחיש שינויים במצב כשהאירועים מתרחשים בציר זמן. כל אירוע מעובד על ידי מחזיק המצב המתאים, והתוצאה היא שינוי במצב:
אירועים יכולים להגיע מהמקורות הבאים:
- משתמשים: בזמן האינטראקציה עם ממשק המשתמש של האפליקציה.
- מקורות אחרים של שינוי מצב: ממשקי API שמציגים נתוני אפליקציה מ-UI, מדומיין או משכבות נתונים כמו אירועי פסק זמן של Snackbar, תרחישי שימוש או מאגרים, בהתאמה.
צינור העיבוד של מצבי ממשק המשתמש
אפשר לחשוב על ייצור מצב באפליקציות Android כעל צינור עיבוד שכולל את השלבים הבאים:
- קלט: המקורות של שינוי המצב. הם יכולים להיות:
- מקומיים לשכבת ממשק המשתמש: יכולים להיות אירועים של משתמשים, כמו משתמש שמזין כותרת ל "משימה" באפליקציה לניהול משימות, או ממשקי API שמספקים גישה ללוגיקה של ממשק המשתמש שמניעה שינויים במצב ממשק המשתמש – לדוגמה, קריאה לשיטה
openב-DrawerStateב-Jetpack Compose. - חיצוניים לשכבת ממשק המשתמש: אלה מקורות משכבות הדומיין או הנתונים שגורמים לשינויים במצב ממשק המשתמש – לדוגמה, חדשות שסיימו להיטען מ-
NewsRepositoryאו אירועים אחרים. - שילוב של האפשרויות שלמעלה.
- מקומיים לשכבת ממשק המשתמש: יכולים להיות אירועים של משתמשים, כמו משתמש שמזין כותרת ל "משימה" באפליקציה לניהול משימות, או ממשקי API שמספקים גישה ללוגיקה של ממשק המשתמש שמניעה שינויים במצב ממשק המשתמש – לדוגמה, קריאה לשיטה
- State holders: Types that apply לוגיקה עסקית and UI logic to sources of state change, and that process user events to produce UI state.
- פלט: מצב ממשק המשתמש שהאפליקציה יכולה לעבד כדי לספק למשתמשים את המידע שהם צריכים.
ממשקי API של מצב הייצור
יש שני ממשקי API עיקריים שמשמשים ליצירת מצב, בהתאם לשלב בצינור:
| שלב בפייפליין | API |
|---|---|
| קלט | כדי למנוע קפיצות בממשק המשתמש, כדאי להשתמש בממשקי API אסינכרוניים כמו Coroutines ו-Flows כדי לבצע עבודה מחוץ לשרשור v-UI. |
| פלט | כדי לבטל את התוקף של ממשק המשתמש ולעבד אותו מחדש כשמצב משתנה, צריך להשתמש בממשקי API של מאגרי נתונים שניתנים לצפייה, כמו Compose State או StateFlow. מאגרי נתונים שניתן לצפות בהם מוודאים שלממשק המשתמש תמיד יש מצב ממשק משתמש להצגה במסך. |
הבחירה ב-API אסינכרוני לקלט משפיעה יותר על אופי צינור עיבוד הנתונים של המצב מאשר הבחירה ב-API ניתן לצפייה לפלט. הסיבה לכך היא שהקלט מכתיב את סוג העיבוד שאפשר להחיל על צינור עיבוד הנתונים.
הרכבה של צינור עיבוד נתונים לייצור
בקטעים הבאים מפורטות טכניקות ליצירת מצבים שמתאימות במיוחד לסוגים שונים של קלט, וממשקי ה-API של הפלט שמתאימים להן. כל צינור ייצור של מצב הוא שילוב של קלט ופלט, והוא חייב להיות:
- מודע למחזור החיים: אם ממשק המשתמש לא גלוי או לא פעיל, צינור הייצור של המצב לא יכול לצרוך משאבים אלא אם נדרש במפורש.
- קל להבנה: ממשק המשתמש צריך להיות מסוגל להציג בקלות את מצב ממשק המשתמש שנוצר. ב-Jetpack Compose, צריכת ערך דינמי היא מרכזית לממשק המשתמש, כי פונקציות קומפוזביליות יכולות להתעדכן על סמך שינויים בערך הדינמי.
קלט בצינורות עיבוד נתונים של מצב
הנתונים שמוזנים לצינור עיבוד נתונים של מצב מספקים את המקורות שלהם לשינוי המצב באמצעות:
- פעולות חד-פעמיות שיכולות להיות סינכרוניות או אסינכרוניות – לדוגמה, קריאות לפונקציות
suspend. - ממשקי API של סטרימינג – לדוגמה,
Flows. - כל האפשרויות.
בקטעים הבאים מוסבר איך אפשר להרכיב צינור ייצור של מצב לכל אחד מהקלטות שלמעלה.
ממשקי API חד-פעמיים כמקורות לשינוי מצב
ניהול מצב באמצעות מחזיקי נתונים שניתנים לצפייה. משתמשים ב-mutableStateOf API, במיוחד כשעובדים עם ממשקי API ליצירת טקסט. לניהול מצב מורכב יותר או לשילוב עם רכיבים ארכיטקטוניים אחרים, צריך להשתמש ב-MutableStateFlow API. שני ממשקי ה-API מציעים שיטות שמאפשרות לבצע עדכונים אטומיים בטוחים לערכים שהם מארחים, בין אם העדכונים הם סינכרוניים או אסינכרוניים.
לדוגמה, נניח שיש עדכוני סטטוס באפליקציה פשוטה של הטלת קובייה. כל הטלה של הקובייה על ידי המשתמש מפעילה את השיטה הסינכרונית Random.nextInt, והתוצאה נכתבת בסטטוס של ממשק המשתמש.
מצב הכתיבה
@Stable
interface DiceUiState {
val firstDieValue: Int?
val secondDieValue: Int?
val numberOfRolls: Int?
}
private class MutableDiceUiState: DiceUiState {
override var firstDieValue: Int? by mutableStateOf(null)
override var secondDieValue: Int? by mutableStateOf(null)
override var numberOfRolls: Int by mutableStateOf(0)
}
class DiceRollViewModel : ViewModel() {
private val _uiState = MutableDiceUiState()
val uiState: DiceUiState = _uiState
// Called from the UI
fun rollDice() {
_uiState.firstDieValue = Random.nextInt(from = 1, until = 7)
_uiState.secondDieValue = Random.nextInt(from = 1, until = 7)
_uiState.numberOfRolls = _uiState.numberOfRolls + 1
}
}
StateFlow
data class DiceUiState(
val firstDieValue: Int? = null,
val secondDieValue: Int? = null,
val numberOfRolls: Int = 0,
)
class DiceRollViewModel : ViewModel() {
private val _uiState = MutableStateFlow(DiceUiState())
val uiState: StateFlow<DiceUiState> = _uiState.asStateFlow()
// Called from the UI
fun rollDice() {
_uiState.update { currentState ->
currentState.copy(
firstDieValue = Random.nextInt(from = 1, until = 7),
secondDieValue = Random.nextInt(from = 1, until = 7),
numberOfRolls = currentState.numberOfRolls + 1,
)
}
}
}
שינוי מצב ממשק המשתמש משיחות אסינכרוניות
לשינויי מצב שדורשים תוצאה אסינכרונית, מפעילים Coroutine ב-CoroutineScope המתאים. ההרשאה הזו מאפשרת לאפליקציה לבטל את הפעולה אם CoroutineScope מבוטל. לאחר מכן, מחזיק המצב כותב את התוצאה של הפעלת ה-method של suspend לתוך ה-API הניתן לצפייה שמשמש לחשיפת מצב ממשק המשתמש.
לדוגמה, אפשר לעיין ב-AddEditTaskViewModel בדוגמה לארכיטקטורה. כששיטת ההשעיה saveTask שומרת משימה באופן אסינכרוני, השיטה update ב-MutableStateFlow מעבירה את שינוי המצב למצב ממשק המשתמש.
מצב הכתיבה
@Stable
interface AddEditTaskUiState {
val title: String
val description: String
val isTaskCompleted: Boolean
val isLoading: Boolean
val userMessage: String?
val isTaskSaved: Boolean
}
private class MutableAddEditTaskUiState : AddEditTaskUiState() {
override var title: String by mutableStateOf("")
override var description: String by mutableStateOf("")
override var isTaskCompleted: Boolean by mutableStateOf(false)
override var isLoading: Boolean by mutableStateOf(false)
override var userMessage: String? by mutableStateOf<String?>(null)
override var isTaskSaved: Boolean by mutableStateOf(false)
}
class AddEditTaskViewModel(...) : ViewModel() {
private val _uiState = MutableAddEditTaskUiState()
val uiState: AddEditTaskUiState = _uiState
private fun createNewTask() {
viewModelScope.launch {
val newTask = Task(uiState.value.title, uiState.value.description)
try {
tasksRepository.saveTask(newTask)
// Write data into the UI state.
_uiState.isTaskSaved = true
}
catch(cancellationException: CancellationException) {
throw cancellationException
}
catch(exception: Exception) {
_uiState.userMessage = getErrorMessage(exception))
}
}
}
}
StateFlow
data class AddEditTaskUiState(
val title: String = "",
val description: String = "",
val isTaskCompleted: Boolean = false,
val isLoading: Boolean = false,
val userMessage: String? = null,
val isTaskSaved: Boolean = false
)
class AddEditTaskViewModel(...) : ViewModel() {
private val _uiState = MutableStateFlow(AddEditTaskUiState())
val uiState: StateFlow<AddEditTaskUiState> = _uiState.asStateFlow()
private fun createNewTask() {
viewModelScope.launch {
val newTask = Task(uiState.value.title, uiState.value.description)
try {
tasksRepository.saveTask(newTask)
// Write data into the UI state.
_uiState.update {
it.copy(isTaskSaved = true)
}
}
catch(cancellationException: CancellationException) {
throw cancellationException
}
catch(exception: Exception) {
_uiState.update {
it.copy(userMessage = getErrorMessage(exception))
}
}
}
}
}
שינוי מצב ממשק המשתמש משרשורים ברקע
מומלץ להפעיל Coroutines ב-dispatcher הראשי כדי ליצור את מצב ממשק המשתמש – כלומר, מחוץ לבלוק withContext בקטעי הקוד שבהמשך.
עם זאת, אם אתם צריכים לעדכן את מצב ממשק המשתמש בהקשר שונה של הרקע, אתם יכולים לעשות את הפעולות הבאות:
- משתמשים בשיטה
withContextכדי להריץ קורוטינות בהקשר מקביל אחר. - כשמשתמשים ב-
MutableStateFlow, משתמשים בשיטהupdateכרגיל. - כשמשתמשים ב-Compose State, צריך להשתמש ב-method
Snapshot.withMutableSnapshotכדי להבטיח עדכונים אטומיים של State בהקשר מקביל.
לדוגמה, נניח שבקטע הקוד DiceRollViewModel שבהמשך, SlowRandom.nextInt היא פונקציה suspend שדורשת הרבה משאבי מחשוב וצריך להפעיל אותה מתוך שגרת המשך (coroutine) שמוגבלת ל-מעבד (CPU).
מצב הכתיבה
class DiceRollViewModel(
private val defaultDispatcher: CoroutineScope = Dispatchers.Default
) : ViewModel() {
private val _uiState = MutableDiceUiState()
val uiState: DiceUiState = _uiState
// Called from the UI
fun rollDice() {
viewModelScope.launch() {
// Other Coroutines that may be called from the current context
…
withContext(defaultDispatcher) {
Snapshot.withMutableSnapshot {
_uiState.firstDieValue = SlowRandom.nextInt(from = 1, until = 7)
_uiState.secondDieValue = SlowRandom.nextInt(from = 1, until = 7)
_uiState.numberOfRolls = _uiState.numberOfRolls + 1
}
}
}
}
}
StateFlow
class DiceRollViewModel(
private val defaultDispatcher: CoroutineScope = Dispatchers.Default
) : ViewModel() {
private val _uiState = MutableStateFlow(DiceUiState())
val uiState: StateFlow<DiceUiState> = _uiState.asStateFlow()
// Called from the UI
fun rollDice() {
viewModelScope.launch() {
// Other Coroutines that may be called from the current context
…
withContext(defaultDispatcher) {
_uiState.update { currentState ->
currentState.copy(
firstDieValue = SlowRandom.nextInt(from = 1, until = 7),
secondDieValue = SlowRandom.nextInt(from = 1, until = 7),
numberOfRolls = currentState.numberOfRolls + 1,
)
}
}
}
}
}
ממשקי Stream API כמקורות לשינוי מצב
במקרים שבהם מקורות של שינוי מצב יוצרים כמה ערכים לאורך זמן בזרמים, גישה פשוטה ליצירת מצב היא צבירה של התפוקות מכל המקורות ליחידה מגובשת אחת.
כשמשתמשים ב-Kotlin Flows, אפשר להשיג את זה באמצעות הפונקציה combine.
דוגמה לכך אפשר לראות בדוגמה 'Now in Android' בכתובת
InterestsViewModel:
class InterestsViewModel(
authorsRepository: AuthorsRepository,
topicsRepository: TopicsRepository
) : ViewModel() {
val uiState = combine(
authorsRepository.getAuthorsStream(),
topicsRepository.getTopicsStream(),
) { availableAuthors, availableTopics ->
InterestsUiState.Interests(
authors = availableAuthors,
topics = availableTopics
)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InterestsUiState.Loading
)
}
השימוש באופרטור stateIn ליצירת StateFlows מאפשר לממשק המשתמש שליטה מדויקת יותר בפעילות של צינור עיבוד הנתונים ליצירת מצב, כי יכול להיות שהוא צריך להיות פעיל רק כשממשק המשתמש גלוי.
- משתמשים ב-
SharingStarted.WhileSubscribedאם צינור הנתונים צריך להיות פעיל רק כשממשק המשתמש גלוי, בזמן איסוף הנתונים בתהליך באופן שמודע למחזור החיים. - משתמשים ב-
SharingStarted.Lazilyאם הצינור צריך להיות פעיל כל עוד המשתמש עשוי לחזור לממשק המשתמש – כלומר, ממשק המשתמש נמצא במחסנית האחורית או בכרטיסייה אחרת מחוץ למסך.
במקרים שבהם אי אפשר לצבור מקורות של מצב שמבוססים על זרם, ממשקי API של זרם כמו Kotlin Flows מציעים קבוצה עשירה של טרנספורמציות כמו מיזוג, השטחה וכן הלאה, כדי לעזור בעיבוד הזרמים למצב ממשק המשתמש.
ממשקי API של שידור ושל חיוב חד-פעמי כמקורות לשינוי מצב
במקרה שבו צינור העיבוד של מצב תלוי גם בקריאות חד-פעמיות וגם בזרמים כמקורות לשינוי מצב, הזרמים הם האילוץ המגדיר. לכן, צריך להמיר את הקריאות החד-פעמיות לממשקי API של סטרימינג, או להעביר את הפלט שלהן לסטרימינג ולהמשיך את העיבוד כמו שמתואר בקטע על סטרימינג שלמעלה.
בדרך כלל, כדי להפיץ שינויים במצב, צריך ליצור מופע פרטי אחד או יותר של MutableStateFlow. אפשר גם ליצור תהליכי עבודה של תמונת מצב ממצב כתיבה.
כדאי לעיין ב-TaskDetailViewModel ממאגר architecture-samples. מצב ממשק המשתמש תלוי בסטרימינג של המשימה הנוכחית (_task) ובמקור חד-פעמי (_isTaskDeleted) שמתעדכן כשהמשימה נמחקת. הדגל הזה נחוץ כדי להבחין בין מצב שבו משימה לא נמצאת במסד הנתונים בגלל מזהה שגוי, לבין מצב שבו היא לא נמצאת כי המשתמש פשוט מחק אותה:
מצב הכתיבה
class TaskDetailViewModel @Inject constructor(
private val tasksRepository: TasksRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private var _isTaskDeleted by mutableStateOf(false)
private val _task = tasksRepository.getTaskStream(taskId)
val uiState: StateFlow<TaskDetailUiState> = combine(
snapshotFlow { _isTaskDeleted },
_task
) { isTaskDeleted, taskAsync ->
TaskDetailUiState(
task = taskAsync.data,
isTaskDeleted = isTaskDeleted
)
}
// Convert the result to the appropriate observable API for the UI
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = TaskDetailUiState()
)
fun deleteTask() = viewModelScope.launch {
tasksRepository.deleteTask(taskId)
_isTaskDeleted = true
}
}
StateFlow
class TaskDetailViewModel @Inject constructor(
private val tasksRepository: TasksRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val _isTaskDeleted = MutableStateFlow(false)
private val _task = tasksRepository.getTaskStream(taskId)
val uiState: StateFlow<TaskDetailUiState> = combine(
_isTaskDeleted,
_task
) { isTaskDeleted, taskAsync ->
TaskDetailUiState(
task = taskAsync.data,
isTaskDeleted = isTaskDeleted
)
}
// Convert the result to the appropriate observable API for the UI
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = TaskDetailUiState()
)
fun deleteTask() = viewModelScope.launch {
tasksRepository.deleteTask(taskId)
_isTaskDeleted.update { true }
}
}
סוגי פלט בצינורות עיבוד נתונים של מצב הייצור
הבחירה בממשק ה-API של הפלט למצב ממשק המשתמש ואופי ההצגה שלו תלויים במידה רבה בממשק ה-API שבו האפליקציה משתמשת כדי לעבד את ממשק המשתמש, כמו Compose. Jetpack Compose היא ערכת הכלים המודרנית המומלצת ליצירת ממשקי משתמש נייטיביים. השיקולים כוללים את הדברים הבאים:
- קריאת מצב בדרך שמביאה בחשבון את מחזור החיים.
- האם לחשוף את הסטטוס בשדה אחד או יותר מ-stateHolder.
בטבלה הבאה מפורטים ממשקי ה-API שבהם צריך להשתמש בצינור ליצירת ערך דינמי כשמשתמשים ב-Jetpack Compose:
| קלט | פלט |
|---|---|
| ממשקי API של הפעלה חד-פעמית | StateFlow או ללחוץ על סמל ההודעה החדשה State |
| Stream APIs | StateFlow |
| ממשקי API של שידור ושל פעולה חד-פעמית | StateFlow |
הפעלה של צינור עיבוד נתונים לייצור
אתחול של צינורות עיבוד נתונים לייצור של מצב מסוים כולל הגדרה של התנאים הראשוניים להרצת צינור עיבוד הנתונים. למשל, יכול להיות שיהיה צורך לספק ערכי קלט ראשוניים שחשובים להפעלת צינור הנתונים – לדוגמה, id לתצוגת הפרטים של כתבה חדשותית, או הפעלה של טעינה אסינכרונית.
אם אפשר, כדאי לאתחל את צינור עיבוד הנתונים של המצב באופן עצלני כדי לחסוך במשאבי המערכת. בפועל, זה לרוב אומר להמתין עד שיהיה צרכן של הפלט. אפשר לעשות את זה באמצעות ממשקי API עם הארגומנט started בשיטה stateIn.Flow במקרים שבהם זה לא רלוונטי, צריך להגדיר פונקציית idempotent initialize כדי להפעיל באופן מפורש את צינור הייצור של המצב, כמו שמוצג בקטע הקוד הבא:
class MyViewModel : ViewModel() {
private var initializeCalled = false
// This function is idempotent provided it is only called from the UI thread.
@MainThread
fun initialize() {
if(initializeCalled) return
initializeCalled = true
viewModelScope.launch {
// seed the state production pipeline
}
}
}
דוגמאות
בדוגמאות הבאות של Google אפשר לראות איך נוצר מצב בשכבת ממשק המשתמש. כדאי לעיין בהם כדי לראות איך ההנחיות האלה באות לידי ביטוי בפועל:
מקורות מידע נוספים
למידע נוסף על מצב ממשק המשתמש, אפשר לעיין במקורות המידע הנוספים הבאים:
מאמרי עזרה
צפייה בתוכן
מומלץ בשבילכם
- הערה: טקסט הקישור מוצג כש-JavaScript מושבת
- שכבת ממשק המשתמש
- פיתוח אפליקציה שפועלת אופליין
- מחזיקי מצב ומצב ממשק המשתמש {:#mad-arch}