Вы можете столкнуться с распространенными проблемами Compose. Эти ошибки могут привести к тому, что код будет работать достаточно хорошо, но может снизить производительность пользовательского интерфейса. Следуйте рекомендациям по оптимизации приложения в Compose.
Используйте remember, чтобы минимизировать ресурсоемкие вычисления
Composable-функции могут выполняться очень часто, например для каждого кадра анимации. Поэтому в теле composable-функции должно быть как можно меньше вычислений.
Важный метод – сохранение результатов вычислений с помощью remember. Таким образом, вычисление выполняется один раз, и вы можете получать результаты в любое время.
Например, в приведенном ниже коде список имен сортируется очень неэффективным способом:
@Composable fun ContactList( contacts: List<Contact>, comparator: Comparator<Contact>, modifier: Modifier = Modifier ) { LazyColumn(modifier) { // DON’T DO THIS items(contacts.sortedWith(comparator)) { contact -> // ... } } }
Каждый раз, когда ContactsList перекомпоновывается, весь список контактов сортируется заново, даже если он не изменился. Если пользователь прокручивает список, composable-функция перекомпонуется всякий раз, когда появляется новая строка.
Чтобы решить эту проблему, отсортируйте список за пределами LazyColumn и сохраните его с помощью remember:
@Composable fun ContactList( contacts: List<Contact>, comparator: Comparator<Contact>, modifier: Modifier = Modifier ) { val sortedContacts = remember(contacts, comparator) { contacts.sortedWith(comparator) } LazyColumn(modifier) { items(sortedContacts) { // ... } } }
Теперь список сортируется один раз, когда ContactList создается впервые. Если контакты или компаратор изменяются, отсортированный список генерируется заново. В противном случае composable-функция может продолжать использовать кэшированный отсортированный список.
Всегда передавайте в качестве ключей любые входные данные, на которые ссылается блок вычислений remember, чтобы кешированное значение обновлялось при изменении этих входных данных. Поскольку remember хранит свои ключи и кэшированный результат в таблице слотов и сравнивает ключи с помощью Object.equals() при каждом проходе рекомпозиции, используйте remember только при кэшировании дорогостоящих вычислений или тяжелых распределений объектов (таких как средства форматирования дат, шейдеры или сортировка списков) или когда необходимо сохранить идентичность объекта.
Будьте осторожны, передавая большую коллекцию или объект с ресурсоемкой реализацией equals() в качестве ключа в remember для простых вычислений, поскольку сравнение ключа может стоить дороже, чем непосредственное выполнение вычислений:
@Composable
fun ContactHeader(contacts: List<Contact>) {
// Avoid: Comparing the `contacts` list with .equals() on recomposition
// is more expensive than reading contacts.size or contacts.firstOrNull() directly.
val firstContactName = remember(contacts) {
contacts.firstOrNull()?.name.orEmpty()
}
Text(firstContactName)
}
Используйте ключи и типы контента для ленивых макетов
Ленивые макеты эффективно повторно используют элементы, регенерируя или перекомпоновывая их только тогда, когда это необходимо. Однако вы можете помочь оптимизировать ленивые макеты для рекомпозиции и повторного использования слотов.
Предположим, что действие пользователя приводит к перемещению элемента в списке. Например, вы показываете список заметок, отсортированных по времени изменения, причем последняя измененная заметка находится вверху.
@Composable fun NotesList(notes: List<Note>) { LazyColumn { items( items = notes ) { note -> NoteRow(note) } } }
Однако с этим кодом есть проблема. Предположим, что нижняя нота изменилась. Теперь это самая последняя измененная заметка, поэтому она перемещается в начало списка, а все остальные заметки сдвигаются на одну позицию вниз.
Без вашей помощи Compose не понимает, что неизмененные элементы просто перемещаются в списке. Вместо этого Compose считает, что старый "элемент 2" был удален и был создан новый для элемента 3, элемента 4 и так далее. В результате Compose перекомпонует каждый элемент в списке, хотя на самом деле изменился только один из них.
Решение – предоставить ключи элементов. Предоставление стабильного ключа для каждого элемента позволяет Compose избежать ненужных рекомпозиций. В этом случае Compose может определить, что элемент, который сейчас находится на месте 3, является тем же элементом, который раньше находился на месте 2. Поскольку никакие данные для этого элемента не изменились, Compose не нужно его перекомпоновывать.
@Composable fun NotesList(notes: List<Note>) { LazyColumn { items( items = notes, key = { note -> // Return a stable, unique key for the note note.id } ) { note -> NoteRow(note) } } }
При работе с ленивыми макетами (LazyColumn, LazyRow, LazyGrid) также учитывайте следующие рекомендации:
- Используйте уникальные ключи, которые можно сохранить в Bundle. Ключи должны быть уникальными, детерминированными и сохраняемыми в Android
Bundle, поскольку ленивые макеты используютrememberSaveable. Рекомендуется использовать примитивные типы, такие какInt,LongилиString. Не используйте в качестве ключей индексы списков илиhashCode(). - Всегда указывайте
contentTypeдля неоднородных списков. ПередавайтеcontentType = { it.type }вitemsилиitemsIndexed, чтобы Compose повторно использовал слоты композиции только между элементами с одинаковой визуальной структурой во время прокрутки. - Используйте
itemsIndexedвместоindexOf. Не вызывайтеlist.indexOf(item)внутри блокаitems, так как это превращает макет в сканированиеO(N²). Вместо этого используйтеitemsIndexed. - Поднимите общее состояние и операции чтения
CompositionLocal. Соберите общие экземплярыFlowи прочитайте значенияCompositionLocal(например,LocalContext.currentилиLocalDensity.current) один раз в родительской composable-функции над ленивым макетом и передайте разрешенные значения в composable-функции элементов. - Используйте
RowилиColumnдля небольших ограниченных списков. Для небольших статических или ограниченных коллекций (например, от 1 до 10 элементов) стандартныйRowилиColumnсforEachпозволяет избежать ленивой виртуализации и накладных расходов на предварительную выборку.
Используйте computedStateOf или derivedStateOf, чтобы ограничить рекомпозиции
Один из рисков использования состояния в ваших композициях заключается в том, что если состояние меняется быстро, ваш пользовательский интерфейс может быть перекомпонован больше, чем вам нужно. Например, вы показываете список с прокруткой. Вы проверяете состояние списка, чтобы увидеть, какой элемент является первым видимым элементом в списке:
val listState = rememberLazyListState() LazyColumn(state = listState) { // ... } val showButton = listState.firstVisibleItemIndex > 0 AnimatedVisibility(visible = showButton) { ScrollToTopButton() }
Проблема в том, что, когда пользователь прокручивает список, значение listState постоянно меняется. Это означает, что список постоянно перекомпоновывается. Однако на самом деле вам не нужно перекомпоновывать его так часто – вам не нужно перекомпоновывать его до тех пор, пока вверху не появится новый элемент. Это приводит к большому количеству дополнительных вычислений, что снижает производительность пользовательского интерфейса.
Решение – использовать производное состояние (или computedStateOf в Compose Runtime 1.13 и более поздних версиях). Производное состояние позволяет указать Compose, какие изменения состояния должны вызывать рекомпозицию. В этом случае укажите, что вас интересует, когда меняется первый видимый элемент. При изменении этого значения состояния пользовательский интерфейс должен перекомпоноваться, но если пользователь ещё не прокрутил достаточно, чтобы вывести новый элемент наверх, перекомпоновывать его не нужно.
val listState = rememberLazyListState() LazyColumn(state = listState) { // ... } val showButton by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } } AnimatedVisibility(visible = showButton) { ScrollToTopButton() }
Выберите подходящий подход к получению состояния на основе вашей версии Compose и стоимости вычислений:
remember { computedStateOf { ... } }(Compose Runtime 1.13 и более поздних версий). Повторно вычисляет значение при каждом чтении без кеширования результата и делает композицию недействительной, только если вычисленный результат изменяется. Посколькуremember { computedStateOf { listState.firstVisibleItemIndex > 0 } }не требует ведения учета кеша и накладных расходов на обратную запись при недействительности, используйте его для базовых проверок условий и простых вычислений, основанных на быстро меняющихся состояниях (например, смещениях прокрутки).remember { derivedStateOf { ... } }: кеширует результат вычисления для нескольких операций чтения. ИспользуйтеderivedStateOfв версиях Compose ниже 1.13 или когда вычисление требует больших ресурсов и считывается несколько раз во время прохода компоновки. Не передавайте значения состояния снимка, считанные внутриderivedStateOf(например,listState.firstVisibleItemIndex), в качестве ключейremember, так как это приведет к повторному созданиюDerivedStateпри каждом изменении состояния и предотвратит пропуск.- Простые геттеры Kotlin (
get()) и прямое чтение свойств. Для простых логических выражений, где не требуется подавление недействительности, лучше использовать простой настраиваемый геттер и читать коллекцию.sizeнапрямую, а не упаковывать ее вderivedStateOf. Не используйтеproduceStateсsnapshotFlowдля синхронного вывода состояния, так как это приводит к ненужным накладным расходам на диспетчеризацию сопрограмм.
Откладывайте чтение как можно дольше
Если вы обнаружили проблему производительности, попробуйте отложить считывание состояния. Откладывание чтения состояния гарантирует, что Compose будет повторно запускать минимально возможный код при рекомпозиции. Например, если в вашем пользовательском интерфейсе есть состояние, которое поднято высоко в дереве composable-функций, и вы считываете состояние в дочерней composable-функции, вы можете обернуть чтение состояния в лямбда-функцию. Это позволяет выполнять чтение только тогда, когда оно действительно необходимо. В качестве примера посмотрите реализацию в приложении Jetsnack. В нем на экране сведений реализован эффект, похожий на сворачивающуюся панель инструментов. Чтобы понять, почему этот метод работает, прочитайте запись в блоге Jetpack Compose: Debugging Recomposition.
Чтобы добиться этого эффекта, composable-функции Title нужно смещение прокрутки, чтобы сместить себя с помощью Modifier. Вот упрощенная версия кода Jetsnack
до оптимизации:
@Composable fun SnackDetail() { // ... Box(Modifier.fillMaxSize()) { // Recomposition Scope Start val scroll = rememberScrollState(0) // ... Title(snack, scroll.value) // ... } // Recomposition Scope End } @Composable private fun Title(snack: Snack, scroll: Int) { // ... val offset = with(LocalDensity.current) { scroll.toDp() } Column( modifier = Modifier .offset(y = offset) ) { // ... } }
Когда состояние прокрутки меняется, Compose аннулирует ближайшую родительскую область рекомпозиции. В этом случае ближайшая область видимости – это composable-функция SnackDetail. Обратите внимание, что Box – это встроенная функция, поэтому она не является областью рекомпозиции. Поэтому Compose рекомпонует SnackDetail и все composable-функции внутри SnackDetail. Если вы измените код так, чтобы он считывал состояние только там, где вы его фактически используете, вы сможете уменьшить количество элементов, которые необходимо перекомпоновать.
@Composable fun SnackDetail() { // ... Box(Modifier.fillMaxSize()) { // Recomposition Scope Start val scroll = rememberScrollState(0) // ... Title(snack) { scroll.value } // ... } // Recomposition Scope End } @Composable private fun Title(snack: Snack, scrollProvider: () -> Int) { // ... val offset = with(LocalDensity.current) { scrollProvider().toDp() } Column( modifier = Modifier .offset(y = offset) ) { // ... } }
Параметр scroll теперь является лямбда-выражением. Это означает, что Title по-прежнему может ссылаться на поднятое состояние, но значение считывается только внутри Title, где оно действительно необходимо. В результате при изменении значения прокрутки ближайшей областью рекомпозиции становится composable-функция Title. Compose больше не нужно рекомпоновать всю функцию Box.
Это хороший результат, но вы можете лучше! Если вы вызываете рекомпозицию только для того, чтобы изменить макет или перерисовать Composable-функцию, это должно вас насторожить. В этом случае вы просто меняете смещение composable-функции Title, что можно сделать на этапе макета.
@Composable private fun Title(snack: Snack, scrollProvider: () -> Int) { // ... Column( modifier = Modifier .offset { IntOffset(x = 0, y = scrollProvider()) } ) { // ... } }
Ранее в коде использовался метод Modifier.offset(x: Dp, y: Dp), который принимает смещение в качестве параметра. Переключившись на лямбда-версию модификатора, вы можете убедиться, что функция считывает состояние прокрутки на этапе макета. В результате при изменении состояния прокрутки Compose может полностью пропустить фазу композиции и перейти сразу к фазе макета. Если вы передаете часто меняющиеся переменные состояния в модификаторы, по возможности используйте лямбда-версии модификаторов.
Вот ещё один пример такого подхода. Этот код ещё не оптимизирован:
// Here, assume animateColorBetween() is a function that swaps between // two colors val color by animateColorBetween(Color.Cyan, Color.Magenta) Box( Modifier .fillMaxSize() .background(color) )
В этом примере цвет фона быстро меняется между двумя цветами. Таким образом, это состояние меняется очень часто. Затем composable-функция считывает это состояние в модификаторе фона. В результате поле должно перекомпоновываться в каждом кадре, поскольку цвет меняется в каждом кадре.
Чтобы улучшить этот код, используйте модификатор на основе лямбда-выражения, в данном случае drawBehind.
Это означает, что состояние цвета считывается только на этапе рисования. В результате,
Compose может полностью пропустить этапы композиции и макета — когда цвет
изменения, Compose переходит сразу к этапу рисования.
val color by animateColorBetween(Color.Cyan, Color.Magenta) Box( Modifier .fillMaxSize() .drawBehind { drawRect(color) } )
Избегайте обратной записи
Compose основан на предположении, что вы никогда не будете записывать в состояние, которое уже было прочитано. Это называется обратной записью и может привести к бесконечной рекомпозиции каждого кадра.
В примере ниже показана composable-функция с такой ошибкой.
@Composable fun BadComposable() { var count by remember { mutableIntStateOf(0) } // Causes recomposition on click Button(onClick = { count++ }, Modifier.wrapContentSize()) { Text("Recompose") } Text("$count") count++ // Backwards write, writing to state after it has been read</b> }
Этот код обновляет счетчик в конце composable-функции после считывания его значения в предыдущей строке. Если вы запустите этот код, то увидите, что после нажатия кнопки, которая вызывает рекомпозицию, счетчик быстро увеличивается в бесконечном цикле, поскольку Compose рекомпонует эту Composable-функцию, видит устаревшее чтение состояния и поэтому планирует ещё одну рекомпозицию.
Вы можете полностью избежать обратной записи, если никогда не будете записывать в состояние в Composition. По возможности всегда записывайте в состояние в ответ на событие и в лямбда-выражение, как в предыдущем примере onClick.
Подробные сведения об обратной записи, циклах рекомпозиции и координации фаз приведены в статье Обратная запись в Compose.
Дополнительные ресурсы
- Руководство по производительности приложений. Узнайте о рекомендациях, библиотеках и инструментах, которые помогут повысить производительность приложений для Android.
- Проверить производительность. Проверьте производительность приложения.
- Сравнение производительности приложений: Сравнивайте производительность приложений.
- Запуск приложения. Оптимизируйте запуск приложения.
- Профили базовой конфигурации. Узнайте, что такое профили базовой конфигурации.
Рекомендуем
- Примечание. Текст ссылки показывается, когда JavaScript отключен.
- Состояние и Jetpack Compose
- Модификаторы графики
- Thinking in Compose