Урок Kotlin в Android Studio: Paging 3 для бесконечных списков

Новостные ленты, маркетплейсы с тысячами товаров и ленты социальных сетей объединяет одна проблема: в них слишком много данных, чтобы загружать всё сразу. Телефон начнёт тормозить, а интернет — расходоваться впустую. Решение придумали в Google — библиотека Paging 3 загружает только ту порцию данных, которую пользователь видит прямо сейчас, и догружает следующую, когда он докручивает до конца списка. В этом уроке мы разберёмся, как добавить такую умную подгрузку в приложение на Kotlin прямо в Android Studio.


Зачем нужен Paging и как он работает

Представьте, что у вас есть коробка с десятью тысячами фотографий. Показывать их все одновременно — плохая затея. Вместо этого вы достаёте из коробки первую пачку из двадцати штук, показываете пользователю, а когда он просматривает их до конца — достаёте следующую пачку. Paging 3 делает ровно это, только вместо фотографий — любые элементы списка.

Библиотека состоит из трёх главных частей: PagingSource (отвечает за получение данных, например с сервера), PagingData (контейнер с порцией элементов) и PagingDataAdapter (специальный адаптер для RecyclerView, который понимает, когда нужно запросить следующую порцию). Все они работают вместе, а мы лишь описываем правила.


Добавляем зависимости

Откройте build.gradle.kts уровня модуля app и добавьте строки для Paging 3. На момент написания статьи актуальна версия 3.3.0, которая совместима с Kotlin 2.x.

dependencies {
    implementation("androidx.paging:paging-runtime-ktx:3.3.0")
    implementation("androidx.paging:paging-compose:3.3.0") // если нужен Compose
}

После синхронизации Gradle библиотека готова к работе. Никаких дополнительных разрешений в манифесте не требуется.


Создаём PagingSource

PagingSource — это класс, который знает, откуда брать данные. В нашем примере мы сделаем простой источник, генерирующий строки с номерами: «Элемент 1», «Элемент 2» и так далее. В реальном приложении здесь будет запрос к серверу или базе данных Room.

class NumbersPagingSource : PagingSource<Int, String>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, String> {
        return try {
            val page = params.key ?: 0
            val pageSize = params.loadSize
            val start = page * pageSize
            val items = (start until start + pageSize).map { "Элемент $it" }

            LoadResult.Page(
                data = items,
                prevKey = if (page > 0) page - 1 else null,
                nextKey = if (items.size == pageSize) page + 1 else null
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }

    override fun getRefreshKey(state: PagingState<Int, String>): Int? {
        return state.anchorPosition?.let { anchorPosition ->
            state.closestPageToPosition(anchorPosition)?.prevKey
        }
    }
}

Метод load вызывается библиотекой, когда нужна следующая порция данных. Параметр params.key — это номер страницы, params.loadSize — количество элементов в порции. Мы вычисляем, какие строки нужно вернуть, и возвращаем их вместе с ключами предыдущей и следующей страниц. Если следующей страницы нет (данные кончились), возвращаем null — библиотека поймёт, что список загружен до конца.


Создаём Pager и PagingData

Pager — это объект, который объединяет PagingSource и конфигурацию (размер страницы). Обычно его создают в ViewModel, чтобы данные переживали поворот экрана.

class MainViewModel : ViewModel() {
    val pager = Pager(PagingConfig(pageSize = 20)) {
        NumbersPagingSource()
    }
}

Адаптер для RecyclerView

Вместо обычного RecyclerView.Adapter используем PagingDataAdapter. Его главное отличие — метод submitData, который принимает PagingData и автоматически запрашивает новые порции при прокрутке.

class NumbersAdapter : PagingDataAdapter<String, NumbersAdapter.ViewHolder>(DIFF_CALLBACK) {

    class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        val textView: TextView = view.findViewById(android.R.id.text1)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context)
            .inflate(android.R.layout.simple_list_item_1, parent, false)
        return ViewHolder(view)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        getItem(position)?.let { item ->
            holder.textView.text = item
        }
    }

    companion object {
        private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<String>() {
            override fun areItemsTheSame(oldItem: String, newItem: String): Boolean {
                return oldItem == newItem
            }
            override fun areContentsTheSame(oldItem: String, newItem: String): Boolean {
                return oldItem == newItem
            }
        }
    }
}

DiffCallback помогает адаптеру эффективно обновлять только изменившиеся элементы, не перерисовывая весь список. Для простых строк сравнение прямое, для сложных объектов — по уникальному идентификатору.


Собираем всё в Activity

В MainActivity.kt получим ViewModel, адаптер и свяжем их. Flow pager.flow отдаёт порции PagingData, которые мы собираем с помощью collectLatest и передаём адаптеру.

class MainActivity : AppCompatActivity() {
    private lateinit var viewModel: MainViewModel
    private lateinit var adapter: NumbersAdapter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val recyclerView = findViewById<RecyclerView>(R.id.recyclerView)
        recyclerView.layoutManager = LinearLayoutManager(this)

        adapter = NumbersAdapter()
        recyclerView.adapter = adapter

        viewModel = ViewModelProvider(this)[MainViewModel::class.java]

        lifecycleScope.launch {
            viewModel.pager.flow.collectLatest { pagingData ->
                adapter.submitData(pagingData)
            }
        }
    }
}

Запустите приложение. Вы увидите список из двадцати элементов. Прокрутите вниз — он продолжит расти. Библиотека сама вызывает load у PagingSource, когда вы приближаетесь к концу.


Полезные советы

  • Не создавайте Pager внутри composable-функции. Используйте collectAsLazyPagingItems() в Compose или храните Pager во ViewModel — иначе при перерисовке создастся новый объект и данные потеряются.
  • Room поддерживает Paging из коробки. Если у вас база данных Room, просто верните PagingSource из DAO-метода с типом Flow<PagingSource<Int, Entity>> — всё остальное библиотека сделает сама.
  • Добавьте обработку ошибок. В PagingSource оборачивайте код в try-catch и возвращайте LoadResult.Error с исключением. Тогда в UI можно показать кнопку «Повторить».

Что мы освоили

Этот урок в Android Studio на языке Kotlin показал, как работать с большими списками без ущерба для производительности. Вы создали PagingSource, настроили Pager, написали адаптер и увидели, как библиотека автоматически подгружает новые порции при прокрутке. Теперь вы можете добавить бесконечную ленту в своё приложение — хоть с серверными данными, хоть с локальной базой. Попробуйте заменить NumbersPagingSource на реальные запросы к API через Retrofit — это логичный следующий шаг.