Урок 11: Lazy Column – ленивые списки (сравнение с RecyclerView)

Урок 11: Lazy Column – ленивые списки (сравнение с RecyclerView)

Введение

Я продолжаю разрабатывать экраны для своего гипотетического образовательного приложения. И прямо сейчас настало время создать экран со списком уроков.

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

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

Урок 11: Lazy Column – ленивые списки (сравнение с RecyclerView)

Я использую два параметра функции Scaffold. Первый это topBar, а второй сам контент всего экрана. В топ баре вызываем еще одну функцию TopAppBar, у которой есть возможность задать обязательный параметр заголовка и любой иконки. Здесь заголовок пустой, но я могу добавить функцию Text с какой-то навигационной подсказкой.

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LessonsListScreen() {

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("Назад") },
                navigationIcon = {
                    IconButton(onClick = { }) {
                        Icon(
                            imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft,
                            contentDescription = "Back Button",
                        )
                    }
                }
            )
        },
        content = { paddingValues ->
            Column(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(paddingValues)
            ) {
                Column(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(16.dp),
                    horizontalAlignment = Alignment.CenterHorizontally
                ) {
                    Text(
                        text = "Бесплатный курс",
                        style = MaterialTheme.typography.headlineSmall
                    )
                    Spacer(modifier = Modifier.height(8.dp))
                    Text(
                        text = "Kotlin для начинающих",
                        style = MaterialTheme.typography.headlineLarge
                    )
                    Spacer(modifier = Modifier.height(16.dp))
                    Button(
                        shape = RoundedCornerShape(13.dp),
                        onClick = {},
                        modifier = Modifier
                            .height(56.dp)
                            .padding(horizontal = 40.dp)
                            .fillMaxWidth()
                    ) {
                        Text(
                            text = "Начать",
                            style = MaterialTheme.typography.labelMedium
                        )
                    }
                }

                Spacer(modifier = Modifier.height(50.dp))

                Text(
                    text = "Содержание курса",
                    style = MaterialTheme.typography.headlineMedium.copy(
                        fontSize = 18.sp,
                        fontFamily = FontFamily(Font(R.font.gilroy_semibold)),
                    ),
                    modifier = Modifier.padding(horizontal = 16.dp)
                )

                Spacer(modifier = Modifier.height(16.dp))
            }
        }
    )
}

Итак, это будет список уроков. А это значит, что у них должны быть порядковые номера. Следовательно разработку начнем с вывода простого списка из цифр по возрастанию, начиная с единицы.

Если вы уже сталкивались с реализацией списков, используя XML подход, то вероятнее всего помните, как трудоемко давалась реализация RecyclerView. Однако, на Jetpack Compose списки реализовываются всего в несколько строк. Но обо всем по порядку.

Реализация статичного списка

Для удобства я создам еще одну колонку специально для будущего списка. Чтобы отобразить один и тот же текст N раз, можно воспользоваться Kotlin функцией repeat. Вы также можете сделать это с помощью любого цикла, которым владеете. Сделаем это 50 раз. В качестве значения будем использовать индекс, к которому будем прибавлять единицу на каждой итерации. И добавлю немного оформления, чтобы лучше было видно.

Column {
    repeat(50) { index ->
        Text(
            text = "${index + 1}",
            modifier = Modifier
                .padding(start = 20.dp)
                .fillMaxWidth(),
            fontSize = 24.sp
        )
    }
}
Урок 11: Lazy Column – ленивые списки (сравнение с RecyclerView)

Такая реализация имеет ряд очевидных проблем:

  • Первая проблема – такой список не скроллится. Column по умолчанию не обладает этим свойством. То есть мы просто вывели по порядку какие-то значения. Но это решается добавлением для колонки модификатора verticalScroll, который принимает обязательный параметр ScrollState. Это нужно, чтобы состояние скролла не сбрасывалось в момент рекомпозиции. Поэтому мы создаем переменную со стейтом и запоминаем его с помощью специальной функции rememberScrollState(). Проверяем и пока все хорошо – наш список стал теперь успешно скроллится.
val scrollState = rememberScrollState()

Column(
    modifier = Modifier.verticalScroll(scrollState)
) {
    repeat(50000) { index ->
        Text(
            text = "${index + 1}",
            modifier = Modifier
                .padding(start = 20.dp)
                .fillMaxWidth(),
            fontSize = 24.sp
        )
    }
  • Вторая проблема – и это ключевое, мы сейчас создали все 50 элементов одновременно. Пока они у нас легковесные – это просто цифры, но когда здесь будет рендериться гораздо больше объектов и они будут более сложные – приложение начнет тормозить или вовсе упадет с ошибкой. Проверить это очень просто. Давайте попробуем отрисовать 50.000 элементов. После запуска мы видим заметное увеличение времени запуска приложения, а потом и вовсе получили краш. Открою консоль с фильтрацией логов по уровню error и увидим там ошибку OutOfMemoryError.
Урок 11: Lazy Column – ленивые списки (сравнение с RecyclerView)

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

Принцип работы RecyclerView

Для всех остальных случаев в XML был придуман компонент RecyclerView, который с помощью адаптера генерировал только видимые на экране элементы списка. Нам ни к чему создавать весь набор объектов, которые пользователь не видит в данный момент.

Если вы вдруг не знакомы с концепцией RecyclerView – коротко напомню. И да, произносится “ресайклер”, а не “рекуклер”, как говорят некоторые мои избранные падаваны.

Работу RecyclerView можно представить следующим образом.

Урок 11: Lazy Column – ленивые списки (сравнение с RecyclerView)

На экране отображаются только те элементы списка, которые видны пользователю. Когда верхний элемент при прокрутке списка выходит за пределы экрана, его содержимое очищается, но сам элемент не удаляется. Этот “пустой” элемент перемещается вниз и заполняется новыми данными, таким образом переиспользуется. Отсюда и название Recycle, что говорит нам о повторном использовании элементов для повышения производительности. Даже если нам надо отобразить 50.000 элементов – в памяти будет одновременно создано только то количество, которое требуется для видимого отображения на экране. На моей схеме, например, это количество равняется 7.

Такой компонент есть и в Jetpack Compose, только реализовывается он в разы проще. И это Lazy Column – этот элемент будет создавать компоненты по необходимости, а не все сразу.

Реализация Lazy Column в Jetpack Compose

Итак, чтобы его внедрить заменяем функцию Column вместе с ее модификатором на LazyColumn. Переменная со стейтом нам уже тоже не требуется.

И казалось бы все сделано верно, но пока этого мало, более того, мы видим ошибку. Я даже закомментирую repeat для большей наглядности. Она говорит о том, что вызовы @Composable функций могут происходить только из контекста другой @Composable функции. Если Text – это системная системная композабл функция, то что тут не так?

Почему в обычной Column можно писать любые Text(), Button(), Box(), а в LazyColumn это не работает?

Разобраться в этом очень просто. Провалимся в объявление LazyColumn, здесь последний параметр функции имеет тип LazyListScope.

Урок 11: Lazy Column – ленивые списки (сравнение с RecyclerView)

И сейчас я рекомендую вспомнить материал из 3 урока про DSL, здесь это демонстрируется очень наглядно. Помните я говорил, что скоуп – это некоторая “комната”, где разрешено размещение только определенных элементов. Так вот.

  • Column (или Row) — это простой контейнер: он сразу создаёт и раскладывает все переданные ему дочерние Composable. ColumnScope позволяет размещать любые composable элементы, но только в ряд.
  • LazyColumn (или LazyRow) — это “ленивый” список. Его задача – не создавать сразу все элементы. Но чтобы LazyColumn понимала, сколько у нас элементов, какие у них ключи и как их «доставать» по мере скролла, создан специальный DSL. LazyListScope содержит ограниченный набор функций: item { ... }, items(...), stickyHeader(...). Вот они все перечислены в интерфейсе скоупа.

И обращаю внимание, что тип скоупа ленивого списка не является composable. Следовательно поэтому мы видим ошибку при размещении функции Text внутри.

Если бы разработчики фреймворка разрешили в LazyColumn произвольно вызывать Text(), Box(), Button() без обёртки в item/items, система бы не знала, когда и как эти элементы пересоздавать по мере прокрутки. Тогда терялась бы вся суть «ленивой» оптимизации.

Разбор функций скоупа LazyListScope

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

Чаще всего пользуются функцией items. И как видно из подсказки – первый обязательный параметр это количество элементов в списке. Пропишем также 50.000, чтобы проверить эффективность. index можно достать точно также из лямбды, по аналогии с repeat, поэтому здесь ничего менять не придется. Проверим что получилось.

LazyColumn {

    items(50000) { index ->
        Text(
            text = "${index + 1}",
            modifier = Modifier
                .padding(start = 20.dp)
                .fillMaxWidth(),
            fontSize = 24.sp
        )
    }
}

Итак, мы заменили Column на LazyColumn, что сразу решило проблему производительности при отображении большого списка. Принцип работы LazyColumn заключается в создании и отображении только тех элементов, которые видимы пользователю. Таким образом, даже с огромным числом объектов, например 50.000, интерфейс работает стабильно, а приложение больше не вылетает с ошибкой OutOfMemoryError.

Функция stickyHeader

StickyHeader – это фиксируемый заголовок, который всегда остается в зоне видимости, пока его элементы не исчезнут. Отлично подходит для разделения списка на логические группы.

Представьте, что у нас есть список, разбитый на разделы. Мы можем закрепить заголовок раздела в верхней части экрана. Добавим stickyHeader с небольшой стилизацией и проверим, как это работает.

LazyColumn {

    stickyHeader {
        Text(
            text = "Заголовок раздела",
            modifier = Modifier
                .fillMaxWidth()
                .background(Color.LightGray)
                .padding(8.dp),
        )
    }

    items(50) { index ->
        Text(
            text = "${index + 1}",
            modifier = Modifier
                .padding(start = 20.dp)
                .fillMaxWidth(),
            fontSize = 24.sp
        )
    }
    
}

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

Урок 11: Lazy Column – ленивые списки (сравнение с RecyclerView)

itemsIndexed

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

	val lessons = listOf("Урок 1", "Урок 2", "Урок 3")

LazyColumn {
    itemsIndexed(lessons) { index, lesson ->
        Text(
            text = "${index + 1}. $lesson",
						modifier = Modifier
			          .padding(start = 20.dp)
		            .fillMaxWidth(),
            fontSize = 24.sp
        )
    }
}

Вместо статичных элементов мы используем динамические данные. Теперь наш список может отображать любое содержимое, просто передав нужные значения в массив. Причем, как вы понимаете, это может быть не просто список примитивов, а более набор сложных объектов для отображения.

Урок 11: Lazy Column – ленивые списки (сравнение с RecyclerView)

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

Бесплатные Telegram-боты для обучения

Практика с проверкой кода и помощью ИИ-ментора

AndroidSprint AI Mentor

Проверяет Pull Request'ы в GitHub, проводит тестовые собеседования с голосом и таймером, помогает разбираться с кодом 24/7

Попробовать ИИ-ментора →

KotlinSprint Bot

22 урока Kotlin, 220 тестов, 120 практических задач с код-ревью

Начать обучение Kotlin →

AndroidSprint Bot

Тесты по Android SDK, Jetpack Compose, архитектуре приложений

Тесты по Android →

Тебе также может быть интересно

Узнать подробнее
Курс AndroidSprint

Глубокое обучение Android разработке с 0 до получения оффера. Только персональная практика с гарантией получения продуктового опыта.

Курс AndroidSprint - Глубокое <strong>обучение Android разработке с 0 до получения оффера</strong>. Только персональная практика с гарантией получения продуктового опыта.
Узнать подробнее
Узнать подробнее
Практикум по Kotlin

Изучение Котлин с 0 для профессиональной разработки. Личный ментор и разбор кода задач через git-flow.

Практикум по Kotlin - Изучение Котлин <strong>с 0 для профессиональной разработки</strong>. Личный ментор и разбор кода задач через git-flow.
Узнать подробнее
Узнать подробнее
Бесплатные уроки по Kotlin разработке

Самостоятельное освоение базы по языку для дальнейшего развития в Android/back-end разработке или в автотестах.

Бесплатные уроки по Kotlin разработке - <span>Самостоятельное освоение базы по языку для дальнейшего развития в Android/back-end разработке или в автотестах.</span>
Узнать подробнее
Узнать подробнее
Onboarding в разработку

Полное обучение Android разработке с нуля до получения оффера. Делаем упор на практику и обратную связь

Onboarding в разработку - <span>Полное обучение Android разработке с нуля до получения оффера. Делаем упор на практику и обратную связь</span>
Узнать подробнее
Узнать подробнее
Обучающий Kotlin телеграм бот (с тестами)

Ваш основной инструмент для изучения основ языка. Бесплатные тесты и практика внутри.

Обучающий Kotlin телеграм бот (с тестами) - Ваш основной <span>инструмент для изучения основ языка.</span> Бесплатные тесты и практика внутри.
Узнать подробнее
Узнать подробнее
Бесплатные уроки по Android разработке

Самостоятельное обучение разработке Андроид приложений. Понятные видеоуроки с разжеванными примерами.

Бесплатные уроки  по Android разработке - Самостоятельное <span>обучение разработке Андроид приложений.</span> Понятные видеоуроки с разжеванными примерами.
Узнать подробнее
Узнать подробнее
Курс по UI/Unit тестированию

Для ручных тестировщиков, которые готовы осваивать автотесты с использованием актуального стека технологий. [в разработке]

Курс по UI/Unit тестированию - Для ручных тестировщиков, которые <span>готовы осваивать автотесты</span> с использованием актуального стека технологий. [в разработке]
Узнать подробнее
Узнать подробнее
Обучающий Android телеграм бот (с тестами)

Бесплатные теоретические тесты для самопроверки. А также информер на практических спринтах по Android.

Обучающий Android телеграм бот (с тестами) - Бесплатные <span>теоретические тесты для самопроверки.</span> А также информер на практических спринтах по Android.
Узнать подробнее