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

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

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

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

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

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

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

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

События могут исходить из:

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

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

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

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

API государственного производства

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

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

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

Монтаж государственного производственного трубопровода

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

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

Затраты в государственные производственные трубопроводы

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

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

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

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

Используйте API MutableStateFlow в качестве наблюдаемого изменяемого контейнера состояния. В приложениях Jetpack Compose вы также можете рассмотреть mutableStateOf особенно при работе с API Compose text . Оба 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() сохраняет задачу асинхронно, метод 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 как обычно.
  • При использовании 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 этого можно добиться с помощью функции объединения . Пример этого можно увидеть в примере «Сейчас в 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 из репозитория образцов архитектуры ниже:

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

{% дословно %} {% дословно %} {% дословно %} {% дословно %}