सामग्री पर जाएं
सभी पोस्ट

Room के साथ Paging 3: 2026 में Android पर बढ़ती ट्रांज़ैक्शन लिस्ट को स्मूद बनाए रखना

Jetpack Paging 3 से Room-आधारित ट्रांज़ैक्शन लिस्ट को कैसे पेज करें ताकि Compose में सालों का डेटा भी स्मूद बना रहे — कोई RemoteMediator नहीं, कोई नेटवर्क नहीं, बस एक लोकल डेटाबेस सही तरीके से किया गया।

MFKAPPS 6 मिनट पढ़ना

एक बजटिंग ऐप की ट्रांज़ैक्शन लिस्ट तीस पंक्तियों वाले डेमो में ठीक दिखती है। दो साल बाद, जब किसी असली यूज़र के पास चार हज़ार पंक्तियाँ हो जाती हैं और हिस्ट्री स्क्रीन को स्क्रॉल करना अटकना शुरू हो जाता है, तब यह ठीक दिखना बंद हो जाती है। इसका समाधान बड़ा LIMIT नहीं है, और न ही “नीचे बस एक लोडिंग स्पिनर जोड़ दो” है — समाधान है Jetpack Paging 3, जो बिना किसी नेटवर्क लेयर के सीधे Room से जुड़ा हो। यहाँ बताया गया है कि मैंने इसे Granyn के लिए कैसे बनाया, और वे दो गलतियाँ जिनमें से हर एक ने मुझसे एक पूरा दिन खर्च करवाया, इससे पहले कि मैं इसे सही तरीके से कर पाया।

क्यों अकेला LazyColumn समस्या नहीं है

नैव वर्ज़न हर ट्यूटोरियल की हर लिस्ट की तरह काम करता है: एक Room क्वेरी, SELECT * FROM transactions ORDER BY date DESC, जो Flow<List<Transaction>> में मैप होती है और एक LazyColumn में कलेक्ट होती है। Compose का lazy लेआउट कंपोज़िशन और रेंडरिंग को लेकर वाकई lazy है — यह स्क्रीन से बाहर की पंक्तियों को रीकंपोज़ नहीं करता। तो अटकन Compose की समस्या नहीं है। यह इससे पहले की समस्या है: Room को उन चार हज़ार पंक्तियों में से हर एक को ऑब्जेक्ट्स में मटीरियलाइज़ करना पड़ता है, हर एमिशन पर, हर बार जब टेबल में कहीं भी एक ट्रांज़ैक्शन बदलता है। एक खर्च जोड़ें और पूरी लिस्ट फिर से बनती है। यही असली लागत है, और कोई भी लिस्ट-रेंडरिंग ऑप्टिमाइज़ेशन इसे नहीं छूता।

Paging 3 यहाँ वास्तव में क्या देता है

Paging 3 को आमतौर पर इसके नेटवर्क यूज़ केस से समझाया जाता है — एक RemoteMediator जो किसी API से पेज लाता है और उन्हें लोकली कैश करता है। यह वह मामला नहीं है। लोकल-फर्स्ट ऐप के लिए, पूरी पाइपलाइन Room के अंदर ही रहती है: कोई मीडिएटर नहीं, कोई नेटवर्क स्टेट नहीं, कोई रीट्राई लॉजिक नहीं। Room आपके लिए एक PagingSource जनरेट करता है, एक @Query से जो Flow<List<Transaction>> के बजाय PagingSource<Int, 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 यहाँ एक सामान्य लिस्ट की तुलना में ज़्यादा मायने रखता है: बिना किसी स्थिर key के, Compose पंक्तियों को उनकी लिस्ट पोज़िशन से key करता है, और सबसे ऊपर एक नया ट्रांज़ैक्शन इंसर्ट करना — जो बजटिंग ऐप में लगातार होता है, क्योंकि नई एंट्रीज़ तारीख़ के अनुसार सबसे ऊपर सॉर्ट होती हैं — हर पंक्ति की पहचान को शिफ़्ट कर देता है और अनावश्यक रीकंपोज़िशन की एक लहर ट्रिगर करता है।

