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

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

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

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

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

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

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

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

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

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

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

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

  • קלט: המקורות של שינוי המצב. הם יכולים להיות:
    • לוקאלי לשכבת ממשק המשתמש: אלה יכולים להיות אירועים של משתמשים, כמו משתמש שמזין כותרת ל'משימה' באפליקציה לניהול משימות, או ממשקי API שמספקים גישה ללוגיקה של ממשק המשתמש שמניעה שינויים במצב ממשק המשתמש. לדוגמה, קריאה לשיטה open ב-DrawerState ב-Jetpack פיתוח נייטיב.
    • מחוץ לשכבת ממשק המשתמש: אלה מקורות משכבות הדומיין או הנתונים שגורמים לשינויים במצב ממשק המשתמש. לדוגמה, חדשות שסיימו להיטען מ-NewsRepository או אירועים אחרים.
    • שילוב של כל האפשרויות שצוינו למעלה.
  • State holders: Types that apply business logic and/or UI logic to sources of state change and process user events to produce UI state.
  • פלט: מצב ממשק המשתמש שהאפליקציה יכולה לעבד כדי לספק למשתמשים את המידע שהם צריכים.
צינור עיבוד הנתונים של המדינה
איור 2: צינור ההפקה של המדינה

ממשקי 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. במקרים שבהם זה לא רלוונטי, צריך להגדיר פונקציית idempotentinitialize() כדי להפעיל במפורש את צינור הייצור של המצב, כמו שמוצג בקטע הקוד הבא:

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