Мобильная разработка Фоновая работа и пуши: ограничения ОС, планировщики, уведомления
0%

Фоновая работа и пуши: ограничения ОС, планировщики, уведомления

Фоновая работа и пуши: ограничения ОС, планировщики, уведомления

Серверный разработчик, впервые попавший в мобильную разработку, ищет cron. Не находит, удивляется, находит WorkManager или BGTaskScheduler, пишет «раз в 15 минут синхронизируй», выкатывает и получает багрепорт: «у половины пользователей данные обновляются раз в сутки». Это не баг библиотеки. Это устройство мира.

На сервере ваш процесс — главный. Он потребляет столько CPU, сколько ему выделено, и никто не спрашивает, зачем. На телефоне всё наоборот: главный — пользователь, а физический ресурс, за который дерутся все приложения, — заряд аккумулятора и внимание владельца устройства. ОС здесь не сервер приложений, а арбитр с полномочиями отобрать процессорное время, закрыть сеть, отложить будильник и убить процесс без объяснений. Ваш фоновый код — не «задача, которая выполнится», а заявка, которую могут удовлетворить, отложить или молча выбросить.

Отсюда единственная идея, которую нужно унести из статьи: фоновая работа на мобильном — best-effort, а не гарантия. Любая архитектура, где корректность зависит от того, что фоновая задача действительно выполнилась в срок, сломается — не в проде через полгода, а сразу, просто вы об этом узнаете через полгода. Правильная формулировка задачи звучит иначе: «если система даст окно — данные обновятся заранее и открытие будет мгновенным; если не даст — приложение доберёт всё при открытии, и пользователь этого не заметит». Это же соображение, только с другой стороны, лежит в основе оффлайн-архитектуры.

Откуда взялись ограничения

Ни одно из сегодняшних правил не придумали из вредности. Каждое — ответ на конкретный класс злоупотреблений, обнаруженный на телеметрии сотен миллионов устройств.

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

Таксономия: пять разных задач, которые называют «фоном»

Слово «фон» склеивает совершенно разные по механике вещи. Перед выбором API нужно понять, в какую из категорий попадает ваша задача, — они управляются разными подсистемами с разными гарантиями.

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

Практическое правило перед написанием любой фоновой задачи — три вопроса:

  1. Можно ли это вообще не делать? Половина фоновых задач в реальных приложениях — предзагрузка данных, которые пользователь не откроет, и аналитика, которую можно отправить при следующем запуске. Не делать — самый дешёвый вариант.
  2. Можно ли сделать это на сервере? «Пересчитать рекомендации», «проверить, не изменился ли статус», «сформировать дайджест» — это серверные задачи, которые ошибочно поселили на клиенте. Сервер посчитает и пришлёт пуш, когда действительно есть что сказать.
  3. Можно ли отложить до открытия приложения? Если да — откладывайте, но кэшируйте предыдущий результат, чтобы экран открылся мгновенно с данными, пусть и слегка устаревшими.

Только то, что прошло все три фильтра, заслуживает фонового API.

Что вообще происходит с процессом: iOS

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

Ключевой и самый недооценённый момент: Suspended — не «медленно работает», а «не работает вообще». Ваш Timer, ваш URLSession с обычной конфигурацией, ваш while в отдельном потоке — всё замирает. Возобновится, когда система решит, что пора, и «когда» — не ваше решение.

Отдельный узел графа — force quit: смахивание карточки в свитчере. Пользователь этим жестом говорит системе «я не хочу, чтобы это приложение работало». iOS исполняет буквально: silent push перестаёт будить приложение, Background App Refresh для него не планируется, пока человек не запустит приложение вручную. Исключения узкие: значимые изменения местоположения, регионы, PushKit-звонки. Обычные alert-уведомления при этом продолжают приходить и показываться — их рисует система, а не приложение, поэтому «мне пуши приходят, а данные не обновляются» — не парадокс, а ровно это поведение.

API-инструменты iOS

Догрузить начатое. beginBackgroundTask даёт короткое окно после сворачивания — порядка 30 секунд на современных версиях (документированного числа нет, смотрите backgroundTimeRemaining). Это не «фоновая работа», это «не потерять то, что уже началось».

