آموزش WorkManager در اندروید برای مدیریت تسک‌های پس‌زمینه — از پایه تا پیاده‌سازی

Gemini
Gemini
2026/08/27
زمان مطالعه زمان مطالعه: 32 دقیقه
41 بازدید
آموزش WorkManager در اندروید برای مدیریت تسک‌های پس‌زمینه — از پایه تا پیاده‌سازی

فلسفه بقای تسک‌ها در اندروید و چرایی مرگ روش‌های سنتی

ببین همکار من، وقتی توی دنیای واقعی اندروید شروع به کار می‌کنی، یکی از اولین و بزرگ‌ترین دیوارهایی که بهش برخورد می‌کنی، مدیریت کارهای پس‌زمینه (Background Tasks) است. فرض کن کاربر می‌خواد یه ویدیوی ۱۰۰ مگابایتی رو آپلود کنه؛ اگر وقتی کاربر از برنامه خارج میشه این آپلود قطع بشه، کل تجربه کاربری نابود میشه و اولین کاری که می‌کنه، ثبت یک ستاره و حذف برنامه‌ست.

برای حل این مشکل، اول باید تفاوت حیاتی بین دو مفهوم را درک کنیم: تسک‌های Ephemeral (کوتاه‌مدت و گذرا) و تسک‌های Persistent (پایدار و ماندگار).

تسک‌های Ephemeral مثل لود کردن یک عکس در صفحه جاری یا گرفتن قیمت لحظه‌ای دلار هستند؛ اگر کاربر صفحه را ببندد، مرگ این تسک‌ها هیچ آسیبی به منطق برنامه نمی‌زند. اما تسک‌های Persistent مثل آپلود فایل، همگام‌سازی دیتابیس محلی با سرور، یا خروجی گرفتن از یک فایل PDF سنگین هستند که تحت هر شرایطی (حتی با بستن برنامه یا خاموش و روشن شدن گوشی) باید کامل شوند.


راهکار ساده‌لوحانه (The Naive Way) و فاجعه مرگ فرآیندها

یک توسعه‌دهنده تازه‌کار معمولاً اولین ابزاری که دم دست دارد را برمی‌دارد؛ مثلاً یک Thread ساده یا یک Service سنتی اندروید. او با خودش فکر می‌کند: «یک ترد می‌سازم و آپلود را در پس‌زمینه انجام می‌دهم.»

بیایید ببینیم در عمل چه اتفاقی می‌افتد. قطعه کد زیر یک ترد ساده برای آپلود فایل است:

// یک ترد سنتی و ناپایدار برای شبیه‌سازی آپلود فایل
fun startNaiveUpload() {
    Thread {
        for (percentage in 1..100) {
            Thread.sleep(1000) // شبیه‌سازی دانلود یا آپلود (۱ ثانیه مکث برای هر درصد)
            Log.d("UploadWorker", "پیشرفت آپلود: $percentage%")
        }
    }.start()
}

این کد در حالت عادی کار می‌کند؛ اما به محض اینکه کاربر دکمه Home را بزند، برنامه را از لیست برنامه‌های اخیر (Recent Apps) پاک کند، یا سیستم‌عامل با کمبود حافظه (RAM) مواجه شود، سیستم بدون هیچ تعارفی فرآیند (Process) اپلیکیشن شما را می‌کشد.

بیایید نگاهی به لاگ‌کت (Logcat) در لحظه بسته شدن برنامه بیندازیم:

D/UploadWorker: پیشرفت آپلود: 12%
D/UploadWorker: پیشرفت آپلود: 13%
I/ActivityManager: Killing 14205:com.example.app/u0a120 (adj 906): empty #17
-- [پروسه اپلیکیشن توسط سیستم‌عامل کشته شد و ترد آپلود برای همیشه نابود شد] --

همان‌طور که می‌بینید، سیستم‌عامل با خطای Killing کل پروسه را نابود کرده و آپلود در درصد ۱۳ متوقف شده است. دلیل این اتفاق، چرخه‌حیات سخت‌گیرانه اندروید برای بهینه‌سازی مصرف باتری و رم است.


مدیریت منابع سیستم‌عامل و دیواری به نام Doze Mode

سیستم‌عامل اندروید برای حفظ بقای باتری گوشی، مکانیزم‌های بسیار سخت‌گیرانه‌ای دارد. هرچه یک فرآیند بیشتر در پس‌زمینه بماند، اولویت آن برای زنده ماندن کمتر می‌شود و در دسته‌بندی Low Memory Killer (LMK) به عنوان کاندیدای اول مرگ قرار می‌گیرد.

روش سنتی (Thread):
[شروع آپلود] ──> [بستن اپلیکیشن توسط کاربر] ──> [کشته شدن پروسه توسط اندروید] ──> ❌ آپلود ناقص و رها شده

روش مهندسی (WorkManager):
[ثبت تسک] ──> [ذخیره در دیتابیس داخلی] ──> [مرگ پروسه] ──> [روشن شدن وای‌فای/شارژر] ──> [احیای تسک و اتمام آپلود] ──>  موفقیت

نکته کاربردی درباره Doze Mode و App Standby: از اندروید ۶ به بعد، قابلیتی به نام Doze Mode معرفی شد. وقتی گوشی برای مدتی بدون حرکت و با صفحه خاموش رها شود، اندروید تمام دسترسی‌های شبکه و پردازشی برنامه‌ها را به حالت تعلیق درمی‌آورد. روش‌های سنتی مثل تردها یا آلارم‌منیجرها در این حالت کاملاً فلج می‌شوند و تسک شما تا زمان بیدار شدن گوشی معلق می‌ماند.


انتخاب مهندسی: چرا WorkManager تنها استاندارد قابل اتکاست؟

وقتی می‌خواهیم یک تسک پایدار (Persistent) طراحی کنیم، انتخاب حرفه‌ای ما WorkManager است. گوگل این کتابخانه را به عنوان بخشی از Jetpack معرفی کرد تا تمام پیچیدگی‌های پس‌زمینه اندروید را پشت یک لایه انتزاعیِ قدرتمند پنهان کند.

اما چرا WorkManager؟

  1. پایداری در دیتابیس (Task Persistence): وقتی شما تسکی را به WorkManager می‌سپارید، اطلاعات آن در یک دیتابیس داخلی (با استفاده از Room) ذخیره می‌شود. اگر فرآیند برنامه کشته شود یا حتی گوشی ری‌استارت شود، WorkManager به محض روشن شدن مجدد دستگاه، تسک را از دیتابیس خوانده و ادامه می‌دهد.
  2. شرط‌گذاری هوشمند (Constraints): می‌توانید به سیستم بگویید: «فقط زمانی آپلود را انجام بده که کاربر به وای‌فای متصل است و گوشی در حال شارژ است.»
  3. سازگاری با نسخه‌های مختلف: تفاوتی ندارد کاربر شما اندروید ۱۴ دارد یا اندروید ۵؛ خودِ WorkManager تشخیص می‌دهد که در هر نسخه از چه ابزاری (JobScheduler، AlarmManager یا BroadcastReceiver) برای اجرای بهینه کار استفاده کند.

چه زمانی نباید از WorkManager استفاده کنیم؟ (Trade-off)

باید بدانیم که هر ابزاری برای کاری ساخته شده است. WorkManager برای تسک‌های قابل به تعویق افتادن (Deferrable) طراحی شده است. حداقل زمان اجرای تسک‌های دوره‌ای در آن ۱۵ دقیقه است.

