ייצור מצב ממשק המשתמש

ממשקי משתמש מודרניים הם בדרך כלל סטטיים. המצב של ממשק המשתמש משתנה כאשר המשתמש מקיים אינטראקציה עם ממשק המשתמש או כאשר האפליקציה צריכה להציג נתונים חדשים.

במסמך הזה מפורטות הנחיות ליצירה ולניהול של ממשק משתמש . בסוף השורה אתם צריכים:

  • לדעת באילו ממשקי API כדאי להשתמש כדי ליצור את המצב של ממשק המשתמש. הדבר תלוי של מקורות השינוי למדינה שזמינים לבעלי המדינה שלך, ציות לעקרונות של זרימת נתונים חד-כיוונית.
  • להכיר את האופן שבו צריך לתחום את סביבת הייצור של מצב ממשק המשתמש כדי להיות מודעים במשאבי המערכת.
  • הסבר איך לחשוף את מצב ממשק המשתמש לצריכה על ידי ממשק המשתמש.

בעיקרון, ייצור מדינה הוא היישום המצטבר של השינויים האלה למצב של ממשק המשתמש. המצב תמיד קיים, והוא משתנה כתוצאה מאירועים. בטבלה הבאה מוצג סיכום של ההבדלים בין אירועים למצבים:

אירועים מדינה
זמניים, לא צפויים וקיימים לתקופה מוגדרת. תמיד קיים.
הקלט להפקת מדינה. הפלט של ייצור המדינה.
המוצר של ממשק המשתמש או מקורות אחרים. צריכה את הנתונים האלה על ידי ממשק המשתמש.

סיכום מצוין לסיכום הוא המצב: קורים אירועים שונים. התרשים הבא עוזר להמחיש שינויים במצב שבו אירועים מתרחשים בציר הזמן. כל אירוע מעובד על ידי בעל המדינה המתאים, וכתוצאה מכך שינוי מצב:

אירועים לעומת מצב
איור 1: אירועים גורמים לשינוי במצב

המקור של אירועים יכול להיות:

  • משתמשים: בזמן שהם מקיימים אינטראקציה עם ממשק המשתמש של האפליקציה.
  • מקורות אחרים של שינוי במצב: ממשקי API שמציגים נתוני אפליקציות מממשק המשתמש, דומיין, או שכבות נתונים כמו אירועי זמן קצוב לתפוגה בסרגל הצד, תרחישים לדוגמה מאגרי נתונים בהתאמה אישית.

צינור עיבוד הנתונים לייצור מצב ממשק המשתמש

אפשר להתייחס לסביבת הייצור של מצב באפליקציות ל-Android כצינור עיבוד נתונים הכוללים:

  • קלט: מקורות השינוי של המדינה. אלו יכולות להיות:
    • מקומי לשכבת ממשק המשתמש: אלה יכולים להיות אירועי משתמש כמו משתמש שנכנס כותרת לרשימת משימות באפליקציה לניהול משימות, או ממשקי API שמספקים גישה ללוגיקת ממשק משתמש שמבצעת שינויים במצב ממשק המשתמש. לדוגמה, קריאה ל-method open ב-DrawerState ב-Jetpack פיתוח נייטיב.
    • חיצוני לשכבת ממשק המשתמש: אלה מקורות מהדומיין או מהנתונים שכבות שגורמות לשינויים במצב של ממשק המשתמש. לדוגמה, חדשות שהסתיימו בטעינה מ-NewsRepository או מאירועים אחרים.
    • שילוב של כל האפשרויות האלה.
  • בעלי מדינות (States): סוגים שמחילים לוגיקה עסקית ו/או לוגיקת ממשק משתמש למקורות של שינוי מצב ועיבוד אירועי משתמש כדי להפיק מצב ממשק המשתמש.
  • פלט: המצב בממשק המשתמש שהאפליקציה יכולה לעבד כדי לספק למשתמשים למידע הדרוש להם.
צינור עיבוד הנתונים של המדינה
איור 2: צינור עיבוד הנתונים של מצב הייצור

ממשקי 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 מדגימות את ייצור המדינה שכבת ממשק המשתמש. מומלץ לעיין בהם כדי לראות את ההנחיות האלה בפועל: