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

2026 में WorkManager: Android पर यूनीक वर्क, चेनिंग और बैकग्राउंड जॉब्स की टेस्टिंग

Android पर WorkManager की एक प्रैक्टिकल गाइड — यूनीक वर्क पॉलिसी, चेनिंग, एक्सपेडाइटेड वर्क, और शिप करने से पहले बैकग्राउंड जॉब को असल में कैसे टेस्ट करें।

MFKAPPS 7 मिनट पढ़ना

मुझे असल दुनिया में जो ज़्यादातर WorkManager कोड दिखता है, वह एक सिंगल OneTimeWorkRequest होता है जिसे enqueue करके भुला दिया जाता है। यह तब तक काम करता है जब तक यूज़र किसी बटन को दो बार टैप नहीं करता, जॉब के बीच में ऐप का प्रोसेस मर नहीं जाता, या कोई रिव्यूअर यह नहीं पूछता कि आपको कैसे पता चलेगा कि जॉब वाकई चली। WorkManager की असली वैल्यू “इसे बाद में शेड्यूल करो” नहीं है — यह यूनीकनेस, चेनिंग, और रिट्राई से जुड़ी गारंटियां हैं जो एक बैकग्राउंड जॉब को सही बनाए रखती हैं जब उसके आस-पास की दुनिया अविश्वसनीय हो। इस वैल्यू का ज़्यादातर हिस्सा इस्तेमाल नहीं हो पाता क्योंकि इसे देने वाले API सरफेस को नज़रअंदाज़ करना आसान है।

मैं तीन ऐप्स में तीन अलग-अलग जॉब्स के लिए WorkManager इस्तेमाल करता हूँ — Subly में एक नाइटली एक्सपोर्ट, Stocky में पैंट्री डेटा रीकंप्यूट, और Granyn में CSV बैकअप राइट्स — और मैंने जो भी बग्स शिप किए हैं, वे सब इन तीनों में से किसी एक को छोड़ देने से जुड़े हैं।

यूनीक वर्क: डुप्लिकेट जॉब्स के खिलाफ गार्ड

सबसे आम WorkManager बग कोई क्रैश नहीं, बल्कि एक डुप्लिकेट है। एक यूज़र पहले टैप का स्पिनर दिखने से पहले “एक्सपोर्ट” पर दो बार टैप कर देता है, और अब दो identical एक्सपोर्ट जॉब्स क्यू में आ जाती हैं। अकेला enqueue() इसे रोकने के लिए कुछ नहीं करता — यह खुशी-खुशी दोनों को क्यू में डाल देता है।

enqueueUniqueWork इसका समाधान है, और पॉलिसी आर्ग्युमेंट वह हिस्सा है जिसे लोग गलत समझते हैं:

fun scheduleExport(context: Context) {
    val request = OneTimeWorkRequestBuilder<ExportWorker>()
        .setConstraints(
            Constraints.Builder()
                .setRequiredNetworkType(NetworkType.NOT_REQUIRED)
                .build(),
        )
        .build()

    WorkManager.getInstance(context).enqueueUniqueWork(
        "monthly_export",
        ExistingWorkPolicy.KEEP,
        request,
    )
}

तीन ExistingWorkPolicy वैल्यूज़ तीन अलग-अलग इरादों से मेल खाती हैं, और गलत वाली चुनना ही असली बग है:

  • KEEP — अगर इस नाम वाला काम पहले से पेंडिंग या चल रहा है, तो नई रिक्वेस्ट को छोड़ दें। “एक्सपोर्ट” के लिए यही सही है — दूसरे टैप को पहले वाले को न तो रीस्टार्ट करना चाहिए, न डुप्लिकेट।
  • REPLACE — मौजूदा काम को कैंसिल करके नए सिरे से शुरू करें। यह तब सही है जब नई रिक्वेस्ट में अपडेटेड इनपुट डेटा हो जो पुराने को बासी बना दे — यूज़र ने बीच में एक्सपोर्ट की डेट रेंज बदल दी।
  • APPEND_OR_REPLACE — अगर मौजूदा काम शुरू नहीं हुआ है तो उससे चेन हो जाएं, वरना नई चेन शुरू करें। दुर्लभ; ज़्यादातर उन क्रमिक जॉब्स के लिए जो जिस क्रम में रिक्वेस्ट की गईं उसी क्रम में चलनी चाहिए।

enqueueUniquePeriodicWork रिकरिंग जॉब्स के लिए वही पॉलिसी आर्ग्युमेंट लेता है, और वही तर्क लागू होता है: एक नाइटली रीकंप्यूट जॉब को लगभग हमेशा KEEP या UPDATE होना चाहिए, REPLACE नहीं — रिप्लेस करने से पीरियोडिक शेड्यूल का एंकर टाइम रीसेट हो जाता है, जो चुपचाप बदल देता है कि जॉब कब चलेगी।

चेनिंग: हाथ से बनाए कॉलबैक के बिना सीक्वेंसिंग

