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

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

Лучшая практика

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

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

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

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

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

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

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

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

Логика

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

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

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

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

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

Composables как государственный владелец

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

Никакого государственного подъема не требуется

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

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

    ClickableText(
        text = AnnotatedString(message.content),
        onClick = { showDetails = !showDetails } // Apply simple UI logic
    )

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

Переменная showDetails — это внутреннее состояние этого элемента пользовательского интерфейса. В этом компонуемом объекте он только читается и изменяется, и логика, применяемая к нему, очень проста. Таким образом, подъем состояния в этом случае не принесет особой пользы, поэтому вы можете оставить его внутренним. Это делает его составным владельцем и единственным источником истины расширенного состояния.

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

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

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

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

Класс простого государственного держателя в качестве государственного владельца

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

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

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

Модели представления не сохраняются как часть композиции. Они предоставляются платформой и ограничены ViewModelStoreOwner , который может быть действием, фрагментом, графом навигации или местом назначения графа навигации. Для получения дополнительной информации об областях 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) { /* ... */ }
}

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

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

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

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

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

Образцы

Кодлабы

Видео

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