Создание состояния пользовательского интерфейса

Современные пользовательские интерфейсы редко бывают статичными. Состояние интерфейса меняется, когда пользователь взаимодействует с ним или когда приложению необходимо отобразить новые данные.

В этом документе изложены правила создания и управления состоянием пользовательского интерфейса. По завершении работы с документом вы должны:

  • Определите, какие API следует использовать для управления состоянием пользовательского интерфейса. Это зависит от характера источников изменения состояния, доступных в ваших хранилищах состояния, в соответствии с принципами однонаправленного потока данных .
  • Поймите, как следует определять параметры формирования состояния пользовательского интерфейса с учетом системных ресурсов.
  • Поймите, как следует предоставлять доступ к состоянию пользовательского интерфейса для его использования.

По сути, создание состояния — это постепенное применение этих изменений к состоянию пользовательского интерфейса. Состояние существует всегда и изменяется в результате событий. Различия между событиями и состоянием суммированы в таблице ниже:

События Состояние
Преходящие, непредсказуемые и существующие в течение конечного периода времени. Существует всегда.
Производственные ресурсы государства. Объем государственного производства.
Продукт пользовательского интерфейса или других источников. Используется пользовательским интерфейсом.

Отличный мнемонический приём, который суммирует вышесказанное, — это состояние; события происходят . Приведённая ниже диаграмма помогает визуализировать изменения состояния по мере возникновения событий на временной шкале. Каждое событие обрабатывается соответствующим держателем состояния и приводит к изменению состояния:

События против государства
Рисунок 1 : События вызывают изменение состояния.

События могут происходить из следующих источников:

  • Пользователи : В процессе взаимодействия с пользовательским интерфейсом приложения.
  • Другие источники изменения состояния : API, предоставляющие данные приложения из пользовательского интерфейса, предметной области или уровня данных, например, события истечения времени ожидания Snackbar, варианты использования или репозитории соответственно.

Конвейер производства состояния пользовательского интерфейса

Процесс формирования состояния в приложениях Android можно рассматривать как конвейер обработки, включающий в себя:

  • Входные данные : Источники изменения состояния. Они могут быть следующими:
    • Локальные события на уровне пользовательского интерфейса: это могут быть пользовательские события, например, ввод пользователем заголовка для задачи в приложении для управления задачами, или API, предоставляющие доступ к логике пользовательского интерфейса , которая управляет изменениями состояния интерфейса. Например, вызов метода open DrawerState в Jetpack Compose.
    • Вне уровня пользовательского интерфейса: это источники из предметной области или уровня данных, которые вызывают изменения состояния пользовательского интерфейса. Например, новости, завершившие загрузку из NewsRepository , или другие события.
    • Смесь всего вышеперечисленного.
  • Владельцы состояний : Типы, которые применяют бизнес-логику и/или логику пользовательского интерфейса к источникам изменения состояния и обрабатывают пользовательские события для формирования состояния пользовательского интерфейса.
  • Вывод : Состояние пользовательского интерфейса, которое приложение может отображать, чтобы предоставлять пользователям необходимую информацию.
Государственный производственный конвейер
Рисунок 2 : Конвейер производства на уровне состояния

API для производства состояний

В зависимости от этапа конвейера обработки данных, в процессе создания состояния используются два основных API:

Этап трубопровода API
Вход Для обеспечения плавной работы интерфейса следует использовать асинхронные API для выполнения задач вне основного потока. Например, сопрограммы или потоки в Kotlin, а также RxJava или коллбэки в языке программирования Java.
Выход Для аннулирования и повторной отрисовки пользовательского интерфейса при изменении состояния следует использовать API-интерфейсы, поддерживающие наблюдаемые данные. Например, StateFlow, Compose State или LiveData. Наблюдаемые данные гарантируют, что пользовательский интерфейс всегда будет иметь состояние для отображения на экране.

Из двух вариантов выбор асинхронного API для ввода оказывает большее влияние на характер конвейера генерации состояния, чем выбор наблюдаемого API для вывода. Это связано с тем, что входные данные определяют тип обработки, которая может быть применена к конвейеру .

