Paging 3 для Kotlin: полное руководство с примерами

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Paging 3 для Kotlin: полное руководство с примерами

1. Что это и зачем нужна

Paging 3 — это библиотека от Google для Android, написанная на Kotlin, которая решает одну из самых частых задач в мобильной разработке: постраничную загрузку данных (pagination). Когда у вас есть список из тысяч или миллионов элементов (например, лента новостей, сообщения в чате, товары в каталоге), загружать их все сразу невозможно — это приведёт к переполнению памяти, зависанию UI и огромному расходу трафика. Paging 3 автоматизирует процесс подгрузки данных порциями (страницами) по мере прокрутки списка пользователем.

Библиотека построена на современных принципах: она использует корутины Kotlin для асинхронной работы, Flow для реактивного обновления данных и полностью интегрируется с Jetpack Compose и RecyclerView. В отличие от предыдущих версий (Paging 2), Paging 3 предлагает более чистую архитектуру, меньше бойлерплейта и встроенную поддержку обработки ошибок, обновления данных и кэширования.

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

2. Установка

Paging 3 — это библиотека для Android/Kotlin, поэтому она устанавливается через систему сборки Gradle, а не через pip или npm. Добавьте зависимость в файл build.gradle.kts (модуль app):

// build.gradle.kts (Module: app)
dependencies {
    // Основная библиотека Paging 3
    implementation("androidx.paging:paging-runtime-ktx:3.3.5")
    
    // Если используете Jetpack Compose (опционально)
    implementation("androidx.paging:paging-compose:3.3.5")
    
    // Если используете RxJava (опционально)
    implementation("androidx.paging:paging-rxjava3:3.3.5")
    
    // Для тестирования (опционально)
    testImplementation("androidx.paging:paging-common:3.3.5")
}

После синхронизации Gradle библиотека готова к использованию. Минимальная версия SDK — 21 (Android 5.0).

3. Быстрый старт — минимальный рабочий пример

Создадим простейший экран, который загружает список чисел от 1 до 1000 по 20 элементов на страницу. Пример состоит из трёх частей: источник данных (PagingSource), ViewModel и UI (RecyclerView).

Шаг 1. PagingSource — источник данных

class NumberPagingSource : PagingSource<Int, Int>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Int> {
        return try {
            // Определяем ключ следующей страницы (если null — первая загрузка)
            val currentPage = params.key ?: 0
            val pageSize = params.loadSize  // обычно 20
            
            // Генерируем данные для текущей страницы
            val start = currentPage * pageSize
            val end = minOf(start + pageSize, 1000)
            val numbers = (start until end).toList()
            
            // Возвращаем результат с ключами для следующей и предыдущей страницы
            LoadResult.Page(
                data = numbers,
                prevKey = if (currentPage > 0) currentPage - 1 else null,
                nextKey = if (end < 1000) currentPage + 1 else null
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }
    
    // Возвращает ключ для перезагрузки (обычно null или первый ключ)
    override fun getRefreshKey(state: PagingState<Int, Int>): Int? = null
}

Шаг 2. ViewModel

class MainViewModel : ViewModel() {
    val pager = Pager(
        config = PagingConfig(pageSize = 20, enablePlaceholders = false),
        pagingSourceFactory = { NumberPagingSource() }
    ).flow.cachedIn(viewModelScope)
}

Шаг 3. Activity с RecyclerView

class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding
    private val viewModel: MainViewModel by viewModels()
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        
        val adapter = NumberAdapter()
        binding.recyclerView.adapter = adapter
        binding.recyclerView.layoutManager = LinearLayoutManager(this)
        
        // Подписываемся на Flow из ViewModel
        lifecycleScope.launch {
            viewModel.pager.collectLatest { pagingData ->
                adapter.submitData(pagingData)
            }
        }
    }
}

// Простой адаптер
class NumberAdapter : PagingDataAdapter<Int, NumberAdapter.ViewHolder>(diffCallback) {
    
    companion object {
        val diffCallback = object : DiffUtil.ItemCallback<Int>() {
            override fun areItemsTheSame(oldItem: Int, newItem: Int) = oldItem == newItem
            override fun areContentsTheSame(oldItem: Int, newItem: Int) = oldItem == newItem
        }
    }
    
    class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val textView: TextView = itemView.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) {
        holder.textView.text = getItem(position)?.toString() ?: "Загрузка..."
    }
}

Готово! При запуске вы увидите список чисел, который подгружается по мере прокрутки.

4. Основные методы/функции/классы

4.1 PagingSource

Сигнатура: abstract class PagingSource<Key, Value>

Что делает: Базовый класс для источника данных. Определяет, как загружать страницы и как управлять ключами пагинации.

class MySource : PagingSource<Int, User>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, User> {
        val page = params.key ?: 1
        val response = api.getUsers(page, params.loadSize)
        return LoadResult.Page(
            data = response.users,
            prevKey = if (page > 1) page - 1 else null,
            nextKey = if (response.hasNext) page + 1 else null
        )
    }
    
    override fun getRefreshKey(state: PagingState<Int, User>): Int? {
        return state.anchorPosition?.let { anchor ->
            state.closestItemToPosition(anchor)?.id
        }
    }
}

4.2 Pager

Сигнатура: class Pager<Key, Value>(config: PagingConfig, pagingSourceFactory: () -> PagingSource<Key, Value>)

