跳到主要内容
教程目录

iOS 推送通知怎么配?从 APNs p8 密钥到 iOS 真机收到第一条推送

最后更新

iOS 推送通知要跑通,需要三样东西:App ID 开通 Push Notifications 并重新生成描述文件,签名时带上 aps-environment,再用 APNs 的 .p8 认证密钥签 JWT,把推送发到这台手机的设备令牌上。按下面五步做完,你能在电脑上执行一条命令,让自己的 iPhone 弹出通知。

适合 App 已经能在真机上运行、第一次接推送的开发者,自建推送服务或接第三方推送平台之前,都建议先把这条链路跑通一次。开始前准备好 Apple 开发者账号、一部 iPhone 和数据线,以及装好 KXBuild 的电脑,还没装的按安装页操作。

整件事分五步。

  1. 在开发者后台给 App ID 开通 Push Notifications,重新生成描述文件。
  2. 给工程加 entitlements,写上 aps-environment。
  3. 在代码里请求通知权限、注册推送、拿到设备令牌。
  4. 在开发者后台创建 APNs 认证密钥(.p8)。
  5. 用密钥从电脑给这部手机发一条推送。

第 1 步:怎么开通 Push Notifications 能力?

登录 developer.apple.com,进入 Certificates, Identifiers & Profiles,在 Identifiers 页点开 App 的 App ID,在 Capabilities 列表勾选 Push Notifications,点 Save。旁边的 Configure 是给旧式推送证书用的,用 .p8 密钥就不用配置。

App ID 必须是精确的 Bundle ID,通配 App ID(com.example.*)不能开推送。还没有精确 App ID 的,按在 Windows 上申请 iOS 证书和描述文件注册一个。

开通推送后为什么要重新生成描述文件?

描述文件里记录着 App ID 当时开通的能力,开通推送之前生成的描述文件不包含推送,必须重新生成。

  1. 在 Profiles 页逐个点开这个 App ID 的描述文件(Development、Ad Hoc、App Store),点 Edit,直接 Save,再 Download。状态显示 Invalid 的也是这样处理。
  2. 用新文件替换工程 keychain 目录里的旧文件,旧的删掉。目录里同时留着新旧两份时,构建可能匹配到不含推送的旧文件。

在 VS Code 里双击 .mobileprovision 可以查看内容,Entitlements 里有 aps-environment 就说明这份描述文件已经包含推送。Development 描述文件里的值是 development,Ad Hoc 和 App Store 的是 production。

描述文件相关的报错,比如 doesn't include the Push Notifications capability,按iOS 签名报错与 ipa 安装失败的解决办法处理。

第 2 步:怎么给工程加 entitlements?

App 要在签名时带上 aps-environment 才能注册推送。文件里写 development 即可,构建时 KXBuild 按所用描述文件取值,Debug 用开发描述文件得到 development,Release 用 App Store 或 Ad Hoc 描述文件得到 production,一个文件同时用于调试和上架。

在 App 源码文件夹里新建 MyApp.entitlements,内容如下。这个文件不需要加入 target,以后要改值或加其他能力,双击它用属性列表编辑器编辑即可。

1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
4<dict>
5	<key>aps-environment</key>
6	<string>development</string>
7</dict>
8</plist>

然后让 target 用这个文件。在工程目录执行下面的命令,路径相对于 .xcodeproj 所在目录。

1kxbuild project add-setting -k CODE_SIGN_ENTITLEMENTS -v MyApp/MyApp.entitlements -t MyApp -P .\MyApp.xcodeproj

设置好后在工程设置编辑器的 Build Settings 页搜索 CODE_SIGN_ENTITLEMENTS 能看到它。工程原本已有 entitlements 文件的(例如用了 App Groups),把 aps-environment 加进原文件即可,不用新建。

第 3 步:怎么请求权限并拿到设备令牌?

远程推送要做两件事。向用户请求通知权限,决定通知能不能显示出来。向 APNs 注册,拿到这部手机上这个 App 的设备令牌,你的后台靠它把推送发到这台设备。