// Не потерять отправку правки, если пользователь свернул приложение прямо в момент запроса.
func flushPendingWrites() {
    var taskID: UIBackgroundTaskIdentifier = .invalid
    taskID = UIApplication.shared.beginBackgroundTask(withName: "flush-writes") {
        // Обработчик истечения: система вот-вот убьёт процесс. Обязаны свернуться сами.
        syncEngine.cancelInFlight()
        UIApplication.shared.endBackgroundTask(taskID); taskID = .invalid
    }
    Task {
        await syncEngine.flushOutbox()                    // короткая работа, секунды
        UIApplication.shared.endBackgroundTask(taskID)    // отдать окно обратно системе
        taskID = .invalid
    }
}

Отложенная работа. BGTaskScheduler (iOS 13+) — единственный легальный способ попросить окно в будущем. Два основных типа: BGAppRefreshTaskRequest — короткое окно (десятки секунд) на обновление контента, и BGProcessingTaskRequest — длинное (минуты) на тяжёлое, обычно ночью на зарядке. Идентификаторы обязаны быть перечислены в Info.plist в ключе BGTaskSchedulerPermittedIdentifiers, а регистрация — произойти до конца didFinishLaunching.

import BackgroundTasks

enum BackgroundJobs {
    static let refresh = "com.example.shop.refresh"
    static let cleanup = "com.example.shop.cleanup"

    // Вызывается строго в didFinishLaunchingWithOptions, до возврата из метода.
    static func register() {
        BGTaskScheduler.shared.register(forTaskWithIdentifier: refresh, using: nil) { task in
            handleRefresh(task as! BGAppRefreshTask)
        }
        BGTaskScheduler.shared.register(forTaskWithIdentifier: cleanup, using: nil) { task in
            handleCleanup(task as! BGProcessingTask)
        }
    }

    static func scheduleRefresh() {
        let request = BGAppRefreshTaskRequest(identifier: refresh)
        // earliestBeginDate — это «не раньше», а не «в это время».
        request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
        try? BGTaskScheduler.shared.submit(request)
    }

    static func scheduleCleanup() {
        let request = BGProcessingTaskRequest(identifier: cleanup)
        request.requiresExternalPower = true      // фактически «ночью на зарядке»
        try? BGTaskScheduler.shared.submit(request)
    }

    private static func handleRefresh(_ task: BGAppRefreshTask) {
        scheduleRefresh()   // перепланировать СРАЗУ: задача не порождает следующую сама

        let work = Task {
            do { try await syncEngine.pullDelta(); task.setTaskCompleted(success: true) }
            catch { task.setTaskCompleted(success: false) }  // неуспех влияет на будущие окна
        }

        // Окно могут отобрать в любой момент. Не завершиться вовремя —
        // получить убитый процесс и штраф в будущем планировании.
        task.expirationHandler = { work.cancel(); task.setTaskCompleted(success: false) }
    }

    private static func handleCleanup(_ task: BGProcessingTask) { /* аналогично */ }
}

Три ошибки, которые делают все: не перепланировать задачу внутри обработчика (она выполнится ровно один раз за всю жизнь установки), не реализовать expirationHandler (процесс убьют, а планировщик запомнит), и трактовать earliestBeginDate как расписание. Планировщик iOS строит модель поведения пользователя: приложение, которое человек открывает каждое утро, получает окно перед этим временем; приложение, которое не открывали месяц, не получит окна вовсе. Это фича, а не баг.

Загрузки, переживающие смерть процесса. Отдельный механизм: URLSession с background(withIdentifier:) передаёт передачу системному демону nsurlsessiond. Она продолжается, когда приложение suspended и даже когда убито, а по завершении система перезапускает приложение в фоне и вызывает application(_:handleEventsForBackgroundURLSession:completionHandler:). Это правильный способ качать большие файлы; флаг isDiscretionary разрешает системе выбрать момент (Wi-Fi, зарядка) и сильно повышает шансы завершиться.

Background modes. В Info.plist объявляются режимы: audio, location, voip, fetch, remote-notification, processing, bluetooth-central и другие. Каждый — обещание перед ревью сторов, что функциональность реально есть и видна пользователю. Объявить location ради того, чтобы «приложение подольше жило», — типовой путь к отклонению по гайдлайну 2.5.4; подробнее о процессе — в статье про релиз и сторы.

