iOS 后台任务与 APNs 推送

系统梳理 iOS 后台执行与推送两条链路,讲清 BGTaskScheduler 的后台刷新与处理任务、后台 URLSession 会话、静默推送与 content-available 的配额限制、APNs 令牌与证书密钥鉴权、通知扩展与富媒体通知、到达率与限流,并给出调试与验证手段。

开篇

「App 退到后台还能干活吗」这个问题,iOS 给的答案是「有限度地可以」。和 Android 的常驻服务、前台服务不同,iOS 的后台执行是系统授予的临时配额:你申请、系统决定给不给、给多久、什么时候给。不理解这套配额模型,写出来的后台逻辑在模拟器上跑得好好的,上架后在真机上永远不触发。

推送是另一条独立链路。它经常被当成「后台任务的替代品」——用静默推送唤醒 App 去同步数据。这条路的实际到达率远低于开发者的预期,且受系统限流、用户设置、网络状态三重影响。

本文按「后台模型 → BGTaskScheduler → 后台 URLSession → 时间配额 → APNs 基础 → 鉴权 → 静默推送 → 通知扩展 → 到达率 → 调试」的顺序展开,基于 iOS 17/18 与 Xcode 15/16。结论先给:后台刷新只能做「尽力而为」的补数据,关键同步必须靠用户主动触发或推送兜底。

一、iOS 的后台执行模型

先厘清概念。App 进入后台后,状态有四种:

状态说明能执行代码
Active前台运行是
Inactive过渡态(来电、切 App 动画)是
Background后台,短时间可运行数秒到 30 秒
Suspended挂起,内存保留但不执行否
Terminated被系统回收否

从 background 到 suspended 的过渡由系统决定,通常在你从 applicationDidEnterBackground 返回后的几秒内。beginBackgroundTask 可以再争取一段有限时间:

var taskID: UIBackgroundTaskIdentifier = .invalid

func applicationDidEnterBackground(_ app: UIApplication) {
    taskID = app.beginBackgroundTask(withName: "finish-upload") {
        // 到期回调:必须在这里结束任务,否则会被强杀
        app.endBackgroundTask(self.taskID)
        self.taskID = .invalid
    }
    uploader.finish { [weak self] in
        guard let self, self.taskID != .invalid else { return }
        UIApplication.shared.endBackgroundTask(self.taskID)
        self.taskID = .invalid
    }
}

beginBackgroundTask 不是「后台执行许可」,而是一次性的延期执行额度,历史上约 180 秒,近几个版本实际更短。超时不调用 endBackgroundTask 会导致 App 被系统终止并记入崩溃日志(0x8badf00d 类似的后台超时)。

真正能长期存在的后台能力只有三类:

  • 后台刷新(BGAppRefreshTask):系统在它认为合适的时候唤醒 App 几秒钟。
  • 后台处理(BGProcessingTask):通常在有电源和网络时运行较长时间的任务。
  • 后台传输(background URLSession):把传输交给系统守护进程,App 不在也能继续。

此外还有定位(CLLocationManager 的 significant change)、音频播放、VoIP 推送、蓝牙外设等特殊后台模式,都需要在 UIBackgroundModes 里声明对应能力,并在上架审核时说明用途。声明了不用的能力会被审核拒绝。

二、BGTaskScheduler 的两种任务

BGTaskScheduler(iOS 13+)取代了老的 UIApplication.setMinimumBackgroundFetchInterval。它有两类任务,语义差别很大。

BGAppRefreshTask:轻量、短时(约 30 秒),用于「刷新一下列表」这类工作。系统会根据用户使用习惯预测何时唤醒。

BGProcessingTask:重量、长时(可达数分钟),要求设备空闲且通常需要充电,适合数据库整理、大文件下载、模型更新。

注册必须在 didFinishLaunchingWithOptions 里完成,且标识符要与 Info.plist 的 BGTaskSchedulerPermittedIdentifiers 一致:

import BackgroundTasks

func registerBackgroundTasks() {
    BGTaskScheduler.shared.register(
        forTaskWithIdentifier: "com.example.app.refresh",
        using: nil
    ) { task in
        handleAppRefresh(task as! BGAppRefreshTask)
    }

    BGTaskScheduler.shared.register(
        forTaskWithIdentifier: "com.example.app.processing",
        using: nil
    ) { task in
        handleProcessing(task as! BGProcessingTask)
    }
}

