Jetpack Compose में recomposition: असल में इसे क्या ट्रिगर करता है, और मैंने अंदाज़ा लगाना क्यों छोड़ दिया
2026 के लिए Jetpack Compose recomposition की एक व्यावहारिक गाइड — टाइप स्थिरता, अस्थिर लैम्ब्डा, LazyColumn keys, और कंपाइलर की अपनी metrics रिपोर्ट पढ़ना।
अटकने वाली Compose स्क्रीन का लगभग कभी कोई स्पष्ट कारण नहीं होता। न कोई स्टैक ट्रेस, न क्रैश, न लिंट चेतावनी — बस एक लिस्ट जो जितनी होनी चाहिए उससे थोड़ी धीमी लगती है, या एक टेक्स्ट फ़ील्ड जो हर keystroke से आधा फ्रेम पीछे रहता है। आम तौर पर इसकी वजह होती है recomposition का ज़रूरत से ज़्यादा UI ट्री पर चलना, और निराशाजनक बात यह है कि Compose आपको इसके होने पर कभी नहीं बताता। यह बस चुपचाप, हर फ्रेम में, ज़रूरत से ज़्यादा काम करता रहता है, जब तक आप खुद ढूँढने न निकलें। यहाँ बताया गया है कि असल में क्या तय करता है कि कोई composable skip होगा या दोबारा चलेगा, और वे दो टूल्स जो अंदाज़े की जगह पढ़ने को लाते हैं।
Recomposition असल में क्या है
Compose वेरिएबल्स को नहीं, state reads को ट्रैक करता है। जब कोई composable फ़ंक्शन किसी State<T> को पढ़ता है — चाहे सीधे या remember { mutableStateOf(...) } के ज़रिए — रनटाइम उस read को मौजूदा composition scope के खिलाफ़ रिकॉर्ड करता है। जब state की वैल्यू बदलती है, तो सिर्फ़ वे scopes दोबारा चलने के लिए शेड्यूल होते हैं जिन्होंने उसे असल में पढ़ा था। बाकी सब कुछ, सिद्धांत रूप में, अछूता रहता है। इस वाक्य में असल काम करने वाला हिस्सा है “जिन scopes ने उसे असल में पढ़ा था,” और यह पता लगाना कि वे scopes कौन-से हैं, वहीं है जहाँ चीज़ें गड़बड़ाती हैं।
कंपाइलर का असली सवाल: क्या यह टाइप स्थिर है
हर composable फ़ंक्शन के parameters को Compose कंपाइलर द्वारा स्थिर (stable) या अस्थिर (unstable) के रूप में वर्गीकृत किया जाता है। स्थिर टाइप वह है जिसके बारे में कंपाइलर यह साबित कर सकता है कि वह composition को बताए बिना नहीं बदलेगा — तो अगर वैल्यू पिछली बार जैसी ही equals() है, तो Compose फ़ंक्शन को दोबारा चलाना पूरी तरह skip कर देता है। अस्थिर टाइप पर इतना भरोसा नहीं किया जा सकता, इसलिए उसका इस्तेमाल करने वाला composable अपने parent के हर recomposition पर दोबारा चलता है — चाहे उसने असल में जो पढ़ा हो वह बदला हो या नहीं।
// स्थिर: हर property val है, और String/Int परिभाषा से स्थिर हैं।
data class PantryItem(val id: Long, val name: String, val quantity: Int)
// अस्थिर: var property का मतलब है कंपाइलर यह साबित नहीं कर सकता कि यह
// instance किसी composable के रेफरेंस रखते हुए बदल नहीं जाएगा।
data class PantryItemDraft(var name: String, var quantity: Int)
var वाला वर्शन कोई स्टाइल की ज़िद नहीं है — यह उस composable के बीच का फ़र्क है जिसे Compose skip कर सकता है और जिसे नहीं कर सकता। यही समस्या सादे List<T> और Map<K, V> parameters के साथ भी आती है: कंपाइलर List इंटरफ़ेस को अस्थिर मानता है, क्योंकि कुछ भी caller को MutableList देने और बाद में उसे बदलने से नहीं रोकता। स्थिर val फ़ील्ड्स से भरी एक data class, अगर एक सादी List में लपेटी जाए, तब भी एक अस्थिर parameter ही रहती है — और असली कोडबेस में स्थिरता के चुपचाप टूटने की यह सबसे आम एक जगह है।
बिना ध्यान दिए यह कहाँ घुस जाता है
अपनी ही स्क्रीन्स में जिन ज़्यादातर अनावश्यक recomposition का मैंने पता लगाया है, वे दो पैटर्न में आते हैं:
Hoist न किए गए lambda। किसी composable के शरीर के अंदर inline परिभाषित लैम्ब्डा parent के हर recomposition पर एक नया ऑब्जेक्ट होता है, भले ही उसके capture किए गए वैल्यू न बदले हों। किसी child composable को पास होने पर, यह नया instance equality check में फ़ेल हो जाता है और child को recompose होने पर मजबूर करता है, चाहे उसने असल में जो पढ़ा हो वह बदला हो या नहीं।
// PantryScreen के हर recompose पर दोबारा बनता है — ItemRow पर skip को बेकार करता है।
PantryList(items = items, onDelete = { id -> viewModel.delete(id) })
// एक बार hoist किया गया, PantryScreen के recompositions में स्थिर रहता है।
val onDelete = remember(viewModel) { { id: Long -> viewModel.delete(id) } }
PantryList(items = items, onDelete = onDelete)
किसी repository से सीधे पास हुए collections। collectAsStateWithLifecycle() से इकट्ठा किया गया Flow<List<PantryItem>> हर emission पर आपको एक ताज़ा List instance देता है, भले ही सामग्री एक जैसी हो — यह तब आम है जब किसी असंबंधित table पर हुए write के ठीक बाद Room की क्वेरी दोबारा चलती है। अगर वह लिस्ट कई child composables को पास हो, तो सभी हर emission पर recompose होते हैं। जब आपको असल में एक calculated वैल्यू की परवाह है, कच्ची लिस्ट की नहीं, तो read साइड को derivedStateOf में लपेटना इसे ठीक कर देता है:
val isEmpty by remember {
derivedStateOf { items.isEmpty() }
}
derivedStateOf सिर्फ़ तभी recomposition ट्रिगर करता है जब उसका calculated आउटपुट बदलता है, न कि जब उसके inputs बदलते हैं — तो 40 आइटम से 41 पर जाने वाली लिस्ट किसी ऐसे composable को दोबारा ट्रिगर नहीं करती जिसे सिर्फ़ इससे मतलब है कि लिस्ट खाली है या नहीं।
लिस्ट: वह key जो प्रति-पंक्ति recomposition को सीमित करती है
LazyColumn डिफ़ॉल्ट रूप से आइटम की position को identity key के रूप में इस्तेमाल करता है। आख़िर के अलावा कहीं भी किसी आइटम को दोबारा क्रम में लगाएँ, insert करें या हटाएँ, और उस index के बाद की हर पंक्ति को “बदली हुई” माना जाता है — क्योंकि Compose जो ट्रैक कर रहा है वह position है, content नहीं। एक स्पष्ट, स्थिर key देने से recomposition को सिर्फ़ उन पंक्तियों तक सीमित करके यह ठीक हो जाता है जिनकी content असल में बदली है:
LazyColumn {
items(items = pantryItems, key = { it.id }) { item ->
PantryItemRow(item)
}
}
यही वह पैटर्न है जो Stocky की pantry लिस्ट के पीछे है, जिसमें बारकोड स्कैन और रसीद imports से नियमित रूप से कुछ सौ पंक्तियाँ रहती हैं — बिना स्थिर key के, किसी एक आइटम की quantity edit करना बदली हुई एक पंक्ति की बजाय पूरी दिखाई दे रही लिस्ट को recompose कर देता।
कंपाइलर की अपनी रिपोर्ट पढ़ना
यह अंदाज़ा लगाना ज़रूरी नहीं कि कौन-से composables अस्थिर हैं — Compose कंपाइलर आपको सीधे बता सकता है। Gradle build में एक metrics flag जोड़ने से एक रिपोर्ट बनती है जो हर composable फ़ंक्शन, वह skippable है या नहीं, और ठीक-ठीक कौन-सा parameter उसे अस्थिर बनाता है, यह सूचीबद्ध करती है:
// build.gradle.kts
composeCompiler {
metricsDestination = layout.buildDirectory.dir("compose_metrics")
reportsDestination = layout.buildDirectory.dir("compose_metrics")
}
बनने वाली *-composables.txt रिपोर्ट हर उस फ़ंक्शन पर अस्थिर parameter का नाम बताती है जिसे वह flag करती है, जिससे “यह स्क्रीन धीमी क्यों है” सवाल एक profiling session से बदलकर एक grep बन जाता है। किसी भी लिस्ट या बार-बार अपडेट होने वाली वैल्यू वाली स्क्रीन को शिप करने से पहले मैं इसे चलाता हूँ — यह सस्ता है, सटीक है, और data class के अंदर var की गलती को लगभग उतने ही समय में पकड़ लेता है जितना फ़ाइल खोलने में लगता है।
निष्कर्ष
Recomposition अपने-आप में कोई परफ़ॉर्मेंस समस्या नहीं है — Compose को फ़ंक्शन सस्ते में और बार-बार दोबारा चलाने के लिए डिज़ाइन किया गया है। समस्या असीमित recomposition है: एक पूरी स्क्रीन दोबारा चलती है क्योंकि एक अस्थिर parameter, एक hoist न किया गया lambda, या एक गायब list key ने कंपाइलर को बता दिया कि वह कुछ भी सुरक्षित रूप से skip करने लायक साबित नहीं कर सकता। इन तीन चीज़ों को ठीक करें — val फ़ील्ड्स वाले स्थिर टाइप्स, hoist किए गए lambdas, और हर LazyColumn आइटम पर स्पष्ट keys — और जो ज़्यादातर Compose परफ़ॉर्मेंस समस्या जैसा दिखता है, वह पहले से ही हल निकलता है।
// संबंधित पठन
जर्नल से और भी
Android का In-App Review API: बिना परेशान किए रेटिंग माँगना
Google के In-App Review API की एक व्यावहारिक गाइड — यह असल में कैसे काम करता है, इसे कब ट्रिगर करना चाहिए, और सामान्य 'हमें रेट करें' पॉपअप आपकी Play Store रेटिंग को चुपचाप कैसे नुकसान पहुँचा रहा है।
Android ऐप शॉर्टकट्स और Quick Settings Tiles: बिना ऐप खोले पानी लॉग करना
Android के डायनामिक ShortcutManager API और TileService की एक व्यावहारिक गाइड — कैसे एक-टैप एक्शन को पूरी तरह ऐप को छोड़कर काम करने दें, साथ ही वे गड़बड़ियाँ जो ज़्यादातर इम्प्लीमेंटेशन को फँसा देती हैं।
2026 में Android पर फोरग्राउंड सर्विस टाइप्स: वह चुनना जो आपके फीचर पर सच में लागू हो
Android के फोरग्राउंड सर्विस टाइप प्रतिबंधों की एक व्यावहारिक गाइड — dataSync, mediaPlayback, specialUse, shortService — और बिना मारे या रिजेक्ट हुए सही वाला कैसे चुनें।