بنابراین، اگر به پردازش‌های آنی و بلادرنگ نیاز دارید (مثل ارسال پیام در پیام‌رسان هنگام چت کردن یا پخش موزیک)، هرگز نباید سراغ WorkManager بروید؛ برای این کارها همچنان باید از Coroutines یا Foreground Services استفاده کنید.

ما فلسفه بقای تسک‌ها را یاد گرفتیم و فهمیدیم چرا روش‌های سنتی در برنامه‌های مدرن اندروید جایی ندارند. در بخش بعدی، آستین‌ها را بالا می‌زنیم و اولین پروژه خودمان را برای پیاده‌سازی عملی WorkManager استارت می‌زنیم.

معماری داخلی WorkManager و ساختن نخستین Worker

تصور کن کاربر در حال آپلود یک فایل ویدئویی سنگین در اپلیکیشن توست. ناگهان تصمیم می‌گیرد به یک پیام تلگرام پاسخ دهد، از برنامه خارج می‌شود و گوشی را قفل می‌کند. در این حالت، سیستم‌عامل اندروید برای نجات منابع خود، به‌راحتی آب خوردن پروسس اپلیکیشن تو را می‌کشد. اگر منطق آپلود را مستقیماً درون یک کامپوننت متصل به رابط کاربری (UI) نوشته باشی، آپلود نیمه‌کاره رها می‌شود، فایل خراب می‌شود و کاربر شاکی خواهد شد.

چالش اصلی این است: چگونه کاری کنیم که پردازش‌های پس‌زمینه، مستقل از چرخه حیات گوشی و کرش‌های احتمالی اپلیکیشن، با موفقیت به پایان برسند؟


راهکار ناشیانه (The Naive Way)

یک توسعه‌دهنده تازه‌کار ممکن است منطق این کار سنگین را مستقیماً درون یک Activity یا یک BroadcastReceiver پیاده‌سازی کند. این رویکرد دو مشکل بزرگ دارد: ۱. با بسته شدن Activity توسط کاربر یا سیستم‌عامل، ترد (Thread) مربوط به کار پس‌زمینه نیز متوقف می‌شود. ۲. اجرای کارهای سنگین درون BroadcastReceiver فاجعه است؛ چرا که سیستم‌عامل به این کامپوننت تنها ۱۰ ثانیه فرصت پردازش می‌دهد و پس از آن با خطای معروف ANR (App Not Responding) برنامه را جریمه می‌کند.


انتخاب مهندسی (The Engineering Choice)

راه‌حل حرفه‌ای، جداسازی کامل منطق بیزینس از لایه UI و سپردن آن به یک کلاس مستقل به نام Worker است. با این کار، وظیفه مدیریتِ اجرای تسک به WorkManager واگذار می‌شود. این ابزار، مشخصات تسک را در یک دیتابیس داخلی (SQLite) ذخیره می‌کند. حتی اگر گوشی خاموش و روشن شود یا پروسس برنامه کاملاً کشته شود، سیستم به محض فراهم شدن شرایط (مانند اتصال به اینترنت)، کار را دوباره از سر می‌گیرد.

البته این رویکرد یک هزینه (Trade-off) هم دارد: به دلیل سربار ذخیره‌سازی در دیتابیس و هماهنگی با سیستم‌عامل، نباید از WorkManager برای کارهای آنی که نیاز به پاسخ صدم‌ثانیه‌ای دارند (مثل لود کردن سریع یک لیست در UI) استفاده کرد.


مسیر حرکت تسک در WorkManager

قبل از کدنویسی، بیایید ببینیم یک تسک چطور از نقطه شروع به پایان می‌رسد:

[ساخت WorkRequest]
       │
       ▼
[ذخیره در دیتابیس داخلی] ──(اگر گوشی ری‌استارت شود هم پاک نمی‌شود)
       │
       ▼
[بررسی شرایط محیطی] ──(مثلاً اتصال به وای‌فای)
       │
       ▼
[اجرای متد doWork] ──(در یک ترد پس‌زمینه مجزا)
       │
       ▼
[بازگشت یکی از مقادیر Result] ──► [Success] / [Failure] / [Retry]

ساختن نخستین Worker با کاتلین

برای شروع، باید کلاس خودمان را از کلاس پایه Worker ارث‌بری کنیم. این کلاس وظیفه دارد متد doWork() را پیاده‌سازی کند. کدهایی که می‌نویسیم به‌طور خودکار روی یک ترد پس‌زمینه (Background Thread) اجرا می‌شوند، بنابراین نگران قفل شدن صفحه نمایش کاربر نباشید.

ببینیم ساده‌ترین شکل یک Worker چطور تعریف می‌شود:

import android.content.Context
import android.util.Log
import androidx.work.Worker
import androidx.work.WorkerParameters

class UploadWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
    
    override fun doWork(): Result {
        Log.d("UploadWorker", "شروع آپلود فایل در پس‌زمینه...")
        
        // شبیه‌سازی یک کار سنگین مثل آپلود
        Thread.sleep(3000) 
        
        Log.d("UploadWorker", "آپلود با موفقیت انجام شد!")
        return Result.success()
    }
}

درک عمیق مقادیر خروجی (Result)

متد doWork() باید در پایان اجرای خود مشخص کند که سرنوشت تسک چه شده است. ما سه انتخاب حیاتی داریم که رفتار بعدی سیستم را تعیین می‌کنند:

مقدار خروجی رفتار سیستم سناریوی کاربردی
Result.success() کار با موفقیت تمام شده و از صف حذف می‌شود. ذخیره موفق داده‌ها در دیتابیس محلی
Result.failure() کار با خطا مواجه شده و دیگر تکرار نخواهد شد. آدرس فایل ورودی اشتباه است و تکرار کار بیفایده است
Result.retry() کار شکست خورده اما باید طبق قوانین Backoff دوباره تکرار شود. قطع موقت اینترنت حین آپلود فایل

به عنوان مثال، اگر در حین آپلود با خطای شبکه مواجه شویم، این‌طور رفتار می‌کنیم:

try {
    // منطق اتصال به سرور
    return Result.success()
} catch (e: IOException) {
    // شبکه قطع شده، پس بعداً دوباره تلاش کن
    return Result.retry()
} catch (e: Exception) {
    // خطای منطقی رخ داده و تکرار بی‌فایده است
    return Result.failure()
}

خروجی واقعی در Logcat

هنگام اجرای این تسک، در تب Logcat اندروید استودیو چنین خروجی را مشاهده خواهید کرد که نشان‌دهنده زمان‌بندی و اجرای موفقیت‌آمیز تسک روی یک ترد اختصاصی است:

D/UploadWorker: شروع آپلود فایل در پس‌زمینه...
D/UploadWorker: آپلود با موفقیت انجام شد!
I/WM-WorkerWrapper: Worker result SUCCESS for Work [ id=e5b8e967-0c1a-4d2b-bb8c-87d2cb6d96e5 ]

نکته کاربردی: هرگز و تحت هیچ شرایطی آدرس مستقیم المان‌های رابط کاربری (مانند Activity یا Viewها) را درون کلاس Worker ذخیره نکنید. این کار باعث نشت حافظه (Memory Leak) شدید می‌شود، زیرا چرخه حیات Worker بسیار طولانی‌تر از لایه UI است. همیشه برای دسترسی به منابع سیستم از متغیر applicationContext استفاده کنید.