提交任务时用 earliestBeginDate 表达「不早于何时」,注意它是下限而非承诺:

func scheduleRefresh() {
    let request = BGAppRefreshTaskRequest(identifier: "com.example.app.refresh")
    request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)   // 15 分钟后
    do { try BGTaskScheduler.shared.submit(request) }
    catch { print("调度失败: \(error)") }
}

处理函数必须在任务到期前调用 setTaskCompleted(success:),并且要响应 expirationHandler:

func handleAppRefresh(_ task: BGAppRefreshTask) {
    scheduleRefresh()   // 关键:先排下一次,否则只跑一次

    let operation = RefreshOperation()
    task.expirationHandler = { operation.cancel() }   // 到期即取消

    operation.completionBlock = {
        task.setTaskCompleted(success: !operation.isCancelled)
    }
    queue.addOperation(operation)
}

三个容易漏的点:必须在处理函数开头重新排下一次,否则任务只执行一次;expirationHandler 里要真正取消正在做的工作;setTaskCompleted 只允许调用一次。

调试时可以强制触发,但只能在真机 + 调试器下:

Xcode 调试时在 LLDB 里执行:
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.example.app.refresh"]

模拟器上后台任务基本不会按预期触发,这是最常见的「代码没问题但就是不走」的原因。

三、后台 URLSession

如果你的后台工作本质是「传数据」,不要用 BGTaskScheduler 硬扛,直接用后台会话。系统会把传输交给独立的守护进程 nsurlsessiond,App 被挂起甚至被终止后传输仍会继续,完成后系统再唤醒 App 回调。

let config = URLSessionConfiguration.background(withIdentifier: "com.example.app.upload")
config.isDiscretionary = false        // true 表示允许系统择机执行(更省电但更慢)
config.sessionSendsLaunchEvents = true // 完成后唤醒 App
config.waitsForConnectivity = true

let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
let task = session.uploadTask(with: request, fromFile: fileURL)
task.countOfBytesClientExpectsToSend = Int64(fileSize)   // 帮助系统排期
task.resume()

三个必须遵守的约束:

第一,后台会话只能用 delegate,不能用 completion handler。因为 App 可能不在运行时回调,闭包无法存活。

第二,必须实现 application(_:handleEventsForBackgroundURLSession:completionHandler:),把 completion handler 存起来,等 urlSessionDidFinishEvents 时再调用:

func application(_ app: UIApplication,
                 handleEventsForBackgroundURLSession identifier: String,
                 completionHandler: @escaping () -> Void) {
    backgroundSessionCompletionHandler = completionHandler
    // 确保用同一个 identifier 重建 session
    _ = backgroundSession
}

func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
    DispatchQueue.main.async {
        self.backgroundSessionCompletionHandler?()
        self.backgroundSessionCompletionHandler = nil
    }
}

忘了调用这个 handler,系统会认为 App 没有正确处理事件,后续的后台传输会被降权。

第三,上传要用 uploadTask(with:fromFile:),用 Data 版本会把整个文件读进内存,大文件必然被系统杀掉。更细的会话配置、缓存与 ATS 设置,可以参考 iOS 网络层设计与 URLSession 里的通用部分。

四、后台时间配额与调度现实

配额是 iOS 后台最容易被误解的部分。没有公开的固定数值,但社区实测的规律是:

  • BGAppRefreshTask:每天大约 20-40 次机会,实际触发取决于用户打开 App 的频率、设备电量、充电状态、网络质量。重度用户可能一天十几次,轻度用户可能几天一次。
  • BGProcessingTask:通常需要设备充电且空闲,一天可能只有 0-2 次。
  • 后台传输:不限次数,但 isDiscretionary = true 时会优先在充电 + Wi-Fi 时执行。
  • 静默推送:每个 App 每小时 2-3 次是常见上限,超过会被限流,且低电量模式下完全不投递。

系统的调度决策基于一个隐式模型:它根据你 App 的历史行为预测用户何时会打开。如果你每次被唤醒都干很久或者频繁提交任务,系统会降低你的优先级。反过来,用户越常用你的 App,你被唤醒的机会越多。

