文章

iOS 推送通知:从 APNs 到前台展示的全链路深度解析

面试高频:APNs 全链路(device token / 推送证书 vs Key / JWT 有效期)、UNUserNotificationCenter 注册与前台展示、Notification Service Extension 与静默推送,一篇讲透 iOS 推送。

iOS 推送通知:从 APNs 到前台展示的全链路深度解析

一句话概括

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 会变要及时上报、静默推送不可靠、重要改动走通知扩展。把这条链路的每一环都吃透,跨端项目里”收不到推送”“点了不跳转”这类线上问题,你就能从服务器日志一路定位到原生代理,而不是只会说”苹果又抽风了”。

本文由作者按照 CC BY 4.0 进行授权