Что происходит с процессом: Android

Android устроен иначе: процесс приложения не «замораживается» — он уничтожается, а работа живёт в системных компонентах, которые переживут смерть процесса. Отсюда WorkManager как основной API: задание хранится в базе на диске и выполнится даже после перезагрузки устройства.

Поверх этого работают два механизма экономии энергии, которые надо понимать буквально.

Doze (с Android 6). Экран выключен, устройство неподвижно и не на зарядке — система переводит всё в «глубокий сон»: сетевой доступ закрыт, wakelock’и игнорируются, обычные будильники и джобы отложены. Периодически открывается maintenance window, в котором всё накопившееся выполняется пачкой, — и интервал между окнами растёт: сначала минуты, потом десятки минут, потом часы. Есть и лёгкий режим (Light Doze) для случая «экран погас, но телефон в кармане движется».

App Standby Buckets (с Android 9). Система классифицирует приложения по частоте использования: ACTIVE (используется прямо сейчас), WORKING_SET (регулярно), FREQUENT (раз в несколько дней), RARE (редко), RESTRICTED (с Android 11 — «пожиратель батареи» либо явно ограничен пользователем). От бакета зависят три квоты: как часто выполняются джобы, как часто срабатывают будильники и сколько высокоприоритетных FCM-сообщений в сутки приложению позволено получить. Приложение, которым не пользуются, деградирует до состояния, в котором фон практически отсутствует.

Окна фонового исполнения на iOS и Android: когда ваш код имеет право работать

Схема нелинейна по времени и передаёт главное: зелёные зоны — свободное исполнение, синие — окна, которые выдаёт система, серые — периоды, когда вашего кода нет. Полосы у iOS и Android разной природы (suspend против Doze), но продуктовый эффект один: между окнами вы не существуете.

WorkManager: правильный способ отложенной работы

// Воркер идемпотентен: повтор после падения не должен ломать данные.
class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
    override suspend fun doWork(): Result = try {
        syncEngine.pullDelta()
        syncEngine.flushOutbox()
        Result.success()
    } catch (e: IOException) {
        // Временная сетевая ошибка — retry с backoff, который считает система.
        if (runAttemptCount < 5) Result.retry() else Result.failure()
    } catch (e: SerializationException) {
        Result.failure()   // повторять бессмысленно: контракт сломан, нужен новый релиз
    }
}

fun scheduleSync(context: Context) {
    val constraints = Constraints.Builder()
        .setRequiredNetworkType(NetworkType.UNMETERED)  // только Wi-Fi: трафик стоит денег
        .setRequiresBatteryNotLow(true)
        .build()

    val request = PeriodicWorkRequestBuilder<SyncWorker>(
        repeatInterval = 6, repeatIntervalTimeUnit = TimeUnit.HOURS,
        flexTimeInterval = 1, flexTimeIntervalUnit = TimeUnit.HOURS,  // окно гибкости
    ).setConstraints(constraints)
     .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
     .build()

    WorkManager.getInstance(context).enqueueUniquePeriodicWork(
        "sync",
        ExistingPeriodicWorkPolicy.KEEP,   // не сбрасывать расписание при каждом запуске
        request,
    )
}

Несколько вещей, которые стоят продакшн-инцидентов:

  • Минимальный период периодической работы — 15 минут, и это «не чаще», а не «каждые». Реальная частота в бакете RARE — раз в сутки и реже.
  • ExistingPeriodicWorkPolicy.REPLACE в onCreate — классическая ошибка: расписание сбрасывается при каждом запуске, и работа не выполняется никогда, если приложение открывают часто.
  • Data ограничена ~10 КБ. Передавайте идентификаторы, а данные читайте из базы.
  • Срочная работаsetExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST): система постарается выполнить немедленно, но квота ограничена и зависит от бакета.
  • WorkManager не переживает принудительную остановку приложения пользователем и вендорские «оптимизаторы»; об этом ниже.

Foreground service: когда работа обязана идти сейчас

Если задача идёт прямо сейчас и по инициативе пользователя (загрузка файла, запись трека, воспроизведение), Android требует уведомление на экране — честная сделка: видимость в обмен на исполнение. С Android 14 у сервиса обязан быть объявленный foregroundServiceType с соответствующим разрешением, а Google Play требует заполнить форму с обоснованием. С Android 15 типы dataSync и mediaProcessing получили суммарный лимит около 6 часов в сутки, после чего вызывается onTimeout и сервис обязан остановиться сам.

