Jabbit — фоновые задачи в KMP с API как у WorkManager (почти)

от автора

Привет!

Представьте, что вы с удовольствием пишите один код на все платформы на 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.onLinenavigator.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/