Перейти к содержимому
Все записи

Paging 3 и Room: как сохранить плавность растущего списка транзакций в Android-приложении в 2026

Как постранично загружать список транзакций на Room с помощью Jetpack Paging 3, чтобы годы данных оставались плавными в Compose — без RemoteMediator, без сети, просто правильно устроенная локальная база данных.

MFKAPPS 4 мин чтения

Список транзакций в приложении для учёта бюджета выглядит нормально в демо с тридцатью строками. Выглядеть нормально он перестаёт через два года, когда у реального пользователя накапливается четыре тысячи записей, а прокрутка экрана истории начинает подтормаживать. Решение — не увеличить LIMIT и не «просто добавить спиннер загрузки внизу» — это Jetpack Paging 3, подключённый напрямую к Room, вообще без сетевого слоя. Вот как я реализовал это для Granyn, и две ошибки, каждая из которых стоила мне дня работы, прежде чем всё заработало правильно.

Почему LazyColumn сам по себе не является проблемой

Наивная версия работает как любой список из любого туториала: один запрос Room, SELECT * FROM transactions ORDER BY date DESC, преобразованный в Flow<List<Transaction>> и собираемый в LazyColumn. Ленивый layout в Compose действительно ленив в отношении композиции и рендеринга — он не перекомпонует строки за пределами экрана. Так что подтормаживание — это не проблема Compose. Проблема выше по цепочке: Room приходится материализовывать все четыре тысячи строк в объекты при каждой эмиссии, каждый раз, когда где-либо в таблице меняется хотя бы одна транзакция. Добавьте один расход — и весь список пересобирается заново. Вот в чём реальная стоимость, и никакая оптимизация рендеринга списка её не устраняет.

Что на самом деле даёт здесь Paging 3

Paging 3 обычно объясняют через сетевой сценарий использования — RemoteMediator, который загружает страницы из API и кэширует их локально. Здесь речь не об этом. В local-first приложении весь пайплайн живёт внутри Room: ни медиатора, ни сетевого состояния, ни логики повторных попыток. Room сам генерирует PagingSource из @Query, который возвращает PagingSource<Int, Transaction> вместо Flow<List<Transaction>>:

@Dao
interface TransactionDao {
    @Query("SELECT * FROM transactions ORDER BY date DESC")
    fun pagingSource(): PagingSource<Int, Transaction>
}

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

Собираем поток

PagingSource оборачивается в Pager, который владеет конфигурацией постраничной загрузки — сколько строк на странице, насколько далеко вперёд делать предзагрузку:

class TransactionRepository(private val dao: TransactionDao) {
    fun pagedTransactions(): Flow<PagingData<Transaction>> =
        Pager(
            config = PagingConfig(
                pageSize = 40,
                prefetchDistance = 20,
                enablePlaceholders = true,
            ),
            pagingSourceFactory = { dao.pagingSource() },
        ).flow
    }

Стоит отдельно отметить enablePlaceholders = true: поскольку Room может дёшево выполнить SELECT COUNT(*) на локальной таблице, Paging 3 может точно рассчитать размер полосы прокрутки и показывать строки-заглушки для ещё не загруженного контента — быстрая прокрутка до конца истории из четырёх тысяч строк не требует предварительной загрузки всех четырёх тысяч строк. Это роскошь, недоступная постраничной загрузке через сеть, и именно поэтому локальная пагинация ощущается мгновенной так, как обычно не ощущаются ленты с бесконечной прокруткой.

Сторона Compose

collectAsLazyPagingItems() превращает Flow<PagingData<Transaction>> в то, к чему LazyColumn может обращаться по индексу напрямую:

@Composable
fun TransactionList(viewModel: TransactionViewModel) {
    val transactions = viewModel.pagedTransactions.collectAsLazyPagingItems()

    LazyColumn {
        items(
            count = transactions.itemCount,
            key = transactions.itemKey { it.id },
        ) { index ->
            val transaction = transactions[index]
            if (transaction != null) {
                TransactionRow(transaction)
            } else {
                TransactionRowPlaceholder()
            }
        }
    }
}

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

Ошибка, стоившая мне дня работы: фильтрация

Экран истории также поддерживает фильтрацию по категории. Первым моим побуждением было держать один Pager живым и фильтровать поток PagingData ниже по цепочке с помощью .filter { } на каждой эмиссии PagingData. Это неправильно, и это достаточно распространённая ловушка, чтобы назвать её прямо: фильтрация постфактум всё равно постранично проходит через нефильтрованный запрос, поэтому категория с пятью совпадениями из четырёх тысяч строк может пролистать почти всю таблицу, прежде чем показать эти пять результатов, а количество заглушек оказывается неверным, потому что отражает число строк во всей таблице, а не в отфильтрованной выборке.

Решение — сделать фильтр частью запроса, а не шагом постобработки, и пересобирать Pager при изменении фильтра:

@Query("""
    SELECT * FROM transactions
    WHERE (:categoryId IS NULL OR category_id = :categoryId)
    ORDER BY date DESC
""")
fun pagingSource(categoryId: Long?): PagingSource<Int, Transaction>
val pagedTransactions: Flow<PagingData<Transaction>> = filterState
    .flatMapLatest { categoryId -> repository.pagedTransactions(categoryId) }
    .cachedIn(viewModelScope)

flatMapLatest отменяет старый Pager и запускает новый, привязанный к новому фильтру, так что заглушки, счётчики и предзагрузка оказываются корректными для запроса, который пользователь реально видит. cachedIn(viewModelScope) — это то, что переживает изменения конфигурации: без него поворот экрана перезапускает пагинацию с нуля.

Вывод

Paging 3 поверх Room не нуждается ни в одной из сетевых механик, с которыми его обычно объясняют, — ни в RemoteMediator, ни в UI повторных попыток на основе LoadState, ни в офлайн-кэше, который нужно согласовывать. То, что остаётся, по-настоящему просто: PagingSource из запроса, Pager с конфигурацией предзагрузки и collectAsLazyPagingItems() на стороне Compose. Единственное, что стоит сделать правильно с самого начала, — держать фильтры внутри запроса, а не применять их после него; всё остальное следует из этого. Список транзакций, остающийся отзывчивым при четырёх тысячах строк, — это не функция производительности, которую пристраивают потом; это разница между приложением, которое работает в демо, и приложением, которое продолжает работать спустя два года реального использования.

// По теме

Ещё из журнала

MFKAPPS 4 мин чтения

Room TypeConverters в 2026 году: как хранить enum'ы, даты и списки, не повреждая схему

Практическое руководство по Room TypeConverters на Android — enum'ы, Instant/LocalDate и списки — а также ошибки, которые превращают конвертер в незаметный баг повреждения данных.

#android #engineering #room
MFKAPPS 4 мин чтения

R8 и ProGuard для Kotlin, Room и Compose: сбой, который случается только в релизе

Почему приложение Android на Room + Compose идеально работает в debug и падает в продакшене, и какие именно правила keep R8/ProGuard ловят это раньше пользователя.

#android #engineering #kotlin
MFKAPPS 4 мин чтения

Индексы базы данных Room в 2026 году: находим по-настоящему медленный запрос и исправляем его

Практическое руководство по индексированию базы Room/SQLite на Android — чтение EXPLAIN QUERY PLAN, добавление @Index без угадывания и ошибки, которые незаметно сводят индекс на нет.

#android #engineering #room