class TrackRecordingService : Service() {
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        // Уведомление обязано появиться в течение ~10 секунд после старта, иначе крэш;
        // тип сервиса обязателен с Android 14 и должен совпадать с разрешением в манифесте.
        startForeground(
            NOTIF_ID,
            buildNotification("Запись маршрута", "12,4 км · 1:07"),
            ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION,
        )
        recorder.start()
        return START_STICKY
    }
}

Соблазн «сделаю всё foreground-сервисом, зато будет работать» обходится дорого: постоянное уведомление раздражает, Play проверяет обоснование, а пользователь, который отключил уведомление канала, всё равно получит сервис с ограничениями. Foreground-сервис — для того, что пользователь осознанно запустил и видит.

Точные будильники

AlarmManager с точным временем — единственный способ разбудить приложение в конкретную секунду, и именно поэтому его закрутили сильнее всего. С Android 12 нужно разрешение SCHEDULE_EXACT_ALARM, которое пользователь может отозвать; с Android 13 появилось USE_EXACT_ALARM, автоматически выданное, но политика Play разрешает его только будильникам, таймерам и календарям. Проверяйте canScheduleExactAlarms() перед каждым использованием и имейте деградацию на неточный будильник. Всё, что не является «звонок в 7:00», должно жить в WorkManager.

Сравнение гарантий: чего ждать от каждого механизма

Механизм Платформа Гарантия времени Типичная задержка Переживает перезагрузку Требует UI
beginBackgroundTask iOS нет, ~30 с бюджета сразу нет нет
BGAppRefreshTask iOS нет часы, зависит от привычек да нет
BGProcessingTask iOS нет до суток, обычно ночью да нет
Background URLSession iOS нет, но работа не теряется минуты — часы да нет
WorkManager (periodic) Android нет, минимум 15 мин от 15 мин до суток да нет
WorkManager (expedited) Android нет, квота по бакету секунды — минуты да иногда
Foreground service Android да, пока сервис жив сразу нет да
Точный AlarmManager Android да, при наличии разрешения секунды да, при BOOT_COMPLETED нет
Silent push обе нет секунды — никогда да нет
Alert push обе доставка не гарантирована секунды да да

Обратите внимание на столбец «гарантия времени»: жирное «да» стоит ровно там, где пользователь видит происходящее. Это не совпадение, а принцип: исполнение покупается видимостью.

Пуши: как это устроено на самом деле

Пуш — не «сервер отправил сообщение приложению». Пуш — это сигнал через чужую инфраструктуру, адресованный операционной системе, а не вашему коду. На устройстве один постоянный сокет (apsd на iOS, Google Play Services на Android), общий на все приложения: держать по соединению на приложение — верный способ убить батарею, поэтому платформы централизовали канал.

Из этой диаграммы вытекают все практические правила.

Токен принадлежит установке, а не пользователю. На одном устройстве могут смениться аккаунты, у одного пользователя может быть три устройства. Храните таблицу device_tokens с полями user_id, token, platform, app_version, updated_at и чистите её по кодам ошибок: APNs отвечает 410 Unregistered, FCM — UNREGISTERED/NOT_FOUND. Не чистить — значит через год отправлять половину пушей в никуда и портить себе метрики доставки.

Токен меняется. Переустановка, восстановление из бэкапа, очистка данных, иногда обновление ОС. Обработчик onNewToken / didRegisterForRemoteNotificationsWithDeviceToken обязан отправлять токен на сервер при каждом запуске приложения, а не только при первом получении: сервер мог потерять запись, а клиент считает, что уже отправил.

Доставка не гарантирована ни одним из провайдеров. Это записано в документации APNs и FCM прямым текстом. Устройство было офлайн дольше TTL, пользователь отключил уведомления, приложение попало в RESTRICTED, квота высокоприоритетных сообщений исчерпана, вендорский «оптимизатор» выгрузил процесс — сообщения просто нет.

Анатомия payload

Анатомия push-payload: какое поле что делает на устройстве

