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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Управляйте состоянием с помощью наблюдаемых носителей данных. Используйте API mutableStateOf , особенно при работе с текстовыми API Compose . Для более сложного управления состоянием или при интеграции с другими архитектурными компонентами используйте API MutableStateFlow . Оба API предоставляют методы, позволяющие безопасно обновлять атомарные значения, независимо от того, являются ли обновления синхронными или асинхронными.

Например, рассмотрим обновление состояния в простом приложении для бросания игральных костей. Каждый бросок кости пользователем вызывает синхронный метод Random.nextInt , и результат записывается в состояние пользовательского интерфейса.

Составное состояние

@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
    }
}

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,
            )
        }
    }
}

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

Для изменений состояния, требующих асинхронного результата, запустите сопрограмму в соответствующем CoroutineScope . Это позволит приложению отменить выполненную работу при закрытии CoroutineScope . Затем владелец состояния записывает результат вызова метода suspend в наблюдаемый API, используемый для предоставления доступа к состоянию пользовательского интерфейса.

Например, рассмотрим AddEditTaskViewModel в примере архитектуры . Когда метод saveTask , приостанавливающий выполнение задачи, асинхронно сохраняет задачу, метод update в MutableStateFlow передает изменение состояния в состояние пользовательского интерфейса.

Составное состояние

@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))
            }
        }
    }
}

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))
                }
            }
        }
    }
}

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

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

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

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

Составное состояние

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
                }
            }
        }
    }
}

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,
                    )
                }
            }
        }
    }
}

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-samples . Состояние пользовательского интерфейса зависит от потока данных о текущей задаче ( _task ) и одноразового источника ( _isTaskDeleted ), который обновляется при удалении задачи. Этот флаг необходим для различения случаев, когда задача не найдена в базе данных из-за неверного идентификатора, и случаев, когда она не найдена, потому что пользователь только что удалил её:

Составное состояние

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, taskAsync ->
        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
    }
}

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, taskAsync ->
        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 }
    }
}

Типы выходных данных в конвейерах государственного производства

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

В таблице ниже приведено краткое описание API, которые следует использовать для конвейера обработки состояния при работе с Jetpack Compose:

Вход Выход
API-интерфейсы, работающие один раз StateFlow или Compose State
API потоковой передачи StateFlow
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 %}