В приложении 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 выполняет логику интерфейса для состояния списка.
JumpToBottom и прокруткой до новых сообщенийИерархия composable-функций выглядит следующим образом:
Состояние LazyColumn передается на экран чата, чтобы приложение могло выполнить логику интерфейса и прочитать состояние из всех композиций, которым оно требуется:
LazyColumn из LazyColumn в ConversationScreenТаким образом, итоговый код выглядит так:
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Класс обычного хранилища состояния как владелец состояния
Если 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, хранится вне композиции.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 – когда вы внедряете держатель состояния на уровне экрана вверху и передаете состояние и события дочерним композициям. Кроме того, это может привести к перегрузке сигнатур составных функций.
Хотя передача событий в виде отдельных параметров лямбда-функции может перегрузить сигнатуру функции, это позволяет максимально наглядно показать, за что отвечает функция, которую можно составить. Вы можете быстро понять, что делает тот или иной элемент.
Вместо создания классов-оберток для объединения состояния и событий в одном месте лучше использовать передачу свойств, поскольку это позволяет уменьшить видимость обязанностей композиции. Кроме того, отсутствие классов-оберток позволяет передавать в функции только необходимые параметры, что является рекомендуемой практикой.
Это же правило действует, если события относятся к навигации. Подробнее об этом можно узнать в документации по навигации.
Если вы обнаружили проблему производительности, то можете отложить чтение состояния. Подробную информацию можно найти в документации по эффективности.
Состояние элемента интерфейса
Вы можете перенести состояние элемента интерфейса в держатель состояния на уровне экрана, если бизнес-логике нужно его читать или записывать.
В примере с приложением для обмена сообщениями подсказки для пользователей показываются в групповом чате, когда пользователь вводит @ и подсказку. Эти подсказки берутся из уровня данных, а логика для расчета списка подсказок считается бизнес-логикой. Вот как выглядит эта функция:
@ и подсказку.Объект 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, ознакомьтесь с дополнительными ресурсами.
Образцы
Практические работы
Видео
Рекомендуем
- Примечание. Текст ссылки показывается, когда JavaScript отключен.
- Сохранение состояния интерфейса в Compose
- Списки и сетки
- Архитектура интерфейса Compose