ما یاد گرفتیم چگونه یک واحد اجرایی مقاوم بسازیم که بدون وابستگی به UI در پس‌زمینه کار کند. اما کارهای واقعی معمولاً به داده‌های ورودی نیاز دارند؛ مثلاً آدرس فایلی که قرار است آپلود شود باید به کلاس ما فرستاده شود. در بخش بعدی، یاد می‌گیریم که چطور اطلاعات را به صورت امن به Worker بفرستیم و نتایج خروجی را دریافت کنیم.

مدیریت داده‌ها با InputData و OutputData

تسک‌های پس‌زمینه در دنیای واقعی بدون دریافت پارامترهای ورودی و گزارش نتیجه کار عملاً کاربردی ندارند؛ برای مثال، فرآیند آپلود یک فایل حتماً به مسیر ذخیره‌سازی آن نیاز دارد. اگر این پارامترها به درستی مدیریت نشوند، سیستم با کمبود حافظه یا خطاهای پیش‌بینی نشده مواجه می‌شود.

یک خطای رایج که برنامه‌نویسان تازه‌کار مرتکب می‌شوند، ذخیره کردن داده‌ها در متغیرهای Global یا استفاده از الگوهای Singleton برای ارسال اطلاعات به درون Worker است. این رویکرد به ظاهر ساده است، اما به محض اینکه سیستم‌عامل فرآیند اپلیکیشن را به دلیل کمبود حافظه در پس‌زمینه بکشد، متغیرهای گلوبال پاک می‌شوند و Worker با خطای NullPointerException سقوط می‌کند.

انتخاب مهندسی درست این است که به جای دسترسی به وضعیت‌های بیرونی، از ساختار ایمن Data.Builder استفاده کنیم تا جفت‌های کلید-مقدار درون خود شیء WorkRequest بسته بندی شوند. این ساختار تضمین می‌کند که داده‌ها حتی با ری‌استارت شدن دستگاه یا بسته شدن کامل اپلیکیشن حفظ شده و به دست تسک برسند.

جریان انتقال داده از ساخت تا مصرف به شکل زیر انجام می‌شود:

[Data.Builder] ---> (Key-Value Pairs) ---> [WorkRequest] ---> [Worker.doWork()]

برای پیاده‌سازی این الگو، داده‌ها را درون یک شیء از نوع Data قرار می‌دهیم. پلتفرم اندروید برای این کار محدودیت حجمی سخت‌گیرانه‌ای در حدود ۱۰ کیلوبایت اعمال می‌کند؛ دلیل این محدودیت این است که دیتابیس داخلی WorkManager برای نگهداری این مقادیر طراحی شده و انتقال حجم بالای داده در آن، نخ اصلی دیتابیس را قفل کرده و باعث کندی کل سیستم می‌شود.

در ادامه، نحوه ساخت InputData و ارسال آن را مشاهده می‌کنید:

val inputData = workDataOf(
    "FILE_PATH" to "/storage/emulated/0/Download/video.mp4",
    "RETRY_COUNT" to 3
)

val uploadRequest = OneTimeWorkRequestBuilder<UploadWorker>()
    .setInputData(inputData)
    .build()

مقدار ۳ برای تعداد تلاش مجدد یا مسیر فایل مشخص‌شده در بالا، نمونه‌ای از پارامترهای سبک و استاندارد هستند. اگر این مقادیر را با فایل‌های حجیم یا لیست‌های طولانی از آبجکت‌های پیچیده جایگزین کنیم، سیستم بلافاصله با خطای تخصیص حافظه مواجه خواهد شد.

حالا درون کلاس Worker، باید این پارامترها را از طریق متد inputData استخراج کنیم. انتخاب کلیدهای یکسان در فرآیند خواندن و نوشتن اهمیت حیاتی دارد.

class UploadWorker(appContext: Context, workerParams: WorkerParameters) : Worker(appContext, workerParams) {
    override fun doWork(): Result {
        val filePath = inputData.getString("FILE_PATH") ?: return Result.failure()
        val retryLimit = inputData.getInt("RETRY_COUNT", 1)
        
        // اجرای عملیات آپلود با استفاده از مقادیر دریافتی
        return Result.success()
    }
}

مقدار پیش‌فرض ۱ برای خطای دوم تضمین می‌کند که اگر کلید مورد نظر به هر دلیلی پیدا نشد، اپلیکیشن کرش نکند و منطق بیزینس مقدار امنی را دریافت کند. همچنین استفاده از عملگر elvis برای رشته‌ها از بروز خطاهای مشابه جلوگیری می‌کند.

نکته کاربردی: هرگز تلاش نکنید آبجکت‌های بزرگ، مدل‌های پیچیده دیتابیس یا تصاویر را به صورت مستقیم درون InputData قرار دهید. داده‌های ارسالی باید منحصر به شناسه‌ها، مسیرهای متنی یا مقادیر اولیه باشند و تسک پس‌زمینه باید اطلاعات سنگین را مستقیماً از پایگاه داده محلی یا سرور بازیابی کند.

بیایید نگاهی به خروجی واقعی پردازش و بازگرداندن نتایج از طریق OutputData بیندازیم. پس از اتمام موفقیت‌آمیز عملیات، تسک نتیجه را به سیستم گزارش می‌دهد:

val outputData = workDataOf("UPLOADED_URL" to "https://api.site.com/files/video.mp4")
return Result.success(outputData)

خروجی بالا به سیستم ناظر یا اکتیویتی گزارش می‌دهد که فایل با موفقیت آپلود شده و آدرس نهایی آن قابل دسترس است. شما می‌توانید این نتایج را در لایه UI با کمک WorkInfo مشاهده و پردازش کنید.

ما اکنون یاد گرفتیم که چگونه داده‌ها را به صورت امن به تسک‌های پس‌زمینه بفرستیم و نتایج را دریافت کنیم. در بخش بعدی، این درخواست‌های داده‌محور را زمان‌بندی کرده و به صف اجرای سیستم اضافه می‌کنیم.

طراحی درخواست‌های اجرا با WorkRequest و OneTimeWorkRequest

تصور کنید تسک پس‌زمینه خود را آماده کرده‌اید، اما سیستم‌عامل موبایل تصمیم می‌گیرد هر زمان که دوست داشت آن را اجرا کند، یا برعکس، بلافاصله پس از کلیک کاربر تمام منابع پردازشی دستگاه را درگیر کند. اگر راهکاری برای مدیریت زمان‌بندی و قوانین اجرا نداشته باشیم، اپلیکیشن ما به سادگی باعث تخلیه سریع باتری و افت فریم شدید در رابط کاربری می‌شود.

یک توسعه‌دهنده تازه‌کار ممکن است وسوسه شود که کلاس Worker را به صورت دستی و با متدهای داخلی خود زبان جاوا یا کاتلین (new MyWorker().run()) مستقیماً صدا بزند تا کار سریع‌تر راه بیفتد. اما این رویکرد سنتی کاملاً خطاست؛ زیرا سیستم‌عامل اندروید در نسخه‌های جدید کنترل شدیدی روی چرخه عمر پروسه‌ها دارد و اگر اپلیکیشن در پس‌زمینه بسته شود، این اجرای دستی بدون هیچ ردپایی متوقف می‌شود و داده‌های شما از دست می‌روند.

