Connect with us

Разработка

Надежное выполнение фоновых задач в iOS: часть 1

Уроки, извлеченные в процессе поиска надежного запуска фоновых задач в iOS 26.

Опубликовано

/

     
     

Можно подумать, что если у вас в кармане лежит суперкомпьютер, то он уж точно должен уметь надёжно выполнять разные рутинные задачи в фоне, пока вы занимаетесь своими делами. Примерно так же, как  cron в Unix-системах делает это ещё с 1970-х.

В первой статье этой серии из трёх частей я расскажу о начале своего пути с BGTaskScheduler — фреймворком Apple для запуска фоновых задач на iOS. А также о том, как методом проб, ошибок и наблюдений мне удалось добиться достаточно хорошей надёжности в моём приложении Calendar Copilot, которое я использую для синхронизации событий между календарями.

Сразу раскрою итог: BGTaskScheduler не настолько надёжен, как cron, но добиться достаточно стабильного расписания можно. Правда, есть важная оговорка — вам или вашим пользователям придётся открывать приложение хотя бы раз в неделю или две. Если этого не делать, iOS со временем начинает реже запускать фоновые задачи, а затем вообще прекращает это делать ради экономии батареи.

Кратко о типах фоновой обработки в iOS

Есть несколько распространённых типов фоновых задач.

  1. BGAppRefreshTask — предназначен для поддержания содержимого приложения в актуальном состоянии. Apple описывает его как «короткую задачу обновления», и на этом конкретика в документации практически заканчивается.
  2. BGProcessingTask — для более тяжёлой работы: «задача обработки, выполнение которой может занимать несколько минут».
  3. BGContinuedProcessingTask — появился в iOS 26 и описывается как «задача, которая запускается на переднем плане и при необходимости может продолжать работу в фоне». Система показывает её прогресс через Live Activity и позволяет пользователю отменить выполнение. Поскольку BGContinuedProcessingTask должен запускаться на переднем плане, для автоматической синхронизации он бесполезен.

Apple не публикует никаких жёстких числовых ограничений, по крайней мере я их не нашёл. Поэтому реальные лимиты и ограничения пришлось выяснять методом проб и ошибок, подключив телеметрию на собственном устройстве.

Надежное выполнение фоновых задач в iOS: часть 1

Схема архитектуры выполнения: запуск обработки действий может быть вызван четырьмя событиями — обычным запуском приложения пользователем, пробуждением через BGAppRefreshTask или BGProcessingTask, либо наблюдателем за EKEventStoreChanged, который срабатывает при внешних изменениях календаря. Каждый запрос проходит через ActionProcessor.requestExecution, а актор RunGate разрешает только одно активное выполнение на весь процесс. Я называю это блокировкой выполнения. Запросы, поступившие во время активного запуска, не накапливаются, а объединяются в один отложенный повторный запуск, который выполняется максимум один раз после завершения текущего.

Планирование обоих типов задач строится одинаково: отправляем запрос и регистрируем обработчик запуска. Внутри обработчика нужно задать expirationHandler, который iOS вызывает незадолго до истечения разрешённого времени:

// Refresh: schedule every 30 min, anchored to the last run.
let refresh = BGAppRefreshTaskRequest(identifier: "com.popovetsky.CalCopilot.refresh")
refresh.earliestBeginDate = lastRun.addingTimeInterval(30 * 60)       // last run + 30 min
try BGTaskScheduler.shared.submit(refresh)
 
// Processing: schedule every hour, anchored to the last run.
let processing = BGProcessingTaskRequest(identifier: "com.popovetsky.CalCopilot.process")
processing.earliestBeginDate = lastRun.addingTimeInterval(60 * 60)    // last run + 60 min
try BGTaskScheduler.shared.submit(processing)
 
// Processing example
BGTaskScheduler.shared.register(
    forTaskWithIdentifier: "com.popovetsky.CalCopilot.process",
    using: nil
) { task in
    handle(task as! BGProcessingTask)
}
 
func handle(_ task: BGProcessingTask) {
    // Set an expiration handler so you can cancel unfinished work.
    // Apple: "Not setting an expiration handler results in the system marking
    // your task as complete and unsuccessful". Skip it and you lose the run
    // with no chance to clean up.
    task.expirationHandler = {
        cancelInFlightWork()                  // stop cleanly, don't corrupt a half-done write
        task.setTaskCompleted(success: false) // careful: this can run at the same time as the normal finish below
    }
 
    Task {
        let ok = await runSync()              // <-- the actual work happens here
        task.setTaskCompleted(success: ok)    // normal finish (the other side of that race)
    }
}