वह गलती जिसने मुझसे एक दिन खर्च करवाया: फ़िल्टरिंग

हिस्ट्री स्क्रीन कैटेगरी के हिसाब से फ़िल्टरिंग भी सपोर्ट करती है। मेरी पहली सहज प्रतिक्रिया थी एक 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 को कैंसल करता है और नए फ़िल्टर के दायरे में एक नया Pager शुरू करता है, इसलिए प्लेसहोल्डर, काउंट, और प्रीफ़ेचिंग — सब उस क्वेरी के लिए सही होते हैं जिसे यूज़र वास्तव में देख रहा है। cachedIn(viewModelScope) वह चीज़ है जो कॉन्फ़िगरेशन बदलावों में बची रहती है — इसके बिना, स्क्रीन घुमाने पर पेजिंग शुरू से रीस्टार्ट हो जाती है।

निष्कर्ष

Room पर Paging 3 को उस नेटवर्क मशीनरी की ज़रूरत बिल्कुल नहीं पड़ती जिसके साथ इसे आमतौर पर सिखाया जाता है — कोई RemoteMediator नहीं, कोई LoadState रीट्राई UI नहीं, समेटने के लिए कोई ऑफ़लाइन कैश नहीं। जो बचता है वह वाकई सरल है: एक क्वेरी से एक PagingSource, प्रीफ़ेच कॉन्फ़िग वाला एक Pager, और Compose की तरफ़ collectAsLazyPagingItems()। शुरुआत में सही करने लायक एक ही बात है — फ़िल्टर को क्वेरी के अंदर रखना, उसके डाउनस्ट्रीम नहीं — बाकी सब इसी से निकलता है। एक ट्रांज़ैक्शन लिस्ट जो चार हज़ार पंक्तियों पर भी रिस्पॉन्सिव बनी रहे, यह कोई परफ़ॉर्मेंस फ़ीचर नहीं है जिसे आप बाद में जोड़ते हैं; यही वह फ़र्क़ है जो एक ऐसे ऐप के बीच होता है जो डेमो में काम करता है, और एक ऐसे ऐप के बीच जो असली इस्तेमाल में दो साल बाद भी काम करता रहता है।

// संबंधित पठन

जर्नल से और भी

MFKAPPS 6 मिनट पढ़ना

2026 में Room TypeConverters: अपने स्कीमा को खराब किए बिना एनम, डेट, और लिस्ट स्टोर करना

Android पर Room TypeConverters के लिए एक व्यावहारिक गाइड — एनम, Instant/LocalDate, और लिस्ट — साथ ही वे ग़लतियाँ जो एक कनवर्टर को एक चुपचाप डेटा-करप्शन बग में बदल देती हैं।

#android #engineering #room
MFKAPPS 5 मिनट पढ़ना

Kotlin, Room और Compose के लिए R8 और ProGuard: वह क्रैश जो सिर्फ़ रिलीज़ में होता है

एक Room + Compose Android ऐप डिबग में बिल्कुल ठीक क्यों चलता है और प्रोडक्शन में क्रैश क्यों होता है, और वे ख़ास R8/ProGuard keep नियम जो इसे यूज़र से पहले पकड़ लेते हैं।

#android #engineering #kotlin
MFKAPPS 5 मिनट पढ़ना

2026 में Room डेटाबेस इंडेक्स: वाकई धीमी क्वेरी को ढूँढना और ठीक करना

Android पर Room/SQLite डेटाबेस को इंडेक्स करने की व्यावहारिक गाइड — EXPLAIN QUERY PLAN पढ़ना, बिना अंदाज़े के @Index जोड़ना, और वे गलतियाँ जो चुपचाप इंडेक्स को बेअसर कर देती हैं।

#android #engineering #room