工程上的推论是:

  1. 后台任务里的工作必须幂等且可中断。它可能跑一半被终止,下次从头再来不能出问题。
  2. 不要在后台任务里做「必须成功」的事。把它当作「有机会就做一点」的增量同步。
  3. 关键数据同步必须有前台触发路径。App 启动时、下拉刷新时都要能补齐。
  4. earliestBeginDate 只是下限。设 15 分钟不代表 15 分钟后一定跑,可能是 15 小时。

五、APNs 基础与设备令牌

APNs 是 Apple 的推送通道,你的服务端把消息发给 APNs,APNs 投递到设备。链路是:设备 → APNs(注册,拿 device token)→ 你的服务端(上报 token)→ 你的服务端 → APNs(发消息)→ 设备。

注册与 token 获取:

// 启动时申请权限
UNUserNotificationCenter.current().requestAuthorization(
    options: [.alert, .badge, .sound]
) { granted, error in
    guard granted else { return }
    DispatchQueue.main.async {
        UIApplication.shared.registerForRemoteNotifications()
    }
}

// AppDelegate 回调
func application(_ app: UIApplication,
                 didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    let token = deviceToken.map { String(format: "%02x", $0) }.joined()
    api.uploadPushToken(token)   // 上报给服务端
}

func application(_ app: UIApplication,
                 didFailToRegisterForRemoteNotificationsWithError error: Error) {
    print("注册失败: \(error)")   // 模拟器、无网络、证书配置错误都会走这里
}

关键认识:device token 会变。重装 App、恢复备份、切换设备都会得到新 token。服务端必须支持一个用户对应多个 token,并在收到 APNs 返回的 410 Unregistered 时删除失效 token。

registerForRemoteNotifications 必须在主线程调用,且在 requestAuthorization 授权成功后调用。授权被拒时也可以注册(仍能收静默推送),但用户看不到任何提示,实际意义有限。

六、推送鉴权:证书与密钥

服务端访问 APNs 有两种鉴权方式。

基于令牌(Token-based,推荐):用 Apple Developer 后台生成的 .p8 私钥签发 JWT。

Header:  { "alg": "ES256", "kid": "<Key ID>" }
Payload: { "iss": "<Team ID>", "iat": <签发时间戳> }
签名:     ES256 用 .p8 私钥签名

请求头带 authorization: bearer <JWT>,同一个 Key 可以给所有 App 发推送,且不会过期(JWT 本身有效期最长 1 小时,需定期重签)。这是新项目的唯一正确选择。

基于证书(Certificate-based):为每个 App 生成 .p12 推送证书,一年一换。现在主要用于维护老系统。

几个易踩的点:

  • 沙箱环境地址是 api.sandbox.push.apple.com,生产是 api.push.apple.com。从 Xcode 直接安装的 App 用沙箱 token,TestFlight 和 App Store 用生产 token,服务端要能区分。
  • HTTP/2 是唯一协议,老的二进制协议已废弃。
  • 每个推送请求的 apns-topic 必须是 App 的 bundle id。
  • 私钥 .p8 只能下载一次,丢了要重新生成。

证书、Provisioning Profile 与推送能力的配置细节,和 App Store 上架流程与签名机制 里讲的签名体系是同一套机制,推送能力(Push Notifications)必须在 App ID 上开启并重新生成 profile。

七、静默推送与 content-available

静默推送(background notification)用来在用户无感知的情况下唤醒 App 同步数据。payload 必须包含 content-available: 1,且不要带 alert:

{
  "aps": {
    "content-available": 1
  },
  "sync": {
    "since": "2026-10-06T00:00:00Z"
  }
}

用 apns-push-type: background 和 apns-priority: 5 发送:

curl -v \
  --http2 \
  --header "apns-topic: com.example.app" \
  --header "apns-push-type: background" \
  --header "apns-priority: 5" \
  --header "authorization: bearer $JWT" \
  --data '{"aps":{"content-available":1}}' \
  https://api.push.apple.com/3/device/$DEVICE_TOKEN

App 侧处理:

func application(_ app: UIApplication,
                 didReceiveRemoteNotification userInfo: [AnyHashable: Any],
                 fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
    guard userInfo["aps"] as? [String: Any] != nil else {
        completionHandler(.noData); return
    }
    Task {
        let hasNew = await syncService.syncIncremental()
        completionHandler(hasNew ? .newData : .noData)
    }
}