У BGProcessingTask есть ещё два параметра: requiresExternalPower и requiresNetworkConnectivity. Если поставить requiresExternalPower = true, задача будет запускаться только тогда, когда устройство подключено к питанию. Я оставил false, потому что на моём устройстве большинство запусков обработки и так происходили во время зарядки. Я предпочёл сохранить возможность редких запусков без зарядки, а не запрещать их полностью.

processing.requiresExternalPower = false   // allow off-charger runs

Как повысить вероятность успеха с помощью намеренно агрессивного планирования

Довольно быстро становится понятно, что BGTaskScheduler вообще не похож на cron. То, что вы попросили iOS запустить задачу в определённое время, ещё совершенно не означает, что она действительно запустится тогда. Иногда выполнение происходит на несколько часов позже. На своём телефоне я заметил закономерность: BGProcessingTask чаще запускался ночью, пока телефон стоял на зарядке, а BGAppRefreshTask — днём, когда телефон работал от батареи и я регулярно разблокировал экран.

После множества оптимизаций производительности полный цикл CalCopilot обычно стал укладываться менее чем в секунду и почти всегда — менее чем в две. Этого было достаточно для обоих типов задач, поэтому я зарегистрировал и BGAppRefresh, и BGProcessing, чтобы дать iOS как можно больше возможностей выполнить работу. Изначально каждый запуск последовательно делал четыре вещи:

  1. Выполнял все включённые действия.
  2. Отправлял уведомления.
  3. Повторно планировал все фоновые задачи.
  4. Выполнял проверки состояния и обслуживание хранилища.

Позже последний пункт я оставил только для BGProcessingTask, потому что он получает больше времени и не требует частого запуска. Одной из моих ранних ошибок было выполнять проверки состояния и обслуживание хранилища внутри refresh-задачи. Я заметил, что после этого пробуждения через refresh происходили заметно менее надёжно. По одному устройству я не могу доказать, что именно тяжёлые refresh-задачи уменьшали частоту запусков. Но Apple описывает два фактора, которые это вполне объясняют: ограничение частоты и энергетический бюджет приложения.

В докладе WWDC 2020 «Background execution demystified» говорится, что этот бюджет уменьшается по мере работы приложения и постепенно восстанавливается в течение дня. Apple рекомендует минимизировать расход энергии и мобильного трафика при каждом запуске, поэтому теперь мои refresh-задачи значительно короче processing-задач.

Что делать с внезапным завершением задач

BGTaskScheduler непредсказуем не только в том, когда он запускает задачу, но и в том, сколько времени даст ей на выполнение. Поэтому я добавил собственные таймеры упреждающего завершения — для processing 25 секунд и для refresh — 3 секунды. Это позволяет аккуратно остановить незавершённую работу и корректно сообщить системе об ошибочном завершении.

Но даже с такими ограничениями iOS иногда завершает задачу раньше. Если снова посмотреть на обработчик выше, можно увидеть два пути, вызывающих setTaskCompleted(success:) у одного и того же объекта задачи. Один путь — обычное завершение после runSync(). Второй — expirationHandler, который iOS вызывает, когда время задачи почти закончилось. Но setTaskCompleted(success:) должен быть вызван ровно один раз.

Проблема в том, что expirationHandler и обычное завершение могут выполняться одновременно. Обычный Bool здесь небезопасен, потому что оба пути могут читать и изменять его параллельно. Поэтому состояние завершения я сохранил в OSAllocatedUnfairLock — низкоуровневой блокировке Apple для защиты общего состояния. Первый вызов переводит состояние из «не завершено» в «завершено» и сообщает iOS о результате. Все последующие вызовы видят, что задача уже завершена, и ничего больше не делают.

Телеметрия на моём устройстве показала, что иногда iOS вызывает expirationHandler уже после нормального завершения задачи. Для этого я написал BGTaskCompletionToken, который запоминает, что задача уже была завершена:

public final class BGTaskCompletionToken: @unchecked Sendable {
    private let completed = OSAllocatedUnfairLock<Bool>(initialState: false)
 