Ключевое правило схемы: в payload кладут ссылку на данные, а не данные. Размер ограничен 4 КБ, канал ненадёжен, порядок доставки не гарантирован, а содержимое проходит через чужую инфраструктуру. Правильный контракт — «пуш звонит в дверь, клиент идёт за данными сам»: в payload лежит тип события и курсор, приложение делает обычный запрос синхронизации и получает консистентное состояние. Это заодно решает проблему двух пушей, пришедших не в том порядке.

// Бэкенд: одна отправка, два формата. Общая часть — только доменное событие.
type OrderEvent = { orderId: string; title: string; body: string; cursor: string };

function apnsPayload(e: OrderEvent) {
  return {
    aps: {
      alert: { title: e.title, body: e.body },
      sound: "default",
      "thread-id": `order-${e.orderId}`,       // группировка в одну стопку
      category: "ORDER_ACTIONS",               // какие кнопки показать
      "interruption-level": "time-sensitive",  // пробивает Focus, требует entitlement
      "mutable-content": 1,                    // разрешить расширению доработать контент
    },
    order_id: e.orderId,
    cursor: e.cursor,                          // по нему клиент дотянет данные
  };
}

// Заголовки APNs так же важны, как тело:
//   apns-push-type: alert | background | voip | liveactivity — обязателен
//   apns-priority: 10 (немедленно) | 5 (энергоэффективно, обязателен для background)
//   apns-expiration: unix-время, после которого сообщение бессмысленно
//   apns-collapse-id: новые сообщения с тем же id вытесняют старые в очереди

function fcmMessage(e: OrderEvent, token: string) {
  return {
    message: {
      token,
      // notification-часть рисует система, когда приложение в фоне,
      // и onMessageReceived при этом НЕ вызывается — частый источник недоумения.
      notification: { title: e.title, body: e.body },
      data: { order_id: e.orderId, cursor: e.cursor },   // только строки
      android: {
        priority: "HIGH",          // пробивает Doze, но расходует квоту бакета
        ttl: "3600s",
        collapseKey: `order-${e.orderId}`,
        notification: { channelId: "orders", tag: `order-${e.orderId}` },
      },
    },
  };
}

Silent push: единственный способ разбудить приложение по событию

Silent push — сообщение без видимой части, чья цель — дать приложению шанс поработать. На iOS это "content-available": 1 с заголовками apns-push-type: background и apns-priority: 5 (с приоритетом 10 такой пуш будет отвергнут). На Android — data-only сообщение.

Ограничения жёсткие и о них важно знать заранее:

  • iOS даёт бюджет порядка нескольких silent push в час, адаптивный: система смотрит, делаете ли вы что-то полезное. Пуши сверх бюджета придерживаются или выбрасываются.
  • В режиме энергосбережения silent push не доставляются.
  • После force quit silent push не будят приложение до ручного запуска.
  • На Android data-only сообщение с обычным приоритетом ждёт maintenance window Doze; с высоким — пробивает Doze, но расходует суточную квоту бакета. Систематическое злоупотребление высоким приоритетом ради того, что не требует срочности, ведёт к понижению квоты.

Отсюда честный вывод: silent push — оптимизация, а не транспорт. Он ускоряет доставку данных, когда всё сложилось, и не выполняет обязательств, когда не сложилось. Функциональность вида «баланс обновляется только по silent push» сломана по построению.

Notification Service Extension и обработчики

Когда контент нужно доработать на устройстве — расшифровать (сервер не должен знать текст сообщений в E2E-мессенджере), подтянуть картинку, локализовать, — используется отдельный процесс-расширение.

// iOS: NotificationService.swift в отдельном таргете-расширении.
// Отдельный процесс, лимит памяти около 24 МБ, не больше 30 секунд.
final class NotificationService: UNNotificationServiceExtension {
    private var handler: ((UNNotificationContent) -> Void)?
    private var content: UNMutableNotificationContent?

    override func didReceive(
        _ request: UNNotificationRequest,
        withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
    ) {
        handler = contentHandler
        content = request.content.mutableCopy() as? UNMutableNotificationContent
        guard let content else { return contentHandler(request.content) }
        // Ключ лежит в Keychain с группой доступа, общей у приложения и расширения.
        if let blob = content.userInfo["ciphertext"] as? String,
           let text = try? MessageCrypto.decrypt(blob) { content.body = text }
        contentHandler(content)
    }

