ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity接入iOS APNs推送全链路指南:证书配置到真机避坑

Unity接入iOS APNs推送全链路指南:证书配置到真机避坑

上线前最后一天,测试同事拿了一台iPhone过来,当着我面杀掉App进程,然后从后端触发了一条推送。我等了三十秒,通知栏纹丝不动。她没说话,但那个眼神我到现在都记得。排查了一下午,最后发现是证书环境配错——用的开发推送密钥往生产环境发,APNs直接静默丢弃,连错误都不给返。这种“无声失败”就是Unity接iOS远程推送最磨人的地方。

这篇文章把Unity项目接入iOS APNs推送的完整链路讲清楚,从证书申请、Unity端注册代码、Token回传、后端发推送请求,到真机调试的常见坑全部覆盖。无论你是刚接手Unity项目的小白,还是被APNs证书和Token折磨过的老手,照着这篇都能少走弯路。我踩过的坑,你能避就避。

1. APNs推送链路拆解:Unity项目里三方角色谁做什么

1.1 一条推送从服务器到通知栏要经过几道门

很多人第一次接触iOS推送时,以为后端把消息发给Unity就完事了,其实完全不是这条路线。一条iOS远程推送要经过的全链路是这样的:

后端服务(Provider)先向APNs(即Apple Push Notification service)发起一个HTTP/2请求,请求里带上设备Token、推送内容和一些业务数据;APNs校验这个请求的签名和Token有效后,把它转交给目标设备;iOS系统收到之后,根据推送payload决定是在通知栏展示、更新角标还是静默唤醒App。

这个过程里,Unity引擎本身并不负责消息传输。你用Unity写游戏没错,但推送走的不是Unity的Socket,也不是游戏服务器的长连接,而是苹果自己的推送通道。理解了这一点,后面很多绕弯子的地方就能想通:Unity只是作为iOS应用层的一个代码执行环境,帮你完成“注册设备Token”和“处理推送事件”这两件系统层面要求的事。

1.2 Unity在链路里只负责两件事

我把Unity在整个APNs链路中的职责概括成两个动作:

  • 注册并获取DeviceToken:App启动后调用系统API请求用户授权,然后向APNs注册,拿到一个唯一标识当前设备的Token。这个Token必须回传给你自己的后端,后端用它来定位目标设备。
  • 承接系统回调:App在前台收到推送时,系统不会自动弹横幅(iOS 10以后默认前台不展示),需要代码决定要不要展示;App被点击唤醒时,你需要拿到推送里的业务数据,跳到对应页面或者做业务逻辑处理。

顺带明确一个容易混淆的点:推送弹窗的展示是系统完成的,不是App代码完成的。当App在后台或被杀死时,用户根本不需要打开App就能看到通知栏横幅,因为那是锁屏界面的系统UI。所以“杀掉App也能收到推送”反而说明你的推送通道是通的。

1.3 搞懂Development与Production双环境

APNs把环境分成开发和生产两套,很多人第一步就栽在这。正确的理解是这样:

环境APNs端点适用的App构建证书/密钥要求
Developmentapi.sandbox.push.apple.com用开发证书/开发描述文件签名的App开发模式签发
Productionapi.push.apple.comApp Store / TestFlight / Ad Hoc发布的App发布模式签发

关键规则是:设备上安装的App签名类型,决定了它去APNs哪个环境注册。如果你用Xcode跑一个开发签名的包,它拿到的Token就只能用开发环境端点发;这时候后端如果用生产端点发,APNs不会报错,但消息会被静默丢弃。这就是我开头遇到的那个坑。

判断App当前是哪种环境,可以看Xcode的Build Settings里Code Signing,或者运行时检查entitlements里的aps-environment字段是development还是production。这个字段是系统签名时自动写入的,不用手动配,但要知道怎么看,排查环境不匹配时非常有价值。

2. 证书与推送密钥:最容易被卡住的第一关

2.1 新项目推荐直接创建Auth Key,省掉证书续期噩梦

APNs支持两种身份认证方式:

  • 令牌认证(Token-based):创建一把APNs Auth Key,文件后缀是.p8,搭配Team ID和Key ID使用,后端用ES256算法生成JWT访问APNs。
  • 证书认证(Certificate-based):创建推送证书,后缀.cer,需要搭配对应的私钥导出.p12后端使用,还要配推送描述文件。

