开篇
「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,你被唤醒的机会越多。
工程上的推论是:
- 后台任务里的工作必须幂等且可中断。它可能跑一半被终止,下次从头再来不能出问题。
- 不要在后台任务里做「必须成功」的事。把它当作「有机会就做一点」的增量同步。
- 关键数据同步必须有前台触发路径。App 启动时、下拉刷新时都要能补齐。
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 | 后台常驻轮询 |
| 大文件上传/下载 | 后台 URLSession | BGProcessingTask | 普通 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 网络层设计与 URLSession — 会话配置、缓存与 ATS 的通用部分
- App Store 上架流程与签名机制 — 推送能力与签名、profile 的配置关系
- iOS 性能调优与 Instruments 剖析 — 用 MetricKit 观察后台唤醒与耗电
- HarmonyOS 网络与数据持久化 — 对照另一套移动系统的后台与网络模型
小结
iOS 后台执行的核心心智是「系统给你机会,而不是你要求执行」。BGTaskScheduler 与后台 URLSession 是两条正规路径,前者适合轻量刷新,后者适合数据传输;beginBackgroundTask 只是一次性的延期额度,不能当成长期方案。配额、调度时机、低电量模式这些变量都由系统掌握,所以任何后台逻辑都必须可中断、幂等、有前台兜底。
推送链路的可靠性同样需要打折。APNs 返回 200 只表示「已接受」,不表示「已送达」;静默推送受小时级限流和用户设置双重约束;富媒体通知受扩展内存限制。把推送定位成「尽力而为的唤醒信号」,把数据一致性交给前台的主动拉取,是唯一稳妥的设计。下一步建议把后台同步与本地持久化结合,设计一套「本地优先 + 增量同步」的数据层。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。