    // Время вышло — обязаны отдать хоть что-то, иначе пользователь увидит заглушку.
    override func serviceExtensionTimeWillExpire() { if let content { handler?(content) } }
}
// Android: сервис вызывается для data-сообщений; для notification-сообщений
// в фоне уведомление рисует система и сюда управление НЕ приходит.
class AppMessagingService : FirebaseMessagingService() {

    override fun onNewToken(token: String) {
        // Токен сменился: отправить на бэкенд через WorkManager,
        // потому что прямо сейчас сети может не быть.
        WorkManager.getInstance(this).enqueue(
            OneTimeWorkRequestBuilder<RegisterTokenWorker>()
                .setInputData(workDataOf("token" to token))
                .setConstraints(Constraints.Builder()
                    .setRequiredNetworkType(NetworkType.CONNECTED).build())
                .build()
        )
    }

    override fun onMessageReceived(message: RemoteMessage) {
        // Времени мало (порядка 10-20 секунд) и процесс могут убить.
        // Тяжёлое отдаём WorkManager, здесь — только показать уведомление.
        val cursor = message.data["cursor"] ?: return
        syncTrigger.enqueue(cursor)
        message.notification?.let { showNotification(it.title, it.body) }
    }
}

Где пуш теряется: карта потерь

Когда продакт спрашивает «почему до пользователя не дошло», полезно иметь перед глазами весь путь и все точки отказа, а не гадать.

Практический вывод для инструментирования: считать нужно не «отправлено», а воронку отправлено → принято провайдером → показано на устройстве → открыто. Первые два числа даёт бэкенд, третье и четвёртое — только клиент, и без клиентской телеметрии вы обсуждаете доставку вслепую. Подробнее о том, как строить такие воронки, — в статье о метриках продукта.

Разрешения на уведомления: единственный необратимый диалог

Уведомления — разрешение, которое пользователь даёт один раз, а отзывает навсегда. Отказ восстанавливается только походом в системные настройки, куда никто не ходит.

  • iOS: UNUserNotificationCenter.requestAuthorization показывает системный диалог. Есть provisional authorization — тихая доставка сразу в центр уведомлений без диалога, с возможностью пользователя одним нажатием разрешить или запретить. Для приложений, чья ценность уведомлений неочевидна на первом экране, это часто лучший вариант.
  • Android 13+: POST_NOTIFICATIONS — обычное рантайм-разрешение, со всеми последствиями, включая «не спрашивать снова» после двух отказов.
  • Каналы Android (с 8.0): уведомления обязаны идти в канал, и важность канала после создания менять нельзя — пользователь главнее. Проектируйте каналы как продуктовые категории («заказы», «акции», «чат»), а не как технические ярлыки: человек, отключивший «акции», сохранит «заказы», и вы не потеряете канал коммуникации целиком.

Тайминг запроса решает всё. Диалог на первом экране приложения — это отказ по умолчанию, потому что человек ещё не понимает ценности. Правильный паттерн — прайминг: сначала свой экран, объясняющий пользу («сообщим, когда курьер выедет»), с кнопками «хорошо» и «позже», и системный диалог только после согласия. Свой экран можно показать повторно; системный — нет.

// Спрашиваем ровно в момент, когда ценность очевидна: после оформления заказа.
func requestNotificationsAfterCheckout() async {
    let center = UNUserNotificationCenter.current()
    let settings = await center.notificationSettings()
    guard settings.authorizationStatus == .notDetermined else { return }

    // primingScreen — собственный экран с объяснением; его можно показать ещё раз.
    guard await primingScreen.confirm() else { return }

    let granted = try? await center.requestAuthorization(options: [.alert, .sound, .badge])
    if granted == true {
        await MainActor.run { UIApplication.shared.registerForRemoteNotifications() }
    }
}

Кроссплатформа: что меняется и что нет

Ключевая мысль, к которой трек возвращается регулярно: фреймворк не отменяет ограничения ОС. Flutter не отменит Doze, React Native не выдаст лишний silent push. Меняется только слой, на котором вы пишете код, и цена доступа к платформенным деталям.

