Где поднимать состояние

В приложении Compose, где размещается состояние интерфейса, зависит от того, требуется ли оно логике интерфейса или бизнес-логике. В этом документе описаны два основных сценария.

Метод

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

Общий предок может находиться за пределами композиции. Например, при переносе состояния в ViewModel, поскольку это связано с бизнес-логикой.

На этой странице подробно описана эта рекомендация и приведены важные предостережения.

Типы состояний и логика интерфейса

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

Состояние интерфейса

Состояние интерфейса – это свойство, описывающее интерфейс. Существует два типа состояния интерфейса:

  • Состояние интерфейса экрана – это то, что нужно показывать на экране. Например, класс NewsUiState может содержать новостные статьи и другую информацию, необходимую для отрисовки интерфейса. Этот уровень обычно связан с другими уровнями иерархии, поскольку содержит данные приложений.
  • Состояние элемента интерфейса – это свойства, присущие элементам интерфейса и влияющие на то, как они отображаются. Элемент интерфейса может быть показан или скрыт, а также иметь определенный шрифт, размер шрифта или цвет шрифта. В Jetpack Compose состояние находится вне composable-функции, и вы можете даже поднять его выше по иерархии, в вызывающую composable-функцию или хранилище состояния. Пример: ScaffoldState для composable-функции Scaffold.

Логические

Логика в приложении может быть бизнес-логикой или логикой интерфейса:

  • Бизнес-логика – это реализация требований к продукту для данных приложения. Например, когда пользователь нажимает кнопку, статья добавляется в закладки в приложении для чтения новостей. Логика сохранения закладки в файл или базу данных обычно размещается в слоях домена или данных. Обычно держатель состояния делегирует эту логику этим слоям, вызывая их методы.
  • Логика интерфейса определяет, как отображать состояние интерфейса на экране. Например, получение правильной подсказки в строке поиска, когда пользователь выбрал категорию, прокрутка до определенного элемента в списке или логика перехода на определенный экран, когда пользователь нажимает кнопку.

Логика интерфейса

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

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

Компонуемые функции как владельцы состояния

Если логика и состояние элементов интерфейса простые, их можно хранить в композициях. Вы можете оставить внутреннее состояние в рамках composable-функции или поднять его на более высокий уровень по мере необходимости.

Не нужно поднимать состояние

Подъем состояния требуется не всегда. Состояние можно хранить внутри composable-функции, если оно не нужно другим composable-функциям. В этом фрагменте кода есть composable-функция, которая разворачивается и сворачивается при нажатии:

@Composable
fun ChatBubble(
    message: Message
) {
    var showDetails by rememberSaveable { mutableStateOf(false) } // Define the UI element expanded state

    Text(
        text = AnnotatedString(message.content),
        modifier = Modifier.clickable {
            showDetails = !showDetails // Apply UI logic
        }
    )

    if (showDetails) {
        Text(message.timestamp)
    }
}

Переменная showDetails – это внутреннее состояние элемента интерфейса. Она считывается и изменяется только в этом компоненте, и логика, применяемая к ней, очень проста. В этом случае вынос состояния не принесет большой пользы, поэтому его можно оставить внутренним. В этом случае composable-функция становится владельцем и единым источником достоверных данных о развернутом состоянии.

Подъем состояния в компонентах

Если вам нужно передавать состояние элемента интерфейса другим компонентам и применять к нему логику в разных местах, вы можете поднять его выше в иерархии интерфейса. Кроме того, это упрощает повторное использование и тестирование ваших функций.

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

  • Кнопка JumpToBottom позволяет прокрутить список сообщений до конца. Кнопка выполняет логику интерфейса на основе состояния списка.
  • Список MessagesList прокручивается вниз после того, как пользователь отправляет новые сообщения. UserInput выполняет логику интерфейса для состояния списка.
Приложение для чата с кнопкой "Перейти в конец" и прокруткой в конец при получении новых сообщений
Рисунок 1. Приложение для чата с кнопкой JumpToBottom и прокруткой до новых сообщений

Иерархия composable-функций выглядит следующим образом:

Дерево с возможностью выбора в чате
Рисунок 2. Chat composable tree

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

Передача состояния LazyColumn из LazyColumn в ConversationScreen
Рисунок 3. Передача состояния LazyColumn из LazyColumn в ConversationScreen

Таким образом, итоговый код выглядит так:

Дерево чата с компонентами, в котором LazyListState передан в ConversationScreen
Рисунок 4. Chat composable tree with LazyListState hoisted to ConversationScreen

Вот код:

@Composable
private fun ConversationScreen(/*...*/) {
    val scope = rememberCoroutineScope()

    val lazyListState = rememberLazyListState() // State hoisted to the ConversationScreen

    MessagesList(messages, lazyListState) // Reuse same state in MessageList

    UserInput(
        onMessageSent = { // Apply UI logic to lazyListState
            scope.launch {
                lazyListState.scrollToItem(0)
            }
        },
    )
}

@Composable
private fun MessagesList(
    messages: List<Message>,
    lazyListState: LazyListState = rememberLazyListState() // LazyListState has a default value
) {

    LazyColumn(
        state = lazyListState // Pass hoisted state to LazyColumn
    ) {
        items(messages, key = { message -> message.id }) { item ->
            Message(/*...*/)
        }
    }

    val scope = rememberCoroutineScope()

    JumpToBottom(onClicked = {
        scope.launch {
            lazyListState.scrollToItem(0) // UI logic being applied to lazyListState
        }
    })
}

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

Обратите внимание, что lazyListState определяется в методе MessagesList со значением по умолчанию rememberLazyListState(). Это распространенный подход в Compose. Это делает их более гибкими и позволяет использовать их повторно. После этого вы сможете использовать composable-функцию в разных частях приложения, где не нужно управлять состоянием. Обычно это происходит при тестировании или предварительном просмотре композиции. Именно так определяется состояние поля LazyColumn.

Наименьший общий предок для LazyListState – ConversationScreen
Рисунок 5. Наименьший общий предок для LazyListState – ConversationScreen

Класс обычного хранилища состояния как владелец состояния

Если composable-функция содержит сложную логику интерфейса, связанную с одним или несколькими полями состояния элемента интерфейса, она должна делегировать эту ответственность хранилищам состояния, например простому классу хранилища состояния. Это позволяет тестировать логику компонента отдельно и снижает ее сложность. Такой подход соответствует принципу разделения ответственности: composable-функция отвечает за создание элементов [пользовательского] интерфейса, а хранилище состояния содержит логику интерфейса и состояние элементов [пользовательского] интерфейса.

Классы хранилищ простого состояния предоставляют удобные функции вызывающим функциям вашей composable-функции, поэтому им не нужно писать эту логику самостоятельно.

Эти простые классы создаются и сохраняются в композиции. Поскольку они следуют жизненному циклу композиции, они могут принимать типы, предоставляемые библиотекой Compose, например rememberNavController() или rememberLazyListState().

Примером такого класса является LazyListState, реализованный в Compose для управления сложностью интерфейса LazyColumn или LazyRow.

// LazyListState.kt

@Stable
class LazyListState constructor(
    firstVisibleItemIndex: Int = 0,
    firstVisibleItemScrollOffset: Int = 0
) : ScrollableState {
    /**
     *   The holder class for the current scroll position.
     */
    private val scrollPosition = LazyListScrollPosition(
        firstVisibleItemIndex, firstVisibleItemScrollOffset
    )

    suspend fun scrollToItem(/*...*/) { /*...*/ }

    override suspend fun scroll() { /*...*/ }

    suspend fun animateScrollToItem() { /*...*/ }
}

LazyListState содержит состояние LazyColumn, в котором хранится scrollPosition для этого элемента интерфейса. Также он предоставляет методы для изменения позиции прокрутки, например для перехода к определенному элементу.

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

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

Бизнес-логика

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

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

ViewModel как владелец состояния

Преимущества использования ViewModels в Android-разработке делают их подходящим решением для предоставления доступа к бизнес-логике и подготовки данных приложения для отображения на экране.

Когда вы поднимаете состояние интерфейса в ViewModel, вы перемещаете его за пределы композиции.

Состояние, переданное в ViewModel, хранится вне композиции.
Рисунок 6. Состояние, переданное в ViewModel, хранится вне композиции.

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

ViewModel – это источник истины и наименьший общий предок для состояния интерфейса.

Состояние интерфейса экрана

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

Рассмотрим ConversationViewModel приложения для чата и то, как оно предоставляет доступ к состоянию экрана и событиям для его изменения:

class ConversationViewModel(
    channelId: String,
    messagesRepository: MessagesRepository
) : ViewModel() {

    val messages = messagesRepository
        .getLatestMessages(channelId)
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = emptyList()
        )

    // Business logic
    fun sendMessage(message: Message) { /* ... */ }
}

Компонуемые функции используют состояние интерфейса экрана, переданное в ViewModel. Внедрите экземпляр ViewModel в композиции на уровне экрана, чтобы обеспечить доступ к бизнес-логике.