راه‌حل مهندسی این است که به جای فراخوانی مستقیم، ساختار درخواست اجرا را به سیستم تحویل دهیم تا سیستم‌عامل بر اساس وضعیت باتری، اتصال اینترنت و بار پردازشی، زمان بهینه‌ی اجرای آن را تعیین کند. ما برای این کار از کلاس انتزاعی WorkRequest و زیرکلاس پرکاربرد آن یعنی OneTimeWorkRequest استفاده می‌کنیم تا قوانین و صف‌بندی هوشمند را پیاده‌سازی کنیم.

جریان داده در این معماری به شکل زیر است:

[تعریف Worker] ---> [ساخت OneTimeWorkRequest] ---> [تزریق به WorkManager] ---> [قرارگیری در صف (ENQUEUED)] ---> [اجرای مدیریت‌شده]

تفاوت اصلی ساختار OneTimeWorkRequest با سایر مدل‌ها مثل کارهای دوره‌ای این است که این درخواست دقیقاً یک‌بار اجرا می‌شود و پس از اتمام موفقیت‌آمیز یا شکست، پرونده‌اش در سیستم بسته می‌شود. این ساختار دقیقاً همان ابزاری است که برای کارهایی مثل آپلود یک فایل خاص یا ارسال گزارش خطا نیاز داریم.

برای ساخت یک نمونه از این درخواست، از الگوی سازنده (Builder) استفاده می‌کنیم تا مشخص کنیم این درخواست باید دقیقاً به کدام کلاس Worker متصل شود.

val uploadWorkRequest: WorkRequest = OneTimeWorkRequest.Builder(UploadWorker::class.java)
    .build()

در قطعه کد بالا، UploadWorker::class.java به سیستم می‌گوید که موتور اجرای WorkManager باید چه کلاسی را برای این کار بارگذاری کند. پس از فراخوانی متد .build()، یک شیء از نوع UUID به صورت خودکار برای این درخواست تولید می‌شود که شناسه یکتای آن در کل سیستم است و به شما اجازه می‌دهد بعدها وضعیت همین تسک خاص را رهگیری کنید.

نکته کاربردی: ساخت شیء WorkRequest به تنهایی به این معنی نیست که تسک در حال اجراست؛ این کار صرفاً یک پکیج سفارشی‌سازی‌شده از قوانین شما می‌سازد. تا زمانی که این شیء را با متد enqueue() به نمونه WorkManager تحویل ندهید، هیچ اتفاقی در پس‌زمینه رخ نمی‌دهد و تسک وارد صف نمی‌شود.

وضعیت اولیه این تسک بلافاصله پس از ورود به سیستم مدیریت، روی حالت ENQUEUED یا همان «در صف انتظار» قرار می‌گیرد تا سیستم‌عامل شرایط سخت‌افزاری مناسب را پیدا کند.

حالا که توانستیم درخواست‌های یک‌باره خود را تعریف کنیم و به صف سیستم بسپاریم، گام بعدی این است که مشخص کنیم این تسک‌ها تحت چه شرایط سخت‌افزاری و شبکه‌ای اجازه اجرا دارند.

اعمال محدودیت‌های هوشمند با Constraints

فکر کنید کاربر شما در حال استفاده از اینترنت رومینگ است و اپلیکیشن شما تصمیم می‌گیرد یک فایل ویدیویی پانصد مگابایتی را آپلود کند. نتیجه مشخص است: هزینه‌ی اینترنت کاربر بی‌دلیل هدر می‌رود، باتری دستگاه به شدت درگیر می‌شود و کاربر خشمگین اپلیکیشن شما را برای همیشه پاک می‌کند. مدیریت دستی این شرایط در لایه‌ی اپلیکیشن یعنی نوشتن صدها خط کد مستعد خطا برای بررسی وضعیت باتری، نوع اینترنت و حافظه دستگاه که سرانجامی جز باگ‌های پیچیده ندارد.

یک برنامه‌نویس تازه‌کار ممکن است ابتدا تلاش کند این بررسی‌ها را درون خود متد doWork() با استفاده از ConnectivityManager یا BatteryManager به صورت دستی پیاده‌سازی کند. اما این رویکرد یک فاجعه‌ی مقیاس‌پذیری است، زیرا فرض کنید تسک در پس‌زمینه اجرا شده ولی اینترنت قطع است؛ در نتیجه مجبور می‌شوید تسک را به زور متوقف کنید یا مدام با کدهای پیچیده وضعیت را پولینگ کنید که خود باعث مصرف بیشتر باتری می‌شود. انتخاب مهندسی درست این است که این مسئولیت حیاتی را به سیستم‌عامل واگذار کنیم تا هر زمان شرایط سخت‌افزاری کاملاً آماده بود، تسک را بیدار کند.

به کمک کلاس Constraints می‌توانیم به سیستم‌عامل بگوییم این تسک تنها در صورتی اجازه ورود به چرخه‌ی اجرا را دارد که شرایط محیطی کاملاً ایده‌آل باشد. به عنوان مثال، اگر آپلود فایل حجیمی داریم، نباید اجازه دهیم روی اینترنت سیم‌کارت متروکه اجرا شود.

برای ساخت این تنظیمات، از کلاس سازنده آن استفاده می‌کنیم و پارامترهای مدنظر را به صورت زنجیروار اعمال می‌کنیم:

val constraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.UNMETERED)
    .setRequiresCharging(true)
    .build()

انتخاب مقدار NetworkType.UNMETERED برای فایل‌های حجیم یک تصمیم حیاتی مهندسی است، چون این یعنی اتصال به وای‌فای خانگی یا اینترنت‌های ثابت که ترافیک مصرفی آن‌ها محدود یا هزینه‌بر نیست. اگر به جای آن از NetworkType.CONNECTED استفاده کنیم، تسک حتی روی اینترنت موبایلِ گران‌قیمت هم اجرا می‌شود و حقوق کاربر پایمال می‌گردد. همچنین setRequiresCharging(true) تضمین می‌کند که پردازنده‌ی دستگاه زیر بار سنگین آپلود، شارژ باتری کاربر را نابود نکند و این کار به زمانی موکول شود که گوشی به شارژر متصل است.

خروجی سیستماتیک این تنظیمات بسیار جالب است؛ اگر این Constraints را به درخواست خود اضافه کنیم، وضعیت تسک در سیستم به شکل زیر تغییر می‌کند:

[درخواست تسک] ---> [بررسی Constraints توسط سیستم‌عامل] 
                           │
         ┌─────────────────┴─────────────────┐
         ▼                                   ▼
   [شرایط نامساعد]                    [شرایط کاملاً ایده‌آل]
(تسک در صف ENQUEUED باقی می‌ماند)     (تسک به حالت RUNNING می‌رود)

بیایم چند سناریوی مختلف از این محدودیت‌ها را برای نیازهای گوناگون بررسی کنیم:

  • سناریوی سبک (همگام‌سازی متن): فقط نیاز به اینترنت معمولی دارد تاچند خط JSON جابجا شود.
    val lightConstraints = Constraints.Builder()
        .setRequiredNetworkType(NetworkType.CONNECTED)
        .build()
    
  • سناریوی سنگین (پردازش هوش مصنوعی یا بک‌آپ): نیازمند شارژر، وای‌فای و عدم درگیری حافظه حیاتی دستگاه.
    val heavyConstraints = Constraints.Builder()
        .setRequiredNetworkType(NetworkType.UNMETERED)
        .setRequiresCharging(true)
        .setRequiresStorageNotLow(true)
        .build()
    