SwiftUI 工程用 @UIApplicationDelegateAdaptor 接一个 AppDelegate。把 App 入口文件(例如 MyAppApp.swift)换成下面的内容,MyAppApp 和 ContentView 按你的工程改名。

 1import SwiftUI
 2import UserNotifications
 3
 4final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
 5    func application(_ application: UIApplication,
 6                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
 7        let center = UNUserNotificationCenter.current()
 8        center.delegate = self
 9        center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
10            print("通知权限 granted=\(granted) error=\(String(describing: error))")
11        }
12        application.registerForRemoteNotifications()
13        return true
14    }
15
16    func application(_ application: UIApplication,
17                     didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
18        let token = deviceToken.map { String(format: "%02x", $0) }.joined()
19        print("设备令牌: \(token)")
20    }
21
22    func application(_ application: UIApplication,
23                     didFailToRegisterForRemoteNotificationsWithError error: Error) {
24        print("注册推送失败: \(error)")
25    }
26
27    // App 在前台时也显示横幅
28    func userNotificationCenter(_ center: UNUserNotificationCenter,
29                                willPresent notification: UNNotification,
30                                withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
31        completionHandler([.banner, .list, .sound, .badge])
32    }
33
34    // 用户点了通知
35    func userNotificationCenter(_ center: UNUserNotificationCenter,
36                                didReceive response: UNNotificationResponse,
37                                withCompletionHandler completionHandler: @escaping () -> Void) {
38        print("点开了通知: \(response.notification.request.content.userInfo)")
39        completionHandler()
40    }
41}
42
43@main
44struct MyAppApp: App {
45    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
46
47    var body: some Scene {
48        WindowGroup {
49            ContentView()
50        }
51    }
52}

registerForRemoteNotifications() 不依赖用户是否同意,拒绝了也能拿到设备令牌,只是通知不会显示。正式 App 里请求权限的时机可以往后放,在用户开启提醒之类的功能时再弹,同意率会高很多。默认情况下 App 在前台收到的推送不显示,willPresent 里返回 .banner 才会弹横幅。

用 Debug 配置按 F5 运行到手机上,调试方法见iOS 真机调试。第一次启动会弹出通知权限对话框,点允许。在「输出 → KXBuild Console」里找到「设备令牌:」开头的一行,复制后面那串 64 位十六进制字符,下一步要用。

设备令牌分环境。Debug 包拿到的是开发环境(sandbox)的令牌,只能用开发环境地址发送。App 删除重装、换手机、恢复系统后令牌都可能变,正式 App 每次启动都要把最新的令牌上报给自己的后台。

第 4 步:怎么创建 APNs p8 密钥?

给 APNs 发推送需要证明你是这个开发者团队。Apple 提供两种凭据,.p8 认证密钥和 .p12 推送证书。.p8 密钥不会过期,一个团队密钥能给团队下所有 App 发推送,新项目都用它。

新建的团队密钥只对一个环境有效,开发调试建一个 Sandbox 密钥,上 TestFlight 或 App Store 前再建一个 Production 密钥,每个环境最多两个。推送证书每年要续,每个 App 每个环境一张,只在对接只支持证书的老系统时才需要。

  1. 在 Certificates, Identifiers & Profiles 的 Keys 页点「+」,填个名字,勾选 Apple Push Notifications service (APNs)。
  2. 点旁边的 Configure,环境选 Sandbox,范围选 Team Scoped (All Topics),保存后点 Continue、Register。以后要给 TestFlight、App Store 包发推送时,按同样步骤再建一个环境选 Production 的密钥。
  3. 点 Download 下载 AuthKey_XXXXXXXXXX.p8。这个文件只能下载一次,丢了只能吊销重建,下载后立刻备份。

还要记下两个值。Key ID 是密钥详情页上的 10 位字符,也在文件名里。Team ID 在开发者网站右上角账号下的 Membership details 里,也是 10 位字符。

.p8 等同于团队的推送权限,不要提交到代码仓库,也不要打进 App。

第 5 步:怎么从电脑发一条测试推送?

APNs 是一个 HTTP/2 接口。每次请求要带一个用 .p8 签名的 JWT(ES256 算法),加上目标 App 的 Bundle ID 和设备令牌。

环境 地址 用于
开发 https://api.sandbox.push.apple.com Debug 包,也就是按 F5 装上去的
生产 https://api.push.apple.com TestFlight、App Store、Ad Hoc 包

准备发送脚本

下面这个 Node.js 脚本不依赖任何第三方包,能生成 JWT,也能直接发送推送。新建一个文件夹,把 .p8 放进去,新建 apns.mjs,改掉开头四个常量。

 1import crypto from 'node:crypto'
 2import fs from 'node:fs'
 3import http2 from 'node:http2'
 4
 5const TEAM_ID = 'ABCDE12345'                 // Team ID
 6const KEY_ID = 'XYZ1234567'                  // Key ID
 7const KEY_FILE = 'AuthKey_XYZ1234567.p8'     // .p8 文件
 8const BUNDLE_ID = 'com.yourname.myapp'       // App 的 Bundle ID
 9const HOST = 'https://api.sandbox.push.apple.com' // 生产环境改成 https://api.push.apple.com
10
11function makeJwt() {
12  const b64 = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url')
13  const unsigned = b64({ alg: 'ES256', kid: KEY_ID }) + '.' + b64({ iss: TEAM_ID, iat: Math.floor(Date.now() / 1000) })
14  const key = crypto.createPrivateKey(fs.readFileSync(KEY_FILE))
15  const sig = crypto.sign('sha256', Buffer.from(unsigned), { key, dsaEncoding: 'ieee-p1363' })
16  return unsigned + '.' + sig.toString('base64url')
17}
18
19const [cmd, deviceToken] = process.argv.slice(2)
20if (cmd === 'jwt') {
21  console.log(makeJwt())
22} else if (cmd === 'send' && deviceToken) {
23  const payload = fs.readFileSync('payload.json')
24  const client = http2.connect(HOST)
25  client.on('error', (err) => { console.error(err.message); process.exit(1) })
26  const req = client.request({
27    ':method': 'POST',
28    ':path': '/3/device/' + deviceToken,
29    authorization: 'bearer ' + makeJwt(),
30    'apns-topic': BUNDLE_ID,
31    'apns-push-type': 'alert',
32    'apns-priority': '10',
33  })
34  let status
35  let body = ''
36  req.on('response', (headers) => { status = headers[':status'] })
37  req.setEncoding('utf8')
38  req.on('data', (chunk) => { body += chunk })
39  req.on('end', () => { console.log(status, body); client.close() })
40  req.end(payload)
41} else {
42  console.log('用法 node apns.mjs jwt 或 node apns.mjs send <设备令牌>')
43}

同一文件夹里再建一个 payload.json,这是推送的内容,用 UTF-8 保存。

1{"aps":{"alert":{"title":"你好","body":"这是第一条推送"},"sound":"default","badge":1}}

aps 是系统保留的字段,alert 是标题和正文,sound 为 default 时播放默认提示音,badge 是图标右上角的数字。自定义数据写在 aps 外面,App 点开通知时从 userInfo 里读取。整个 payload 不能超过 4KB。

JWT 签名要求签名为 64 字节的原始格式,脚本里的 dsaEncoding: 'ieee-p1363' 就是做这个的,自己用其他语言实现时这里最容易出错。

发送

以下两种做法任选其一。

  • 用脚本直接发送。在脚本所在文件夹执行下面的命令,把设备令牌换成上一步复制的那串。
  • 用 curl 发送。先用脚本生成 JWT,再用 curl 发请求,适合在服务器或 CI 上调试。

用脚本发送的命令。

1node apns.mjs send 设备令牌

用 curl 发送的命令。

1$jwt = node apns.mjs jwt
2curl.exe -v --http2 -H "authorization: bearer $jwt" -H "apns-topic: com.yourname.myapp" -H "apns-push-type: alert" -H "apns-priority: 10" --data-binary "@payload.json" https://api.sandbox.push.apple.com/3/device/设备令牌

curl 必须支持 HTTP/2。执行 curl.exe -V,Features 一行里有 HTTP2 才行。Windows 自带的 curl 和 Git 自带的 curl 都不支持,会报 option --http2: the installed libcurl version does not support this,换用 curl.se 官网下载的 Windows 版,或者直接用脚本发送。

macOS 和 Linux 上把 curl.exe 换成 curl、$jwt 换成 $(node apns.mjs jwt) 即可。PowerShell 里不要写成 curl,它是 Invoke-WebRequest 的别名。

返回 200 就表示 APNs 已经接收,手机锁屏上几秒内就会出现通知。返回其他状态码时,响应里的 reason 说明了原因,对照下一节处理。

不想写脚本时,也可以登录 Apple 的 CloudKit Console,在 Push Notifications 页面填入 Bundle ID 和设备令牌,从网页发一条测试推送。

iOS 推送通知收不到怎么查?

先看 App 有没有拿到设备令牌,再看 APNs 的返回,最后看手机端。

拿不到设备令牌

Console 里出现「注册推送失败」,错误信息是 no valid "aps-environment" entitlement string found for application,说明签名时没带上推送能力。检查三处。App ID 是否勾选了 Push Notifications。keychain 里的描述文件是不是开通推送后重新下载的,旧文件是否删干净。CODE_SIGN_ENTITLEMENTS 是否指向了正确的文件。

构建时报 doesn't include the aps-environment entitlement,是描述文件没有推送能力,重新生成描述文件。安装时报 entitlements 无效,通常是 aps-environment 的值和描述文件不一致,比如 Debug 配置用了写着 production 的文件。

APNs 返回错误

返回 原因和处理
400 BadDeviceToken 设备令牌和环境不匹配,Debug 包的令牌发到了生产地址,或者反过来。也可能是令牌复制不完整
400 DeviceTokenNotForTopic apns-topic 和 App 的 Bundle ID 不一致
403 InvalidProviderToken Key ID、Team ID 填错,.p8 不是这个团队的,或者密钥没有开通当前环境
403 ExpiredProviderToken JWT 超过一小时,或者电脑时间不准。重新生成,并校准系统时间
410 Unregistered App 已经从手机上删除,这个令牌作废了
429 TooManyProviderTokenUpdates JWT 换得太频繁。正式环境一个 JWT 应复用 20 到 60 分钟

返回 200 但手机没显示

  • 第一次启动时点了不允许。到手机「设置 → 通知」里找到这个 App,打开允许通知。
  • 手机开着专注模式或勿扰模式,通知进了通知中心但不弹出。
  • App 在前台,而代码里没有实现 willPresent。
  • 手机没联网,或者所在网络屏蔽了 Apple 的推送端口。换成蜂窝数据试一次。

Debug 能收到,TestFlight 收不到

TestFlight、App Store 和 Ad Hoc 包走生产环境,设备令牌也换成了生产环境的。检查 Release 用的是不是 App Store 或 Ad Hoc 描述文件,后台是否改用了 https://api.push.apple.com,用的是不是这个包上报的新令牌。生产环境要用环境为 Production 的 .p8 密钥签 JWT,Sandbox 密钥发不到生产环境。以前建的、两个环境通用的旧密钥可以继续用。

静默推送(payload 只有 content-available: 1,让 App 在后台拉数据)还要在 Info.plist 的 UIBackgroundModes 数组里加上 remote-notification,推送类型用 background、优先级用 5。系统会按电量和使用情况决定是否唤醒 App,不保证每条都送达。

这条链路跑通后,iOS 推送通知的客户端部分就完成了,剩下是把设备令牌上报给自己的后台、由后台签 JWT 发送。描述文件和证书的更多用法见 Windows 给 iOS App 签名。

常见问题

.p8 密钥和 .p12 推送证书选哪个?

新项目选 .p8 密钥。它不会过期,一个团队密钥能给团队下所有 App 发推送;.p12 推送证书每年要续,每个 App 每个环境一张,只在对接只支持证书的老系统时才需要。

设备令牌会变吗?

会变。App 删除重装、换手机、恢复系统后令牌都可能变化,而且 Debug 包和 TestFlight 包的令牌属于不同环境,所以正式 App 每次启动都要把最新令牌上报给后台。

推送内容最多能放多少数据?

整个 payload 不能超过 4KB。aps 是系统保留字段,自定义数据写在 aps 外面,App 点开通知时从 userInfo 里读取。

.p8 文件丢了怎么办?

只能在 Keys 页吊销旧密钥再新建一个,因为 .p8 文件只能下载一次。新密钥的 Key ID 会变,发送端的配置要一起更新,每个环境最多保留两个密钥。