एक बैकअप फ्लो शायद ही कभी एक स्टेप का होता है — डेटा को सीरियलाइज़ करना, कंप्रेस करना, डिस्क पर लिखना, राइट को वेरिफाई करना। WorkRequests को चेन करना इसे नेस्टेड कॉलबैक्स की बजाय डेटा के रूप में व्यक्त करता है:

val serialize = OneTimeWorkRequestBuilder<SerializeWorker>().build()
val compress = OneTimeWorkRequestBuilder<CompressWorker>().build()
val write = OneTimeWorkRequestBuilder<WriteToDiskWorker>().build()
val verify = OneTimeWorkRequestBuilder<VerifyBackupWorker>().build()

WorkManager.getInstance(context)
    .beginUniqueWork("full_backup", ExistingWorkPolicy.REPLACE, serialize)
    .then(compress)
    .then(write)
    .then(verify)
    .enqueue()

हर worker का आउटपुट अपने आप अगले worker का इनपुट बन जाता है, Data के ज़रिए:

class SerializeWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
    override suspend fun doWork(): Result {
        val path = serializeToTempFile()
        return Result.success(workDataOf("serialized_path" to path))
    }
}

class CompressWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
    override suspend fun doWork(): Result {
        val path = inputData.getString("serialized_path") ?: return Result.failure()
        val compressedPath = compress(path)
        return Result.success(workDataOf("compressed_path" to compressedPath))
    }
}

Data जानबूझकर छोटा रखा गया है — यह एक जनरल-पर्पस पेलोड चैनल नहीं, बल्कि एक साइज़-लिमिटेड इंटरनल डेटाबेस से बैक्ड है। फ़ाइल की सामग्री नहीं, बल्कि फ़ाइल पाथ्स और IDs पास करें। अगर चेन में कोई स्टेप फेल होता है, तो Result.failure() उसके बाद की हर चीज़ को चलने से रोक देता है; चेन गायब इनपुट के साथ चुपचाप आगे नहीं बढ़ती।

रिट्राई और बैकऑफ़: डिफ़ॉल्ट को आपको हैरान न करने दें

Result.retry() WorkManager को worker को दोबारा कोशिश करने के लिए कहता है, और डिफ़ॉल्ट रूप से यह 30 सेकंड इंतज़ार करता है, फिर एक्सपोनेंशियली बैक ऑफ करता है, 5 घंटे पर कैप्ड। असली नेटवर्क डिपेंडेंसी वाली जॉब के लिए डिफ़ॉल्ट आमतौर पर ठीक रहता है। लोकल, ट्रांज़िएंट फेलियर की वजह से रिट्राई हो रही जॉब के लिए — जैसे फ़ाइल लॉक, या कुछ पल के लिए कम स्टोरेज की स्थिति — 30 सेकंड अक्सर उस चीज़ के लिए बहुत लंबा होता है जिसका यूज़र सक्रिय रूप से इंतज़ार कर रहा है।

डिफ़ॉल्ट पर चुपचाप भरोसा करने की बजाय पॉलिसी को स्पष्ट रूप से सेट करें:

OneTimeWorkRequestBuilder<WriteToDiskWorker>()
    .setBackoffCriteria(
        BackoffPolicy.LINEAR,
        10, TimeUnit.SECONDS,
    )
    .build()

और worker के अंदर ही जानबूझकर Result.retry() को Result.failure() से अलग करें — यही वह हिस्सा है जिसे लोग उल्टा कर देते हैं:

override suspend fun doWork(): Result {
    return try {
        writeBackupFile()
        Result.success()
    } catch (e: IOException) {
        if (runAttemptCount < 3) Result.retry() else Result.failure()
    } catch (e: SecurityException) {
        // Permission won't fix itself by retrying.
        Result.failure()
    }
}

पांच घंटों में पांच बार रिट्राई हुई एक परमिशन एरर रेज़िलिएंस नहीं है, यह एक ऐसी जॉब है जो यूज़र को पता चलने से पहले पांच बार चुपचाप फेल हो चुकी है। सिर्फ उन्हीं फेलियर मोड्स को रिट्राई करें जिन्हें समय वाकई ठीक कर सकता है।

एक्सपेडाइटेड वर्क: उस जॉब के लिए जिसे यूज़र देख रहा है

हर बैकग्राउंड जॉब यह इंतज़ार नहीं कर सकती कि WorkManager का शेड्यूलर तय करे कि वह कब चलेगी। अगर कोई यूज़र “अभी एक्सपोर्ट करें” पर टैप करता है और उम्मीद करता है कि वह सेकंडों में शुरू हो — न कि जब सिस्टम के Doze हेयुरिस्टिक्स इजाज़त दें — तो उसे एक्सपेडाइटेड मार्क करें:

OneTimeWorkRequestBuilder<ExportWorker>()
    .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
    .build()