    @discardableResult
    public func complete(
        _ task: BGTaskCompletable?,
        success: Bool,
        beforeComplete: (() -> Void)? = nil
    ) -> Bool {
        let shouldComplete = completed.withLock { done -> Bool in
            guard !done else { return false }
            done = true
            return true
        }
        guard shouldComplete else { return false }
        beforeComplete?()
        task?.setTaskCompleted(success: success)
        return true
    }
}

Для борьбы с гонкой данных я использую этот токен так:

let token = BGTaskCompletionToken()
task.expirationHandler = { token.complete(task, success: false) }
// ... run the sync ...
token.complete(task, success: ok)

Подводные камни планирования фоновых задач

Для каждого идентификатора BGTaskScheduler хранит только один ожидающий запрос. Apple прямо говорит, что произойдёт при отправке второго: «Отправка нового запроса для ещё не выполненной задачи, уже находящейся в очереди, заменяет предыдущий запрос». Поэтому очень важно не попасть в ситуацию, когда вы постоянно отодвигаете задачу дальше в будущее и она в итоге вообще никогда не запускается.

Поскольку я использовал агрессивную стратегию повторного планирования, оба типа задач переотправлялись постоянно: после каждого запуска; после каждого изменения календаря; при каждом переходе приложения в фон. В первой версии я вычислял: desiredEarliest = now + interval и отправлял новый запрос без каких-либо проверок. В итоге refresh-задача, которая должна была запуститься через пять минут, удалялась и заменялась новой задачей на 30 минут позже. Через пять минут очередное повторное планирование снова сдвигало её. Получалось, что iOS всё время была «почти готова» разбудить приложение.

Так делать нельзя. Сначала нужно сравнить уже ожидающий запрос с тем, который вы собираетесь отправить:

static func shouldSkipSubmit(
    existingEarliest: Date?,
    desiredEarliest: Date,
    tolerance: TimeInterval = scheduleReplacementTolerance
) -> Bool {
    guard let existingEarliest else {
        // A nil earliestBeginDate means the existing request can run as
        // soon as the scheduler permits it, so replacing it would only
        // move the request later.
        return true
    }
    // Keep the existing request if it can start at or before the new time.
    // This includes the "already overdue" case (existing earliest in the
    // past): iOS can run it at any time, and resubmitting would push the
    // earliestBeginDate further out. Keep the request that iOS is already
    // waiting on.
    return existingEarliest <= desiredEarliest.addingTimeInterval(tolerance)
}

В итоге я добавил допуск tolerance в 5 секунд, чтобы игнорировать незначительные различия между существующей и новой датами. Каждый новый таск проходит по одному из четырех вариантов:

 

Надежное выполнение фоновых задач в iOS: часть 1

Дерево решений: Большинство путей заканчиваются вариантом «оставить ожидающий запрос без изменений». Последний путь отправляет запрос только в том случае, если новый запрос будет иметь более раннюю дату earliestBeginDate. Защита делает большинство повторных активаций бездействующими, поэтому постоянные повторные активации безопасны.

Второе важное изменение — вычислять время от фиксированной точки. Вместо now + interval я привязываюсь ко времени последнего запуска:

// The interval is 1 hour on iOS 26. It doubles after a failed submit,
// and it never exceeds 4 hours.
let minimumDelay: TimeInterval = processingSchedulingRetryCount > 0 ? 300 : 60
let desiredEarliest = max(
    now.addingTimeInterval(minimumDelay),       // a near-term floor
    lastRun.addingTimeInterval(interval)        // anchored to the last run, not to now
)

Даже корректный запрос, вычисленный от now, при каждом повторном планировании будет уезжать всё дальше в будущее. lastRun + interval сохраняет целевое время стабильным. Для refresh используется интервал 30 минут, для processing — один час.

И ещё: эти 60 секунд в minimumDelay не означают, что iOS запустит приложение через минуту. Документация Apple по earliestBeginDate прямо говорит: «система не гарантирует запуск задачи в указанное время, она лишь гарантирует, что задача не будет запущена раньше». earliestBeginDate — это нижняя граница, а не расписание.

Подводные камни постепенного ухудшения планирования

Чтобы понять, как BGTaskScheduler ведёт себя в реальных условиях, я добавил телеметрию на основе OpenTelemetry и включил её на собственном телефоне.

