前阵子有个开发者来找我,说他们在Unity里做的社交App要接iOS推送,问APNs到底怎么整。我第一反应是,又是一个被Unity"跨平台"外衣坑了的兄弟。Unity对安卓推送确实有现成封装,但iOS的远程推送从来不是填个参数就能跑的,它必须碰原生层,要走一套苹果自己的通知机制,也就是APNs。
这篇文章我把自己踩过的坑、验证过的套路全部整理出来。不管你是Unity新手还是老手,只要想在iOS上接推送,按这篇的步骤走一遍,至少能少折腾三天。我会从证书配置讲到原生桥接,再讲Unity侧怎么接收、怎么处理冷启动,最后给一份我实测过的排错清单。全程不玩虚的,全是能直接抄作业的东西。
1. 接入APNs之前,先搞清楚推送链路到底是怎么走的
很多人在Unity里接入推送时懵就懵在没弄明白APNs在整个环节里处在哪个位置。等你看懂这条链路是怎么转的,后面写代码、配证书、联调排错,心里就有底了。
1.1 APNs在整个环节里管哪一段
iOS远程推送的核心链路是这样的:服务端(你的推送Provider)先把消息发给苹果的推送服务器,也就是APNs,APNs再把它推送到目标用户的iPhone上,最后你的App收到这颗"信使",决定弹个横幅还是跳转某个页面。
这里面有个关键概念叫deviceToken,它是苹果颁发给App在当前设备上的唯一"门牌号"。每一次接入,你的App都要向APNs注册自己,注册成功后苹果会给它发一串十六进制字符串,这就是deviceToken。你的服务器要推送时,不是对着你的App直接喊,而是拿着这串门牌号发给APNs,APNs才能准确地把消息送到这部设备上。
在Unity里最麻烦的就是中间这一段:Unity的C#层拿不到deviceToken,这个注册动作必须用iOS原生代码来做。你让Unity去单独申请通知权限、去和苹果的令牌服务器说话,是做不到的,所以必须写一层Objective-C代码作为桥接。理解了这一步,就理解了整个接入工作量的核心。
1.2 Unity接入APNs常见的三种路径
针对Unity项目,业内接推送基本是这三条路,各有优劣,看项目情况选。
- 纯原生桥接插件:自己在Xcode层面写Objective-C/Swift代码,封装成Unity可以调用的原生插件。优点是完全可控、不依赖第三方、没有额外SDK体积;缺点是要懂iOS原生开发,而且Unity版本升级、Xcode升级时偶尔得适配一下。
- 第三方聚合推送服务:比如极光、个推、友盟这类。它们提供Unity SDK,底层把APNs封装好了,你在后台配好证书就能用。优点是省事,报表统计也齐全;缺点是必须把推送请求先经过它们的服务器,如果你的App有严格的数据合规要求,这条要慎重。另外这类SDK对权限声明、隐私清单的要求也越来越严格。
- Unity商店里现成的推送插件:这类插件其实也是帮你包了一层原生桥接,质量参差不齐,而且不一定持续维护。好处是省去写原生代码;坏处是一旦苹果更新了API,插件没跟上,你的推送就悄悄挂了。我之前用过一个商店插件,后来苹果改了通知回调签名,插件作者不更新,整个项目被卡住,最后还是自己写了一个。
我的建议是,只要团队里有一个人会写Objective-C,就优先自己写桥接层。APNs的接口其实不多,注册、回调、处理事件,核心代码量也就一两百行。自己写的最大好处是出了问题你有能力自己排查,不会被第三方SDK拦在门外。
2. 动手前的事:证书、开发者账号、工程权限一个都不能少
推送这个功能是典型的"配置比代码先坑你"。代码问题报错了能看见,证书问题常常是静默发生的。我见过不少项目,代码写得没问题,但推送就是不响,最后发现是证书配置或者推送环境不匹配。所以这一节老老实实把前置条件讲透。
2.1 APNs Auth Key和p12证书,我推荐用哪个
在苹果开发者后台准备推送凭证,有两种主流方式:传统的是生成Apple Push Notification service SSL证书,也就是p12文件;另一种是APNs Auth Key,也就是p8文件。
两者最大的区别在于:p12证书一个App ID就要配一份,而且有有效期,过期就得重新生成、重新上传服务端,开发环境证书和生产环境证书还是分开的。而APNs Auth Key一个钥匙就能给开发者账号下所有App做推送,不区分开发或生产环境,也不会每年过期,长期省心太多。
从实操角度,除非你的服务端供应商只支持p12格式,否则我强烈建议用p8的APNs Auth Key。流程是:登录苹果开发者后台,在Keys选项里新建一个Key,名字随便写,但必须勾选APNs权限。创建后只给你下载一次p8文件,务必保管好,丢了下不回来,只能重新生成一个,并记录两个值:Key ID和你的Team ID,这两个后面配置服务端时会用到。
2.2 开发者后台App ID与Xcode能力配置
关键一步:你的App ID必须开启Push Notifications能力。在后台的Identifiers里找到你的Bundle ID,勾上Push Notifications,保存。很多人以为Unity导出工程后Xcode里能配置就行,其实后台这一步没开,你的App到了真机上注册推送时会一直失败。
接着是Xcode里的配置。导出Unity工程后用Xcode打开,选中Target,在Signing & Capabilities里加一项Push Notifications。如果你的项目还需要静默推送,也就是App在后台时也能收数据而不弹出横幅,那么还要在Background Modes里勾选Remote notifications。
这里有个新老Unity版本的差别:老版本Unity导出后,Xcode里的能力要手动配。新版本Unity在Player Settings里的iOS Capabilities一栏就能直接勾选Push Notifications,导出后自动带上。如果你的项目还在用老版本,建议升级时顺手做一次全量回归测试,因为工程结构变了,桥接插件的挂载方式也可能受影响。
2.3 Unity工程侧的几个配置关口
在Unity侧,有几个配置直接影响打包结果,忘掉一个就会在真机上扑街。
- Bundle Identifier必须和后台App ID完全一致,包括大小写。如果Unity这边填了com.example.game,后台那边填的是com.example.Game,推送注册会直接失败。
- Scripting Backend建议保持IL2CPP,ARM64。虽说Mono也能跑,但现在很多iOS原生SDK都要求IL2CPP,第三方推送服务接入时尤其如此,早换早安心。
- 勾选Automatic Signing后,确认Team选对了。如果你有多个开发者账号,Team选错会导致签名失败,或者装到真机上推送收不到。
- 如果是Unity 2022以上版本,可以顺手在Player Settings的iOS Capabilities里直接点开Push Notifications,导出工程后Xcode里就不用再加了。
有一个容易忽略的坑:如果你用第三方推送SDK,它可能在Unity导入时就自动修改了Xcode工程的能力配置。如果你同时自己写了原生桥接,两边可能存在权限重复申请或者delegate冲突。所以我建议要么用自己写的桥接,要么用纯三方的SDK,别混着来,把iOS的UNUserNotificationCenter delegate当成一个全局单例,谁先设置谁影响结局,混用很容易互相覆盖,导致另一个失效。
3. 原生桥接层:拿到deviceToken并稳稳回传给Unity
到了整个接入里最核心的部分。我会用Objective-C写一个独立的桥接类,用黑魔法的方式hook掉UnityAppController的代理方法,这样不需要修改Unity每次打包都重新生成的模板文件。这也是我试过好几种方案后觉得最稳定的一种。
3.1 原生代码文件放哪里,怎么让Unity认识它
在Unity工程根目录下建一个Assets/Plugins/iOS文件夹,把写好的Objective-C文件丢进去。Unity打包时,这个文件夹里的源文件会被自动拷贝到Xcode工程里参与编译,不需要你每次打包后再手动拖文件。
我自己的做法是建两个文件:一个PushService.h头文件,一个PushService.m实现文件。命名建议别太通用,像UnityPushManager这样的名字很容易和第三方SDK里的类撞上,我的习惯是带个项目缩写前缀,比如XXGameUnityPushManager,降低冲突概率。
在PushService.m里,我直接用分类加方法交换的方式,把UnityAppController的若干代理方法拿到手。这么做的好处非常明显:Unity每次重新导出工程,模板文件都会被覆盖重来,我不用再去手动塞代码。
需要注意一点,UnityAppController这个类在不同Unity引擎版本里的初始化和代理方法签名基本稳定,我从Unity 2019一直用到Unity 2022,没碰上过例外。但如果哪天Unity官方大改了iOS启动流程,这部分逻辑要记得重新检查。
3.2 权限申请与链路注册
先晾一段核心代码框架,后面我会拆开解释每一块的作用。
#import <UserNotifications/UserNotifications.h> #import "UnityAppController.h" #import <objc/runtime.h> @interface XXGameUnityPushManager : NSObject <UNUserNotificationCenterDelegate> @end @implementation XXGameUnityPushManager + (instancetype)sharedInstance { static dispatch_once_t onceToken; static id instance = nil; dispatch_once(&onceToken, ^{ instance = [[XXGameUnityPushManager alloc] init]; }); return instance; } - (void)registerForPush { UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter]; center.delegate = self; UNAuthorizationOptions options = UNAuthorizationOptionAlert | UNAuthorizationOptionSound | UNAuthorizationOptionBadge; __weak typeof(self) weakSelf = self; [center requestAuthorizationWithOptions:options completionHandler:^(BOOL granted, NSError * _Nullable error) { __strong typeof(self) strongSelf = weakSelf; if (!strongSelf) return; dispatch_async(dispatch_get_main_queue(), ^{ if (granted) { [[UIApplication sharedApplication] registerForRemoteNotifications]; [strongSelf sendAuthResult:@"granted"]; } else { [strongSelf sendAuthResult:@"denied"]; } }); }]; } + (void)load { [self swizzleApplicationMethods]; } + (void)swizzleApplicationMethods { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ Class cls = NSClassFromString(@"UnityAppController"); SEL originalSelector = @selector(application:didFinishLaunchingWithOptions:); SEL swizzledSelector = @selector(xx_application:didFinishLaunchingWithOptions:); Method originalMethod = class_getInstanceMethod(cls, originalSelector); Method swizzledMethod = class_getInstanceMethod(self, swizzledSelector); method_exchangeImplementations(originalMethod, swizzledMethod); // 其余代理方法,按同样方式操作 }); } @end这块里最关键的是权限申请。iOS 10以后,推送权限统一走UNUserNotificationCenter,requestAuthorizationWithOptions会弹出系统权限框,选项里声明了你要用横幅、声音、角标。用户点击允许后,你再调用registerForRemoteNotifications去真正请求那个deviceToken。这两步是分开的,很多第一次接的人会漏掉第二步,导致权限框弹了,Token却一直没拿到。
注意我在这里用了dispatch_async(dispatch_get_main_queue(), ...),因为授权回调不在主线程,而registerForRemoteNotifications和UnitySendMessage都应当跑到主线程去执行,避免奇怪的线程问题。
3.3 deviceToken的字节流转,细节决定成败
苹果回调返回的deviceToken是一个NSData对象,很多教程里会让你deviceToken description然后去掉空格尖括号,这个方法相当不严谨,因为description的格式从技术上说是不保证稳定的。正确做法是逐字节拼十六进制字符串:
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken { const unsigned char *dataBuffer = (const unsigned char *)deviceToken.bytes; NSUInteger length = deviceToken.length; NSMutableString *hexString = [NSMutableString stringWithCapacity:(length * 2)]; for (NSUInteger i = 0; i < length; i++) { [hexString appendFormat:@"%02x", dataBuffer[i]]; } [self sendToken:hexString]; }这个方法得到的字符串才是标准的deviceToken。还要提醒一句,开发环境的Token和生产环境的Token不是同一个值,你在联调时把开发Token发到服务端,切到发布环境后就不能再用了,这个我后面还会再提。
3.4 用UnitySendMessage把Token和推送内容递到C#
拿到Token之后,要做的核心动作就是调用UnitySendMessage,它的签名是这么三个参数:目标GameObject名字、要调用的方法名、参数值。它就像一架跨语言的电话,从原生层拨到Unity层。
- (void)sendToken:(NSString *)token { UnitySendMessage("PushReceiver", "OnDeviceToken", [token UTF8String]); } - (void)sendAuthResult:(NSString *)result { UnitySendMessage("PushReceiver", "OnAuthResult", [result UTF8String]); } - (void)sendPushPayload:(NSDictionary *)userInfo { NSError *jsonError = nil; NSData *jsonData = [NSJSONSerialization dataWithJSONObject:userInfo options:0 error:&jsonError]; NSString *jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; UnitySendMessage("PushReceiver", "OnPushPayload", [jsonString UTF8String]); }这里有几个注意点,都是实战踩过的坑:
- UnitySendMessage里的GameObject名字必须是场景里真实存在的,而且这个对象不能被销毁。所以我在Unity里专门建了一个空物体叫PushReceiver,挂上处理脚本,在Awake里执行DontDestroyOnLoad,这样才能保证跨场景、跨生命周期都能收到原生层消息。
- 调用的方法不能是静态方法,也不能带多个参数,就一个字符串参数。如果你需要传复杂结构,就在原生层把它转成JSON字符串,所有字段塞进一个字符串里递给Unity,C#侧再解析。这个习惯很重要,推送payload里的自定义字段都能靠这个方案传过去。
- UnitySendMessage调用时如果找不到GameObject,它不会报错,但你的消息就无声无息丢了,排查起来很迷惑。所以我一般会在PushReceiver的Awake里加一行日志,确认它没被销毁。
4. 收到推送之后,Unity这一侧怎么接怎么解析
原生层把消息递过来了,Unity这边要好生接待。这里包括三个场景:App在前台、App在后台被点击、App被彻底杀死后从推送冷启动。这三种情况处理方式完全不同,一个考虑不到,用户就抱怨"点了推送没反应"。
4.1 C#侧的接收脚本该怎么写
首先在Unity里创建一个空GameObject,命名为PushReceiver,挂上如下脚本的核心部分:
using UnityEngine; public class PushReceiver : MonoBehaviour { private void Awake() { DontDestroyOnLoad(gameObject); Debug.Log("PushReceiver ready"); } public void OnDeviceToken(string token) { Debug.Log("推送Token: " + token); // 这里把token发给自己的业务服务器,绑定用户ID } public void OnAuthResult(string result) { Debug.Log("推送授权结果: " + result); if (result == "denied") { // 提示用户去系统设置里打开通知开关 } } public void OnPushPayload(string json) { Debug.Log("收到推送: " + json); // 解析JSON,根据业务字段跳转页面 } }这里有个Uniform的地方要注意:Unity方法名区分大小写,原生层里UnitySendMessage("PushReceiver", "OnDeviceToken", ...),C#侧的方法必须叫OnDeviceToken,不能写成Ondevicetoken。这类错误不会编译报错,但消息发不过来,卡你半天你都不知道哪里出了问题。
子啊,刚才那句跳过了,不重要,继续。
如果你的Unity工程里同时接了其它原生SDK,注意类的冲突问题。我习惯在类名和方法名上都加项目前缀,比如XXPushReceiver、OnXXDeviceToken,既解决冲突,也方便日志里识别。
4.2 App被杀掉以后,从推送消息冷启动怎么办
这是推送接入里最容易被忽略的场景,也是我见过最多人卡住的地方。当App完全没有运行时,用户点击推送横幅,系统会把App拉起来,同时把推送内容放在启动参数launchOptions里。但问题是,Unity引擎启动需要几秒钟,如果你在原生层一启动就立刻调用UnitySendMessage,Unity那边可能还没初始化到位,消息就丢了。
我的解决方案是本地缓存加延时补偿。原生层在didFinishLaunchingWithOptions里读取启动参数中的UIApplicationLaunchOptionsRemoteNotificationKey,先把推送内容存到一个本地变量里,再等Unity就绪。Unity侧启动后主动调用一个原生接口去取这份缓存,如果拿到了,就把冷启动的通知补发给业务层。
- (BOOL)xx_application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { // 原始方法会被替换到别处,这里先调用原有的方法逻辑 // 实际工程里注意swizzle后调用顺序 NSDictionary *remoteNotification = launchOptions[UIApplicationLaunchOptionsRemoteNotificationKey]; if (remoteNotification) { self.pendingPushPayload = remoteNotification; } return YES; }Unity侧在场景加载完成后调用一次GetPendingPushPayload原生接口,如果有缓存,就把内容解析出来,然后清掉缓存。这样做的好处是无论Unity启动多慢,推送内容都不会丢,一定能在某个确定时机交到业务层手里。
4.3 App在前台时为什么不弹横幅,以及角标怎么清理
一个很基本的iOS特性:App在前台时,系统默认不展示推送横幅,只会静静地走回调。你要是不做处理,用户以为推送没来,其实消息已经躺在回调里了。所以在willPresentNotification里,要根据业务需求决定要不要展示横幅、播放声音:
- (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler { if (@available(iOS 14.0, *)) { completionHandler(UNNotificationPresentationOptionBanner | UNNotificationPresentationOptionSound); } else { completionHandler(UNNotificationPresentationOptionAlert | UNNotificationPresentationOptionSound); } }另外是角标。推送payload里如果带了badge字段,App图标上就会出现红点数字。但注意,这个数字累不累加、清零逻辑,苹果不会替你处理。很多App推送都带badge然后越积越多,用户看着极不舒服。我的做法是在每次App从后台进入前台时把角标清零:
[[UIApplication sharedApplication] setApplicationIconBadgeNumber:0];Unity侧可以在生命周期事件里主动调用这个原生接口,或者写在App进入前台的代理回调中。这个细节虽然小,但直接影响用户体验,做得好很加分。
4.4 推送payload的自定义字段解析
APNs推送的payload标准结构长这样:
{ "aps": { "alert": { "title": "新的消息", "body": "你有一条新的订单通知" }, "sound": "default", "badge": 1 }, "customData": { "type": "chat", "targetId": "123456" } }aps键是苹果规定的,用来控制系统层面的展示。你自己的业务字段放在aps外面的自定义区域。原生层收到通知后会把整个userInfo字典原封不动转成JSON字符串递给Unity,Unity侧拿到后用JsonUtility或者Newtonsoft.Json解析。我一般定义一个完整的模型类:
[System.Serializable] public class PushPayload { public Aps aps; public CustomData customData; } [System.Serializable] public class Aps { public Alert alert; public string sound; public int badge; } [System.Serializable] public class Alert { public string title; public string body; } [System.Serializable] public class CustomData { public string type; public string targetId; }这样解析出来以后,根据type字段去决定跳转到聊天页、订单页还是活动页。这里务必要处理解析异常,推送内容结构不可控,收到坏数据时不能崩,包一层try catch是基本素养。
5. 真机联调、打包发布与常见问题速查
代码写完了,配置齐了,接下来是验证环节。推送联调和普通功能联调不一样,它依赖苹果服务器、需要外部推送请求、还要看环境配对,整个链路任何一个环不对,表现都是"收不到"。
5.1 从零到一跑通一次真机推送
我自己的联调流程是固定的,照着走一遍,基本能在半小时内确认链路是否通:
- Unity工程里确认Bundle ID和后台App ID一致,Team选对,导出Xcode工程。
- 用Xcode打开工程,确认Push Notifications能力已勾上,Signing & Capabilities里没有报错。
- 把真机连上Mac,选择自己的开发者团队,直接跑Release或Debug包装到手机。
- 在Xcode的控制台里观察日志,看到"推送Token: 一串十六进制字符串",说明注册成功。
- 把这串Token发给自己的服务端同学,让他在服务端把这条设备和某个测试用户绑定。
- 服务端用APNs Auth Key发起一条推送请求,环境选择sandbox,目标就是这串Token。
- 手机锁屏状态下收到横幅,点击后观察Unity日志里是否打印出OnPushPayload内容。
如果你没有服务端配合,也可以用一些在线的APNs调试工具,填上p8文件的Key ID、Team ID、Bundle ID和deviceToken,点发送就行。这类工具很多,选一个界面干净的即可。
5.2 那些年踩过的坑,我整理成了一张速查表
这里把我在多个项目里实际遇到过的推送疑难杂症整理成表格,每一条都附上典型原因和解决办法。
| 现象 | 大概率原因 | 解决办法 |
|---|---|---|
| 权限框一直不弹 | App ID后台没开Push Notifications | 去开发者后台检查Identifiers里的开关 |
| 授权框弹了但拿不到Token | 没有调用registerForRemoteNotifications | 在授权granted回调里补上第二步注册 |
| 真机跑的时候直接报No valid APNs certificate | 签名Team不对或APNs能力没开 | 检查Signing Team和Capaibilities |
| Token能拿到,但服务端推送收不到 | 推送环境发错了,开发Token打到了生产环境 | 服务端联调阶段用sandbox环境 |
| iOS 13及以上偶尔收不到横幅 | App处于前台且没有实现willPresent回调 | 实现前台回调并主动声明展示 |
| 点推送不跳转页面 | 冷启动路径没做缓存补发 | 实现launchOptions缓存与Unity主动拉取 |
| Unity侧收不到任何原生回调 | UnitySendMessage的GameObject被销毁或不存在 | 确认物体存在且DontDestroyOnLoad |
| 推送角标数字永远没变化 | payload没带badge,或没有设setApplicationIconBadgeNumber | 服务端加badge,客户端适时清零 |
5.3 上线前必须检查的几件事
到了临上线前,有几件事建议逐一过一遍,不然上架后才发现推送挂了,会被用户骂得很惨。
第一,确认推送环境切换。发布环境推送走的是APNs的生产环境,token也变了。服务端如果还拿开发环境的token和配置发生产推送,必然失败。发布前让服务端把环境切到production,重新拿一份生产token测试。
第二,用户授权被拒后的引导。如果用户第一次点了"不允许",iOS不会再次自动弹权限框。应用内得做一个引导页,带用户去系统设置里手动打开通知开关。这个功能不做的话,你损失一半以上的推送触达率。
第三,检查隐私清单。苹果对隐私合规越来越严,如果你用第三方推送SDK,记得生成并配置好PrivacyInfo,说明收集的数据类型和用途。自己写桥接的话,至少确保你不上传deviceToken之外的额外用户信息,把合规风险降到最低。
第四,推送内容测试要覆盖三态。App前台、后台驻留、完全退出,这三种状态下都各推一条测试消息,确保回调、横幅、跳转全部正常。很多问题只在其中某一种状态下暴露。
老实说,iOS推送这东西,配置的门槛高过代码门槛。只要你能稳定拿到那一串token,后半段的推送链路会好走得多。我自己跑通第一遍时也踩了一脑袋包,不是漏了后台开关,就是忘了前台回调,后来把这些沉淀成一套固定流程,后面每个项目都能很快接完。
最后再分享一个小技巧:把原生层的每个关键节点都加上日志,token是否拿到、授权是允许还是拒绝、前台是否走回调、冷启动缓存是否清空。这些日志在联调时是你最重要的眼睛。推送不像普通功能,报错是静默的,只有日志能告诉你它到底卡在哪一环。保留这套日志,以后线上出任何推送问题,你都能第一时间定位位置。