Ниже приведен пример использования ViewModel в composable-функции на уровне экрана. Здесь компонент ConversationScreen() использует состояние интерфейса экрана, переданное в ViewModel:

@Composable
private fun ConversationScreen(
    conversationViewModel: ConversationViewModel = viewModel()
) {

    val messages by conversationViewModel.messages.collectAsStateWithLifecycle()

    ConversationScreen(
        messages = messages,
        onSendMessage = { message: Message -> conversationViewModel.sendMessage(message) }
    )
}

@Composable
private fun ConversationScreen(
    messages: List<Message>,
    onSendMessage: (Message) -> Unit
) {

    MessagesList(messages, onSendMessage)
    /* ... */
}

Детализация данных ресурса

"Передача свойств" – это передача данных через несколько вложенных дочерних компонентов в то место, где они считываются.

Типичный пример использования в Compose – когда вы внедряете держатель состояния на уровне экрана вверху и передаете состояние и события дочерним композициям. Кроме того, это может привести к перегрузке сигнатур составных функций.

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

Вместо создания классов-оберток для объединения состояния и событий в одном месте лучше использовать передачу свойств, поскольку это позволяет уменьшить видимость обязанностей композиции. Кроме того, отсутствие классов-оберток позволяет передавать в функции только необходимые параметры, что является рекомендуемой практикой.

Это же правило действует, если события относятся к навигации. Подробнее об этом можно узнать в документации по навигации.

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

Состояние элемента интерфейса

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

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

Функция, которая показывает подсказки пользователей в групповом чате, когда пользователь вводит символ &quot;@&quot; и подсказку.
Рисунок 7. Функция, которая показывает подсказки пользователей в групповом чате, когда пользователь вводит @ и подсказку.

Объект ViewModel, реализующий эту функцию, будет выглядеть следующим образом:

class ConversationViewModel(/*...*/) : ViewModel() {

    // Hoisted state
    var inputMessage by mutableStateOf("")
        private set

    val suggestions: StateFlow<List<Suggestion>> =
        snapshotFlow { inputMessage }
            .filter { hasSocialHandleHint(it) }
            .mapLatest { getHandle(it) }
            .mapLatest { repository.getSuggestions(it) }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = emptyList()
            )

    fun updateInput(newInput: String) {
        inputMessage = newInput
    }
}

inputMessage – это переменная, в которой хранится состояние TextField. Каждый раз, когда пользователь вводит новые данные, приложение вызывает бизнес-логику для создания suggestions.

suggestions – это состояние интерфейса экрана, которое используется в Compose UI путем сбора данных из StateFlow.

Caveat

Для некоторых состояний элементов интерфейса Compose перенос в ViewModel может потребовать особого подхода. Например, некоторые держатели состояния элементов интерфейса Compose предоставляют методы для изменения состояния. Некоторые из них могут быть функциями приостановки, которые запускают анимацию. Если вы вызываете эти функции приостановки из CoroutineScope, не относящегося к области действия Composition, может возникнуть исключение.

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

Однако при вызове метода close() объекта DrawerState с использованием viewModelScope из Compose UI возникает исключение времени выполнения типа IllegalStateException с сообщением "a MonotonicFrameClock is not available in this CoroutineContext”" (объект недоступен в ).

Чтобы устранить эту проблему, используйте CoroutineScope, область действия которого ограничена композицией. Он предоставляет MonotonicFrameClock в CoroutineContext, который необходим для работы функций приостановки.

Чтобы исправить эту ошибку, измените CoroutineContext сопрограммы в ViewModel на область действия, ограниченную композицией. Вот как это может выглядеть:

class ConversationViewModel(/*...*/) : ViewModel() {

    val drawerState = DrawerState(initialValue = DrawerValue.Closed)

    private val _drawerContent = MutableStateFlow(DrawerContent.Empty)
    val drawerContent: StateFlow<DrawerContent> = _drawerContent.asStateFlow()

    fun closeDrawer(uiScope: CoroutineScope) {
        viewModelScope.launch {
            withContext(uiScope.coroutineContext) { // Use instead of the default context
                drawerState.close()
            }
            // Fetch drawer content and update state
            _drawerContent.update { content }
        }
    }
}

// in Compose
@Composable
private fun ConversationScreen(
    conversationViewModel: ConversationViewModel = viewModel()
) {
    val scope = rememberCoroutineScope()

    ConversationScreen(onCloseDrawer = { conversationViewModel.closeDrawer(uiScope = scope) })
}

Подробнее…

Чтобы узнать больше о состоянии и Jetpack Compose, ознакомьтесь с дополнительными ресурсами.

Образцы

Практические работы

Видео