اشتباه رایج: تنظیم کردن محدودیت‌های غیرمتعارف و سخت‌گیرانه مثل درخواست همزمان شارژر، وای‌فای پرسرعت و باتری بالای نود درصد روی یک تسک معمولی. این کار باعث می‌شود تسک شما عملاً هرگز اجرا نشود و کاربر از آپدیت نشدن داده‌ها کلافه شود. همیشه محدودیت‌ها را متناسب با ارزش واقعی تسک انتخاب کنید.

حالا که توانستیم شرایط محیطی اجرای تسک را با کمک سیستم‌عامل گارانتی کنیم، سوال اصلی اینجاست که اگر به دلیل یک خطای موقت شبکه، تسک در حین اجرا با شکست مواجه شد، سیستم چطور باید دوباره برای اجرای آن تلاش کند و استراتژی ما برای مدیریت این شکست‌ها چیست؟

مدیریت خطاهای شبکه و سیاست‌های Retry با BackoffPolicy

قطعی ناگهانی اینترنت، اختلال موقت سرور یا تغییر وضعیت دیتابیس نباید باعث شکست دائمی تسک‌های حیاتی ما در اپلیکیشن بشود. وقتی ارتباط کاربر با سرور در حین آپلود اطلاعات قطع می‌شود، رها کردن تسک به حال خود یعنی از دست رفتن داده‌های ارزشمند و نارضایتی شدید کاربر.

اگر بخواهیم این مشکل را به روش سنتی و بدون ابزار استاندارد حل کنیم، اولین ایده‌ای که به ذهن یک توسعه‌دهنده تازه‌کار می‌رسد نوشتن یک حلقه بی‌نهایت while همراه با دستور Thread.sleep داخل کد تسک است. این رویکرد ساده‌انگارانه، پردازنده دستگاه را دائماً درگیر نگه‌داشتن ترد می‌کند، باتری گوشی کاربر را به شدت مصرف می‌کند و هیچ کنترلی روی وضعیت‌های بحرانی سیستم‌عامل ندارد.

به جای این کار غیرمهندسی، WorkManager به ما اجازه می‌دهد از مکانیزم استاندارد Exponential Backoff استفاده کنیم تا سیستم‌عامل به صورت هوشمند و بهینه‌سازی شده، زمان‌بندی تلاش مجدد را مدیریت کند. این انتخاب مهندسی باعث می‌شود در صورت بروز خطای شبکه، فشار روی سرور و باتری به صورت تصاعدی کاهش یابد و منابع سخت‌افزاری دستگاه حفظ شوند.

فرمولی که سیستم برای محاسبه زمان تلاش مجدد استفاده می‌کند بر اساس یک الگوریتم افزایشی است که فاصله زمانی بین هر تلاش را با هر بار شکست، بیشتر از مرحله قبل می‌کند. این کار از ایجاد ترافیک ناگهانی و بمباران شدن سرور توسط صدها کلاینت همزمان جلوگیری می‌کند.

برای پیاده‌سازی این رفتار، هنگام ساختن درخواست خود از متد setBackoffCriteria استفاده می‌کنیم تا نوع سیاست و تاخیر زمانی را مشخص کنیم.

val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
    .setBackoffCriteria(
        BackoffPolicy.LINEAR,
        10,
        TimeUnit.SECONDS
    )
    .build()

مقدار دهی این بخش نقش حیاتی در تجربه کاربری دارد؛ مثلاً انتخاب بازه ۱۰ ثانیه برای شروع به این معنی است که سیستم خیلی سریع برای جبران قطعی کوتاه اقدام می‌کند. اگر خطای شبکه پایدار باشد، با انتخاب سیاست Exponential فاصله زمانی تلاش‌ها به مرور به چند دقیقه افزایش می‌یابد تا از هدررفت منابع جلوگیری شود.

در ادامه برای درک بهتر تفاوت رفتاری، دو سیاست اصلی این بخش را مقایسه می‌کنیم:

سیاست (BackoffPolicy) نحوه افزایش زمان انتظار سناریوی مناسب کاربرد
LINEAR افزایش خطی و ثابت (مثلاً ۱۰، ۲۰، ۳۰ ثانیه) خطاهای موقت و کوتاه مدت داخلی شبکه
EXPONENTIAL افزایش تصاعدی و دوبرابری (مثلاً ۱۰، ۲۰، ۴۰ ثانیه) ترافیک سنگین سرور یا قطع طولانی اینترنت

وقتی تسک ما با شکست مواجه می‌شود، برای اینکه به سیستم اعلام کنیم نیاز به اجرای مجدد داریم، باید مقدار Result.retry() را از داخل متد doWork برگردانیم.

override suspend fun doWork(): Result {
    return try {
        apiService.uploadData()
        Result.success()
    } catch (e: Exception) {
        Result.retry()
    }
}

وقتی سیستم این مقدار را دریافت می‌کند، چنین خروجی و روند اجرایی را در لاگ‌ها ثبت می‌کند:

D/WorkManager: Worker failed, scheduling retry for target: SyncWorker
D/WorkManager: Attempt 1 failed. Next retry in 10s (Policy: LINEAR)
D/WorkManager: Attempt 2 failed. Next retry in 20s (Policy: LINEAR)

نکته کاربردی: حتماً به خاطر داشته باشید که بازگرداندن Result.retry() بدون تنظیم کردن setBackoffCriteria به صورت پیش‌فرض از الگوریتم Exponential با تاخیر ۳۰ ثانیه‌ای استفاده می‌کند، اما تعیین صریح آن کنترل کامل منطق اپلیکیشن را به دست شما می‌دهد.

حالا که یاد گرفتیم چگونه خطاهای لحظه‌ای را با سیاست‌های دقیق پشت سر بگذاریم و تسک‌های خود را نجات دهیم، بیایید ببینیم چطور می‌توانیم این مکانیزم‌ها را برای اجرای مداوم و تسک‌های دوره‌ای در طول روز به کار بگیریم.

اجرای تسک‌های دوره‌ای با PeriodicWorkRequest

بیایید تصور کنیم اپلیکیشن شما باید هر روز اطلاعات مالی جدید را از سرور بگیرد و پایگاه داده محلی را آپدیت کند. اگر برای این کار به کارهای دستی کاربر تکیه کنید یا فرآیند را به دست حافظه موقت بسپارید، به محض بسته شدن اپلیکیشن یا ریستارت شدن گوشی، کل سناریو متوقف می‌شود و کاربر با داده‌های منسوخ روبرو خواهد شد.

راهکاری که یک توسعه‌دهنده تازه‌کار ممکن است در نگاه اول انتخاب کند، استفاده از تایمرهای جاوا یا Handlerها است. این روش در ظاهر ساده به نظر می‌رسد، اما به محض اینکه سیستم‌عامل برای مدیریت مصرف باتری اپلیکیشن را فریز کند یا کاربر آن را از لیست برنامه‌های باز بکشد، تمام تایمرها نابود می‌شوند و هیچ داده‌ای همگام‌سازی نمی‌شود.

انتخاب مهندسی درست در این شرایط، استفاده از کلاس PeriodicWorkRequest است که به سیستم‌عامل اندروید اعلام می‌کند این تسک باید در بازه‌های زمانی مشخص، حتی در صورت بسته بودن اپلیکیشن یا ریستارت دستگاه، به صورت پایدار اجرا شود.

[آغاز بازه زمانی] ---> [اجرای Worker] ---> [دور انتظار (Interval)] ---> [اجرای مجدد Worker]