Примечание для пользователей Calendar Copilot: приложение не отправляет диагностическую телеметрию, если вы явно не включили параметр Diagnostic Telemetry. По умолчанию он выключен. Включать его стоит только тогда, когда я лично помогаю вам диагностировать проблему.

Если разложить фоновые пробуждения по времени суток, они распределяются вовсе не случайно. Processing-задачи выполнялись ночью, пока телефон был подключён к зарядке. Refresh-задачи запускались только в часы бодрствования и группировались вокруг тех периодов, когда я действительно пользовался телефоном.

Надежное выполнение фоновых задач в iOS: часть 1

Типичный день: утром около 7:00 я отключал телефон от зарядки, а около 22:00 снова подключал. Обратите внимание, что два типа фоновых задач почти не пересекаются.

Казалось бы, синхронизация календаря должна происходить хотя бы в каком-то разумном временном окне. Когда я видел, что refresh-запрос остаётся ожидающим много часов после earliestBeginDate, я считал, что он «завис». Поэтому я написал детектор, который принудительно отправлял новый ближайший запрос, если предыдущий просрочен больше чем в два раза относительно заданного интервала.

Но это оказалось ошибкой. Потому что iOS просто запускает задачу тогда, когда считает удобным, а идея о том, что запрос «завис», в большинстве случаев вообще бессмысленна.

Сравним два дня:

Спокойный день Активный день
Запуски refresh 2 9
Всего фоновых пробуждений 11 42
Срабатывания «зависшей» задачи 5 13

В спокойный день refresh запустился два раза, в активный — девять. Приложение, код планирования и 30-минутные запросы были абсолютно одинаковыми. Изменилось только то, насколько активно я пользовался телефоном. А использование приложения — один из факторов, которые Apple прямо упоминает в докладе WWDC 2020. Срабатывания моего детектора «зависших» задач коррелировали с другим показателем — общей частотой пробуждений. Чем чаще телефон просыпался по любой причине, тем чаще выполнялся мой код планирования. Каждый раз он видел тот же самый естественным образом просроченный refresh-запрос и снова помечал его как «зависший». На самом деле ничего не зависало.

Идея с детектором полностью провалилась, потому что iOS не даёт фиксированного расписания, как cron. Этот урок мне пришлось усвоить много раз. Для cron значение «просрочено в два раза относительно интервала» действительно может означать проблему. Но на iOS мои 30-минутные refresh-запросы иногда запускались примерно раз в три часа. Поэтому большую часть времени совершенно здоровый запрос был просрочен более чем на 60 минут. Мой порог был меньше реальной частоты запуска платформы, поэтому он срабатывал на нормальное поведение. Повторная отправка ближайшего запроса не могла заставить iOS выполнить задачу, которую система уже решила пока не запускать.

Ожидающие запросы могут пережить перезагрузку или обновление iOS

Я также обнаружил, что запланированные запросы иногда остаются в очереди даже после перезагрузки устройства или обновления системы. Поэтому в init() я добавил небольшую проверку:

  • если uptime меньше 600 секунд, значит устройство загрузилось меньше десяти минут назад, и причина — "post_reboot";
  • если сохранённая версия iOS отличается от текущей, значит система только что обновилась, и причина — "post_os_upgrade";
  • если ни одно условие не выполнено, используется обычная логика.

После обнаружения перезагрузки или обновления ОС нужно принудительно отправить новый ближайший запрос, обходя shouldSkipSubmit. Здесь принудительное повторное планирование оправдано, потому что перезагрузка или обновление — это реальное, наблюдаемое и одноразовое событие. Просто прошедшее время само по себе никогда не было доказательством того, что запрос застрял. Моя телеметрия показала, что регистрация фоновой задачи действительно пережила перезагрузку. После первой разблокировки iOS запустила задачу. При этом ожидающий запрос сохранил исходный earliestBeginDate. Без принудительной отправки логика пропуска могла бы оставить старую дату и отложить следующее пробуждение ещё на несколько часов.

Как поведение пользователя и состояние устройства влияют на надёжность

Итак, мы увидели, насколько ограничен контроль разработчика над расписанием. Но поведение самого пользователя влияет на него не меньше.

Принудительное закрытие приложения отключает фоновые запуски до следующего ручного старта

Когда пользователь смахивает приложение из переключателя многозадачности, iOS воспринимает это как указание полностью остановить приложение. Куинн, известный как «eskimo» из Developer Technical Support, объясняет это так:

Когда пользователь принудительно закрывает приложение, смахивая его вверх в интерфейсе многозадачности, iOS интерпретирует это как желание пользователя полностью запретить приложению работу.

  • Если приложение сейчас запущено, iOS завершает его.
  • iOS также устанавливает флаг, запрещающий системе запускать приложение в фоне. Этот флаг снимается только после следующего ручного запуска приложения пользователем.

В той же дискуссии Куинн подчёркивает, что смахивание — это явное выражение намерения пользователя, и документированного способа обойти его нет. Тут нечего «чинить» архитектурой. Принудительное закрытие означает отсутствие фонового выполнения до тех пор, пока пользователь сам снова не откроет приложение. Причём ваш код даже не запускается, поэтому определить наличие такого флага невозможно.

Медленное угасание приложения, которое пользователь перестал открывать

Даже без force-quit iOS постепенно даёт приложению всё меньше фоновых запусков, если пользователь перестаёт его открывать. Apple прямо указывает частоту использования приложения как один из факторов, влияющих на распределение фонового времени выполнения.

По моим наблюдениям, когда я регулярно открываю CalCopilot, refresh-задачи выполняются достаточно стабильно изо дня в день. Но во время отпуска, когда я практически не открывал приложение, к концу недели refresh-пробуждения почти исчезли. В другой период, когда приложение не запускалось на переднем плане более двух недель, refresh-задачи прекратились полностью, а processing-задачи тоже начали постепенно исчезать. Это всего лишь одно устройство, поэтому не стоит воспринимать сроки как точные, но по моему опыту 2–3 недели — примерно максимальный период между запусками приложения, если вы хотите, чтобы фоновые задачи продолжали работать.

Надежное выполнение фоновых задач в iOS: часть 1

На временной шкале видно, что после нескольких дней без запуска приложения на переднем плане количество refresh-пробуждений начало падать, а затем они прекратились полностью. Processing-задачи продолжались дольше.

После перезагрузки фоновые задачи не запускаются до первой разблокировки

После перезапуска устройства iOS не запустит вашу фоновую задачу до тех пор, пока пользователь впервые не разблокирует устройство. Apple объясняла это ещё на WWDC 2019:

Мы гарантируем, что задача не будет запущена до первой разблокировки устройства пользователем, поэтому все нужные файлы должны иметь уровень защиты не строже complete until first user authentication.

Важно, что речь именно о первой разблокировке устройства, а не о следующем запуске приложения на переднем плане. На моём устройстве iOS после перезагрузки и первой разблокировки запускала зарегистрированную фоновую задачу ещё до того, как я вручную открывал CalCopilot. Но я бы не называл это полностью надёжным поведением, потому что Apple требует регистрировать обработчики задач до завершения каждого запуска приложения. Поэтому их нужно регистрировать при каждом фоновом и обычном запуске.

Как правильно объяснить всё это платным пользователям

Если ваше приложение, как и моё, работает полностью локально, без серверов и обходных трюков, пользователям нужно объяснить несколько вещей:

  • Иногда открывайте приложение вручную. На моём телефоне refresh-задачи работали стабильнее, если я открывал приложение хотя бы раз в неделю или две.
  • Если уведомления перестали приходить — откройте приложение. Ручной запуск может восстановить фоновое планирование. В Calendar Copilot уведомления помогают сделать проблемы планирования заметными.
  • Не отключайте Background App Refresh. Если функция выключена в настройках, iOS прекращает все запуски BGAppRefreshTask.
  • Избегайте Low Power Mode и подумайте об отключении Adaptive Power. Apple указывает, что Low Power Mode отключает Background App Refresh, а Adaptive Power может автоматически включить Low Power Mode, когда заряд падает до 20%.

Фоновое выполнение всё равно остаётся зависимым от поведения пользователя и состояния устройства, каким бы аккуратным ни был ваш планировщик.

Что дальше

Во второй части я расскажу о большом скачке в надёжности, благодаря которому мне удалось получить почти идеально стабильную синхронизацию каждый час, 24 часа в сутки, неделями без необходимости открывать приложение.

Продолжение следует.

Источник

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: