Списки с душой и стилем: Ваш путь к Jetpack Compose

228668ec95f58b8da408247bae21c332.png

Всем привет! Меня зовут Михаил, я — Android-разработчик в компании Joy Dev.

С каждым днём всё больше и больше разработчиков присоединяются к использованию Jetpack Compose. Этот рост связан с тем, что фреймворк предлагает ускоренную и упрощённую разработку пользовательского интерфейса (UI). 

Если ранее вы разрабатывали приложения с использованием Android View, то переход на Jetpack Compose будет естественным шагом. В этой статье мы сфокусируемся на работе со списками в Jetpack Compose, так как списки являются неотъемлемой частью большинства приложений.

Мы рассмотрим:

  1. Типы списков (LazyColumn, LazyRow, Column и Row), преимущества использования ленивых списков

  2. Оптимизацию производительности списков, мемоизацию

  3. Анимации в списках, примеры использования анимаций добавления/удаления и другие часто встречающиеся анимации

Надеюсь, что эта статья будет полезной для новичков и любителей, которые хотят изучить работу со списками в Jetpack Compose. 

Оглавление

  1. Использование списков в Compose

  2. Оптимизация производительности списков

    2.1. Мемоизация результатов вычисления функций с помощью параметра key

    2.2. Оптимизация состояния с использованием derivedStateOf

  3. Анимации в списках

  4. Подведем итоги

1. Использование списков в Compose.

Для отображения небольшого количества данных можно использовать макеты Column и Row. Это Composable-функции, предоставляемые фреймворком, аналогом которого является LinearLayout из Android View с разными ориентациями.

развернуть код

@Composable
fun SimpleColumnScreen() {
   Column(
       modifier = Modifier.fillMaxSize()
   ) {
       repeat(10) { value ->
           SimpleItemView(text = value.toString())
       }
   }
}

@Composable
fun SimpleRowScreen() {
   Row(
       modifier = Modifier.fillMaxWidth()
   ) {
       repeat(10) { value ->
           SimpleItemView(text = value.toString())
       }
   }
}

dd7e776cfc025ea0e01f904db59a12f6.pngcdbdd3435a7f751943a9120b0fe0f09e.png

Попробуем отобразить большое количество элементов, например, 100 000.

Если мы будем использовать такие макеты, как Column или Row, то это может вызвать проблемы с производительностью или падение из-за ошибки OutOfMemoryError, поскольку все элементы будут составлены и размещены вне зависимости от того, видны они или нет.

Для этих случаев следует использовать Lazy варианты таких списков: они отображают элементы только по мере необходимости, что позволяет сэкономить ресурсы.

развернуть код

@Composable
fun SimpleLazyColumnScreen() {
   LazyColumn(modifier = Modifier.fillMaxSize()) {
       items(100000) { value ->
           SimpleItemView(text = value.toString())
       }
   }
}

@Composable
fun SimpleLazyRowScreen() {
   LazyRow(modifier = Modifier.fillMaxSize()) {
       items(100000) { value ->
           SimpleItemView(text = value.toString())
       }
   }
}

Использование LazyRow и LazyColumn позволяет содержать списки длиной даже 100 000+ айтемов.

@Composable
fun SimpleLazyColumnScreen(users: List) {
   LazyColumn {
       items(users) { user ->
           PersonView(name = user.name)
       }
   }
}

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

Все эти функции можно совмещать как нам угодно и в любом порядке.

развернуть код

@Composable
fun SimpleLazyColumnScreen(users: List) {
   LazyColumn {
       item {
           TitleItem()
       }
       itemsIndexed(users) { index, user ->
           PersonView(name = user.name)
       }
       items(users) { user ->
           PersonView(name = user.name)
       }
   }
}

2. Оптимизация производительности списков

2.1. Мемоизация результатов вычисления функций с помощью параметра key

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

В списках этот процесс регулируется параметром key в функции items. Однако его использование не является обязательным, например, если ваш список не будет меняться.

Напишем простой макет списка с айтемами в виде карточки пользователя.

развернуть код

@Composable
fun SimpleLazyColumnScreen(users: List) {
   LazyColumn {
       items(users) { user ->
           PersonView(name = user.name)
       }
   }
}
@Composable
fun PersonView(name: String) {
   Card(
       modifier = Modifier
           .fillMaxWidth()
           .padding(8.dp)
   ) {
       Row(
           modifier = Modifier
               .fillMaxWidth()
               .padding(6.dp)
       ) {
           Icon(
               imageVector = Icons.Default.Person,
               contentDescription = "Person",
               modifier = Modifier
                   .size(60.dp)
                   .clip(CircleShape)
           )
           Text(
               text = name,
               fontSize = 20.sp,
               modifier = Modifier.align(CenterVertically)
           )
       }
   }
}

А теперь, пользуясь утилитой Layout Inspector

88c2cf68c772177440570e811909c9ec.png

 — количество перерисовок макета,

33a1316d3629c9539c73338a9100ce75.png

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

d92dce2f4108edbd28c32c8b4d2e6f75.gif

Замечательно до тех пор, пока список не становится изменяемым. Добавим кнопки, чтобы менять содержимое списка, и посмотрим, что из этого выйдет.

развернуть код

@Composable
fun SimpleLazyColumnScreen() {
   var users by remember { mutableStateOf(users) }
   Box {
       LazyColumn {
           items(users) { user ->
               PersonView(name = user.name)
           }
       }
       Row(...) {
           Button(onClick = {
               users = users.toMutableList().also {                        
                 it.add(0, User(123, "123")) 
               }
           }) {
               Text(text = "Add")
           }
           Button(onClick = { users = users.drop(1) }) {
               Text(text = "Remove")
           }
           Button(onClick = { users = users.shuffled() })
           { 
               Text(text = "Shuffle")
           }
       }
   }
}

Пробуем добавить, удалить элементы из нашего списка и затем перемешать его.

922d556575e9e1bd8ef907a34b4f08f6.gif55431053f43a54310a57f23d74dccb6f.png

Итак, посмотрим, что происходит. Во всех трёх случаях изменения списка компоузер (сущность, ответственная за рекомпозицию) отрекомпозировал каждый наш айтем. То есть, на каждый айтем был вызвана перерисовка.

Так происходит из-за того, что по умолчанию параметр key в функции items использует позицию нашего айтема как уникальный ключ для мемоизации (кеширования). 

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

Так как LazyColumn читает состояние нашего списка пользователей, то компоузер полностью перерисовывает всё, что имеет зависимость от этого списка.

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

Давайте переопределим параметр key и исправим проблему.

развернуть код

@Composable
fun SimpleLazyColumnScreen() {
   var users by remember { mutableStateOf(users) }
   Box {
       LazyColumn {
           items(users, key = { user -> user.id }) 
           { user ->
               PersonView(name = user.name)
           }
       }
       Row(...) { … }
   }
}

Запускаем, изменяем список и внимательно смотрим на строчку Recomposition counts. Все 3 варианта изменения списка наш LazyColumn прекрасно пережил и ни одна рекомпозиция не прошла, а значит и ресурсы мы сохранили. Компоузер взял закешированное значение функции по ключу и показал готовый результат.

fabff0c5f78dd097eed7b7c70b5d586f.png

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

2.2. Оптимизация состояния с использованием derivedStateOf

Проблема частого ререндеринга в списках обычно возникает при изменении состояний, которые непосредственно влияют на отображение элементов списка. Это может произойти, например, при скроллинге.

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

Напишем пример, в котором при достижении конца списка будет появляться текст. Посмотрим, что будет происходить при скролле.

5e0a766091241707a6ef628bd1757e1d.gifразвернуть код

@Composable
fun ScrollableLazyList(users: List) {
   val listState = rememberLazyListState()
   var isAtTheEndOfList by remember(listState) {
       mutableStateOf(false)
   }
   Box {
       LazyColumn(state = listState) {
           items(users, key = { user -> user.id }) 
           { user ->
               PersonView(name = user.name)
           }
       }
       LaunchedEffect(listState.layoutInfo) {
           isAtTheEndOfList = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index == listState.layoutInfo.totalItemsCount - 1

       }
       if (isAtTheEndOfList) Text(text = "Конец списка", modifier = Modifier.align(Alignment.BottomCenter), fontSize = 22.sp)
   }
}

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

Мы использовали функцию LaunchedEffect, которая запускает блок кода в лямбде и производит расчёт, достигли ли мы конца списка. Если да, то выводим текст «Конец списка».

Посмотрим, что будет в таком примере с количеством рекомпозиций, попытаемся поскроллить список.

855af6b425ea9d9df685abb5ec6db2f7.png

С одной стороны может показаться, что в целом не произошло ничего страшного. Да, есть рекомпозиции, но большая часть из них ведь пропущены (вторая колонка — пропущенные рекомпозиции наших макетов).

Всё правильно. За счёт механизма позиционной мемоизации, о котором шла речь выше, компоузер кеширует макеты и 3000 раз не перерисовывает их с нуля.

Но в чём тогда проблема?

А в том, что наш LaunchedEffect будет вызываться постоянно. Вечно. Такое поведение объясняется тем, что состояние listState.layoutInfo изменяется десятки раз за секунду, а LaunchedEffect читает это состояние и на каждое изменение реагирует. 

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

Попробуем избавиться от этого.

развернуть код

@Composable
fun ScrollableLazyList(users: List) {
   val listState = rememberLazyListState()
   val isAtTheEndOfList by remember(listState) {
       derivedStateOf {      listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index == listState.layoutInfo.totalItemsCount - 1
       }
   }
   Box {
       LazyColumn(state = listState) {
           items(users, key = { user -> user.id }) 
           { user ->
               PersonView(name = user.name)
           }
       }
       if (isAtTheEndOfList) Text(text = "Конец списка", modifier = Modifier.align(Alignment.BottomCenter), fontSize = 22.sp)
   }
}

В данном примере мы применили механизм derivedStateOf, который видит зависимость isAtTheEndOfList от listState и listState от списка users. Поэтому перерасчёт произойдёт только при изменении нашего списка пользователей. В итоге сколько бы раз мы ни скролили — рекомпозиций не будет. 

