iOS 推送通知:从 APNs 到前台展示的全链路深度解析
面试高频:APNs 全链路(device token / 推送证书 vs Key / JWT 有效期)、UNUserNotificationCenter 注册与前台展示、Notification Service Extension 与静默推送,一篇讲透 iOS 推送。
一句话概括
iOS 推送是一条三角链路:你的服务器(Provider)→ Apple 推送服务(APNs)→ 用户设备,中间靠 device token 唯一定位”哪台设备的哪个 App”,靠证书或 Auth Key 做身份认证。客户端侧由 UNUserNotificationCenter 统一管权限、展示和点击;而 content-available 静默推送和 mutable-content 通知扩展,则分别给了你”后台唤醒”和”到达前改内容”的能力。
跨端同学最怕的就是”用户收不到推送”“点击通知没跳转对页面”——这类问题 90% 要落到 APNs 认证和 UNUserNotificationCenter 的处理流程上。面试常问:device token 是什么、为什么变、证书和 Key 怎么选、前台为什么收不到横幅、静默推送的限制。其中有个经典过时坑:基于 Token 的认证,JWT 有效期是 1 小时,不是 30 分钟(旧资料常写错)。
核心知识点
1. 全链路:Provider → APNs → Device
1
2
3
4
// 链路:
// App 启动 → 请求通知权限 → registerForRemoteNotifications → APNs 返回 deviceToken
// App 把 deviceToken 上报给你的服务器(Provider)
// Provider 用 Key/证书 连 APNs,发 HTTP/2 请求 → APNs 投递到设备
三个角色各自职责:
- Provider(你的服务器):构造 payload,通过 HTTP/2 发给 APNs
- APNs:苹果的中转,负责鉴权、路由、投递;生产
api.push.apple.com,开发api.sandbox.push.apple.com(两者 token 命名空间隔离,沙盒 token 发到生产环境会报BadDeviceToken) - Device:系统收到后按用户设置展示,或唤醒 App 处理
2. Device Token:设备的”门牌号”
device token 是 APNs 生成的、标识”这台设备 + 这个 App”的二进制串,转成十六进制字符串后上报服务器:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import UserNotifications
// 1) 先请求权限,用户同意后再注册远程通知
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
if granted {
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
}
}
// 2) 拿到 token
func application(_ app: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
print("Device Token:", token)
uploadTokenToServer(token) // 上报你的服务器
}
func application(_ app: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error) {
print("注册失败:", error.localizedDescription)
}
面试要点:
- token 会变:App 卸载重装、系统重置、用户恢复备份后都可能变;服务器要把旧的换成新的
- APNs 返回
404/410表示 token 失效,服务器必须立即删除该 token,否则白发还浪费配额 - payload 最大 4KB(alert 类型)
3. 证书 vs Auth Key:强烈推荐 Key
| 维度 | 推送证书(.p12) | 推送 Key(.p8,Token 认证) |
|---|---|---|
| 有效期 | 1 年,要定期续 | Key 本身长期有效,可随时在后台撤销 |
| 覆盖范围 | 绑定单个 App ID | 同 Team ID 下所有 App 通用 |
| 并发 | 有连接数限制 | 支持多服务器同时推 |
| 认证方式 | TLS 证书握手 | 每次请求带 JWT(ES256 签名) |
基于 Token 的认证流程:用 .p8 私钥 + Key ID + Team ID 生成 JWT,Header 是 alg: ES256 + kid,Claim 是 iss: TeamID + iat: 当前秒数。关键修正:JWT 有效期是 1 小时(Apple 文档原文 “no more than one hour”),服务器应缓存并在过期前刷新,不要每发一条都现签,也不要以为只有 30 分钟。
1
2
3
4
// JWT payload(示意,实际由服务端生成)
// header: { "alg": "ES256", "kid": "YOUR_KEY_ID" }
// claims: { "iss": "YOUR_TEAM_ID", "iat": 1710000000 } // iat 距现在 ≤ 1 小时
// 用 .p8 私钥按 ES256 签名,请求头带:Authorization: bearer <jwt>
面试建议:新项目一律用 Auth Key。证书方式只在对接老推送库、或对方只支持证书时才用。
4. UNUserNotificationCenter:客户端的统一入口
iOS 10+ 用 UNUserNotificationCenter 统一管理本地/远程通知。权限请求只弹一次,用户拒绝后只能引导去系统设置:
1
2
3
4
5
6
7
8
9
10
11
let center = UNUserNotificationCenter.current()
center.delegate = self // 一定要设代理,否则前台展示/点击回调收不到
center.getNotificationSettings { settings in
switch settings.authorizationStatus {
case .notDetermined: requestPushPermission() // 合适时机再请求
case .denied: // 引导去设置:UIApplication.openSettingsURLString
case .authorized, .provisional, .ephemeral: break
@unknown default: break
}
}
前台收到的通知默认不展示横幅——必须实现代理的 willPresent 并显式声明展示方式(这是”前台收不到推送”的头号原因):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completion: @escaping (UNNotificationPresentationOptions) -> Void) {
if #available(iOS 14, *) {
completionHandler([.banner, .sound, .badge, .list]) // ✅ 前台也弹横幅
} else {
completionHandler([.alert, .sound, .badge])
}
}
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completion: @escaping () -> Void) {
let info = response.notification.request.content.userInfo
routeByNotification(info) // 按 type 跳对应页面
completionHandler()
}
payload 结构(面试常让默写关键字段):
1
2
3
4
5
6
7
8
9
10
11
12
{
"aps": {
"alert": { "title": "新消息", "body": "晚上一起吃饭?" },
"sound": "default",
"badge": 5,
"thread-id": "chat_123",
"mutable-content": 1,
"content-available": 1
},
"custom_type": "text",
"msg_id": "abc"
}
alert:展示内容(字符串或字典)sound/badge:声音 / 角标thread-id:通知分组(iOS 12+)mutable-content: 1:允许通知扩展改内容(见第 6 节)content-available: 1:静默推送(见第 5 节)
5. 静默推送:后台唤醒,但限制多
content-available: 1 且不带 alert/sound/badge 时,系统会在后台唤醒 App 执行代码(典型如悄悄刷新数据),用户无感知:
1
2
3
4
5
6
7
func application(_ app: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler done: @escaping (UIBackgroundFetchResult) -> Void) {
refreshData { ok in
done(ok ? .newData : .noData)
}
}
限制(面试必答):
- 不保证送达:系统会按电量、网络、使用习惯推迟甚至丢弃
- 后台执行时间约 30 秒,超时系统杀掉
- 低电量模式、用户关闭”后台 App 刷新”时会被禁用
- 频率受限,太频繁直接被忽略
- 必须在
Signing & Capabilities开启 Background Modes → Remote notifications
跨端对照:Flutter
firebase_messaging的onBackgroundMessage、RN 的后台消息,底层就是靠这个静默推送机制。
6. Notification Service Extension:到达前改内容
通知扩展(iOS 10+)能在通知展示给用户之前改 body、解密端到端内容、下载图片/视频附件。触发条件是 payload 带 mutable-content: 1:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttempt: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest,
withContentHandler handler: @escaping (UNNotificationContent) -> Void) {
contentHandler = handler
bestAttempt = (request.content.mutableCopy() as? UNMutableNotificationContent)
if let body = request.content.userInfo["encrypted"] as? String {
bestAttempt?.body = decrypt(body) // 解密后再展示
}
// 下载附件:UNNotificationAttachment(identifier:options:contentURL:)
handler(bestAttempt ?? request.content)
}
override func serviceExtensionTimeWillExpire() {
// 30 秒超时:展示已改好的,或退回原始内容
if let best = bestAttempt, let handler = contentHandler { handler(best) }
}
}
iOS 18 补充:系统引入了优先通知(Priority Notifications)与通知摘要(Apple Intelligence 设备端生成),会按”用户是否常互动”重新排序、合并次要通知。对你的推送策略的影响:标题文案要清晰、别滥用
Time Sensitive,否则会被系统降权。
其实你每天都在用
- 启动后弹”是否允许通知”:
requestAuthorization,只弹一次,拒绝就只能去设置里开 - 聊天消息角标数字:payload 里
badge,或在客户端UIApplication.shared.applicationIconBadgeNumber = 0清零 - App 在前台收到消息没弹窗:八成是
willPresent没实现或没传展示选项 - 点击通知跳到对应会话:在
didReceive里读userInfo["custom_type"]路由 - 微信图片消息带大图预览:靠 Notification Service Extension 下载附件挂
UNNotificationAttachment - 后台悄悄刷新收件箱:
content-available: 1静默推送 - 同一个群聊消息折叠成一组:payload 的
thread-id分组 - 卸载重装后要重新登录推送:device token 变了,老 token 在服务器那边变失效
常见误解(FAQ)
❌ 误区一:”收不到推送,一定是 APNs 挂了或代码错了”
先系统性排查,大部分不是 APNs 的问题:① 网络是否正常;② Auth Key/证书是否过期或被撤销;③ 环境是否对应(开发 token 发到生产环境报 BadDeviceToken);④ 用户在系统设置里关了通知权限;⑤ 前台没实现 willPresent 导致”看起来没收到”;⑥ 静默推送太频繁被系统限流;⑦ 低电量模式禁用后台刷新。服务器侧重点看 APNs 返回的 HTTP 状态:403 鉴权失败、404/410 token 失效要删、413 payload 超 4KB、429 太频繁。
❌ 误区二:”基于 Token 的 JWT 有效期 30 分钟,要频繁刷新”
过时说法,已修正。 Apple 官方明确:APNs 认证 JWT 的 iat 距当前时间不得超过 1 小时,即有效期是 1 小时。正确做法是服务器缓存 token、在过期前(比如提前 5 分钟)刷新,不要每条推送都现签,也不要按”30 分钟”的旧认知去设超时。
❌ 误区三:”静默推送(content-available)和正常推送一样可靠,用来保证送达”
静默推送不保证送达,系统会根据电量、网络、用户习惯推迟或丢弃,且后台只有约 30 秒、低电量模式可能被禁。它适合”最好能悄悄刷新一下”的场景,不能当关键消息通道。重要消息一定要带 alert 走正常通知。
❌ 误区四:”device token 是固定不变的,存一次就行”
token 会因卸载重装、系统重置、恢复备份而变化;APNs 也会在特殊情况让它失效。服务器必须把 404/410 的 token 立即剔除,并在 App 每次拿到新 token 时上报覆盖。硬编码或长期缓存旧 token,就会出现”明明发了却收不到”。
❌ 误区五:”通知扩展能无限处理,下载大视频也没事”
通知扩展只有 30 秒预算,且附件有大小/格式限制(图片一般几 MB 内、视频更受限)。超时就只能展示原始内容(serviceExtensionTimeWillExpire 兜底)。别在扩展里做重活,下载失败要优雅降级。
一句话总结
iOS 推送的本质是“APNs 居中转、device token 定位设备、Key/证书做鉴权、UNUserNotificationCenter 管客户端”——记住 JWT 有效期是 1 小时、前台展示必须实现 willPresent、token 会变要及时上报、静默推送不可靠、重要改动走通知扩展。把这条链路的每一环都吃透,跨端项目里”收不到推送”“点了不跳转”这类线上问题,你就能从服务器日志一路定位到原生代理,而不是只会说”苹果又抽风了”。