Room ile Paging 3: 2026'da Android'de büyüyen bir işlem listesini akıcı tutmak
Room tabanlı bir işlem listesini Jetpack Paging 3 ile sayfalayarak yılların verisinin Compose'da akıcı kalmasını sağlamak — RemoteMediator yok, ağ yok, sadece doğru yapılmış yerel bir veritabanı.
Bir bütçe uygulamasının işlem listesi, otuz satırlık bir demoda gayet iyi görünür. İki yıl sonra, gerçek bir kullanıcının dört bin işlemi olduğunda ve geçmiş ekranını kaydırmak takılmaya başladığında iyi görünmekten çıkar. Çözüm daha büyük bir LIMIT değildir, “alta bir yükleme döndürücüsü ekle” de değildir — çözüm, hiç ağ katmanı olmadan doğrudan Room’a bağlanan Jetpack Paging 3’tür. İşte bunu Granyn için nasıl kurduğum ve doğru yapana kadar bana birer gün kaybettiren iki hata.
Neden yalnızca LazyColumn sorun değil
Naif sürüm her eğitimdeki her liste gibi çalışır: tek bir Room sorgusu, SELECT * FROM transactions ORDER BY date DESC, bir Flow<List<Transaction>>’a eşlenir, bir LazyColumn’da toplanır. Compose’un lazy layout’u composition ve render konusunda gerçekten tembeldir — ekran dışındaki satırları yeniden compose etmez. Yani takılma bir Compose sorunu değildir. Sorun daha yukarıda: Room, tablodaki herhangi bir yerde tek bir işlem değiştiğinde, her emisyonda dört bin satırın her birini nesnelere dönüştürmek zorundadır. Bir gider ekleyin, tüm liste yeniden kurulur. Asıl maliyet budur ve hiçbir liste render optimizasyonu buna dokunmaz.
Paging 3 burada gerçekte ne kazandırıyor
Paging 3 genellikle ağ kullanım durumu üzerinden anlatılır — bir API’den sayfalar çeken ve bunları yerelde önbelleğe alan bir RemoteMediator. Burada durum bu değil. Yerel öncelikli bir uygulama için, tüm boru hattı Room içinde yaşar: mediator yok, ağ durumu yok, yeniden deneme mantığı yok. Room, Flow<List<Transaction>> yerine PagingSource<Int, Transaction> döndüren bir @Query’den sizin için bir PagingSource üretir:
@Dao
interface TransactionDao {
@Query("SELECT * FROM transactions ORDER BY date DESC")
fun pagingSource(): PagingSource<Int, Transaction>
}
Bu tek tip değişikliği entegrasyonun büyük kısmını oluşturur. Room, altta yatan tablo değiştiğinde bir PagingSource’u nasıl geçersiz kılacağını zaten bilir — Flow sorgularını güçlendiren aynı gözlem mekanizması — bu yüzden sayfalar, sizin ekstra bir şey bağlamanıza gerek kalmadan yazmalarda otomatik olarak yenilenir.
Akışı kurmak
PagingSource, sayfalama yapılandırmasının sahibi olan bir Pager içine sarılır — sayfa başına kaç satır, ne kadar öne prefetch yapılacağı:
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 üzerinde durmaya değer: Room, yerel bir tabloda SELECT COUNT(*)’i ucuza çalıştırabildiği için, Paging 3 bir kaydırma çubuğunu doğru boyutlandırabilir ve henüz yüklenmemiş içerik için yer tutucu satırlar gösterebilir — dört bin satırlık bir geçmişin altına hızlı kaydırmak, önce dört bin satırı yüklemeyi gerektirmez. Bu, ağ destekli sayfalamanın sahip olmadığı bir lükstür ve yerel sayfalamanın, sonsuz kaydırma akışlarının genellikle sahip olmadığı bir biçimde anında hissettirmesinin asıl nedenidir.
Compose tarafı
collectAsLazyPagingItems(), Flow<PagingData<Transaction>>’ı bir LazyColumn’un doğrudan indeksleyebileceği bir şeye dönüştürür:
@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 burada normal bir listeye göre daha fazla önem taşır: kararlı bir anahtar olmadan, Compose satırları liste konumuna göre anahtarlar ve en üste yeni bir işlem eklemek — yeni girdiler tarihe göre en üste sıralandığından bir bütçe uygulamasında sürekli olur — her satırın kimliğini kaydırır ve gereksiz bir yeniden compose dalgasını tetikler.
Bana bir gün kaybettiren hata: filtreleme
Geçmiş ekranı ayrıca kategoriye göre filtrelemeyi de destekler. İlk içgüdüm tek bir Pager’ı canlı tutup PagingData akışını her PagingData emisyonunda .filter { } ile aşağı akışta filtrelemekti. Bu yanlış ve yeterince yaygın bir tuzak olduğu için doğrudan adını koymaya değer: sonradan filtrelemek yine de filtrelenmemiş sorgu üzerinden sayfalar, bu yüzden dört bin satır içinden beş eşleşmesi olan bir kategori, beş sonucu göstermeden önce tablonun çoğunu sayfalayabilir; ve yer tutucu sayısı da yanlıştır çünkü filtrelenmiş tablonun değil, tüm tablonun satır sayısını yansıtır.
Çözüm, filtreyi bir son işleme adımı değil sorgunun bir parçası yapmak ve filtre değiştiğinde Pager’ı yeniden kurmaktır:
@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, eski Pager’ı iptal eder ve yeni filtreye kapsamlanmış taze bir tane başlatır, böylece yer tutucular, sayımlar ve prefetch, kullanıcının gerçekte baktığı sorgu için doğru olur. Yapılandırma değişikliklerinden sağ çıkan şey cachedIn(viewModelScope)’tur — o olmadan, ekranı döndürmek sayfalamayı sıfırdan yeniden başlatır.
Sonuç
Room üzerinde Paging 3, genellikle birlikte öğretildiği ağ makinesinin hiçbirine ihtiyaç duymaz — RemoteMediator yok, LoadState yeniden deneme arayüzü yok, uzlaştırılacak bir çevrimdışı önbellek yok. Geriye kalan gerçekten basit: bir sorgudan bir PagingSource, prefetch yapılandırmalı bir Pager ve Compose tarafında collectAsLazyPagingItems(). Baştan doğru yapmaya değer tek şey, filtreleri sorgunun aşağısında değil içinde tutmaktır — gerisi bundan gelir. Dört bin satırda tepkisel kalan bir işlem listesi, sonradan eklediğiniz bir performans özelliği değildir; demoda çalışan bir uygulama ile gerçek kullanımda iki yıl sonra hâlâ çalışan bir uygulama arasındaki farktır.
// İlgili okumalar
Günlükten dahası
2026'da Android'de Room TypeConverters: şemanı bozmadan enum, tarih ve liste saklamak
Android'de Room TypeConverters için pratik bir rehber — enum'lar, Instant/LocalDate ve listeler — ve bir converter'ı sessiz bir veri bozulması hatasına dönüştüren hatalar.
Kotlin, Room ve Compose için R8 ve ProGuard: yalnızca release'te olan çökme
Bir Room + Compose Android uygulaması debug'da neden kusursuz çalışıp üretimde neden çöküyor ve bunu bir kullanıcı bulmadan önce yakalayan spesifik R8/ProGuard keep kuralları.
2026'da Room veritabanı index'leri: gerçekten yavaş olan sorguyu bulmak ve düzeltmek
Android'de Room/SQLite veritabanını index'lemeye pratik bir rehber — EXPLAIN QUERY PLAN okumak, tahmin etmeden @Index eklemek ve bir index'i sessizce etkisiz kılan hatalar.