// Flutter: фоновый обработчик обязан быть top-level функцией с аннотацией,
// потому что запускается в ОТДЕЛЬНОМ изоляте без вашего DI и состояния приложения.
@pragma('vm:entry-point')
Future<void> onBackgroundMessage(RemoteMessage message) async {
  // В этом изоляте нет ничего: инициализируем всё заново.
  await Firebase.initializeApp();
  final cursor = message.data['cursor'];
  if (cursor != null) {
    await SyncQueue.open().enqueue(cursor);   // работу кладём в очередь на диске
  }
}

void main() {
  FirebaseMessaging.onBackgroundMessage(onBackgroundMessage);
  runApp(const ShopApp());
}

Честная картина по стекам:

  • Flutter. firebase_messaging для пушей, flutter_local_notifications для локальных, плагин workmanager оборачивает WorkManager и BGTaskScheduler. Отдельный изолят для фонового обработчика — источник большинства ошибок: там нет ваших синглтонов и провайдеров.
  • React Native. @react-native-firebase/messaging с setBackgroundMessageHandler, Headless JS на Android, expo-notifications и expo-background-task в экосистеме Expo. JS-поток в фоне запускается заново, время исполнения жёстко ограничено.
  • Kotlin Multiplatform. Общей остаётся доменная логика («что делать при событии»), а планирование и получение пушей пишутся отдельно под каждую платформу через expect/actual — и это скорее плюс: этот код обязан быть платформенным.

Сравнение подходов целиком разобрано в статье о кроссплатформе; здесь важен один вывод для планирования бюджета. Фон и пуши — та зона, где кроссплатформенная экономия меньше всего. Плагины покрывают типовые сценарии, но как только появляются нестандартные требования (свой NSE с расшифровкой, foreground-сервис с типом и обоснованием, Live Activity, VoIP-пуши), вы пишете нативный код на обеих платформах плюс мост. Закладывайте в оценку нативную работу, даже если продукт кроссплатформенный.

Вендорский фактор на Android

Отдельная боль, о которой не пишут в официальной документации: производители устройств добавляют собственные ограничения поверх AOSP. Xiaomi, Huawei, Oppo, Vivo, Samsung в разной степени выгружают фоновые процессы, игнорируют WorkManager и отменяют будильники. Сайт dontkillmyapp.com ведёт каталог поведения по вендорам — это обязательное чтение, если ваш продукт работает на этих рынках.

Что с этим делать практически: не бороться, а проектировать под худший случай. Догонять состояние при каждом открытии приложения; для критичных сценариев (будильник, трекер сна) вежливо объяснить пользователю, как добавить приложение в исключения, и дать прямую ссылку в нужный экран настроек (ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, помня, что политика Play разрешает его лишь узкому классу приложений). И обязательно — тестировать на реальных устройствах этих вендоров, а не только на Pixel и эмуляторе; о фермах устройств — в статье о тестировании.

Как это отлаживать

Фоновая работа не отлаживается «запустил и посмотрел»: нормальный цикл — часы ожидания. Поэтому обе платформы дают способы вызвать окно принудительно.

# --- Android ---
# Загнать устройство в Doze прямо сейчас и посмотреть, что переживёт.
adb shell dumpsys battery unplug
adb shell dumpsys deviceidle force-idle
adb shell dumpsys deviceidle step deep      # шагами провести по фазам

# Перевести приложение в «редкий» бакет и увидеть реальные квоты.
adb shell am set-standby-bucket com.example.shop rare
adb shell am get-standby-bucket com.example.shop

# Посмотреть, что WorkManager реально запланировал, и запустить джоб вручную.
adb shell dumpsys jobscheduler | grep -A 20 com.example.shop
adb shell cmd jobscheduler run -f com.example.shop <jobId>

# Вернуть всё в норму, иначе будете отлаживать «странное поведение» неделю.
adb shell dumpsys deviceidle unforce
adb shell dumpsys battery reset

# --- iOS ---
# Отправить пуш в симулятор из файла payload.
xcrun simctl push booted com.example.shop payload.apns
# В отладчике Xcode: принудительно запустить или «просрочить» фоновую задачу.
#   e -l objc -- (void)[[BGTaskScheduler sharedScheduler]
#       _simulateLaunchForTaskWithIdentifier:@"com.example.shop.refresh"]

