Привет!
Представьте, что вы с удовольствием пишите один код на все платформы на Kotlin Multiplatform, и вдруг приходит задача «синкать данные раз в час, только по вайфаю и когда телефон на зарядке».
Под Android вы берёте WorkManager и за пятнадцать минут закрываете тикет. Потом яблочный смежник открывает документацию по BGTaskScheduler и обнаруживает, что модель там вообще другая и работает всё по-другому: нет очереди задач, есть окна, которые система выдаёт по своему усмотрению и т. д.
В итоге в проекте появляется expect fun scheduleSync() с разными реализациями, которые ведут себя по-разному, и никто в команде через год не сможет сказать, что именно произойдёт на iOS, если приложение свернули посреди загрузки.
Так как я андроидер с альтернативным мышлением, мне нравится API WorkManager’а. Я захотел его скопировать под KMP, породив так называемый Kotlin Multiplatform Work Manager или же Jabbit (job + rabbit, да, я знаю) — планировщик фоновых задач для KMP.
Таргеты: Android, iOS, js и wasmJs. Как будет время и желание, появится ещё и декстоп.
Cookbook: вот так делай, да
Создаём воркер (спасибо, что не жоббер). Его единственная задача — когда нужно выполнить код в doWork и вернуть success/failure/retry:
class SyncUserWorker( private val api: Api,) : JabbitWorker { // Вот тут любая работа: сеть/хранилище и т. д. // Нужно вернуть success/failure/retry override suspend fun doWork(job: JobExecution): JobResult { val since = job.inputData.getLong("since", 0L) try { api.sync(since) job.setProgress(jobDataOf("percent" to 100)) return JobResult.success(jobDataOf("synced" to true)) } catch (e: IOException) { if (job.runAttemptCount >= 5) { return JobResult.failure() } else { return JobResult.retry() } } } companion object { const val NAME = "sync" }}
Регистрируем воркер в коде платформы + настраиваем всякое специфичное:
// Android, в Application.onCreateval jabbit = createJabbit(context = this, configuration = configuration)// iOS, в didFinishLaunchingWithOptionsval jabbit = createJabbit( configuration, JabbitIosOptions( backgroundTaskIdentifier = "com.example.app.jabbit" ))// Браузерval jabbit = createJabbit(configuration, JabbitBrowserOptions(queueName = "app"))
Дальше прокидываем инстанс Jabbit в commonMain и пользуемся:
// Шедулим что-нибудь/как-нибудьjabbit.enqueueUniquePeriodic( uniqueName = "sync", policy = ExistingPeriodicJobPolicy.KEEP, request = periodicJob(SyncWorker.NAME, repeatInterval = 6.hours) { setConstraints( constraints { requiredNetworkType = NetworkType.UNMETERED requiresCharging = true } ) addTag("sync") })// Наблюдаем прогресс/результатjabbit.getJobInfosByTagFlow("sync").collect { infos -> val percent = infos.firstOrNull { it.state == JobState.RUNNING } ?.progress ?.getInt("percent")}
Как это работает
Android: просто обёртка над WorkManager
Тут скучно, и это хорошо. Все задачи планируются через WorkManager, но не как отдельные классы воркеров, а как один-единственный внутренний CoroutineWorker, который достаёт имя воркера из inputData и резолвит его через реестр:
val workerName = inputData.getString(WORKER_NAME_KEY) ?: return Result.failure()val worker = configuration.workerFactory.createWorker(workerName) ?: return Result.failure()
Дальше мапим входные параметры в андроидовские и кормим Worker’у. Все методы наблюдения/отмен джоб также делегируются в него. Поэтому всё, что гарантирует сам WorkManager, гарантируется и здесь.
iOS
А вот здесь скучно не получилось. На iOS нет системного сервиса, который хранит очередь задач, поэтому очередь ведём сами: сериализуем записи в JSON, кладём в хранилище и крутим цикл поверх корутин.
Ограничения собираются из того, что даёт система: NWPathMonitor для сетевых ограничений, UIDevice.batteryState для зарядки, атрибуты файловой системы для места на диске.
Когда приложение уходит в фон, Jabbit берёт beginBackgroundTask и дожимает начатое в те секунды, что даёт система. Для запуска при закрытом приложении подаётся BGProcessingTaskRequest с requiresNetworkConnectivity и requiresExternalPower, если этого требуют констрейнты задач в очереди.
Задача, которую прервали посреди выполнения (истекло фоновое окно, приложение прибили), при следующем старте возвращается в ENQUEUED с увеличенным runAttemptCount — ровно так же, как ведёт себя WorkManager, когда система останавливает воркер.
Браузер
Очередь живёт в IndexedDB.
Не в localStorage, хотя с ним было бы проще: оказалось, что localStorage не виден из service worker’а, а без него задачи не переживут закрытие вкладки. (К сожалению, я узнал это слишком поздно).
Констрейнты буквально наскреб из того, что даёт браузер:
-
navigator.onLine,navigator.connectionдля метрированности -
navigator.getBattery()для зарядки (работает через раз) -
navigator.storage.estimate()для оставшегося места на диске.
Половина Chromium-only, поэтому я принял решение, которое стоит проговорить: если API недоступен, констрейнт считается выполненным. Иначе задача залипнет навсегда, потому что Battery API выпилили из какого-нибудь Opera Pro Lite Gaming Edition.
Несколько вкладок — проблема, которой нет на мобилках. Пять открытых вкладок — это пять инстансов над одной очередью в IndexedDB.
Без координации задача выполнится пять раз. Решается выборами лидера через Web Locks:
internal fun holdExclusiveLock(name: String, onAcquired: () -> Unit): Unit = js( "navigator.locks.request(name, { mode: 'exclusive' }, function(){" + "onAcquired();" + "return new Promise(function(){});" + "})")
Какие могут быть проблемы?
Цепочек нет. В WorkManager есть beginWith().then(), и это удобно. Я сознательно не стал их делать: на iOS и в вебе их пришлось бы эмулировать поверх своего движка, и получилась бы штука, которая на Android работает как обещано, а на остальных платформах — как получится. Полурабочая абстракция хуже отсутствующей.
Payload должен быть маленьким. Ограничение WorkManager в 10 КБ на inputData никуда не делось, и API его не прячет. Всё, что больше, — в файл или базу, а в задачу — ключ/путь.
Вывод
Получился, как мне кажется, неплохой PoC, у которого есть потенциал минимум на минорные фичи/импрувы.
Что дальше:
-
попросить клода покрыть всё тестами
-
добавить поддержку десктопа
-
попытаться придумать что-то с цепочками
-
фиксить баги
В общем, если вам такое пригодится — репозиторий здесь, а релизы здесь.
ссылка на оригинал статьи https://habr.com/ru/articles/1073906/