给 iOS App 加推送通知,从 APNs 密钥到真机收到第一条
这篇从零给一个 iOS App 加上远程推送,做完后你能在电脑上执行一条命令,让自己的 iPhone 弹出通知。适合 App 已经能在真机上运行、第一次接推送的开发者,自建推送服务或接第三方推送平台之前,都建议先按这篇把链路跑通一次。开始前准备好 Apple 开发者账号、一部 iPhone 和数据线,以及装好 KXBuild 的电脑,还没装的按安装页操作。
整件事分五步。
- 在开发者后台给 App ID 开通 Push Notifications,重新生成描述文件。
- 给工程加 entitlements,写上
aps-environment。 - 在代码里请求通知权限、注册推送、拿到设备令牌。
- 在开发者后台创建 APNs 认证密钥(.p8)。
- 用密钥从电脑给这部手机发一条推送。
开通 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 当时开通的能力,开通推送之前生成的描述文件不包含推送,必须重新生成。
- 在 Profiles 页逐个点开这个 App ID 的描述文件(Development、Ad Hoc、App Store),点 Edit,直接 Save,再 Download。状态显示 Invalid 的也是这样处理。
- 用新文件替换工程
keychain目录里的旧文件,旧的删掉。目录里同时留着新旧两份时,构建可能匹配到不含推送的旧文件。
在 VS Code 里双击 .mobileprovision 可以查看内容,Entitlements 里有 aps-environment 就说明这份描述文件已经包含推送。Development 描述文件里的值是 development,Ad Hoc 和 App Store 的是 production。
描述文件相关的报错,比如 doesn't include the Push Notifications capability,按iOS 签名报错与 ipa 安装失败的解决办法处理。
给工程加 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 加进原文件即可,不用新建。
请求权限并拿到设备令牌
远程推送要做两件事。向用户请求通知权限,决定通知能不能显示出来。向 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 每次启动都要把最新的令牌上报给自己的后台。
创建 APNs 认证密钥
给 APNs 发推送需要证明你是这个开发者团队。Apple 提供两种凭据,.p8 认证密钥和 .p12 推送证书。.p8 密钥不会过期,一个密钥能给团队下所有 App 发推送,开发和生产环境通用,新项目都用它。推送证书每年要续,每个 App 每个环境一张,只在对接只支持证书的老系统时才需要。
- 在 Certificates, Identifiers & Profiles 的 Keys 页点「+」,填个名字,勾选 Apple Push Notifications service (APNs)。
- 点旁边的 Configure,环境选 Sandbox & Production,范围选 Team Scoped (All Topics),保存后点 Continue、Register。
- 点 Download 下载
AuthKey_XXXXXXXXXX.p8。这个文件只能下载一次,丢了只能吊销重建,下载后立刻备份。
还要记下两个值。Key ID 是密钥详情页上的 10 位字符,也在文件名里。Team ID 在开发者网站右上角账号下的 Membership details 里,也是 10 位字符。
.p8 等同于团队的推送权限,不要提交到代码仓库,也不要打进 App。
从电脑发一条测试推送
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 和设备令牌,从网页发一条测试推送。
收不到推送时怎么查
先看 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,用的是不是这个包上报的新令牌。.p8 密钥创建时环境选了 Sandbox & Production,就不用换密钥。
静默推送(payload 只有 content-available: 1,让 App 在后台拉数据)还要在 Info.plist 的 UIBackgroundModes 数组里加上 remote-notification,推送类型用 background、优先级用 5。系统会按电量和使用情况决定是否唤醒 App,不保证每条都送达。