Фоновая работа и пуши: ограничения ОС, планировщики, уведомления
Серверный разработчик, впервые попавший в мобильную разработку, ищет cron. Не находит, удивляется, находит WorkManager или BGTaskScheduler, пишет «раз в 15 минут синхронизируй», выкатывает и получает багрепорт: «у половины пользователей данные обновляются раз в сутки». Это не баг библиотеки. Это устройство мира.
На сервере ваш процесс — главный. Он потребляет столько CPU, сколько ему выделено, и никто не спрашивает, зачем. На телефоне всё наоборот: главный — пользователь, а физический ресурс, за который дерутся все приложения, — заряд аккумулятора и внимание владельца устройства. ОС здесь не сервер приложений, а арбитр с полномочиями отобрать процессорное время, закрыть сеть, отложить будильник и убить процесс без объяснений. Ваш фоновый код — не «задача, которая выполнится», а заявка, которую могут удовлетворить, отложить или молча выбросить.
Отсюда единственная идея, которую нужно унести из статьи: фоновая работа на мобильном — best-effort, а не гарантия. Любая архитектура, где корректность зависит от того, что фоновая задача действительно выполнилась в срок, сломается — не в проде через полгода, а сразу, просто вы об этом узнаете через полгода. Правильная формулировка задачи звучит иначе: «если система даст окно — данные обновятся заранее и открытие будет мгновенным; если не даст — приложение доберёт всё при открытии, и пользователь этого не заметит». Это же соображение, только с другой стороны, лежит в основе оффлайн-архитектуры.
Откуда взялись ограничения
Ни одно из сегодняшних правил не придумали из вредности. Каждое — ответ на конкретный класс злоупотреблений, обнаруженный на телеметрии сотен миллионов устройств.
Тренд однозначен и в обе стороны не колеблется: свободы фонового исполнения становится меньше с каждым релизом. Архитектурный вывод — не «выжать максимум из текущих лазеек», а построить систему, которая переживёт следующее ужесточение без переписывания. Практически это значит: не полагаться на фон как на единственный путь получения данных, отдавать планирование системе, а не своим таймерам, и держать бизнес-логику независимой от того, кто именно её разбудил.
Таксономия: пять разных задач, которые называют «фоном»
Слово «фон» склеивает совершенно разные по механике вещи. Перед выбором API нужно понять, в какую из категорий попадает ваша задача, — они управляются разными подсистемами с разными гарантиями.
работа)) Доделать начатое Загрузка формы после сворачивания Секунды, не минуты iOS beginBackgroundTask Android короткий Service или WorkManager Отложенная работа Синхронизация и предзагрузка Отправка аналитики пачкой Очистка кэша и индексов Планировщик решает когда Видимая пользователю задача Скачивание файла с прогрессом Запись трека и навигация Плеер и звонок Обязательно уведомление на экране Реакция на внешнее событие Пуш о новом сообщении Геозона и Bluetooth Инициатор снаружи Непрерывный сбор данных Фитнес и локация Самый дорогой класс Требует явного разрешения и обоснования в сторе
Разница принципиальна. Отложенная работа дешева, потому что система вправе склеить её с задачами других приложений в общее окно пробуждения — одно включение радио и процессора на десяток приложений вместо десяти. Видимая задача дорога, но допустима, потому что пользователь её видит и в любой момент может остановить. Непрерывный сбор — красная зона: это главный источник отказов на ревью и главный источник отзывов «жрёт батарею».
Практическое правило перед написанием любой фоновой задачи — три вопроса:
- Можно ли это вообще не делать? Половина фоновых задач в реальных приложениях — предзагрузка данных, которые пользователь не откроет, и аналитика, которую можно отправить при следующем запуске. Не делать — самый дешёвый вариант.
- Можно ли сделать это на сервере? «Пересчитать рекомендации», «проверить, не изменился ли статус», «сформировать дайджест» — это серверные задачи, которые ошибочно поселили на клиенте. Сервер посчитает и пришлёт пуш, когда действительно есть что сказать.
- Можно ли отложить до открытия приложения? Если да — откладывайте, но кэшируйте предыдущий результат, чтобы экран открылся мгновенно с данными, пусть и слегка устаревшими.
Только то, что прошло все три фильтра, заслуживает фонового 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 разной природы (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), общий на все приложения: держать по соединению на приложение — верный способ убить батарею, поэтому платформы централизовали канал.
пользователь + установка,
а не к пользователю BE->>P: POST /3/device/{token} + payload
заголовки: push-type, priority, expiration alt токен протух P-->>BE: 410 Unregistered / UNREGISTERED BE->>BE: удалить токен из базы else принято P-->>BE: 200 + apns-id P->>OS: доставка по постоянному сокету Note over P,OS: устройство офлайн →
хранится до TTL,
потом выбрасывается OS->>OS: решение: показать / разбудить / отложить OS-->>App: расширение или делегат, если позволено App->>BE: GET /sync?cursor=... — за настоящими данными end
Из этой диаграммы вытекают все практические правила.
Токен принадлежит установке, а не пользователю. На одном устройстве могут смениться аккаунты, у одного пользователя может быть три устройства. Храните таблицу device_tokens с полями user_id, token, platform, app_version, updated_at и чистите её по кодам ошибок: APNs отвечает 410 Unregistered, FCM — UNREGISTERED/NOT_FOUND. Не чистить — значит через год отправлять половину пушей в никуда и портить себе метрики доставки.
Токен меняется. Переустановка, восстановление из бэкапа, очистка данных, иногда обновление ОС. Обработчик onNewToken / didRegisterForRemoteNotificationsWithDeviceToken обязан отправлять токен на сервер при каждом запуске приложения, а не только при первом получении: сервер мог потерять запись, а клиент считает, что уже отправил.
Доставка не гарантирована ни одним из провайдеров. Это записано в документации APNs и FCM прямым текстом. Устройство было офлайн дольше TTL, пользователь отключил уведомления, приложение попало в RESTRICTED, квота высокоприоритетных сообщений исчерпана, вендорский «оптимизатор» выгрузил процесс — сообщения просто нет.
Анатомия 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) }
}
}
Где пуш теряется: карта потерь
Когда продакт спрашивает «почему до пользователя не дошло», полезно иметь перед глазами весь путь и все точки отказа, а не гадать.
или токен не почистили] B -->|да| C[Отправка в APNs / FCM] C --> D{Ответ провайдера} D -->|4xx| L2[Потеря: невалидный токен,
сломанный payload, протухший JWT] D -->|200| E{Устройство онлайн?} E -->|нет| F[Хранится до TTL] F -->|TTL истёк| L3[Потеря: сообщение выброшено] F -->|устройство вернулось| G E -->|да| G{Разрешения включены?} G -->|нет| L4[Потеря: пользователь отключил
уведомления или канал] G -->|да| H{Тип сообщения} H -->|alert| I[Показано системой] H -->|silent / data| J{Бюджет и режим питания} J -->|исчерпан, Doze, Low Power, force quit| L5[Потеря: приложение не разбужено] J -->|ок| K[Приложение получило управление] I --> M{Пользователь нажал?} M -->|нет| L6[Не потеря, но и не конверсия] M -->|да| N[Открыт нужный экран по deep link] K --> N
Практический вывод для инструментирования: считать нужно не «отправлено», а воронку отправлено → принято провайдером → показано на устройстве → открыто. Первые два числа даёт бэкенд, третье и четвёртое — только клиент, и без клиентской телеметрии вы обсуждаете доставку вслепую. Подробнее о том, как строить такие воронки, — в статье о метриках продукта.
Разрешения на уведомления: единственный необратимый диалог
Уведомления — разрешение, которое пользователь даёт один раз, а отзывает навсегда. Отказ восстанавливается только походом в системные настройки, куда никто не ходит.
- 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.
Типичные ошибки
- Считать, что фон выполнится. Любая функциональность, где «данные обновляются только в фоне», сломана. Всё, что делает фон, обязано выполняться и при открытии приложения.
- Не перепланировать
BGTaskвнутри обработчика. Задача выполнится ровно один раз за жизнь установки, и вы будете долго искать, почему. - Игнорировать
expirationHandlerиonStopped. Процесс убьют жёстко, планировщик занесёт в чёрный список, следующие окна станут реже. ExistingPeriodicWorkPolicy.REPLACEпри каждом старте. Расписание сбрасывается, работа не выполняется никогда у активных пользователей.- Свои таймеры вместо планировщика.
Timer/setIntervalне переживают suspend, не склеиваются с окнами других приложений и обходятся дороже по батарее в разы. - Слать данные в payload пуша. 4 КБ, ненадёжно, без порядка, читается провайдером. Шлите ссылку и курсор.
- Не чистить токены по кодам ошибок. Через год половина базы токенов мёртвая, метрики доставки бессмысленны, а расходы на отправку реальны.
- Ставить всем сообщениям высокий приоритет. Квота бакета кончится на маркетинговых пушах, и в момент, когда придёт действительно важное сообщение, его придержат.
- Просить разрешение на уведомления на первом экране. Отказ навсегда — самая дорогая ошибка всей темы, потому что она необратима.
- Один канал уведомлений на всё. Пользователь, отключивший «акции», отключает вместе с ними сообщения о заказе, и вы теряете канал целиком.
- Foreground-сервис как способ обойти ограничения. Работает до ревью Play и до первого отзыва про «висит уведомление».
- Тестировать фон только на Pixel и симуляторе. Реальные потери фона живут на устройствах вендоров, которых нет в вашей команде.
Мини-итог
Фоновая работа на мобильном — это переговоры с операционной системой, а не вызов планировщика. Система выдаёт окна по своим эвристикам: как часто человек открывает приложение, на зарядке ли устройство, в Doze ли оно, в каком бакете приложение и не смахнули ли его из свитчера. Ни один API — ни BGTaskScheduler, ни WorkManager, ни silent push — не даёт гарантии времени; гарантию даёт только видимая пользователю работа: foreground-сервис на Android и точный будильник по явному разрешению.
Отсюда проектная дисциплина. Фон — это ускорение, а не единственный путь: всё, что делает фоновая задача, обязано выполняться и при открытии приложения. Пуш — это звонок в дверь, а не транспорт: в payload идёт тип события и курсор, данные клиент забирает обычным идемпотентным запросом синхронизации. Приоритеты и квоты — ограниченный ресурс, который тратят на срочное, а не на маркетинг. Разрешение на уведомления — необратимый актив, который просят в момент очевидной ценности и защищают продуманными каналами. А ограничения ОС стоит воспринимать не как препятствие, а как встроенное в платформу требование к качеству: приложение, которое хорошо живёт внутри этих рамок, экономит батарею пользователя и получает за это лучшие окна.
Источники
- Apple. Background Tasks —
BGTaskScheduler, типы запросов, требования к регистрации. - Apple. Choosing Background Strategies for Your App — какой механизм под какую задачу.
- Apple. Pushing background updates to your App — silent push, заголовки и бюджеты.
- Apple. Sending notification requests to APNs — HTTP/2-протокол, заголовки, коды ошибок.
- Apple. UNNotificationServiceExtension — доработка контента на устройстве.
- Apple. Downloading files in the background — background
URLSession. - Android Developers. Background work overview — карта всех механизмов и правила выбора.
- Android Developers. Optimize for Doze and App Standby — фазы Doze, окна обслуживания, исключения.
- Android Developers. App Standby Buckets — бакеты и связанные с ними квоты.
- Android Developers. WorkManager и Foreground services — включая типы сервисов и таймауты Android 15.
- Android Developers. Schedule alarms — точные будильники и их разрешения.
- Firebase. About FCM messages — типы сообщений, приоритеты, TTL, collapse key.
- Firebase. Receive messages on Android — поведение
onMessageReceivedв фоне и на переднем плане. - dontkillmyapp.com — каталог вендорских ограничений фона на Android.
- Apple. App Review Guidelines, раздел 4.5.4 — что запрещено делать с пушами.
- Про семантику доставки «как минимум один раз» и дедупликацию на приёмнике — Идемпотентность и гарантии доставки.
- Про то, почему ОС вообще управляет пробуждениями процессов, — Процессы и планирование.
Что дальше
Производительность мобильного: старт, плавность, батарея, трафик — как измерить холодный старт и время до первого кадра, откуда берутся пропущенные кадры, чем именно фоновая активность из этой статьи оплачивается в процентах заряда и как отличить настоящую регрессию производительности от шума на одном устройстве.