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

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

Передовая практика

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

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

На этой странице подробно объясняется данная передовая практика, а также следует учитывать одно важное предостережение.

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

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

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

Состояние пользовательского интерфейса — это свойство, описывающее пользовательский интерфейс. Существует два типа состояния пользовательского интерфейса:

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

Логика

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

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

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

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

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

Композиты как государственный владелец

Если логика и логика интерфейса просты, то размещение их внутри компонуемых объектов — хороший подход. При необходимости состояние можно оставить внутренним для компонуемого объекта или объекта типа Hoist.

Государственная поддержка не требуется.

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

@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 представляет собой внутреннее состояние этого элемента пользовательского интерфейса. Она считывается и изменяется только в этом составном объекте, и применяемая к ней логика очень проста. Поэтому поднятие состояния в этом случае не принесет большой пользы, и вы можете оставить его внутренним. В этом случае данный составной объект станет владельцем и единственным источником истины для развернутого состояния.

Подъем внутри составных элементов

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

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

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

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

Составное дерево чата
Рисунок 2. Составное дерево чата

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

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

Итак, в итоге, составными элементами являются:

Компонуемое дерево чата с LazyListState, перемещенным на ConversationScreen.
Рисунок 4. Компонуемое дерево чата с LazyListState , перемещенным на 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 поднимается на необходимый уровень для реализации логики пользовательского интерфейса. Поскольку он инициализируется в компонуемой функции, он хранится в композиции, следуя своему жизненному циклу.

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

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

Простой класс владельцев государственных активов как владелец государственных активов

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

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

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

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

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

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

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

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

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

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

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

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

ViewModels не хранятся как часть композиции. Они предоставляются фреймворком и ограничены областью видимости ViewModelStoreOwner , которым может быть Activity, Fragment, граф навигации или целевая страница графа навигации. Для получения дополнительной информации об областях видимости 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 в компоненте, создаваемом на уровне экрана. Здесь компонент 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 может проявляться «просеивание свойств», является внедрение держателя состояния на уровне экрана на верхнем уровне и передача состояния и событий дочерним компонуемым объектам. Это также может привести к перегрузке сигнатур функций компонуемых объектов.

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

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

Те же рекомендации применимы и к событиям навигации; подробнее об этом можно узнать в документации по навигации .

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

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

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

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

Функция, отображающая предложения пользователя в групповом чате, когда пользователь вводит символ `@` и подсказку.
Рисунок 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 .

Предостережение

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

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

Однако вызов метода close() DrawerState с использованием viewModelScope из Compose UI приводит к исключению времени выполнения типа IllegalStateException с сообщением «объект MonotonicFrameClock недоступен в этом 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, обратитесь к следующим дополнительным ресурсам.

Образцы

Кодлабс

Видео

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