एक्सपेडाइटेड वर्क तुरंत चलता है (ऐप के डेली एक्ज़ीक्यूशन कोटा के अधीन) और अगर ऐप रन के बीच में बैकग्राउंड में चला भी जाए तो इसे एक छोटा ग्रेस पीरियड मिलता है। OutOfQuotaPolicy आर्ग्युमेंट तय करता है कि एक बार ऐप उस दिन का कोटा खर्च कर देने के बाद क्या होता है: RUN_AS_NON_EXPEDITED_WORK_REQUEST एरर फेंकने की बजाय नॉर्मल शेड्यूलिंग पर वापस चला जाता है। इसे सिर्फ उन कामों के लिए इस्तेमाल करें जो वाकई यूज़र-इनिशिएटेड और यूज़र को दिखने वाले हों — रूटीन बैकग्राउंड सिंक के लिए इसका इस्तेमाल करना उस बैटरी-फ्रेंडली शेड्यूलिंग को बेकार कर देता है जो WorkManager का पूरा मकसद है।

टेस्टिंग: वह स्टेप जिसे लगभग सब छोड़ देते हैं

WorkManager खासतौर पर एक टेस्ट आर्टिफ़ैक्ट शिप करता है ताकि किसी worker को वेरिफाई करने के लिए चलते हुए ऐप की ज़रूरत न पड़े। लगभग कोई इसे इस्तेमाल नहीं करता, और यही कोड रिव्यू में इनपुट-डेटा बग ढूंढने और उसे यूज़र की बग रिपोर्ट से ढूंढने के बीच का फर्क है।

@RunWith(AndroidJUnit4::class)
class ExportWorkerTest {

    @Before
    fun setup() {
        val config = Configuration.Builder()
            .setExecutor(SynchronousExecutor())
            .build()
        WorkManagerTestInitHelper.initializeTestWorkManager(
            ApplicationProvider.getApplicationContext(),
            config,
        )
    }

    @Test
    fun exportWorker_writesFile_onSuccess() {
        val request = OneTimeWorkRequestBuilder<ExportWorker>().build()
        val workManager = WorkManager.getInstance(
            ApplicationProvider.getApplicationContext(),
        )

        workManager.enqueue(request).result.get()
        val info = workManager.getWorkInfoById(request.id).get()

        assertThat(info.state).isEqualTo(WorkInfo.State.SUCCEEDED)
    }
}

SynchronousExecutor जॉब को बैकग्राउंड थ्रेड की बजाय इनलाइन चलाता है, ताकि टेस्ट को पूरा होने का इंतज़ार करने के लिए किसी sleep या latch की ज़रूरत न पड़े। यह उन दो बग्स को पकड़ लेता है जिन्हें चेनिंग और रिट्राई लॉजिक मैनुअल टेस्टिंग में खासतौर पर अच्छे से छुपा देते हैं: एक worker जो चुपचाप एक्सेप्शन निगल लेता है और फिर भी success() लौटाता है, और एक चेन स्टेप जो inputData से गलत की पढ़ लेता है।

जो चीज़ असल में मायने रखती थी

WorkManager पर चला रहीं तीनों जॉब्स में, जो पैटर्न टिका रहा वह चालाक नहीं था — वह लगातार था: हर यूनीक जॉब का साफ़ नाम रखें और जानबूझकर उसकी ExistingWorkPolicy चुनें, मल्टी-स्टेप जॉब्स को कॉलबैक्स नेस्ट करने की बजाय चेन करें, सिर्फ उन्हीं फेलियर्स को रिट्राई करें जिन्हें समय ठीक कर सकता है, और प्रोडक्शन में किसी चेन पर भरोसा करने से पहले WorkManagerTestInitHelper टेस्ट लिखें। इनमें से कोई भी विदेशी API नहीं है। ये WorkManager के वे हिस्से हैं जिन्हें डिफ़ॉल्ट पर छोड़ना आसान है, और डिफ़ॉल्ट इतनी बार गलत होते हैं कि इससे फर्क पड़ता है।

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

जर्नल से और भी

MFKAPPS 6 मिनट पढ़ना

2026 में Android App Links: आपके https:// लिंक अब भी ब्राउज़र में क्यों खुलते हैं

Digital Asset Links वेरिफिकेशन, assetlinks.json में छिपी गड़बड़ियाँ, और वो adb कमांड्स जो बताते हैं कि Android लिंक आपकी ऐप को क्यों नहीं दे रहा।

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

Android का In-App Review API: बिना परेशान किए रेटिंग माँगना

Google के In-App Review API की एक व्यावहारिक गाइड — यह असल में कैसे काम करता है, इसे कब ट्रिगर करना चाहिए, और सामान्य 'हमें रेट करें' पॉपअप आपकी Play Store रेटिंग को चुपचाप कैसे नुकसान पहुँचा रहा है।

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

Android ऐप शॉर्टकट्स और Quick Settings Tiles: बिना ऐप खोले पानी लॉग करना

Android के डायनामिक ShortcutManager API और TileService की एक व्यावहारिक गाइड — कैसे एक-टैप एक्शन को पूरी तरह ऐप को छोड़कर काम करने दें, साथ ही वे गड़बड़ियाँ जो ज़्यादातर इम्प्लीमेंटेशन को फँसा देती हैं।

#android #engineering #kotlin