ממשקי משתמש מודרניים הם בדרך כלל סטטיים. המצב של ממשק המשתמש משתנה כאשר המשתמש מקיים אינטראקציה עם ממשק המשתמש או כאשר האפליקציה צריכה להציג נתונים חדשים.
במסמך הזה מפורטות הנחיות ליצירה ולניהול של ממשק משתמש . בסוף השורה אתם צריכים:
- לדעת באילו ממשקי API כדאי להשתמש כדי ליצור את המצב של ממשק המשתמש. הדבר תלוי של מקורות השינוי למדינה שזמינים לבעלי המדינה שלך, ציות לעקרונות של זרימת נתונים חד-כיוונית.
- להכיר את האופן שבו צריך לתחום את סביבת הייצור של מצב ממשק המשתמש כדי להיות מודעים במשאבי המערכת.
- הסבר איך לחשוף את מצב ממשק המשתמש לצריכה על ידי ממשק המשתמש.
בעיקרון, ייצור מדינה הוא היישום המצטבר של השינויים האלה למצב של ממשק המשתמש. המצב תמיד קיים, והוא משתנה כתוצאה מאירועים. בטבלה הבאה מוצג סיכום של ההבדלים בין אירועים למצבים:
אירועים | מדינה |
---|---|
זמניים, לא צפויים וקיימים לתקופה מוגדרת. | תמיד קיים. |
הקלט להפקת מדינה. | הפלט של ייצור המדינה. |
המוצר של ממשק המשתמש או מקורות אחרים. | צריכה את הנתונים האלה על ידי ממשק המשתמש. |
סיכום מצוין לסיכום הוא המצב: קורים אירועים שונים. התרשים הבא עוזר להמחיש שינויים במצב שבו אירועים מתרחשים בציר הזמן. כל אירוע מעובד על ידי בעל המדינה המתאים, וכתוצאה מכך שינוי מצב:
![אירועים לעומת מצב](https://developer.android.com/static/images/topic/architecture/ui-layer/events-vs-state.png?authuser=8&hl=he)
המקור של אירועים יכול להיות:
- משתמשים: בזמן שהם מקיימים אינטראקציה עם ממשק המשתמש של האפליקציה.
- מקורות אחרים של שינוי במצב: ממשקי API שמציגים נתוני אפליקציות מממשק המשתמש, דומיין, או שכבות נתונים כמו אירועי זמן קצוב לתפוגה בסרגל הצד, תרחישים לדוגמה מאגרי נתונים בהתאמה אישית.
צינור עיבוד הנתונים לייצור מצב ממשק המשתמש
אפשר להתייחס לסביבת הייצור של מצב באפליקציות ל-Android כצינור עיבוד נתונים הכוללים:
- קלט: מקורות השינוי של המדינה. אלו יכולות להיות:
- מקומי לשכבת ממשק המשתמש: אלה יכולים להיות אירועי משתמש כמו משתמש שנכנס
כותרת לרשימת משימות באפליקציה לניהול משימות, או ממשקי API שמספקים
גישה ללוגיקת ממשק משתמש שמבצעת שינויים במצב ממשק המשתמש. לדוגמה,
קריאה ל-method
open
ב-DrawerState
ב-Jetpack פיתוח נייטיב. - חיצוני לשכבת ממשק המשתמש: אלה מקורות מהדומיין או מהנתונים
שכבות שגורמות לשינויים במצב של ממשק המשתמש. לדוגמה, חדשות שהסתיימו
בטעינה מ-
NewsRepository
או מאירועים אחרים. - שילוב של כל האפשרויות האלה.
- מקומי לשכבת ממשק המשתמש: אלה יכולים להיות אירועי משתמש כמו משתמש שנכנס
כותרת לרשימת משימות באפליקציה לניהול משימות, או ממשקי API שמספקים
גישה ללוגיקת ממשק משתמש שמבצעת שינויים במצב ממשק המשתמש. לדוגמה,
קריאה ל-method
- בעלי מדינות (States): סוגים שמחילים לוגיקה עסקית ו/או לוגיקת ממשק משתמש למקורות של שינוי מצב ועיבוד אירועי משתמש כדי להפיק מצב ממשק המשתמש.
- פלט: המצב בממשק המשתמש שהאפליקציה יכולה לעבד כדי לספק למשתמשים למידע הדרוש להם.
![צינור עיבוד הנתונים של המדינה](https://developer.android.com/static/images/topic/architecture/ui-layer/state-production-pipeline.png?authuser=8&hl=he)
ממשקי API של מצב ייצור
יש שני ממשקי API עיקריים שמשמשים בייצור מצב, בהתאם לשלב שבו בצינור עיבוד הנתונים שבו אתם נמצאים:
שלב צינור עיבוד הנתונים | API |
---|---|
קלט | עליכם להשתמש בממשקי API אסינכרוניים כדי לבצע את העבודה מחוץ ל-thread של ממשק המשתמש, כדי שממשק המשתמש יהיה פנוי בממשק. לדוגמה, Coroutines או Flows ב-Kotlin ו-RxJava או התקשרות חזרה בשפת התכנות Java. |
פלט | צריך להשתמש בממשקי API גלויים של בעלי נתונים כדי לבטל ולעבד את ממשק המשתמש כשהמצב משתנה. לדוגמה: StateFlow, Compose State או LiveData. בעלי נתונים גלויים מבטיחים שממשק המשתמש תמיד יהיה במצב של ממשק משתמש להצגה במסך |
מבין השניים, לבחירה בממשק API אסינכרוני לקלט יש השפעה רבה יותר על אופי צינור עיבוד הנתונים של המדינה ולא של ה-API הגלוי לפלט. הסיבה לכך היא שהקלט קובעים את סוג העיבוד שעשוי ייושמו בצינור עיבוד הנתונים.
הכנת צינור עיבוד נתונים לייצור של מדינה
החלקים הבאים עוסקים בטכניקות הפקה ברמת המדינה המתאימות ביותר של הקלט, וממשקי ה-API של הפלט שתואמים. כל צינור עיבוד נתונים של מצב של קלט ופלט, והם צריכים להיות:
- מוּדעוּת למחזור החיים: אם ממשק המשתמש לא גלוי או פעיל, צינור עיבוד הנתונים ליצירת מצב לא צריך לצרוך משאבים כלשהם, אלא אם כן נדרש.
- קלים לשימוש: צריך לוודא שממשק המשתמש יכול לעבד בקלות את ממשק המשתמש שנוצר . השיקולים לפלט של צינור עיבוד הנתונים של המדינה משתנים בין ממשקי API שונים של View, כמו מערכת View או Jetpack Compose.
קלט בצינורות עיבוד נתונים לייצור מצב
מקורות הקלט בצינור הייצור של מצב יכולים לספק את מקורות המצב שלהם שינוי באמצעות:
- פעולות חד-פעמיות שעשויות להיות סינכרוניות או אסינכרוניות, לדוגמה
קריאות לפונקציות
suspend
. - ממשקי API של סטרימינג, לדוגמה
Flows
. - כל האפשרויות.
בקטעים הבאים נסביר איך להרכיב צינור עיבוד נתונים של מצב ייצור. בכל אחד מהערכים האלה.
ממשקי API בפעולה אחת כמקורות לשינוי מצב
שימוש ב-API MutableStateFlow
בתור ממשק גלוי שניתן לשנות
קונטיינר של המצב. באפליקציות 'Jetpack פיתוח נייטיב' אפשר גם למצוא
mutableStateOf
במיוחד כשעובדים עם
כתיבת ממשקי API של טקסט. שני ממשקי ה-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
בוטלה. לאחר מכן בעל המדינה כותב את התוצאה של
הפעלת שיטת השעיה ל-API הגלוי שמשמש לחשוף את מצב ממשק המשתמש.
לדוגמה, נבחן את AddEditTaskViewModel
דוגמה של ארכיטקטורה. מה קורה כשמתבצעת השעיה של saveTask()
שומרת משימה באופן אסינכרוני, ה-method 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))
}
}
}
}
שינוי של מצב ממשק המשתמש בשרשורי רקע
עדיף להשיק את Coroutines אצל השולח הראשי לצורכי ייצור
למצב של ממשק המשתמש. כלומר, מחוץ לבלוק withContext
בקטעי הקוד
שלמטה. אבל אם אתם צריכים לעדכן את המצב של ממשק המשתמש ברקע אחר
אפשר לעשות זאת באמצעות ממשקי ה-API הבאים:
- משתמשים בשיטה
withContext
כדי להריץ קורוטינים הקשרים שונים בו-זמנית. - כשמשתמשים ב-
MutableStateFlow
, צריך להשתמש בשיטהupdate
בתור הרגילה. - כשמשתמשים במצב 'כתיבה', צריך להשתמש ב
Snapshot.withMutableSnapshot
כדי להבטיח עדכונים אטומיים למצב בו-זמנית.
לדוגמה, נניח בקטע הקוד DiceRollViewModel
שבהמשך,
SlowRandom.nextInt()
הוא suspend
עתיר חישוב
שצריך לקרוא לו מ-Coroutine שמקשר למעבד (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
}
}
}
}
}
ממשקי API בסטרימינג כמקורות לשינוי מצב
למקורות של שינוי במצב שמניבים ערכים מרובים לאורך זמן ב-streams: אגירת הפלט של כל המקורות לכדי שלמות מאוחדת גישה ישירה לייצור של המדינה.
כשמשתמשים ב-Kotlin Flows אפשר לעשות זאת באמצעות השילוב מותאמת אישית. אפשר לראות דוגמה לכך בקטע "עכשיו ב-Android" דוגמה ב-interestViewModel:
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
המאגר הבא של ארכיטקטורה-דוגמאות:
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 |
ממשקי API של סטרימינג | צפיות | StateFlow או LiveData |
ממשקי API של סטרימינג | פיתוח נייטיב | StateFlow |
ממשקי API עם דוגמה אחת וסטרימינג | צפיות | StateFlow או LiveData |
ממשקי API עם דוגמה אחת וסטרימינג | פיתוח נייטיב | StateFlow |
אתחול צינור עיבוד הנתונים לייצור של מצב
אתחול צינורות עיבוד נתונים של מצב (State) כולל הגדרה של התנאים הראשוניים
כדי שצינור עיבוד הנתונים יפעל. יכול להיות שיהיה צורך לספק ערכי קלט ראשוניים.
קריטי לתחילת צינור עיבוד הנתונים, לדוגמה id
עבור
תצוגת פרטים של מאמר חדשותי, או התחלת טעינה אסינכרונית.
כשזה אפשרי, צריך לאתחל את צינור עיבוד הנתונים של המדינה באופן מדורג
כדי לשמר משאבי מערכת.
בפועל, זה בדרך כלל אומר שצריך להמתין עד שיש לקוח
הפלט. ממשקי ה-API של Flow
מאפשרים זאת עם
הארגומנט started
בפונקציה stateIn
. במקרים שבהם הכלל הזה לא רלוונטי,
להגדיר אידמפוטנטיות
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}