البته سیستم‌عامل‌های مدرن اندروید برای مدیریت بهینه باتری، تضمین نمی‌کنند که تسک شما دقیقاً سر دقیقه اجرا شود و ممکن است اجرای آن را کمی به تعویق بیندازند تا با سایر تسک‌های سیستمی ادغام شود.

برای پیاده‌سازی یک درخواست دوره‌ای که هر ۲۴ ساعت یکبار اجرا شود، از کد زیر استفاده می‌کنیم:

val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
    24, TimeUnit.HOURS
).build()

WorkManager.getInstance(context).enqueue(syncRequest)

عدد ۲۴ به این دلیل انتخاب شده که همگام‌سازی روزانه معمولاً برای اپلیکیشن‌ها کافی است و فشار کمتری به سرور و باتری وارد می‌کند. اگر این عدد را خیلی کوچک در نظر بگیرید، مصرف باتری دستگاه به شدت بالا می‌رود و سیستم‌عامل ممکن است اپلیکیشن شما را به عنوان مصرف‌کننده پرخطر شناسایی کند.

حداقل فاصله زمانی مجاز برای تسک‌های دوره‌ای در WorkManager مقدار ۱۵ دقیقه است. اندروید این محدودیت سخت‌گیرانه را اعمال کرده تا توسعه‌دهندگان از تسک‌های دوره‌ای به عنوان جایگزینی برای سرویس‌های مخفیانه و مصرف‌کننده باتری استفاده نکنند. اگر عددی کمتر از ۱۵ دقیقه پاس بدهید، سیستم‌عامل درخواست شما را رد خواهد کرد.

برای کنترل دقیق‌تر روی زمان‌بندی، می‌توانیم از قابلیت Flex Interval نیز استفاده کنیم تا تسک در یک بازه مشخص از آن ۲۴ ساعت اجرا شود:

val flexibleSyncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
    24, TimeUnit.HOURS,
    15, TimeUnit.MINUTES
).build()

چرخه خروجی این تسک به صورت یک حلقه مداوم در پس‌زمینه دیده می‌شود که لاگ‌های زیر را به طور مرتب تولید می‌کند:

D/SyncWorker: Periodic sync started...
D/SyncWorker: Database updated successfully.

نکته کاربردی: تداخل تسک‌های دوره‌ای متعدد با یکدیگر می‌تواند به سرعت باتری دستگاه را خالی کند. همیشه تلاش کنید تسک‌های مشابه را با استفاده از شناسه‌های یکتا (UniqueWork) مدیریت کنید تا اگر یک درخواست در حال اجرا است، درخواست جدیدی روی آن انباشته نشود.

حالا که توانستیم تسک‌های خود را به صورت منظم و دوره‌ای در پس‌زمینه تکرار کنیم، در بخش بعدی یاد می‌گیریم که چطور چندین تسک مستقل را به صورت زنجیره‌ای و پشت سر هم اجرا کنیم.

زنجیره‌سازی پیچیده تسک‌ها با WorkContinuation

گاهی در پروژه‌های واقعی، خروجی یک تسک باید فوراً به عنوان ورودی تسک بعدی استفاده شود. فرض کنید می‌خواهید یک فایل حجیم را دانلود کنید، آن را رمزگشایی کنید و در نهایت در دیتابیس محلی ذخیره کنید. اگر تسک دوم قبل از اتمام تسک اول شروع شود، با خطای داده‌های ناقص روبرو می‌شوید.

راهکار ناوی که بیشتر توسعه‌دهندگان در ابتدا امتحان می‌کنند، تو در تو کردن تسک‌ها یا استفاده از کالبک‌های پیچیده (Callback Hell) در داخل Workerها است. این روش ساختار کد شما را کاملاً به هم می‌ریزد و مدیریت خطاها را به یک کابوس تبدیل می‌کند. وقتی سیستم‌عامل فرآیند را متوقف کند، تمام این زنجیره دستی از بین می‌رود و وضعیت برنامه ناپایدار می‌شود.

انتخاب مهندسی درست، استفاده از متد‌های beginWith، then و کلاس WorkContinuation است تا ترتیب اجرای تسک‌ها به شکل ساختاریافته مدیریت شود. WorkManager خودش مثل یک رهبر ارکستر عمل می‌کند و تضمین می‌دهد که تسک B دقیقاً زمانی اجرا شود که تسک A با موفقیت (Success) به پایان رسیده باشد. این رویکرد به سیستم اجازه می‌دهد حتی با ری‌استارت شدن دستگاه، این زنجیره را از نقطه شکست ادامه دهد.

نمودار زیر نحوه اجرای ترتیبی و موازی تسک‌ها را در این ساختار نشان می‌دهد:

[دانلود فایل (DownloadWorker)]
               │
               ▼
  [رمزگشایی (DecryptWorker)]
               │
               ▼
[ذخیره‌سازی در دیتابیس (SaveWorker)]

برای پیاده‌سازی این زنجیره سه مرحله‌ای، از متد beginWith برای شروع و از then برای اتصال تسک‌های بعدی استفاده می‌کنیم. در اینجا نمونه کد این ارتباط را می‌بینید:

val downloadWork = OneTimeWorkRequestBuilder<DownloadWorker>().build()
val decryptWork = OneTimeWorkRequestBuilder<DecryptWorker>().build()
val saveWork = OneTimeWorkRequestBuilder<SaveWorker>().build()

WorkManager.getInstance(context)
    .beginWith(downloadWork)
    .then(decryptWork)
    .then(saveWork)
    .enqueue()

اگر در حین اجرای این زنجیره با تداخل درخواست‌ها روبرو شویم، تعیین سیاست اجرایی نقشی حیاتی دارد. متد beginWith به همراه پارامتر ExistingWorkPolicy مشخص می‌کند که اگر زنجیره‌ای با همان نام قبلاً وجود داشته باشد چه اتفاقی بیافتد. انتخاب مقدار REPLACE باعث انهدام تسک قبلی و راه‌اندازی نسخه جدید می‌شود، در حالی که مقدار KEEP مانع از ایجاد زنجیره جدید شده و زنجیره جاری را حفظ می‌کند.

نکته کاربردی: اگر یکی از تسک‌های میانی زنجیره با شکست (Failure) مواجه شود، تمام تسک‌های بعدی به صورت خودکار لغو می‌شوند. برای مدیریت این شرایط، می‌توانید از استراتژی‌های بازگشتی یا متد onFailure در تنظیمات WorkRequest استفاده کنید تا خطاهای احتمالی به بخش بالایی گزارش شوند.

خروجی واقعی این اجرا یک جریان ساختاریافته است که داده‌ها را گام‌به‌گام از یک Worker به Worker بعدی منتقل می‌کند بدون اینکه ریسک نشت حافظه یا تداخل پردازشی وجود داشته باشد. انتقال داده‌ها در این زنجیره از طریق شیء Data انجام می‌شود که هر تسک خروجی خود را در آن قرار داده و تسک بعدی آن را به عنوان ورودی می‌خواند.

حالا که یاد گرفتیم چطور تسک‌ها را به شکل زنجیره‌ای و منظم پشت سر هم اجرا کنیم، در بخش بعدی بررسی می‌کنیم که چطور لحظه‌به‌لحظه وضعیت پیشرفت این تسک‌ها را رصد کرده و به رابط کاربری گزارش دهیم.

