В приложении Compose место, где вы поднимаете состояние пользовательского интерфейса, зависит от того, требуется ли это логике пользовательского интерфейса или бизнес-логике. В этом документе описаны два основных сценария.
Метод
Состояние пользовательского интерфейса следует поднимать до наименьшего общего предка всех composable-функций, которые его считывают и записывают. Состояние должно храниться как можно ближе к месту его использования. Владелец состояния предоставляет потребителям неизменяемое состояние и события для его изменения.
Наименьший общий предок также может находиться за пределами композиции. Например, при переносе состояния в ViewModel, поскольку задействована бизнес-логика.
В этой статье мы подробно расскажем об этой рекомендации и о том, что нужно учитывать при ее применении.
Типы состояния и логики пользовательского интерфейса
Ниже приведены определения типов состояния пользовательского интерфейса и логики, которые используются в этом документе.
Состояние интерфейса
Состояние интерфейса – это свойство, которое описывает интерфейс. Существует два типа состояния пользовательского интерфейса:
- Состояние пользовательского интерфейса экрана – это то, что нужно показать на экране. Например, класс
NewsUiStateможет содержать новостные статьи и другую информацию, необходимую для отрисовки пользовательского интерфейса. Это состояние обычно связано с другими уровнями иерархии, поскольку оно содержит данные приложения. - Состояние элемента интерфейса – это свойство, присущее элементам интерфейса и влияющее на то, как они отображаются. Элемент интерфейса может быть показан или скрыт, а также иметь определенный шрифт, размер шрифта или цвет шрифта. В Jetpack Compose состояние находится за пределами composable-функции, и вы даже можете поднять его из непосредственной близости от composable-функции в вызывающую composable-функцию или хранилище состояния. Пример –
ScaffoldStateдля composable-функцииScaffold.
Логические
Логика в приложении может быть либо бизнес-логикой, либо логикой пользовательского интерфейса:
- Бизнес-логика – это реализация требований к продукту для данных приложения. Например, при нажатии кнопки в приложении для чтения новостей статья добавляется в закладки. Эта логика сохранения закладки в файл или базу данных обычно размещается в слоях домена или данных. Хранилище состояния обычно делегирует эту логику этим уровням, вызывая методы, которые они предоставляют.
- Логика пользовательского интерфейса связана с тем, как отображать состояние пользовательского интерфейса на экране. Например, получение правильной подсказки в строке поиска, когда пользователь выбрал категорию, прокрутка до определенного элемента в списке или логика навигации к определенному экрану, когда пользователь нажимает кнопку.
Логика интерфейса
Когда логике пользовательского интерфейса необходимо прочитать или записать состояние, вы должны ограничить состояние пользовательским интерфейсом, следуя его жизненному циклу. Для этого нужно поднять состояние на правильный уровень в composable-функции. Также это можно сделать в простом классе хранилища состояния, который тоже привязан к жизненному циклу интерфейса.
Ниже приведено описание обоих решений и объяснение, когда какое из них использовать.
Composable-функции как владельцы состояния
Если состояние и логика просты, то хорошим подходом будет размещение логики и состояния элементов пользовательского интерфейса в 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-функции, и применяемая к нему логика очень проста.
Поэтому в данном случае подъем состояния не принесет большой пользы, и вы можете оставить его внутренним. Это делает composable-функцию владельцем и единым источником достоверных данных о развернутом состоянии.
Подъем в composable-функциях
Если вам нужно поделиться состоянием элемента пользовательского интерфейса с другими составными элементами и применить к нему логику пользовательского интерфейса в разных местах, вы можете поднять его выше в иерархии пользовательского интерфейса. Это также упрощает повторное использование и тестирование composable-функций.
В примере ниже показано приложение Chat, которое реализует две функции:
- Кнопка
JumpToBottomпрокручивает список сообщений вниз. Кнопка выполняет логику пользовательского интерфейса в состоянии списка. - Список
MessagesListпрокручивается вниз после того, как пользователь отправляет новые сообщения. UserInput выполняет логику пользовательского интерфейса в состоянии списка.
JumpToBottom и прокруткой вниз при получении новых сообщенийИерархия composable-функций выглядит следующим образом:
Состояние LazyColumn поднимается до экрана чата, чтобы приложение могло выполнять логику интерфейса и считывать состояние из всех composable-функций, которым оно требуется.
LazyColumn из LazyColumn в ConversationScreenВот как выглядят composable-функции:
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 поднимается настолько высоко, насколько это необходимо для логики пользовательского интерфейса, которую необходимо применить. Поскольку он инициализируется в composable-функции, он хранится в композиции и следует ее жизненному циклу.
Обратите внимание, что lazyListState определяется в методе MessagesList со значением по умолчанию rememberLazyListState(). Это распространенный шаблон в Compose.
Это делает composable-функции более гибкими и позволяет использовать их повторно. Затем вы можете использовать composable-функцию в разных частях приложения, где не требуется управлять состоянием. Обычно это происходит при тестировании или предварительном просмотре composable-функции. Именно так LazyColumn определяет свое состояние.
LazyListState – ConversationScreenПростой класс хранилища состояния как владелец состояния
Если composable-функция содержит сложную логику интерфейса, которая включает одно или несколько полей состояния элемента интерфейса, она должна делегировать эту ответственность хранилищам состояния, например простому классу хранилища состояния. Это упрощает логику composable-функции и позволяет тестировать ее отдельно. Такой подход соответствует принципу разделения ответственности: 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 для этого элемента интерфейса. Он также предоставляет методы для изменения позиции прокрутки, например для прокрутки до определенного элемента.
Как видите, чем больше у composable-функции обязанностей, тем больше ей нужно хранилище состояния. Обязанности могут быть в логике пользовательского интерфейса или просто в количестве состояний, которые нужно отслеживать.
Другой распространенный шаблон – использование простого класса хранилища состояния для обработки сложности корневых composable-функций в приложении. Вы можете использовать такой класс для инкапсуляции состояния на уровне приложения, например состояния навигации и размера экрана. Полное описание этого процесса можно найти на странице Логика пользовательского интерфейса и ее хранилище состояния.
Бизнес-логика
Если composable-функции и простые классы хранилища состояния отвечают за логику интерфейса и состояние его элементов, то хранилище состояния на уровне экрана отвечает за следующие задачи:
- Предоставление доступа к бизнес-логике приложения, которая обычно размещается на других уровнях иерархии, например на уровне бизнеса и слое данных.
- Подготовка данных приложения для представления на определенном экране, который становится состоянием пользовательского интерфейса экрана.
ViewModel как владелец состояния
Преимущества AAC ViewModel в разработке для Android делают их подходящими для предоставления доступа к бизнес-логике и подготовки данных приложения для представления на экране.
Когда вы поднимаете состояние пользовательского интерфейса в ViewModel, вы перемещаете его за пределы композиции.
ViewModel, хранится за пределами композиции.ViewModels не хранятся как часть Composition. Они предоставляются фреймворком и ограничены областью действия 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) { /* ... */ } }
Composable-функции используют состояние пользовательского интерфейса экрана, поднятое в ViewModel. Чтобы обеспечить доступ к бизнес-логике, внедрите экземпляр ViewModel в composable-функции на уровне экрана.
Ниже приведен пример использования ViewModel в composable-функции на уровне экрана.
В этом примере 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 может появиться детализация свойств, – это когда вы внедряете хранилище состояния на уровне экрана на верхнем уровне и передаете состояние и события дочерним composable-функциям. Это также может привести к перегрузке сигнатур composable-функций.
Хотя представление событий в виде отдельных лямбда-параметров может перегрузить сигнатуру функции, это максимально увеличивает видимость обязанностей composable-функции. Вы сразу поймете, что он делает.
Передача свойств предпочтительнее создания классов-оболочек для инкапсуляции состояния и событий в одном месте, поскольку это снижает видимость обязанностей composable-функции. Если не использовать классы-оболочки, то вы с большей вероятностью будете передавать composable-функциям только необходимые параметры, что является рекомендуемым подходом.
То же самое относится к событиям навигации. Подробнее об этом рассказывается в документации по навигации.
Если вы обнаружили проблему производительности, вы также можете отложить чтение состояния. Подробную информацию можно найти в документации по эффективности.
Состояние элемента интерфейса
Вы можете поднять состояние элемента пользовательского интерфейса до хранилища состояния на уровне экрана, если есть бизнес-логика, которая должна его читать или записывать.
Продолжим пример с приложением для чата. Приложение показывает пользователю подсказки в групповом чате, когда пользователь вводит @ и подсказку. Эти предложения поступают из уровня данных, а логика вычисления списка пользовательских предложений считается бизнес-логикой. Вот как выглядит эта функция:
@ и подсказку.Объект 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 UI
предоставляют методы для изменения состояния. Некоторые из них могут быть функциями приостановки, которые запускают анимацию. Эти функции приостановки могут вызывать исключения, если вы вызываете их из CoroutineScope, которая не относится к Composition.
Допустим, контент панели приложений динамический и вам нужно получать и обновлять его из слоя данных после закрытия панели. Вам следует поднять состояние панели до ViewModel, чтобы вы могли вызывать как пользовательский интерфейс, так и бизнес-логику для этого элемента из владельца состояния.
Однако вызов метода close() класса DrawerState с использованием viewModelScope из Compose UI вызывает исключение времени выполнения типа IllegalStateException с сообщением "MonotonicFrameClock недоступен в этом CoroutineContext”".
Чтобы устранить эту проблему, используйте CoroutineScope в области действия Composition. Он предоставляет MonotonicFrameClock в CoroutineContext, необходимый для работы функций приостановки.
Чтобы устранить этот сбой, переключите CoroutineContext сопрограммы в ViewModel на ту, которая относится к Composition. Вот как это может выглядеть:
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