ממשקי משתמש מודרניים הם בדרך כלל לא סטטיים. מצב ממשק המשתמש משתנה כשהמשתמש מקיים אינטראקציה עם ממשק המשתמש או כשהאפליקציה צריכה להציג נתונים חדשים.
במסמך הזה מפורטות הנחיות ליצירה ולניהול של מצב ממשק המשתמש. בסוף התהליך, צריך:
- להבין באילו ממשקי API צריך להשתמש כדי ליצור מצב של ממשק משתמש. זה תלוי באופי של מקורות שינוי המצב שזמינים במחזיקי המצב שלכם, בהתאם לעקרונות של זרימת נתונים חד-כיוונית.
- חשוב להבין איך צריך להגדיר את היקף הייצור של מצב ממשק המשתמש כדי לשמור על משאבי המערכת.
- צריך לדעת איך לחשוף את מצב ממשק המשתמש לשימוש על ידי ממשק המשתמש.
באופן בסיסי, יצירת מצב היא יישום מצטבר של השינויים האלה במצב של ממשק המשתמש. הסטטוס תמיד קיים, והוא משתנה כתוצאה מאירועים. בטבלה הבאה מפורטים ההבדלים בין אירועים לבין מצב:
| אירועים | מדינה |
|---|---|
| חולפות, לא צפויות וקיימות לפרק זמן מוגבל. | תמיד קיים. |
| הקלט של יצירת המצב. | הפלט של ייצור המצב. |
| התוצאה של ממשק המשתמש או מקורות אחרים. | הנתונים מוצגים בממשק המשתמש. |
אפשר לזכור את זה בעזרת המשפט הבא: המצב הוא; האירועים קורים. הדיאגרמה הבאה עוזרת להמחיש שינויים במצב כשהאירועים מתרחשים בציר זמן. כל אירוע מעובד על ידי מחזיק המצב המתאים, והתוצאה היא שינוי במצב:
אירועים יכולים להגיע מהמקורות הבאים:
- משתמשים: בזמן האינטראקציה עם ממשק המשתמש של האפליקציה.
- מקורות אחרים של שינוי מצב: ממשקי API שמציגים נתוני האפליקציה משכבות של ממשק משתמש, דומיין או נתונים, כמו אירועי זמן קצוב לתפוגה של סרגל אינטראקטיבי, תרחישי שימוש או מאגרי מידע.
צינור העיבוד של מצבי ממשק המשתמש
אפשר לחשוב על יצירת מצב באפליקציות ל-Android כעל צינור עיבוד שכולל:
- קלט: המקורות של שינוי המצב. הם יכולים להיות:
- לוקאלי לשכבת ממשק המשתמש: אלה יכולים להיות אירועים של משתמשים, כמו משתמש שמזין כותרת ל'משימה' באפליקציה לניהול משימות, או ממשקי API שמספקים גישה ללוגיקה של ממשק המשתמש שמניעה שינויים במצב ממשק המשתמש. לדוגמה, קריאה לשיטה
openב-DrawerStateב-Jetpack פיתוח נייטיב. - מחוץ לשכבת ממשק המשתמש: אלה מקורות משכבות הדומיין או הנתונים שגורמים לשינויים במצב ממשק המשתמש. לדוגמה, חדשות שסיימו להיטען מ-
NewsRepositoryאו אירועים אחרים. - שילוב של כל האפשרויות שצוינו למעלה.
- לוקאלי לשכבת ממשק המשתמש: אלה יכולים להיות אירועים של משתמשים, כמו משתמש שמזין כותרת ל'משימה' באפליקציה לניהול משימות, או ממשקי API שמספקים גישה ללוגיקה של ממשק המשתמש שמניעה שינויים במצב ממשק המשתמש. לדוגמה, קריאה לשיטה
- State holders: Types that apply business logic and/or UI logic to sources of state change and process user events to produce UI state.
- פלט: מצב ממשק המשתמש שהאפליקציה יכולה לעבד כדי לספק למשתמשים את המידע שהם צריכים.
ממשקי API של מצב הייצור
יש שני ממשקי API עיקריים שמשמשים ליצירת מצב, בהתאם לשלב בצינור:
| שלב בפייפליין | API |
|---|---|
| קלט | כדי למנוע בעיות בממשק (jank), מומלץ להשתמש בממשקי API אסינכרוניים כדי לבצע עבודות מחוץ לשרשור UI. לדוגמה, Coroutines או Flows ב-Kotlin, ו-RxJava או callbacks בשפת התכנות Java. |
| פלט | כדי לבטל את התוקף של ממשק המשתמש ולעבד אותו מחדש כשמצב האפליקציה משתנה, צריך להשתמש בממשקי API של מאגרי נתונים שניתנים לצפייה. לדוגמה, StateFlow, Compose State או LiveData. מאגרי נתונים שניתנים לצפייה מבטיחים שלממשק המשתמש תמיד יהיה מצב ממשק משתמש להצגה במסך |
מבין שתי האפשרויות, הבחירה בממשק API אסינכרוני לקלט משפיעה יותר על אופי צינור הייצור של המצב מאשר הבחירה בממשק API ניתן לצפייה לפלט. הסיבה לכך היא שהקלט מכתיב את סוג העיבוד שאפשר להחיל על צינור הנתונים.
הרכבה של צינור עיבוד נתונים לייצור
בקטעים הבאים מפורטות טכניקות ליצירת מצבים שמתאימות במיוחד לסוגים שונים של קלט, וממשקי ה-API של הפלט שמתאימים להן. כל צינור להעברת נתונים של מצב הוא שילוב של קלט ופלט, והוא צריך להיות:
- מודע למחזור החיים: אם ממשק המשתמש לא גלוי או לא פעיל, צינור הייצור של המצב לא צריך לצרוך משאבים כלשהם, אלא אם נדרש אחרת באופן מפורש.
- קל להבנה: ממשק המשתמש צריך להיות מסוגל להציג בקלות את מצב ממשק המשתמש שנוצר. השיקולים לגבי הפלט של צינור הייצור של המצב משתנים בין ממשקי API שונים של View, כמו מערכת View או Jetpack פיתוח נייטיב.
קלט בצינורות עיבוד נתונים של מצב
קלט בצינור עיבוד נתונים של מצב יכול לספק את המקורות של שינוי המצב באמצעות:
- פעולות חד-פעמיות שיכולות להיות סינכרוניות או אסינכרוניות, לדוגמה קריאות לפונקציות
suspend. - ממשקי API של סטרימינג, לדוגמה
Flows. - כל האפשרויות.
בקטעים הבאים מוסבר איך אפשר להרכיב צינור ייצור של מצב לכל אחד מהקלטות שלמעלה.
ממשקי API חד-פעמיים כמקורות לשינוי מצב
משתמשים ב-API MutableStateFlow כמאגר של מצב שניתן לצפייה ולשינוי. באפליקציות Jetpack פיתוח נייטיב, אפשר גם להשתמש ב-mutableStateOf, במיוחד כשעובדים עם ממשקי API של טקסט ב-Compose. שני ממשקי ה-API מציעים שיטות שמאפשרות לבצע עדכונים אטומיים בטוחים לערכים שהם מארחים, בין אם העדכונים סינכרוניים או אסינכרוניים.
לדוגמה, נניח שיש עדכוני סטטוס באפליקציה פשוטה לזריקת קובייה. כל זריקה של הקובייה על ידי המשתמש מפעילה את השיטה הסינכרונית Random.nextInt(), והתוצאה נכתבת בסטטוס של ממשק המשתמש.
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,
)
}
}
}
מצב הכתיבה
@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
}
}
שינוי מצב ממשק המשתמש משיחות אסינכרוניות
לשינויי מצב שדורשים תוצאה אסינכרונית, מפעילים Coroutine ב-CoroutineScope המתאים. ההרשאה הזו מאפשרת לאפליקציה לבטל את הפעולה אם CoroutineScope מבוטל. לאחר מכן, מחזיק המצב כותב את התוצאה של הפעלת ה-method של suspend לתוך ה-API הניתן לצפייה שמשמש לחשיפת מצב ממשק המשתמש.
לדוגמה, אפשר לעיין ב-AddEditTaskViewModel בדוגמה לארכיטקטורה. כששיטת ההשעיה saveTask() שומרת משימה באופן אסינכרוני, השיטה update ב-MutableStateFlow מעבירה את שינוי המצב למצב ממשק המשתמש.
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))
}
}
}
}
}
מצב הכתיבה
@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))
}
}
}
}
שינוי מצב ממשק המשתמש משרשורים ברקע
מומלץ להפעיל שגרות המשך (coroutine) ב-dispatcher הראשי כדי לייצר מצב של ממשק משתמש. כלומר, מחוץ לבלוק withContext בקטעי הקוד שבהמשך. עם זאת, אם אתם צריכים לעדכן את מצב ממשק המשתמש בהקשר שונה של רקע, אתם יכולים לעשות זאת באמצעות ממשקי ה-API הבאים:
- אפשר להשתמש בשיטה
withContextכדי להריץ קורוטינות בהקשר מקביל אחר. - כשמשתמשים ב-
MutableStateFlow, משתמשים בשיטהupdateכרגיל. - כשמשתמשים ב-Compose State, צריך להשתמש ב-
Snapshot.withMutableSnapshotכדי להבטיח עדכונים אטומיים של State בהקשר של פעולות שמתבצעות בו-זמנית.
לדוגמה, נניח שבקטע הקוד DiceRollViewModel שלמטה, SlowRandom.nextInt() היא פונקציה suspend שדורשת הרבה משאבי מחשוב, וצריך לקרוא לה מתוך קורוטינה שמוגבלת על ידי CPU.
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,
)
}
}
}
}
}
מצב הכתיבה
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
}
}
}
}
}
ממשקי Stream API כמקורות לשינוי מצב
במקרים שבהם מקורות של שינוי מצב יוצרים כמה ערכים לאורך זמן בזרמים, גישה פשוטה ליצירת מצב היא צבירה של התפוקות מכל המקורות ליחידה מגובשת אחת.
כשמשתמשים ב-Kotlin Flows, אפשר להשתמש בפונקציה combine כדי להשיג את זה. דוגמה לכך אפשר לראות במדגם 'עכשיו ב-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 שבהמשך:
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, task ->
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 }
}
}
מצב הכתיבה
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, task ->
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
}
}
סוגי פלט בצינורות עיבוד נתונים של מצב הייצור
בחירת ה-API של הפלט למצב ממשק המשתמש, ואופי ההצגה שלו, תלויים בעיקר ב-API שבו האפליקציה משתמשת כדי לעבד את ממשק המשתמש. באפליקציות ל-Android, אפשר לבחור להשתמש ב-Views או ב-Jetpack פיתוח נייטיב. השיקולים כאן כוללים:
- מצב קריאה באופן מודע למחזור החיים.
- האם המצב צריך להיות חשוף בשדה אחד או יותר מתוך מחזיק המצב.
בטבלה הבאה מפורטים ממשקי ה-API שבהם צריך להשתמש בצינור הייצור של המצב לכל קלט וצרכן:
| קלט | צרכן | פלט |
|---|---|---|
| ממשקי API של הפעלה חד-פעמית | צפיות | StateFlow או LiveData |
| ממשקי API של פעולה חד-פעמית | אימייל חדש | StateFlow או ללחוץ על סמל ההודעה החדשה State |
| Stream APIs | צפיות | StateFlow או LiveData |
| Stream APIs | אימייל חדש | StateFlow |
| ממשקי API של שידור ושל פעולה חד-פעמית | צפיות | StateFlow או LiveData |
| ממשקי API של שידור ושל פעולה חד-פעמית | אימייל חדש | StateFlow |
הפעלה של צינור עיבוד נתונים לייצור
אתחול של צינורות עיבוד נתונים לייצור של מצב מסוים כולל הגדרה של התנאים הראשוניים להרצת צינור עיבוד הנתונים. למשל, יכול להיות שיהיה צורך לספק ערכי קלט ראשוניים שחיוניים להפעלת צינור הנתונים, כמו id לתצוגת הפרטים של כתבה חדשותית, או להפעיל טעינה אסינכרונית.
כדי לחסוך במשאבי המערכת, מומלץ לאתחל את צינור עיבוד הנתונים של המצב באופן עצלני כשאפשר.
בפועל, זה לרוב אומר שצריך לחכות עד שיהיה צרכן של הפלט. Flow ממשקי ה-API מאפשרים זאת באמצעות הארגומנט started בשיטה stateIn. במקרים שבהם זה לא רלוונטי, צריך להגדיר פונקציית 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}