Государственная сборка производственного трубопровода

В следующих разделах рассматриваются методы генерации состояний, наиболее подходящие для различных входных данных, а также соответствующие API для обработки выходных данных. Каждый конвейер генерации состояний представляет собой комбинацию входных и выходных данных и должен соответствовать следующим требованиям:

  • Учет жизненного цикла : В случае, когда пользовательский интерфейс не виден или неактивен, конвейер обработки состояния не должен потреблять никаких ресурсов, если это явно не требуется.
  • Удобство использования : пользовательский интерфейс должен легко отображать созданное состояние. Вопросы, касающиеся вывода данных из конвейера создания состояния, будут различаться в зависимости от используемого API представления, например, системы представлений или Jetpack Compose.

Входные данные в государственных производственных цепочках

Входные данные в конвейере обработки состояний могут предоставлять источники изменения состояния либо через:

  • Одноразовые операции, которые могут быть синхронными или асинхронными, например, вызовы функций для suspend работы.
  • API для потоковой передачи данных, например, Flows .
  • Все вышеперечисленное.

В следующих разделах описано, как можно собрать конвейер обработки состояний для каждого из вышеперечисленных входных параметров.

Одноразовые API как источники изменения состояния

Используйте API MutableStateFlow в качестве наблюдаемого, изменяемого контейнера состояния. В приложениях Jetpack Compose также можно рассмотреть 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
    }
}

Изменение состояния пользовательского интерфейса с помощью асинхронных вызовов

Для изменений состояния, требующих асинхронного результата, запустите сопрограмму в соответствующем CoroutineScope . Это позволит приложению отменить выполненную работу при закрытии CoroutineScope . Затем владелец состояния записывает результат вызова метода 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))
            }
        }
    }
}

Изменение состояния пользовательского интерфейса из фоновых потоков.

Для генерации состояния пользовательского интерфейса предпочтительнее запускать корутины в главном диспетчере, то есть вне блока withContext в приведенных ниже фрагментах кода. Однако, если вам необходимо обновить состояние пользовательского интерфейса в другом фоновом контексте, вы можете сделать это, используя следующие API:

  • Используйте метод withContext для запуска сопрограмм в другом параллельном контексте.
  • При использовании MutableStateFlow используйте метод update как обычно.
  • При использовании Compose State используйте Snapshot.withMutableSnapshot , чтобы гарантировать атомарные обновления состояния в параллельном контексте.

Например, предположим, что в приведенном ниже фрагменте кода DiceRollViewModel функция SlowRandom.nextInt() является ресурсоемкой функцией suspend , которую необходимо вызывать из сопрограммы, работающей на процессоре.

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 потоковой передачи данных как источники изменения состояния.

Для источников изменения состояния, которые во времени генерируют множество значений в потоках, агрегирование результатов всех источников в единое целое является простым подходом к созданию состояния.

При использовании 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 для распространения изменений состояния. Вы также можете создавать потоки снимков состояния из состояния Compose.

Рассмотрите объект TaskDetailViewModel из репозитория architecture-amples, расположенного ниже:

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 Compose. Здесь следует учитывать следующее:

В следующей таблице приведено краткое описание того, какие API следует использовать для конвейера обработки состояния для любого заданного входного параметра и потребителя:

Вход Потребитель Выход
API-интерфейсы, работающие один раз Мнения StateFlow или LiveData
API-интерфейсы, работающие один раз Сочинить StateFlow или Compose State
API потоковой передачи Мнения StateFlow или LiveData
API потоковой передачи Сочинить StateFlow
API для однократных и потоковых запросов Мнения StateFlow или LiveData
API для однократных и потоковых запросов Сочинить StateFlow

Инициализация конвейера производства состояний

Инициализация конвейеров обработки данных включает в себя установку начальных условий для их работы. Это может включать в себя предоставление начальных входных значений, критически важных для запуска конвейера, например, 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 демонстрируют создание состояния на уровне пользовательского интерфейса. Изучите их, чтобы увидеть применение этого подхода на практике:

{% verbatim %} {% endverbatim %} {% verbatim %} {% endverbatim %}