以我的经验,新项目一律推荐走Auth Key。理由很现实:

  • .p8密钥不绑定具体推送证书,续期不存在“过期重做”的问题;
  • Auth Key是团队共享的单一密钥,后端从它派生可定期轮换的JWT,安全性和灵活性都更好;
  • 证书方案要维护CSR、私钥、p12、描述文件四件套,人一多、项目一多,混乱几乎是必然的。

创建Auth Key的步骤很简单:登录苹果开发者后台,进入Certificates, Identifiers & Profiles,选择Keys菜单,点添加,勾选APNs能力,生成后下载.p8文件。注意这个文件只能下载一次,一定要放到安全的地方。页面上会显示一个10位左右的Key ID,记下来;Team ID在开发者后台账户设置里能看到,这两个ID是后面生成JWT必需的。

2.2 老项目或第三方SDK强制要求时的p12导出步骤

有些团队的历史项目,或者某些第三方推送SDK只接受p12证书,那就必须走证书路线。如果你跨过.cer直接拿到的只有p12,说明别人已经把证书和私钥导出过了。自己从头创建也不难:

  1. 用钥匙串访问或openssl生成CSR(Certificate Signing Request),私钥保留在本机;
  2. 开发者后台选择Certificates,创建Apple Development Push Services或Apple Push Services(对应开发/生产环境,别选错);
  3. 上传CSR,下载生成的.cer证书;
  4. 双击证书导入钥匙串,在“我的证书”里找到它,右键导出为.p12,会提示设置导出密码;
  5. 如果后端只要PEM格式,用openssl把p12转成不加密的pem文件也可以。

证书方案里最容易错的一步是:导出p12时必须勾选“包含私钥”,否则后端拿到的其实是一张空壳证书,验签必然失败。很多帖子问“为什么我推送返回401”,一半是这个问题。

2.3 Bundle ID与推送能力配置的细节

证书和Key创建完,接下来要去App IDs里确认你做推送的这个App的Bundle ID下面,Push Notifications能力处于启用状态。这个开关在开发者后台的App ID配置里,默认是关闭的,不打开的话,后面一切工作都白做。

另外提醒一点:Unity打包iOS工程时,必须在Xcode的Signing & Capabilities里给工程手动添加Push Notifications能力,以及Background Modes下的Remote notifications(如果要做静默推送)。Unity导出的Xcode工程默认不会自动带这两项,你需要自己加。对于经常忘事的团队,这也是“审核通过却收不到推送”的另一大来源。

3. Unity端C#代码落地:注册、Token回传与接收推送

3.1 请求授权和注册推送的时机

在Unity脚本里请求推送权限,本质是调用iOS原生的UNUserNotificationCenter。我自己更推荐直接使用Unity官方维护的Unity Mobile Notifications插件(Package Manager里搜Mobile Notifications就能加),因为它在Android和iOS之间做了统一封装,iOS底层自动处理了很多版本适配,不用自己写Obj-C桥接。

用这个插件,核心代码很短:

using Unity.Notifications.iOS; public class PushManager : MonoBehaviour { private void Start() { // 请求授权,同时支持横幅、角标、声音 var authOptions = AuthorizationOption.Alert | AuthorizationOption.Badge | AuthorizationOption.Sound; iOSNotificationCenter.RequestAuthorization(authOptions, granted => { Debug.Log("推送授权结果: " + granted); }); // 注册远程推送,拿到Token iOSNotificationCenter.RegisterForRemoteNotifications(); } }

这里有个细节:授权请求必须在Application启动早期调用,且不要在用户还没看到任何UI时就弹窗。苹果对新用户隐私授权很严格,如果你的App刚启动30秒内就弹权限,用户体验差,拒绝率也会高。常见做法是放到登录页或主页出现后,给一个按钮或引导气泡再触发。

如果用不了插件,直接走Unity原生API也是可行的:

using UnityEngine.iOS; NotificationServices.RegisterForNotifications( NotificationType.Alert | NotificationType.Badge | NotificationType.Sound);

这个老API在iOS 10之前没问题,之后的版本可靠性欠佳,Apple已经将它废弃,新项目不建议。我能给的建议是:优先接入Unity Mobile Notifications插件,这也是Unity官方在新版本中推荐的路径。

3.2 DeviceToken回传后端的处理细节

权限授权后,APNs会通过系统回调返回一个设备的Token。插件里通过全局通知获取:

void OnEnable() { iOSNotificationCenter.OnDeviceTokenReceived += OnDeviceTokenReceived; } private void OnDeviceTokenReceived(byte[] token) { string deviceToken = System.BitConverter.ToString(token).Replace("-", "").ToLower(); // 把deviceToken发给自己的后端 }

有几个细节经验要说清:

  • Token是一个二进制串而不是字符串,网上很多帖子里直接转字符串,结果转出乱码,原因就是没做BitConverter处理。
  • 大小写不影响APNs识别,但你的后端最好统一转小写再存,避免和别的设备Token比较时出问题。
  • Token会过期和变化,规则详见后面的踩坑部分;不要让客户端只上报一次Token就再不上报,登录态变化、重装App后都要重新上报。

另外要留意OnDeviceTokenReceived回调是不是被系统延迟触发了。如果你的App在用户未允许权限的情况下,RegisterForRemoteNotifications仍然会被调用,但Token不会返回。所以逻辑顺序应该是:先请求权限成功或失败都尝试注册,再监听回调。

3.3 前台推送不弹窗的补丁方案

iOS 10之后有个著名的默认行为:App在前台时,收到的远程推送默认不会弹出任何界面,系统只会在后台唤醒代码,通知栏里什么都不显示。

这对很多Unity产品来说是不能接受的,尤其是聊天类、提醒类App,用户正开着App刷内容,推送全被静默吞掉了。

解决办法是让App在前台也要展示通知内容。用Unity Mobile Notifications插件处理起来很简单:

void OnEnable() { iOSNotificationCenter.OnNotificationReceived += OnNotificationReceived; } private void OnNotificationReceived(iOSNotification notification) { // 前台时手动展示 iOSNotificationCenter.ShowNotification(notification); }

实际上插件在iOS端默认已经通过UNUserNotificationCenter的willPresent委托设置了展示行为,你只要在iOS Notification Settings里确认前台展示选项打开。查看或修改方法是:在Assets → Mobile Notifications → Settings里,把iOS相关的前台展示开关打开。如果这块不太确定,直接写上面这段兜底逻辑是更稳妥的做法,因为你永远不知道第三方SDK有没有帮你处理代理冲突。

3.4 处理点击事件与Payload解析

用户点击推送横幅后,App会被唤起,这时你往往需要根据推送的customData跳转到对应业务页面。Unity Mobile Notifications里点击事件的回调是:

void OnEnable() { iOSNotificationCenter.OnNotificationOpened += OnNotificationOpened; } private void OnNotificationOpened(iOSNotification notification) { var customData = notification.CustomData; // 根据自定义字段跳转场景或弹窗 }

还有个边界场景要注意:App被推送唤起时分两种情况——App在后台挂着时冷启动被带入,和App完全被杀后从通知点击启动。后者在Start()执行时,OnNotificationOpened可能已经先行触发,你拿到的notification里的数据存在被GC回收的风险。稳妥做法是把推送点击的数据缓存到一个静态字段里,等Start执行完后再消费,避免时序问题。

另外,插件的iOSNotification对象在系统回调结束后不一定保留全部字段,所以业务需要的参数最好都塞进CustomData里,不要指望系统原始payload在回调后还能完整拿到。这是一个很隐蔽的坑,我有一次就是因为此方案在个别机型上丢数据,最后统一改成全部走CustomData才消停。

4. Provider端发送请求:HTTP/2与Payload的写法

4. Provider端发送请求:HTTP/2与Payload的写法

4.1 Payload JSON逐字段说明

后端发推送本质上是一个HTTP/2的POST请求,请求体是JSON,最少只需要一个aps字段。一个最简请求体是这样:

{ "aps": { "alert": { "title": "新消息", "body": "有人回复了你的评论" }, "sound": "default", "badge": 3 }, "customData": "{\"page\":\"comment\"}" }

字段含义和注意事项:

  • alert:通知栏展示的文字,可以只传字符串,也可以传对象设置title和body。
  • sound:提示音,default表示系统默认声音;如果希望自定义,可以用一个App内打包的音频文件名。
  • badge:角标数字,传0可以清掉角标。
  • mutable-content:如果要支持通知扩展(比如自定义推送图片、按钮),必须传1,并且要接一个Notification Service Extension的扩展点,Unity里实现这个需要写原生代码,这个后续单独开篇讲。
  • content-available:传1代表静默推送,只唤醒App拉数据,不在通知栏显示任何内容。

说到这必须强调:alert和content-available一般不要同时出现在一条推送里。如果只想静默更新内容,就别带alert,否则用户会在通知栏看到一个完全没用的横幅。我之前做资讯类App时踩过,后台小哥把content-available:1和alert一起发了,用户通知栏瞬间被无意义推送刷屏,一天后被产品经理找上门。

另外APNs的payload大小限制是最多4KB,超过会被拒绝。自定义字段不要塞太长的内容,能放标识符就放标识符,真正的业务信息让客户端拿着标识符自己去服务器拉。

4.2 JWT生成与请求头

证书方式的后端推送比较直观,用APNs证书创建TLS连接即可。但如果是Auth Key方式,后端需要先生成一个JWT(JSON Web Token),用于请求头的Authorization字段:

  • 请求头:alg: ES256, kid: <你的Key ID>
  • 载荷:iss: <你的Team ID>, iat: <当前时间戳,Unix秒>

生成JWT的代码各后端语言都有成熟库,比如Java的jjwt、Node的jsonwebtoken、Go的golang-jwt/jwt,核心要点是签名算法必须是ES256,密钥就是你的.p8文件里的PrivateKey。

然后发送请求的HTTP头需要完整带上这几项:

POST /3/device/<deviceToken> HTTP/2 apns-topic: <你的Bundle ID> apns-push-type: alert apns-priority: 10 apns-expiration: 0 authorization: bearer <JWT>

这里几个头的含义:

  • apns-topic:必须和App的Bundle ID完全一致,差一个字符都会被拒。
  • apns-push-type:iOS 13之后的新要求,alert表示展示型推送,background表示静默推送。不带这个头在低版本上没事,但iOS 13+有可能直接返回400。
  • apns-priority:10表示立即发送,5表示省电模式可延迟发送。静默推送必须用5。
  • apns-expiration:0表示不缓存,APNs只在设备在线时发送;如果想在离线后尽快补发,可以设一个将来时间戳。

4.3 用curl实测:沙盒与生产一封就通

开发阶段最方便的是直接拿curl手动发一条测试推送,先验通链路再写后端代码。命令大概是:

curl --http2 \ -H "apns-topic: com.example.pushdemo" \ -H "apns-push-type: alert" \ -H "authorization: bearer $JWT_TOKEN" \ -d '{"aps":{"alert":"来自curl的测试推送"}}' \ https://api.sandbox.push.apple.com/3/device/$DEVICE_TOKEN

前提是$JWT_TOKEN和$DEVICE_TOKEN都准备好。JWT可以用在线工具或写个短短几行的脚本生成,DeviceToken从Xcode运行日志里复制,注意去掉空格。

这里最容易困惑的又是环境:开发调试时用api.sandbox.push.apple.com,正式发布后用api.push.apple.com。如果你后端服务有一天突然全部失败,第一个检查项就是看是不是有人把沙盒域名写死到了生产端。

curl一次如果返回200空响应,说明APNs已接收;返回非200时会在响应体里带回JSON错误说明,直接对照下表就能定位问题。

状态码错误字段常见原因
400BadDeviceTokenToken格式错误或设备Token过期
400MissingTopic没带apns-topic头
400BadTopicapns-topic与App的Bundle ID不一致
400DeviceTokenNotForTopic这个Token不是当前Bundle ID的(环境串了)
401MissingAuthentication没带Authorization头
403ExpiredTokenJWT过期了,检查iat时间
410UnregisteredToken失效,设备卸载或用户关闭通知
429TooManyRequests请求太频繁,被限流了

最常见也最坑的是:401错误显示是“authentication failed”时,要注意检查JWT的时间戳是否同步。后端服务器的时钟如果偏了几分钟,生成出来的iat看起来是有效的,但实际上已过期或超前,APNs会拒绝请求。某次我们后端项目部署在内网服务器上,时间比真实时间慢了两分钟,推送全挂,最后同步时钟才解决。

5. 真机测试阶段那些反复验证过的坑

5.1 模拟器永远收不到真实APNs推送,但可以本地模拟

很多人在Unity编辑器或iOS模拟器里测试推送,等了半天nothing。先说结论:iOS模拟器不能注册真实APNs、收不到真实远程推送,这是苹果对模拟器的限制,不是你的代码问题。

但这不代表模拟器上完全没法测推送表现。Xcode 11.4之后的模拟器支持通过simctl命令直接注射一条模拟推送通知到通知中心:

xcrun simctl push booted com.example.pushdemo payload.json

这条命令只负责验证你的App收到推送后展示、点击的逻辑是否正确,它不经过APNs、不校验Token。所以模拟器测试适合查界面和Payload解析,真机测试才适合查注册和Token链路。

5.2 环境错配:didFailToRegister最常见的根因

Unity日志里如果打出didFailToRegisterForRemoteNotificationsWithError,说明系统在注册阶段就没通过。常见原因有这么几类:

  • 开发者后台没开启Push Notifications能力;
  • Xcode工程没加Push Notifications capability;
  • 开发者证书无效或描述文件不匹配;
  • 你测试的手机上旧App残留的签名环境和当前构建不匹配——这个问题非常狡猾,尤其是从Xcode直接Run到真机再替换安装时最容易出现。

我的建议是,测试推送一定要用干净包。即卸载旧App、删掉Xcode里DerivedData的缓存工程、重新打包安装。我记得有个开发者在测试阶段被这个问题卡了两个工作日,后来发现是旧包残留的entitlements一直在干扰新包的注册。

5.3 Token变化与失效的边界

iOS的DeviceToken并不是永久有效的,变化时机通常包括:

  • App被卸载重装;
  • 用户从备份恢复到一个新设备;
  • 用户重置了系统设置;
  • 系统基于安全原因主动轮换Token。

如果你的后端只维护了一份旧Token表,那些用户卸载重装后就会一直推送失败,APNs会返回410 Unregistered。正确的做法是:每次App启动都尝试上报一次Token,后端对比发现变了就更新记录。别等推送失败再补救,那时候你已经把用户得罪了。

还有个小坑:同一个iOS设备的Token在不同的开发/生产环境里也不一样。你拿生产环境的Token去请求沙盒APNs,一样是BadDeviceToken或DeviceTokenNotForTopic。排查时要看清楚手上这份Token是哪个环境拿到的。

5.4 推送延迟、顺序和展示的连串问题

最后聊一个所有APNs接入者都必须面对的事实:APNs不保证推送即时到达,也不保证多条推送顺序。网络差时延迟几秒到几分钟都是正常的,若用户设备离线,APNs会根据你的expiration策略决定是否缓存补发——缓存补发也顶多一条消息到十几条,最终效果取决于系统策略。

如果要给用户发强提醒,比如抢购开始、订单状态变化,我的建议是多管齐下:本地消息提醒配合远程推送,甚至App内弹窗都做上一份。只依赖一条APNs远程推送做关键业务触达,在弱网环境很容易被当成事故处理。

另外要留意推送展示的“一方弹窗”规则——同一时间系统只完整显示一条最上面的通知横幅,后面的推送会进通知中心列表但不会一条接一条覆盖弹窗。测试同事连续发五条消息只看到两条横幅的情况,其实不一定是丢了数据。

6. 最后分享一个让我省心不少的小习惯

本篇的最后不写总结,聊一个实践中很容易被忽视的小技巧:在Unity端把设备Token的控制台日志打得足够清晰。我在OnDeviceTokenReceived里专门加了一行格式化输出,把Token、系统版本、App构建版本一起打出来,后端排查环境问题时,有这条日志能省下一个小时的沟通时间。

具体逻辑是拼一个类似这样格式的日志:

Debug.Log($"[Push] deviceToken={deviceToken} | env=debug | appVer=1.3.2");

和打印[Push] token=xxx相比,多了环境标识和版本信息,报Bug时对端一眼就能看出是不是token过期、旧安装包残留这类低级问题。这个小改动花费不过三分钟,但我在跟后端同事联调时反复受益。搞APNs推送本身就繁琐,能用一个日志减少来回扯皮,何乐而不为。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进