1. APNs远程推送接入前的关键认知:先搞懂一整条链路
如果你的Unity项目要在iOS上接入远程推送,第一反应大概率是:这不就是申请个推送证书,后端调个接口,手机弹个通知吗?等真的动手才发现,Unity这个壳子把本来就很长的推送链路又切成了好几段,每一段都有你想不到的坑。我会把这套APNs接入的完整过程拆开讲清楚,直接把能用的代码和踩过的坑都摆出来。
适合看这篇内容的,是那些Unity项目要上iOS、但原生iOS开发经验不多的开发者。尤其是证书怎么配、deviceToken怎么拿、前台通知为什么不显示、点击通知怎么跳转场景这一类问题,我都会按实际操作的顺序来讲,而不是只贴一段调不通的示例代码。
先把推送的全链路画清楚。一条远程推送从发出到展示,经过的大致是这样:你的应用启动后向iOS系统申请推送权限,系统授权后,应用通过系统回调拿到这台设备独一无二的deviceToken,然后应用自己把deviceToken传到你的后端服务器。后端在需要推送时,把通知内容连同deviceToken发给苹果的APNs服务器,APNs服务器再通过长连接把通知推送到用户手机,由iOS在通知中心展示出来。
也就是说,Unity这边做的只是一件很窄的事:触发注册、拿到token、把token交给后端、接收系统的推送回调并转发到C#层。真正能不能推成功,一半取决于证书和后台配置,一半取决于后端到APNs那一段通信。理解了这一点,后面每一步都不会抓瞎。
1.1 Unity环境和原生推送的三个差异点
Unity工程和原生iOS工程在推送这事的协作上,有三处地方特别容易出错,我先把它们拎出来说。
第一是生命周期不对齐。Unity场景里的MonoBehaviour启动时机,跟iOS的AppDelegate回调时机不是一回事。iOS在didFinishLaunchingWithOptions里可以做很多初始化,但这时候Unity的C#侧脚本未必已经准备就绪。如果冷启动时用户点了一条通知进来,你想把这个通知内容交给C#侧处理,可能发现C#那边还没监听,导致一条通知直接丢掉。
第二是回调通道缺失。iOS系统只会把didRegisterForRemoteNotificationsWithDeviceToken这类消息发给AppDelegate,Unity引擎本身没有一个给你直接用的回调接口。你需要在原生层拦截这些系统回调,再通过UnitySendMessage把数据转发给C#侧的某个GameObject。这一步做不好,token就只能打印在Xcode日志里,C#那边永远拿不到。
第三是工程生成的不可控。Unity每次Build都会重新生成一份完整的Xcode工程,你在Xcode里手工改过的任何原生代码和配置,下次Build就会被覆盖。所以接入推送的原生代码不能靠每次手动往Xcode里拖,要用Unity的构建后处理脚本自动注入,或者用CocoaPods那样能随构建一起装配的方式。
理解了这三个差异,再看网上那些“照着抄就行”的教程,你就会发现很多人卡住是因为它们默认你有一个稳定的Xcode工程可以手动改。实际上Unity工程里做APNs,第一步就得先把“怎么让自定义原生代码稳定出现在每次构建产物里”这个问题解决掉,否则后面调试得越深,返工成本越高。
1.2 Unity侧推送接入的技术选型:四类常见方案对比
很多人第一次搜Unity APNs接入,会搜到四种方案:用第三方推送SDK、用Asset Store里的推送插件、在Xcode工程里手工改代码、自己写构建脚本注入原生代码。我分别说一下它们各自的适用场景。
第三方推送SDK(比如国内常见的一类聚合推送平台)好处是同时解决Android和iOS的统一推送,后台功能也齐全,证书上传到平台就能用。缺点是它会引入一层中间服务,APNs的很多细节被封装了,一旦出问题排查链路更长,而且如果你们的产品对数据合规有要求,第三方SDK的数据通道需要额外评估。
Asset Store里的推送插件适用快速验证Demo,通常价格不贵,封装也比较完整。但插件的原生层是固定的,新需求(比如推送到达时前台的角标处理、通知点击后的特定场景跳转)不一定能覆盖,改起来还要看对方源码给你多少。
在Xcode工程里手工改,是最多教程展示的方式,也是最容易踩坑的方式。Unity每次构建都覆盖Xcode工程这个特性,决定了手改代码只能用于单次调试,没法进入持续交付流程。我自己一开始图方便手改过一次,第二天构建完发现改动全没了,还得重新来一遍,非常耽误时间。
自己写构建后处理脚本注入原生代码,是我推荐的方式。原理在后面的章节会详细展开,简单说就是利用Unity的IPostProcessBuild接口,在构建完成后自动把写好的原生源文件复制进Xcode工程、自动修改AppDelegate的类名、自动配置推送所需的Capability。这样每当有新版本构建,代码都会自动接上,不会因为人忘了某个步骤而出问题。刚开始写脚本会多一点工作量,换来的收益是整个团队都受益。
2. 准备工作:APNs证书、App ID与Unity工程配置
2.1 开发者后台的推送能力开关:App ID和Key怎么开通
证书和App ID这一步,很多教程跳得特别快,但实际出错率最高的反而在这里。我按照先后顺序给你过一遍。
先在开发者后台创建一个App ID(或者说Bundle ID对应的注册),必须打开Push Notifications这个能力开关。这里有一个细节:如果这个App ID以前已经创建过但没开推送,不能直接在旧App ID上把开关打开,通常的做法是新建一个App ID并重新生成描述文件,否则后面构建时aps-environment这个entitlement不会正确写入。
然后要创建推送证书或者密钥。这一步的产出物有两种:一种是.p12格式的推送证书,另一种是.p8格式的密钥文件。它们在APNs通信中扮演的是“让APNs服务器确认你是这个App的合法推送方”的身份凭证。很多新手在这里犯迷糊,以为证书是装到手机上的,实际上证书和密钥是给你的后端服务器或者第三方平台用的,跟App本身不产生直接关系。
如果使用.p12证书,还要区分开发环境(Development)和生产环境(Production)两套证书。两套证书不能混用,开发环境的token用生产证书推送会报BadDeviceToken,反向也是。所以团队协作时,“当前这套环境对应哪个证书”一定要写进工程的README里,不然几个月后换个人维护,很容易在这上面耗掉一两个小时。
如果使用.p8密钥,就不存在开发和生产的区别了,一份密钥可以同时覆盖两个环境,配置起来清爽很多。苹果的开发者后台会生成一份带.p8后缀的密钥文件,同时给你一个Key ID和一个Team ID,这三个参数后端都要留好。
2.2 P12和P8怎么选:和你的后端团队先对齐再动手
我见过不少Unity开发同学在证书选择上反复折腾,根子在于Unity侧根本不需要直接使用证书,真正消费证书的是后端。所以第一步永远是:先去问后端或者推送平台管理员,你们那边支持P12还是P8,或者两种都支持,再决定在开发者后台生成哪种凭证。
两种格式的差异,我整理成一张表方便对照:
| 对比项 | P12证书 | P8密钥 |
|---|---|---|
| 环境区分 | 开发证书和生产证书分开 | 一份密钥同时支持开发和生产 |
| 推荐使用方 | 传统自建后端、部分云推送平台 | 苹果推荐方式,容易生成JWT鉴权 |
| 所需参数 | 证书文件本身 + 私钥密码 | Key ID、Team ID、密钥内容 |
| 有效期 | 一般一年,到期要换 | 管理界面一般不显示到期时间(实际也有限制,按说明维护) |
| 团队交接 | 文件保管混乱时容易丢 | 文本形式,方便保存在配置系统里 |
我个人现在倾向让后端优先支持P8,因为JWT方式的鉴权头生成起来比较统一,在代码里配置一套Key ID和Team ID就够了,不用维护两套证书的有效期,也不用担心开发环境调试时选错证书。
但这里有一个现实问题:很多第三方推送平台的文档还停留在P12时代,需要你先上传.p12文件。如果你所在团队已经定了用某一家平台,那别犹豫,照着平台文档生成P12就好。
关于证书这个环节,我再补一个很多人不知道的点:Unity的Bundle Identifier设置和开发者后台的App ID必须完全一致,连带描述文件里包含的能力也要匹配。在开发者后台注册好支持推送的App ID之后,还需要生成对应的iOS开发描述文件供Xcode签名使用。如果你用的是Unity自动签名,流程一般会自动拉取描述文件,但这要求你本机登录的开发者账号对当前App ID有管理权限。权限不对时,构建出的App就算功能代码全对,安装后也会在注册推送时静默失败。
3. Unity工程的Native代码注入方案与实现
3.1 为什么不要手改Xcode工程:构建后处理自动注入的方案拆解
网上很多APNs接入教程的步骤是:Build到Xcode工程后,在Xcode里新建一个类,把Objective-C代码粘贴进去,然后修改AppDelegate类名,最后手动打开Push Capability。这套流程单独跑一次确实能跑通,但它有几个致命问题。
第一,Unity项目一旦修改了场景、脚本、资源或者打包参数,重新Build之后,之前手工新建的类和改动全部被覆盖。第二,Push Capability的开关不会因为你手改过一次就在每次构建后保持,Xcode工程是重新生成的,项目文件的配置需要额外的工具或脚本在构建后重新写入。第三,靠人肉记忆操作步骤,哪怕是资深开发,换个新人接手,漏了一步就得排查半天。
我采用的方案是构建后处理自动注入。具体思路是这样:在Unity工程的Editor文件夹里放一个C#脚本,实现IPostProcessBuild接口。OnPostprocessBuild方法会在每次iOS构建结束后触发。在这个方法里,我协作几个小工具类完成三件事:把写好的Objective-C原生代码文件复制进Xcode工程的目标群组;修改工程的main.mm文件,让AppDelegate类名指向我自定义的类;通过工具修改工程配置,开启Push Notification能力并加入推送后台模式。
这套方案的核心是把“一次性手动作业”变成“可重复的自动化流程”。构建出来的Xcode工程,每次都是干净的、最新的、可直接携带推送能力的工程,团队任何一个人Build都不会漏步骤。
3.2 继承UnityAppController的子类:完整Objective-C代码实现与讲解
先解释为什么用继承UnityAppController的方式,而不是给它写Category或直接替换AppDelegate。UnityAppController是Unity引擎自己的AppDelegate,它内部做了大量Unity初始化的工作。如果直接换掉AppDelegate,Unity启动会出问题。如果给UnityAppController写Category去覆盖它的didFinishLaunchingWithOptions方法,因为Category方法的优先级比主类方法高,原来的初始化逻辑会被完全替代,等于把Unity的初始化流程丢掉了,这绝对不是想要的结果。
继承UnityAppController并重写特定方法,然后在重写里先调用[super xxx],既保留了Unity原有逻辑,又能插入自己的推送处理代码。这是我实测下来最稳的方案,分享的核心代码如下,有需要的可以直接参考。
#import "UnityAppController.h" #import <UserNotifications/UserNotifications.h> @interface PushAppDelegate : UnityAppController <UNUserNotificationCenterDelegate> @end @implementation PushAppDelegate - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { // 先让Unity完成自己的初始化 [super application:application didFinishLaunchingWithOptions:launchOptions]; UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter]; center.delegate = self; [center requestAuthorizationWithOptions:(UNAuthorizationOptionAlert | UNAuthorizationOptionBadge | UNAuthorizationOptionSound) completionHandler:^(BOOL granted, NSError * _Nullable error) { if (granted) { dispatch_async(dispatch_get_main_queue(), ^{ [[UIApplication sharedApplication] registerForRemoteNotifications]; }); } else { // 用户拒绝授权,这里可以回调给Unity做后续引导 UnitySendMessage("PushHandler", "OnAuthorizationDenied", ""); } }]; // 处理冷启动时用户点击通知进入App的情况 NSDictionary *userInfo = launchOptions[UIApplicationLaunchOptionsRemoteNotificationKey]; if (userInfo) { [self forwardPayloadToUnity:userInfo]; } return YES; }这个didFinishLaunchingWithOptions方法里包含两件关键的事情。第一是申请推送权限,我放在启动流程里,不依赖C#侧任何逻辑,保证系统弹窗尽早出现。第二是把冷启动点击通知产生的payload转给Unity,同时考虑到C#此时可能还没准备好,我通常不会直接在这里就丢掉这份数据,而是先存一份在本地对象中,等C#侧Active后再去主动取。
需要注意,requestAuthorizationWithOptions的block回调不一定在主线程,所以真正调用registerForRemoteNotifications时必须切回主线程。不切的话,注册请求可能不会走正常的回调,这个问题非常隐蔽,我之前在排查时花了不少时间。
#pragma mark - 注册远程通知回调 - (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken { NSString *token = [self hexStringFromData:deviceToken]; NSLog(@"APNs Device Token: %@", token); // 把token转发给C# UnitySendMessage("PushHandler", "OnReceiveDeviceToken", token.UTF8String); } - (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error { NSLog(@"APNs Register Error: %@", error.localizedDescription); UnitySendMessage("PushHandler", "OnRegisterFailed", error.localizedDescription.UTF8String); } - (NSString *)hexStringFromData:(NSData *)data { const unsigned char *bytes = (const unsigned char *)data.bytes; NSMutableString *hex = [NSMutableString string]; for (NSUInteger i = 0; i < data.length; i++) { [hex appendFormat:@"%02x", bytes[i]]; } return hex; }deviceToken的格式处理是一个高频踩坑点。系统回调给的是NSData,直接用description打印会得到<7f3d 9a2b ...>这种带尖括号和空格的格式,这种格式是不能直接传给后端的。我自己写了一个hexStringFromData,把每个字节转成两位十六进制数拼接起来。iOS 15及以前的做法也要兼容,但十六进制字符串本身一直是后端通用的格式。
关于token的回传时机要补充一点:UnitySendMessage的第一个参数是GameObject的名称,在代码里就是场景中必须存在一个名为PushHandler的GameObject,并且它挂载的脚本里要实现OnReceiveDeviceToken这个方法。如果场景里没有这个名字的对象,调用是无效的,而且在iOS日志里不会给你任何直观的错误提示,只会静默失败,这个坑会让很多人摸不着头脑。我惯用的做法是:在原生层调用前先确认Unity消息已经就绪,方法里可以通过UnityAppController的shared状态判断;更简单的是保证PushHandler是常驻场景对象,比如放在一个DontDestroyOnLoad的空节点上,时刻监听。
#pragma mark - UNUserNotificationCenterDelegate // App在前台时收到推送,这个方法决定是否展示、怎么展示 - (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler { NSDictionary *userInfo = notification.request.content.userInfo; [self forwardPayloadToUnity:userInfo]; if (@available(iOS 14.0, *)) { completionHandler(UNNotificationPresentationOptionAlert | UNNotificationPresentationOptionBadge | UNNotificationPresentationOptionSound); } else { completionHandler(UNNotificationPresentationOptionAlert | UNNotificationPresentationOptionSound); } } // 用户点击通知进入App时触发 - (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void (^)(void))completionHandler { NSDictionary *userInfo = response.notification.request.content.userInfo; [self forwardPayloadToUnity:userInfo]; completionHandler(); } - (void)forwardPayloadToUnity:(NSDictionary *)userInfo { if (userInfo == nil) { return; } NSError *error = nil; NSData *jsonData = [NSJSONSerialization dataWithJSONObject:userInfo options:0 error:&error]; if (error) { return; } NSString *jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; UnitySendMessage("PushHandler", "OnReceivePushPayload", jsonString.UTF8String); }从iOS 10开始,本地和远程通知都统一走UNUserNotificationCenter。前台收到推送时,默认行为是不展示横幅的,必须在willPresentNotification回调里主动调用completionHandler并指定展示选项,用户才能在前台看到通知。这一点在文档里写得很清楚,但很多Unity教程并没有提到,结果就是应用在后台能收到通知,切到前台反而“收不到”了,其实是因为通知被系统拦截,只存在于回调里。
还要注意:iOS 10以下的老系统走的是另一套回调(didReceiveRemoteNotification),现在绝大多数Unity的iOS构建最低版本都高于iOS 12了,所以代码里可以只关注新接口。如果项目历史包袱很重,还在兼容iOS 9之类的版本,建议单独评估。
3.3 让UnityAppController变成自定义子类的关键配置
写完PushAppDelegate类之后,还有一个关键动作:让Unity生成的应用启动流程使用自定义的PushAppDelegate,而不是默认的UnityAppController。
Unity生成的iOS工程里,main.mm文件中有这样一行宏定义:
const char* AppControllerClassName = "UnityAppController";需要把它改成:
const char* AppControllerClassName = "PushAppDelegate";如果手工改,效果和前面说的一样,下次构建会恢复原样。我在构建后处理脚本里用字符串替换的方式自动改,扫描main.mm找到UnityAppController这样一段字符并替换成自己的类名,问题就一次性解决了。
另外还需要处理两项Xcode工程配置。第一是开启Push Notifications能力,第二是在Background Modes勾选Remote notifications。前者对应工程的entitlements文件,后者让应用在后台也能响应静默推送。这两项如果不加,通知注册和后台接收都会受限。构建后处理脚本直接编辑工程文件的PBX工程配置,每次构建自动写入,不再依赖手动点击。
脚本主体代码类似这样,放在Unity工程的Editor目录下:
using UnityEditor; using UnityEditor.Callbacks; using UnityEditor.iOS.Xcode; using System.IO; public class PushPostProcessBuild : IPostprocessBuildWithConfig { public int callbackOrder { get { return 100; } } public void OnPostprocessBuild(BuildTarget target, string path, PBXProject project) { if (target != BuildTarget.iOS) return; string projectPath = path + "/Unity-iPhone.xcodeproj/project.pbxproj"; project.ReadFromFile(projectPath); // 这里根据功能API,开启Target的Push Capability并写入aps-environment // 同时给需要签名的Target配置Entitlements文件路径 project.WriteToFile(projectPath); // 修改 main.mm,把 AppControllerClassName 指向自定义的 PushAppDelegate string mainMmPath = path + "/Classes/main.mm"; string mainContent = File.ReadAllText(mainMmPath); mainContent = mainContent.Replace("UnityAppController", "PushAppDelegate"); File.WriteAllText(mainMmPath, mainContent); // 把 PushAppDelegate.m 和 PushAppDelegate.h 复制进 Build 产物对应的目录 } }具体API的写法根据Unity版本会有微调(不同版本中开启Capability、获取TargetGuid的方式略有变化),但思路是稳定不变的。读者写的时候,一定要先确认自己用的Unity版本对应的API,代码报错时优先查版本迁移说明,不要硬套老版本。
3.4 C#侧接收推送:挂载常驻对象并转接给业务层
原生层处理的最终结果,是调用UnitySendMessage把三类数据传给C#侧:deviceToken、授权结果、推送payload。对应的C#脚本需要挂在一个名为PushHandler的常驻GameObject上,方法签名要和原生层匹配。
using UnityEngine; public class PushHandler : MonoBehaviour { public static string LastDeviceToken { get; private set; } private void Awake() { DontDestroyOnLoad(gameObject); LastDeviceToken = PlayerPrefs.GetString("LastDeviceToken", ""); } public void OnReceiveDeviceToken(string token) { LastDeviceToken = token; PlayerPrefs.SetString("LastDeviceToken", token); PlayerPrefs.Save(); Debug.Log("PushHandler DeviceToken: " + token); // 业务层事件,由自己的网络模块订阅 // 例如:PushTokenManager.Instance.UpdateToken(token); } public void OnReceivePushPayload(string json) { Debug.Log("PushHandler Payload: " + json); // 解析JSON,通知业务层跳转或刷新 } public void OnAuthorizationDenied(string empty) { Debug.Log("PushHandler Authorization Denied"); } public void OnRegisterFailed(string error) { Debug.LogError("PushHandler Register Failed: " + error); } }这里有一个非常重要的细节:UnitySendMessage调用的方法参数严格限制为“一个string”,从原生层传过来之后,C#侧需要自己解析。你可以在原生层把payload转成JSON字符串,C#侧再反序列化成字典。要注意的是,Unity的JSON反序列化对字段大小写敏感,原生的notification.request.content.userInfo里的key通常首字母小写(比如aps),而APNs的标准payload又是首字母小写格式,所以别用字段名大写的自定义Model去直接兜底解析,最好先用字典接收再逐步取key。
另外,需要在场景中确保PushHandler存在。常用做法是放在启动场景的一个空节点上,用DontDestroyOnLoad保证切换场景时不会销毁。如果项目有完善的框架层,也可以由框架层在启动时动态创建一个带PushHandler脚本的对象并命名,但要保证创建动作发生在原生调用任何UnitySendMessage之前。以我实际经验来看,直接放到启动场景里最稳。
还有个很容易忽视但影响很大的点:PlayerPrefs保存token。APNs的deviceToken在某些情况下会变化(比如系统更新或应用重装),静态缓存不能代替重新注册,但缓存一个最近一次有效的token,在App冷启动后还没收到系统回调前,可以让后端先有一份旧token可用,减少“启动即发推送但token还没到”的窗口期。后端也要有token失效重试逻辑,不能拿到缓存token就直接认定有效。
4. 真机调试与常见问题排查实录
4.1 为什么模拟器上永远注册不到deviceToken
很多人在模拟器上测试推送,发现didRegisterForRemoteNotificationsWithDeviceToken永远不触发,Xcode日志也没报错。这不是代码问题,苹果的远程推送在模拟器上是无法注册token的,必须在装有有效签名配置的真机上测试。从iOS 11开始模拟器虽然也显示通知设置,但APNs的注册链路在模拟器上并不可用,所以别在模拟器上浪费时间,真机调试是硬性条件。
真机调试还有一个环节容易忽略:Xcode中运行App的签名团队和描述文件,必须包含推送能力。如果你用的是免费开发和个人开发团队,某些证书类型的描述文件可能没有aps-environment这个entitlement,即使真机跑起来也注册不到有效token。建议在Xcode的Capabilities面板确认“Push Notifications”显示为开启状态,并在签名描述文件详情里检查推送权限标记。
调试前还有一个小习惯可以帮助快速定位:在Xcode控制台里观察注册回调的日志。如果看到类似Provider token is not valid或no valid “aps-environment” entitlement这类信息,直接对应到证书和描述文件,而不是去排查代码。如果日志里什么都没有,多半是你没有把回调的NSLog打出来,或者系统弹窗授权被用户第一次点掉了,需要去系统设置里手动打开通知权限再试。
4.2 通知能收到但前台不展示:UNUserNotificationCenterDelegate没有设置
这个问题在论坛里出现频率极高。现象是App退到后台能收到通知,App切到前台时通知只有声音或者干脆没有任何弹出,日志里却能看到推送的payload回调。
原因就是iOS 10之后,前台收到通知默认不展示界面,必须设置UNUserNotificationCenter的delegate,并在willPresentNotification回调里调用带展示选项的completionHandler。这里最容易犯的错误是:只在C#侧代码里写了回调解析,但原生层的delegate设置遗漏了。如果设置了delegate但展示还是不生效,检查completionHandler传入的是不是UNNotificationPresentationOptionAlert,很多代码抄的时候漏掉了Alert选项,只传了Sound,结果前台只响不弹。
如果项目接入了第三方推送SDK,它们内部往往会自己也设置UNUserNotificationCenter的delegate,或者Hook了相关方法。这时你自己的delegate设置可能被覆盖,前台不展示的坑会再次出现。遇到这种情况,建议直接用第三方SDK提供的展示回调接口,或者在SDK的配置里关闭“前台通知展示”的自动处理,再走自己统一的一套逻辑,避免双写冲突。
4.3 点击通知冷启动跳不过去:payload时序问题与缓存方案
用户点击通知启动App,这种场景分两种情况。一种是App完全被杀掉(冷启动),在didFinishLaunchingWithOptions里可以拿到launchOptions里的远程通知内容;另一种是App在后台存活(热启动),走didReceiveNotificationResponse回调。
冷启动的难点在于,拿到payload的时机发生在C#业务层就绪之前。如果你在原生层直接调UnitySendMessage,C#那边的GameObject还没有创建,这条消息就浪费了。我采用的方案是:原生层先缓存最新的一份payload,C#脚本在Awake或Start中去调原生层暴露的接口主动取。这样无论用户什么时候点通知进入App,冷启动也不丢数据。
在C#侧伪代码大概是:
#if UNITY_IOS using System.Runtime.InteropServices; public class PushHandler : MonoBehaviour { [DllImport("__Internal")] private static extern string _FetchPendingPayload(); private void Start() { string pendingPayload = _FetchPendingPayload(); if (!string.IsNullOrEmpty(pendingPayload)) { OnReceivePushPayload(pendingPayload); } } } #endif原生侧对应增加:
static NSDictionary *cachedPayload = nil; - (void)forwardPayloadToUnity:(NSDictionary *)userInfo { cachedPayload = userInfo; // 原有UnitySendMessage逻辑 } const char* _FetchPendingPayload(void) { if (cachedPayload == nil) return ""; NSError *error = nil; NSData *jsonData = [NSJSONSerialization dataWithJSONObject:cachedPayload options:0 error:&error]; NSString *jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; return strdup(jsonString.UTF8String); }这套“缓存+主动拉取”的方式,不只适用于冷启动,也能缓解C#侧脚本在复杂场景加载慢的问题。类似的思路很多公司都会在推送SDK里做,自己实现一遍会更有底。
4.4 push payload解析不出来:字段命名和格式的坑
APNs推送的payload通常这样构造:
{ "aps": { "alert": "你有一条新消息", "sound": "default", "badge": 1, "category": "message" }, "custom_key": "自定义内容" }从系统回调拿到的userInfo是一个字典,字典里所有key在JSON里是首字母小写。C#侧如果用LitJson或Newtonsoft解析,直接按小写key取就不会错。但实际项目中有人为了省事,定义了一个C#的model,字段名起成Alert、Sound这种大写形式,解析自然全空。这是我自己以前犯过的错:以为是推送没发出来,结果是从iOS到C#这一层字段对不上。
建议推送的payload结构在设计阶段就跟客户端和后端约定好:aps部分不用管,苹果规定的字段就那些;自定义字段统一放业务key,且全部用下划线命名,C#解析时按约定转换,不要给后端的推送脚本塞一堆大驼峰JSON字段,到时候解析不一致会很难受。
5. 后端发送与闭环验证
5.1 用curl快速验证APNs通道是否打通
当Unity侧已经能正常拿到deviceToken并上报给后端之后,开始验证推送链路是不是真正通了。这里建议直接模拟一次后端到APNs的请求,绕过你自己后端的推送模块,这样能快速定位是前半段(客户端注册问题)还是后半段(后端与APNs通信问题)出的错。
APNs新协议是HTTP/2,请求里要带两个关键Header:apns-topic对应Bundle ID,authorization对应鉴权信息。使用JWT方式时,authorization头是bearer + 生成的token。如果是P12证书的方式,后端会处理证书握手。
并不打算在这里展开JWT的完整签名算法,但用开发者后台的.p8密钥配合一些在线工具或者开源库,几步就能生成一个curl可用的token。一个最简的例子:
curl -v --http2 \ -H "authorization: bearer <生成的JWT>" \ -H "apns-topic: com.example.app" \ -H "apns-push-type: alert" \ -d '{"aps":{"alert":"Hello from curl","sound":"default","badge":1}}' \ https://api.push.apple.com/3/device/<deviceToken>这段curl如果返回200,说明设备token有效、证书有效、链路是通的。如果返回400或410,提示BadDeviceToken之类的错误,多数是token或者环境串了。开发环境用的是api.sandbox.push.apple.com,生产环境才是api.push.apple.com,这俩域名很长,特别容易写错。
我用这个方式排查过好几次团队内部的“推送发不出去”问题,最后发现都是P12证书的沙箱和正式环境没有分开配置导致的。先跑通curl,再让你的后端把对接细节拿过去比对,效率远高于在后端代码里反复调试。
5.2 线上推送维护的几个实践建议
推送功能上线之后,后续维护有一些经验值得现在就做好。我把它们按重要程度排个序。
第一,后端要建立deviceToken失效反馈机制。APNs在推送失败时会返回错误码,比如BadDeviceToken、Unregistered、ExpiredToken等。后端每次推送后都应该根据这些错误码把失效的token从库里清理掉,否则随着App卸载、重装、系统升级,库里会积累大量废token,每次推送都白白产生大量超时和失败日志。
第二,静默推送的payload结构很特殊。content-available: 1是关键标记,系统才会唤醒应用在后台执行一段短逻辑。静默推送时不应包含alert和sound,否则系统会把通知展示出来,就不“静默”了。这个字段是数字1而不是字符串"1",JSON里写错也会导致异常。
第三,前台通知展示策略要在业务上明确。如果产品希望前台也弹出横幅,就在原生层展示;如果产品只希望后台通知前台静默刷新数据,那willPresent回调里的completionHandler传UNNotificationPresentationOptionNone就行。很多团队没想清楚这一点,导致前台通知要么不弹卷,要么弹出让用户觉得很吵。
第四,推送角标(badge)要特别注意清零时机。如果通知带了badge字段,iOS会展示在App图标上。App处于活跃状态时,需要你自己调用UNUserNotificationCenter的setBadgeCount清零,否则用户看过的旧角标会一直挂着。自己管理角标的逻辑时,前后台切换都得考虑到,不然很容易出现“角标明明清了又出现在桌面”的情况。
最后再分享一个小技巧:记录每条推送到达的完整链路日志
这次接入APNs过程中,我深切体会到日志的重要性。推送这个功能涉及的系统节点多(设备系统、APNs、后端、Unity层),任一节点的黑暗角落都可能掩盖真相。建议从测试阶段开始,每次真机调试都保留一套完整的链路日志:设备端什么时候拿到token、token是多少、后端发请求的时间戳、APNs返回的状态码、设备端回调的时间戳和payload内容。把这套日志打齐,将来不管是谁来排查,都能省下大量时间。
另外,我在实际开发中养成了一个习惯:在C#侧把每条收进来的payload原样存一份滚动日志文件,只存最近几十条。因为线上用户反馈“我点了通知没反应”的时候,你在后台查不到他真正收到的payload是什么,本地日志至少可以让他一键导出发给开发。个人体会是,这套日志机制虽然简单,但排查远程问题时的价值非常高,甚至比异常统计工具来得更快、更直接。
推送功能做起来不难,难的是做完整、排查快。按照上面这套链路走通一遍,再补上全流程的日志和自动构建脚本,后面几个平台版本维护这份代码都会轻松得多。