Что делает: Фабрика для создания Flow<PagingData>. Принимает конфигурацию и фабрику источника данных.

val pager = Pager(
    config = PagingConfig(
        pageSize = 20,
        prefetchDistance = 5,
        enablePlaceholders = false,
        initialLoadSize = 40
    ),
    pagingSourceFactory = { MyPagingSource() }
).flow.cachedIn(viewModelScope)

4.3 PagingConfig

Сигнатура: class PagingConfig(pageSize: Int, prefetchDistance: Int = pageSize, enablePlaceholders: Boolean = true, initialLoadSize: Int = pageSize * 3, maxSize: Int = Int.MAX_VALUE, jumpThreshold: Int = Int.MAX_VALUE)

Что делает: Настройки пагинации: размер страницы, расстояние до предзагрузки, максимальный размер кэша.

val config = PagingConfig(
    pageSize = 50,
    prefetchDistance = 10,
    enablePlaceholders = false,
    initialLoadSize = 100,
    maxSize = 500
)

4.4 PagingDataAdapter

Сигнатура: abstract class PagingDataAdapter<T, VH : RecyclerView.ViewHolder>(diffCallback: DiffUtil.ItemCallback<T>)

Что делает: Адаптер RecyclerView, который автоматически обрабатывает постраничные данные. Вызывает submitData() для обновления списка.

class UserAdapter : PagingDataAdapter<User, UserAdapter.ViewHolder>(USER_COMPARATOR) {
    
    companion object {
        private val USER_COMPARATOR = object : DiffUtil.ItemCallback<User>() {
            override fun areItemsTheSame(old: User, new: User) = old.id == new.id
            override fun areContentsTheSame(old: User, new: User) = old == new
        }
    }
    
    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        holder.bind(getItem(position))
    }
}

4.5 submitData (метод PagingDataAdapter)

Сигнатура: suspend fun submitData(pagingData: PagingData<T>)

Что делает: Принимает новый экземпляр PagingData и обновляет список. Должен вызываться из корутины.

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

4.6 LoadResult.Page и LoadResult.Error

Сигнатура: sealed class LoadResult<Key, Value> с подклассами Page и Error

Что делает: Возвращаемое значение метода load(). Page содержит данные и ключи, Error — исключение.

return try {
    val data = fetchData(params.key)
    LoadResult.Page(
        data = data,
        prevKey = null,
        nextKey = data.lastOrNull()?.id
    )
} catch (e: IOException) {
    LoadResult.Error(e)
}

4.7 PagingState

Сигнатура: class PagingState<Key, Value>(pages: List<Page<Key, Value>>, anchorPosition: Int?, config: PagingConfig, placeholdersEnabled: Boolean)

Что делает: Предоставляет состояние всех загруженных страниц. Используется в getRefreshKey() для определения ключа перезагрузки.

override fun getRefreshKey(state: PagingState<Int, User>): Int? {
    return state.anchorPosition?.let { anchor ->
        state.closestItemToPosition(anchor)?.id
    }
}

4.8 cachedIn (расширение Flow)

Сигнатура: fun <T> Flow<PagingData<T>>.cachedIn(scope: CoroutineScope): Flow<PagingData<T>>

Что делает: Кэширует загруженные страницы в указанном CoroutineScope. Предотвращает повторную загрузку при повороте экрана.

class MyViewModel : ViewModel() {
    val flow = Pager(...).flow.cachedIn(viewModelScope)
}

4.9 LoadParams

Сигнатура: class LoadParams<Key>(key: Key?, loadSize: Int, placeholdersEnabled: Boolean)

Что делает: Параметры, передаваемые в метод load(): ключ страницы, размер загрузки и флаг плейсхолдеров.

override suspend fun load(params: LoadParams<String>): LoadResult<String, Item> {
    val cursor = params.key ?: ""
    val response = api.getItems(cursor, params.loadSize)
    return LoadResult.Page(
        data = response.items,
        prevKey = null,
        nextKey = response.nextCursor
    )
}

4.10 RemoteMediator (для сетевого кэширования)

Сигнатура: abstract class RemoteMediator<Key, Value>

Что делает: Позволяет комбинировать загрузку из сети и локальной базы данных (Room). Автоматически синхронизирует данные.

@OptIn(ExperimentalPagingApi::class)
class MyRemoteMediator(
    private val api: Api,
    private val db: AppDatabase
) : RemoteMediator<Int, User>() {
    override suspend fun load(loadType: LoadType, state: PagingState<Int, User>): MediatorResult {
        return try {
            val page = when (loadType) {
                LoadType.REFRESH -> 1
                LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
                LoadType.APPEND -> {
                    val lastItem = state.lastItemOrNull()
                    if (lastItem == null) 1 else (lastItem.id / state.config.pageSize) + 1
                }
            }
            val response = api.getUsers(page, state.config.pageSize)
            db.runInTransaction {
                if (loadType == LoadType.REFRESH) db.userDao().clearAll()
                db.userDao().insertAll(response.users)
            }
            MediatorResult.Success(endOfPaginationReached = response.users.isEmpty())
        } catch (e: Exception) {
            MediatorResult.Error(e)
        }
    }
}

5. Реальный практический пример (100-200 строк кода)

Создадим приложение для загрузки списка репозиториев GitHub с поиском. Используем Retrofit, Room, Paging 3 и RemoteMediator.

1. Модели данных и API

// Модель для сети
data class Repo(                    

Рекомендаций