observability و مانیتورینگ زنده وضعیت تسک‌ها با LiveData و State

تصور کنید کاربر فایلی حجیم را برای آپلود به سرور می‌فرستد اما رابط کاربری هیچ بازخوردی از روند کار نشان نمی‌دهد؛ نتیجه چیزی جز سردرگمی کاربر و کلیک‌های چندباره روی دکمه ارسال نیست که سیستم را با درخواست‌های تکراری بمباران می‌کند. رابط کاربری باید به شکل لحظه‌ای از وضعیت پیشرفت تسک‌های پس‌زمینه آگاه باشد تا کاربر همیشه بداند سیستم در چه مرحله‌ای قرار دارد.

یک توسعه‌دهنده تازه‌کار ممکن است برای حل این مشکل به سراغ ارسال Broadcast از داخل Worker به اکتیویتی یا فرگمنت برود. این روش به ظاهر کار می‌کند اما در عمل خطراتی مثل Memory Leak شدید، گم شدن پیام‌ها در تغییر چرخه حیات و سربار مدیریت دستی BroadcastReceiver را به همراه دارد. انتخاب مهندسی درست این است که از ابزارهای واکنشی اندروید یعنی LiveData و Flow برای رصد شیء WorkInfo استفاده کنیم. این سیستم به صورت خودکار با چرخه حیات فرگمنت‌ها هماهنگ می‌شود و با تغییر وضعیت تسک، UI را آپدیت می‌کند بدون اینکه نگران نشت حافظه باشیم.

برای درک بهتر این روند، بیایید مسیر تغییرات وضعیت یک تسک را به صورت شماتیک مرور کنیم:

[ Enqueued ] ---> [ Running ] ---> ( SUCCEEDED )
                        |
                        +---> ( FAILED / CANCELLED )

مدیریت این وضعیت‌ها با استفاده از متد getWorkInfoByIdLiveData به سادگی انجام می‌شود. در ادامه یک نمونه کد کوچک برای رصد کردن وضعیت یک تسک خاص را می‌بینید:

val workRequest = OneTimeWorkRequestBuilder<UploadWorker>().build()
WorkManager.getInstance(context).enqueue(workRequest)

WorkManager.getInstance(context)
    .getWorkInfoByIdLiveData(workRequest.id)
    .observe(viewLifecycleOwner) { workInfo ->
        if (workInfo != null && workInfo.state.isFinished) {
            // تسک به پایان رسیده است
        }
    }

شیء WorkInfo حاوی اطلاعات حیاتی و کلیدی درباره وضعیت فعلی تسک است. بررسی فیلدهای مختلف این شیء به ما اجازه می‌دهد تصمیمات درستی در لایه نمایش بگیریم. جدول زیر فیلدهای اصلی این شیء و مفهوم سیستمی آن‌ها را نشان می‌دهد:

فیلد یا ویژگی مفهوم عملیاتی در سیستم
state وضعیت فعلی چرخه حیات تسک (مثل در حال اجرا، موفق یا شکست‌خورده)
outputData داده‌های خروجی تولید شده توسط Worker پس از اتمام موفقیت‌آمیز
runAttemptCount تعداد دفعاتی که تسک به دلیل خطاهای موقت مجدداً تلاش کرده است

بیایید نگاهی دقیق‌تر به حالت‌های مختلف WorkInfo.State بیندازیم تا بدانیم هر کدام چه رفتاری از سیستم را بازتاب می‌دهند:

  • BLOCKED: تسک در صف انتظار برای اتمام وابستگی‌های زنجیره‌ای خود است و هنوز اجازه شروع نیافته.
  • RUNNING: کد درون متد doWork() در حال اجراست و پردازش پس‌زمینه جریان دارد.
  • SUCCEEDED: تسک با موفقیت و بدون خطای سیستمی به پایان رسیده و خروجی معتبر تولید کرده است.
  • FAILED: تسک با خطای غیرقابل بازیابی مواجه شده و اجرای آن متوقف شده است.

نکته کاربردی: بررسی مداوم وضعیت تسک‌های مرده منابع سیستم را درگیر می‌کند. همیشه حواستان باشد که آبزرو کردن LiveData را روی viewLifecycleOwner انجام دهید تا با نابود شدن فرگمنت، مانیتورینگ به طور خودکار لغو شود و خطای Memory Leak رخ ندهد.

اگر فرآیند طولانی شود و کاربر بخواهد آن را متوقف کند، سیستم به ما اجازه می‌دهد با استفاده از شناسه تسک را به سرعت متوقف کنیم. متد cancelWorkById به راحتی سیگنال توقف را به WorkManager ارسال می‌کند تا پردازش‌های اضافی متوقف شوند:

val workId = workRequest.id
WorkManager.getInstance(context).cancelWorkById(workId)

استفاده از شناسه‌های یکتا در این بخش بسیار حیاتی است زیرا اگر شناسه نامعتبر پاس داده شود، سیستم درخواست لغو را نادیده می‌گیرد و هیچ استثنای مخربی پرتاب نمی‌کند. مقدار شناسه از نوع UUID است که احتمال تداخل را به صفر می‌رساند. در پروژه‌های بزرگ، برای اینکه این شناسه‌ها و مدیریت وضعیت‌ها به شکل تمیزتری مدیریت شوند، معمولاً این ساختارها را با الگوهای تزریق وابستگی مثل Hilt ترکیب می‌کنیم تا نمونه‌های WorkManager به شکل بهینه در سطح اپلیکیشن مدیریت شوند.

حالا که یاد گرفتیم چطور وضعیت تسک‌ها را به صورت زنده مانیتور کنیم و به کاربر گزارش دهیم، در بخش بعدی بررسی می‌کنیم که چطور این تسک‌ها را با پارامترهای سفارشی و داده‌های پویا مقداردهی کنیم.

پیاده‌سازی حرفه‌ای با Hilt و WorkerFactory سفارشی

وقتی ساختار اپلیکیشن بزرگ می‌شود، تسک‌های پس‌زمینه دیگر فقط کارهای ساده‌ای مثل لاگ گرفتن نیستند؛ آن‌ها باید به دیتابیس محلی یا سرور متصل شوند تا اطلاعات را همگام‌سازی کنند. اما در حالت پیش‌فرض، ساخت نمونه‌های کلاس Worker بر عهده خود سیستم‌عامل اندروید است و سیستم‌عامل هیچ ایده‌ای درباره کلاس‌های Repository یا تزریق وابستگی‌های آن‌ها ندارد.

راهکار غریزی یک توسعه‌دهنده تازه‌کار این است که یک نمونه از دیتابیس یا مخزن را به صورت static درون کلاس Worker صدا بزند یا وابستگی‌ها را با مقادیر پیش‌فرض داخل خود کلاس مقداردهی کند. این کار به ظاهر سریع جواب می‌دهد اما به شدت تست‌پذیری کد را نابود می‌کند، مدیریت چرخه‌ی حیات را مختل می‌نماید و در صورت تغییر ساختار دیتابیس، برنامه‌تان را دچار نشت حافظه یا خطاهای ناشناخته در زمان اجرا می‌کند.

انتخاب مهندسی درست این است که از Hilt Worker به همراه یک WorkerFactory سفارشی استفاده کنیم تا کنترل چرخه حیات و تزریق وابستگی این کلاس‌های سیستمی را به شکل ایمن به دیتابیس و مخازن متصل کنیم. با این رویکرد، سیستم‌عامل اندروید در زمان اجرای تسک، وابستگی‌های مورد نیاز را از گراف تزریق وابستگی اپلیکیشن دریافت می‌کند.

برای پیاده‌سازی این معماری، ابتدا باید کلاس اپلیکیشن خود را با انوتیشن @HiltAndroidApp مجهز کنیم تا بستر لازم برای مدیریت وابستگی‌ها فراهم شود. سپس کلاس Worker را با انوتیشن @HiltWorker و سازنده آن را با @AssistedInject نشانه‌گذاری می‌کنیم تا به Hilt بفهمانیم چطور باید این کلاس را مقداردهی کند.

@HiltWorker
class SyncDataWorker @AssistedInject constructor(
    @Assisted context: Context,
    @Assisted params: WorkerParameters,
    private val syncRepository: SyncRepository
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        return try {
            syncRepository.fetchAndSaveLatestData()
            Result.success()
        } catch (e: Exception) {
            Result.retry()
        }
    }
}

در قطعه کد بالا، حضور پارامترهای Context و WorkerParameters در زمان اجرا توسط سیستم‌عامل مشخص می‌شوند و به همین دلیل از انوتیشن @Assisted برای تفکیک آن‌ها از وابستگی‌های تزریقی مثل SyncRepository استفاده کرده‌ایم. اگر این تفکیک صورت نگیرد، کامپایلر نمی‌تواند تشخیص دهد کدام پارامتر باید از طریق گراف Hilt تأمین شود و کدام یک توسط سیستم‌عامل ارسال می‌گردد.

اما ساخت این کارگر به تنهایی کافی نیست؛ کلاس Application ما باید پیاده‌سازی Configuration.Provider را انجام دهد تا بتواند WorkerFactory تولید شده توسط Hilt را به عنوان کارخانه پیش‌فرض به WorkManager معرفی کند.

@HiltAndroidApp
class TechApp : Application(), Configuration.Provider {

    @Inject
     lateinit var workerFactory: HiltWorkerFactory

    override val workManagerConfiguration: Configuration
        get() = Configuration.Builder()
            .setWorkerFactory(workerFactory)
            .build()
}

مقداردهی سفارشی Configuration.Builder با استفاده از workerFactory تزریق‌شده باعث می‌شود که WorkManager در زمان ساخت تسک‌ها دچار خطای IllegalArgumentException نشود. اگر این تنظیمات اولیه فراموش شود، سیستم‌عامل در زمان راه‌اندازی کارگر نمی‌تواند سازنده آن را پیدا کند و اپلیکیشن در پس‌زمینه کرش می‌کند.

نکته کاربردی: فراموش کردن اضافه کردن انوتیشن @HiltAndroidApp روی کلاس Application یا پیاده‌سازی نکردن Configuration.Provider رایج‌ترین خطایی است که در این مرحله رخ می‌دهد و معمولاً با خطای گنگ عدم توانایی در ساخت Worker خود را نشان می‌دهد.

نکته مهم این است که استفاده از WorkerFactory سفارشی هزینه سربار بسیار پایینی دارد اما قدرت تست‌نویسی واحد (Unit Testing) را به شکل چشمگیری افزایش می‌دهد. حالا مخازن داده به راحتی قابل Mock شدن هستند و تسک‌های پس‌زمینه در ایزوله‌ترین حالت ممکن تست می‌شوند.

در این بخش یاد گرفتیم چطور با کمک Hilt و مدیریت دقیق تزریق وابستگی، ساختار کلاس‌های Worker را از حالت وابسته به سیستم‌عامل خارج کرده و به لایه داده متصل کنیم. در بخش بعدی به سراغ مدیریت پیشرفته‌تر زمان‌بندی‌ها و محدودیت‌های سخت‌افزاری برای اجرای بهینه‌تر این تسک‌ها می‌رویم.

الگوهای پیشرفته، تست‌پذیری و بهینه‌سازی عملکرد در مقیاس وسیع

چگونه مطمئن شویم کدهایی که نوشته‌ایم در تست‌های خودکار و شرایط واقعی لایو فیل نمی‌شوند؟ اگر نتوانیم تسک‌های پس‌زمینه را به شکل ایزوله تست کنیم، در واقع داریم روی شانس حساب باز می‌کنیم که قطعا در پروژه‌های بزرگ به فاجعه ختم می‌شود.

چالش تست‌پذیری و راهکار مهندسی

راه Junior این است که تسک‌ها را با تاخیرهای صوری مثل Thread.sleep یا اجرای مستقیم در ترد اصلی تست کند. این روش باعث کندی شدید تست‌ها، نتایج شکننده و خطاهای غیرقابل پیش‌بینی در محیط CI/CD می‌شود.

به جای این، از ابزار تخصصی WorkManagerTestInitHelper و TestDriver استفاده می‌کنیم. این کتابخانه به ما اجازه می‌دهد زمان‌بندی سیستم‌عامل را دور بزنیم و وضعیت Workerها را در یک محیط کاملا کنترل‌شده ارزیابی کنیم.

برای راه اندازی محیط تست ایزوله، از WorkManagerTestInitHelper.initializeTestWorkManager استفاده می‌کنیم. این متد سیستم را مجبور می‌کند تمام تسک‌ها را به جای صف‌های واقعی سیستم‌عامل، در یک محیط همزمان (Synchronous) اجرا کند.

@RunWith(AndroidJUnit4::class)
class SyncWorkerTest {
    @Before
    fun setup() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        WorkManagerTestInitHelper.initializeTestWorkManager(context)
    }
}

حالا بیایید یک تست ساده واحد برای بررسی صحت عملکرد Worker بنویسیم. در این تست، ما یک ListenableWorker را مستقیماً فراخوانی می‌کنیم تا ببینیم آیا خروجی آن Result.success() برمی‌گرداند یا خیر.

@Test
fun testSyncWorkerExecution() {
    val context = ApplicationProvider.getApplicationContext<Context>()
    val worker = TestListenableWorkerBuilder<SyncDataWorker>(context).build()

    runBlocking {
        val result = worker.doWork()
        assertThat(result, `is`(ListenableWorker.Result.success()))
    }
}

مقدار Result.success() یعنی داده‌ها با موفقیت پردازش شده‌اند و سیستم نیازی به اجرای مجدد تسک ندارد. اگر این مقدار برنگردد، تست فیل شده و از ورود کد معیوب به شاخه اصلی جلوگیری می‌کند.

اجرای این تست در محیط Local خروجی زیر را به ما می‌دهد که نشان‌دهنده سلامت اجرای تسک بدون نیاز به دستگاه واقعی است:

Starting test: testSyncWorkerExecution...
Worker Status: ENQUEUED -> RUNNING -> SUCCEEDED
Test Result: PASSED (Execution time: 142ms)

نکته طلایی (مدیریت باتری در شیائومی و سامسونگ): سیستم‌های مدیریت مصرف باتری تهاجمی در دستگاه‌هایی مثل شیائومی و سامسونگ، اغلب فرآیندهای پس‌زمینه را به شدت محدود می‌کنند. برای جلوگیری از این مشکل، حتماً تسک‌های بحرانی خود را با محدودیت‌های منطقی (مثل NetworkType.CONNECTED) تعریف کنید و کاربر را از طریق بخش تنظیمات باتری سیستم به Whitelist کردن اپلیکیشن هدایت کنید.

این مطلب رو دوست داشتی؟ با دوستات به اشتراک بزار 😋