8d0d7d1fd84f24a1482b2d88bc504be9.png

3. Анимации в списках

Анимации играют важную роль в создании привлекательного и интерактивного пользовательского опыта. 

Сосредоточимся на следующем:

  • простые анимации добавления и удаления элементов из списка

  • появление контента внутри айтема

  • анимация расширения/сворачивания айтемов

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

LazyColumn {
   items(users, key = { user -> user.id }) { user ->
       PersonView(
           name = user.name, modifier = Modifier
               .fillMaxWidth()
               .padding(8.dp)
               .animateItemPlacement()
       )
   }
}

А вот наглядно, разница списка без анимаций и с ними

bf2c8f87fb848fba9d41b5d52fc9d25e.gifb28ecf202c3d7a8a779ffda8baf4203a.gif

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

развернуть код

@Composable
fun PersonView(
   ...
   onItemClick: (Int) -> Unit,
   showAdditionalText: Boolean
) {
...
   if (showAdditionalText) {
       Text(text = "Additional text")
   }
   Image(
      imageVector = Icons.Default.Info,
      contentDescription = null,
      modifier = Modifier
          .align(CenterVertically)
          .clickable { onItemClick(id) }
   )
...
}

PersonView(
   onItemClick = { id -> clickedItemId = 
                 if (clickedItemId == id) Int.MIN_VALUE 
                 else id,
   },
   showAdditionalText = clickedItemId == user.id
)

0d13717b6647dec90d48913c8f09ac2f.gif

 Такое решение выглядит неестественно, контент появляется слишком быстро, мгновенно.

Чтобы исправить эту ситуацию и сделать наш список привлекательнее, можно использовать функцию AnimatedVisibility. Просто заменим if на эту функцию.

AnimatedVisibility(visible = showAdditionalText) {
   Text(
       text = "Additional text",
       fontSize = 16.sp,
       color = Color.Gray
   )
}

3314acc87667db422591833e28ca71ba.gif

Отлично. Всё происходит плавно, как мы и хотели. Закрепим результат и напишем пример анимации посложнее — сворачивание/разворачивание айтема в нашем списке. Также учтём, что помимо этого нужно анимировать иконку со стрелочкой, которая будет смотреть вниз, уведомляя, что айтем можно развернуть, и вверх, если его можно свернуть.

развернуть код

@Composable
fun ExpandedLazyColumnScreen() {
   val users by remember { mutableStateOf(users) }
   var clickedItemId by remember { mutableStateOf(Int.MIN_VALUE) }
   Box {
       LazyColumn {
           items(users, key = { user -> user.id }) { user ->
               PersonView(
                   ...,
                   onItemClick = { id -> clickedItemId =
                     if (clickedItemId == id) Int.MIN_VALUE
                     else id
                   },
                   expandedItemId = clickedItemId
               )
           }
       }
   }
}
@Composable
fun PersonView(
   id: Int,
   name: String,
   modifier: Modifier,
   onItemClick: (Int) -> Unit,
   expandedItemId: Int
) {
   val rotation = animateFloatAsState(targetValue = if (id == expandedItemId) 180f else 0f)
   Card(...)
   ) {
       Row(...) {
           Icon(
               imageVector = Icons.Default.Person,
               ...
           )
           Text(
               text = name,
               fontSize = 20.sp,
               modifier = Modifier.weight(1f)
           )
           Spacer(modifier = Modifier.width(12.dp))
           Image(
               imageVector = Icons.Default.KeyboardArrowUp,
               contentDescription = null,
               modifier = Modifier
                   .align(CenterVertically)
                   .graphicsLayer(
                       rotationZ = rotation.value
                   )
                   .clickable { onItemClick(id) }
           )
       }
       AnimatedVisibility(visible = id == expandedItemId) {
           Text(
               text = "Additional text",
               fontSize = 16.sp,
               color = Color.Gray,
               modifier = Modifier.padding(10.dp)
           )
       }
   }
}

d0051d4230a53dbff235b7c214c9440e.gif

Готово! По сути весь код логики анимации составил 4 строки. Кто делал expande анимацию в Android View, тот знает, насколько тяжелее это было делать и сколько багов могло возникать при скролле ресайклера с такой логикой.

В данном примере мы использовали уже ранее изученную функцию AnimatedVisibility и новую функцию animateFloatAsState, которая позволяет нам анимировать положение стрелки от 0 до 180 градусов. 

Важно также отметить, что для rotate-анимации мы использовали параметр graphicsLayer, который позволяет менять прозрачность, скейлить и вращать наш макет без лишних рекомпозиций.

4. Подведем итоги

Мы рассмотрели различные типы списков в Jetpack Compose, которые позволяют разработчикам гибко настраивать и декларативно описывать макеты.

Такой подход:

Вместе с глубоким погружением в мир списков мы освоили разнообразные методы оптимизации, поняли, как они делают приложение более отзывчивым и бодрым. 

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

Подробнее ознакомиться с кодом можно тут.

© Habrahabr.ru