静默推送的真实约束比文档描述严得多:

约束实际表现
频率限制每 App 每小时约 2-3 次,超出被静默丢弃
低电量模式完全不投递
用户强制退出 App不投递(直到用户重新打开)
后台 App 刷新关闭不投递
投递确认没有。APNs 返回 200 只代表已接收,不代表已送达

所以静默推送不能作为数据一致性的唯一保障。它的定位是「锦上添花」:能同步就同步,不能同步下次打开 App 时补齐。任何「必须实时」的需求都应该改用可见推送(带 alert),让用户主动点开。

注意 completionHandler 必须在 30 秒内调用,且要如实报告 .newData/.noData/.failed——系统会据此调整你的唤醒频率,长期报 .noData 会被降权。

八、通知扩展与富媒体通知

通知扩展(Notification Service Extension)是 iOS 10 引入的机制,允许 App 在通知展示前修改内容——解密、下载图片、替换文案都靠它。

创建后在 didReceive(_:withContentHandler:) 里处理,注意有 30 秒时间限制:

class NotificationService: UNNotificationServiceExtension {
    var contentHandler: ((UNNotificationContent) -> Void)?
    var bestAttempt: UNMutableNotificationContent?

    override func didReceive(_ request: UNNotificationRequest,
                            withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
        self.contentHandler = contentHandler
        bestAttempt = request.content.mutableCopy() as? UNMutableNotificationContent
        guard let bestAttempt else { return }

        guard let urlString = bestAttempt.userInfo["image"] as? String,
              let url = URL(string: urlString) else {
            contentHandler(bestAttempt); return
        }

        URLSession.shared.downloadTask(with: url) { localURL, _, _ in
            defer { contentHandler(bestAttempt) }
            guard let localURL,
                  let attachment = try? UNNotificationAttachment(
                    identifier: "image", url: localURL, options: nil) else { return }
            bestAttempt.attachments = [attachment]
        }.resume()
    }

    override func serviceExtensionTimeWillExpire() {
        if let contentHandler, let bestAttempt { contentHandler(bestAttempt) }
    }
}

要发富媒体通知,payload 里必须有 mutable-content: 1:

{
  "aps": {
    "alert": { "title": "新消息", "body": "点击查看" },
    "mutable-content": 1
  },
  "image": "https://cdn.example.com/preview.jpg"
}

四个必须注意的约束:

  • 扩展运行在独立进程,内存上限约 24 MB,下载大图会直接被系统杀掉。
  • 必须调用 contentHandler,否则通知不展示。
  • serviceExtensionTimeWillExpire 里要立刻交付 bestAttempt,不能等下载完。
  • 扩展不能和主 App 共享内存,共享数据要走 App Group。

如果要自定义通知的界面(按钮、布局),还要额外做 Notification Content Extension,但它的使用场景少得多,且 iOS 15 之后系统自带的 summary 与 focus 过滤已经覆盖了大部分需求。

九、到达率、限流与优先级

推送「发出去了」和「用户收到了」之间隔着好几层。实际到达率取决于:

  • APNs 是否接受:返回 200 表示已接受,返回 410 表示 token 失效,429 表示被限流。
  • 设备是否在线:离线设备的消息会被 APNs 暂存,最多存约 24 小时或每个 App 一定数量,超出丢弃。
  • 用户是否允许:通知权限被关,可见推送直接丢弃。
  • 系统是否合并:iOS 15 的「定时摘要」(Scheduled Summary)会把非紧急通知攒起来,到点一起投递。
  • Focus 模式:工作/睡眠模式下通知被静默。

工程上的应对:

1. 服务端记录每条推送的 APNs 响应,410 立即删 token
2. 重要通知用 apns-priority: 10 + apns-expiration: 0(立即投递,不存)
3. 批量通知用 apns-collapse-id 合并同主题消息
4. 不要用推送做「消息必达」,客户端要有拉取兜底

apns-priority 只有两个合法值:10(立即)和 5(省电,系统择机)。背景推送只能用 5,用 10 发背景推送会被拒绝。apns-expiration 是 Unix 时间戳,设为 0 表示「过期即丢弃,不重试」。

apns-collapse-id 是常被忽略的利器:同一 id 的多条通知在锁屏上只显示最新一条,适合「进度更新」「同一个会话的新消息」这类场景。

十、调试与验证手段

推送和后台任务的调试手段有限,但有几条固定路径。

真机 + 调试器:后台任务和静默推送在模拟器上基本不可靠,必须用真机。

模拟推送:Xcode 的 Debug → Simulate Location 旁边有推送模拟,或直接用命令行:

xcrun simctl push booted com.example.app payload.json

注意这只模拟投递,不经过 APNs,所以 mutable-content、apns-push-type 等 header 行为都测不到。

APNs 返回码排查:服务端一定要打日志。400 BadDeviceToken 通常是环境不匹配(沙箱 token 发到生产),403 InvalidProviderToken 是 JWT 过期或 Key 不对,413 PayloadTooLarge 是 payload 超过 4 KB(背景推送也是 4 KB,不是网传的 5 KB)。

后台任务日志:用 os_log 而不是 print,然后在 Console.app 里按 subsystem 过滤:

import os

private let logger = Logger(subsystem: "com.example.app", category: "background")

func handleAppRefresh(_ task: BGAppRefreshTask) {
    logger.info("后台刷新开始")
    // ...
}

MetricKit 与 Xcode Organizer:后台唤醒次数、崩溃、耗电影响都能在 Organizer 的 Metrics 面板看到,具体指标口径可参考 iOS 性能调优与 Instruments 剖析 里的 MetricKit 部分。

权衡取舍

需求首选方案备选不适用
增量同步少量数据静默推送 + 前台补齐BGAppRefreshTask后台常驻轮询
大文件上传/下载后台 URLSessionBGProcessingTask普通 URLSession
数据库整理/重建索引BGProcessingTask前台触发BGAppRefreshTask
用户可见的提醒可见推送本地通知静默推送
实时性要求高的消息长连接(仅前台)+ 推送推送 + 拉取兜底后台轮询

选择 BGAppRefreshTask 还是静默推送:前者不需要服务端配合、由系统调度、频率可控性差;后者由服务端主动触发、实时性好、但受严格限流。两者同时使用是最稳的组合——静默推送触发即时同步,后台刷新兜底补数据。

常见坑清单

  • 后台任务只跑一次:忘了在处理函数开头重新 submit,系统执行完就不会再排期。
  • 超时不调 setTaskCompleted:任务被系统强杀,App 被记入后台超时,后续配额被降权。
  • 后台会话用 completion handler:App 不在运行时闭包无法存活,必须用 delegate。
  • 忘了调用 handleEventsForBackgroundURLSession 的 handler:系统认为事件未处理,后续后台传输被降权。
  • 上传用 Data 而非 fromFile:大文件读进内存,后台被系统直接杀掉。
  • 静默推送当作必达通道:每小时 2-3 次限流、低电量模式不投递、用户强退不投递,必须有前台兜底。
  • 背景推送用 apns-priority: 10:会被 APNs 直接拒绝,背景推送只能用 5。
  • device token 当作永久不变:重装/换机/恢复备份都会变,服务端必须支持多 token 并处理 410。
  • 通知扩展里下载大图:扩展内存上限约 24 MB,超限被杀,通知不展示。
  • 模拟器验证后台行为:模拟器不模拟真实调度,必须在真机 + 调试器下用 _simulateLaunchForTaskWithIdentifier 验证。

相关阅读

小结

iOS 后台执行的核心心智是「系统给你机会,而不是你要求执行」。BGTaskScheduler 与后台 URLSession 是两条正规路径,前者适合轻量刷新,后者适合数据传输;beginBackgroundTask 只是一次性的延期额度,不能当成长期方案。配额、调度时机、低电量模式这些变量都由系统掌握,所以任何后台逻辑都必须可中断、幂等、有前台兜底。

推送链路的可靠性同样需要打折。APNs 返回 200 只表示「已接受」,不表示「已送达」;静默推送受小时级限流和用户设置双重约束;富媒体通知受扩展内存限制。把推送定位成「尽力而为的唤醒信号」,把数据一致性交给前台的主动拉取,是唯一稳妥的设计。下一步建议把后台同步与本地持久化结合,设计一套「本地优先 + 增量同步」的数据层。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「iOS 开发」更多文章

  1. Swift Package Manager 与模块化拆分
  2. Core Animation 与 SwiftUI 动画
  3. iOS 安全:Keychain、生物识别与传输安全