Что мерить в проде:

  • Воронка пушей: отправлено, принято провайдером, показано, открыто — с разбивкой по типу сообщения, платформе и версии ОС. Падение «показано/принято» на конкретной версии — сигнал о сломанном канале или новом ограничении.
  • Свежесть данных на открытии: доля сессий, где данные оказались старше N минут. Это прямая метрика полезности вашей фоновой синхронизации; если она не меняется от включения фона — фон не работает и его можно выключить.
  • Энергия: MetricKit на iOS (MXAppLaunchMetric, MXCellularConditionMetric, фоновое время), Android vitals в Play Console (excessive wakeups, stuck partial wake locks, excessive background Wi-Fi scans). Плохие показатели там — не «косметика»: система начнёт ограничивать приложение сама, а Play Console покажет предупреждение в листинге.
  • Доля пользователей с разрешением на уведомления по когортам и по месту запроса. Это продуктовая метрика, которая напрямую связана с retention.

Типичные ошибки

  1. Считать, что фон выполнится. Любая функциональность, где «данные обновляются только в фоне», сломана. Всё, что делает фон, обязано выполняться и при открытии приложения.
  2. Не перепланировать BGTask внутри обработчика. Задача выполнится ровно один раз за жизнь установки, и вы будете долго искать, почему.
  3. Игнорировать expirationHandler и onStopped. Процесс убьют жёстко, планировщик занесёт в чёрный список, следующие окна станут реже.
  4. ExistingPeriodicWorkPolicy.REPLACE при каждом старте. Расписание сбрасывается, работа не выполняется никогда у активных пользователей.
  5. Свои таймеры вместо планировщика. Timer/setInterval не переживают suspend, не склеиваются с окнами других приложений и обходятся дороже по батарее в разы.
  6. Слать данные в payload пуша. 4 КБ, ненадёжно, без порядка, читается провайдером. Шлите ссылку и курсор.
  7. Не чистить токены по кодам ошибок. Через год половина базы токенов мёртвая, метрики доставки бессмысленны, а расходы на отправку реальны.
  8. Ставить всем сообщениям высокий приоритет. Квота бакета кончится на маркетинговых пушах, и в момент, когда придёт действительно важное сообщение, его придержат.
  9. Просить разрешение на уведомления на первом экране. Отказ навсегда — самая дорогая ошибка всей темы, потому что она необратима.
  10. Один канал уведомлений на всё. Пользователь, отключивший «акции», отключает вместе с ними сообщения о заказе, и вы теряете канал целиком.
  11. Foreground-сервис как способ обойти ограничения. Работает до ревью Play и до первого отзыва про «висит уведомление».
  12. Тестировать фон только на Pixel и симуляторе. Реальные потери фона живут на устройствах вендоров, которых нет в вашей команде.

Мини-итог

Фоновая работа на мобильном — это переговоры с операционной системой, а не вызов планировщика. Система выдаёт окна по своим эвристикам: как часто человек открывает приложение, на зарядке ли устройство, в Doze ли оно, в каком бакете приложение и не смахнули ли его из свитчера. Ни один API — ни BGTaskScheduler, ни WorkManager, ни silent push — не даёт гарантии времени; гарантию даёт только видимая пользователю работа: foreground-сервис на Android и точный будильник по явному разрешению.

Отсюда проектная дисциплина. Фон — это ускорение, а не единственный путь: всё, что делает фоновая задача, обязано выполняться и при открытии приложения. Пуш — это звонок в дверь, а не транспорт: в payload идёт тип события и курсор, данные клиент забирает обычным идемпотентным запросом синхронизации. Приоритеты и квоты — ограниченный ресурс, который тратят на срочное, а не на маркетинг. Разрешение на уведомления — необратимый актив, который просят в момент очевидной ценности и защищают продуманными каналами. А ограничения ОС стоит воспринимать не как препятствие, а как встроенное в платформу требование к качеству: приложение, которое хорошо живёт внутри этих рамок, экономит батарею пользователя и получает за это лучшие окна.

Источники

Что дальше

Производительность мобильного: старт, плавность, батарея, трафик — как измерить холодный старт и время до первого кадра, откуда берутся пропущенные кадры, чем именно фоновая активность из этой статьи оплачивается в процентах заряда и как отличить настоящую регрессию производительности от шума на одном устройстве.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов