iOS 远程推送全链路拆解:从 deviceToken 到多账号绑定

3 阅读15分钟

这篇是我自己在做一套有账号体系的 IM 类 App 推送时,把「通知授权」「APNs 注册」「token 绑定」这几个反复踩坑的点彻底捋一遍后的整理。网上很多文章把 requestAuthorization 和 registerForRemoteNotifications 混在一起讲,导致后面所有的判断都是错的。这里按机制本身的边界来拆,代码以 Objective-C 为主,最低部署 iOS 13。


一、先把两套系统分开:这是所有困惑的源头

新手最容易犯的错,是以为「用户授权了通知 → 系统自动给我 deviceToken」。实际上苹果在这件事上设计了两套互不依赖的机制。

1.1 UNUserNotificationCenter:只管「能不能弹出来」


UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];

[center requestAuthorizationWithOptions:(UNAuthorizationOptionAlert | UNAuthorizationOptionSound | UNAuthorizationOptionBadge)

completionHandler:^(BOOL granted, NSError * _Nullable error) {

// granted 决定的是「通知到来时系统要不要展示」

}];

它的职责范围非常明确:

  • 控制横幅、声音、角标、通知中心列表、锁屏展示;

  • 不访问 APNs 服务器,不生成、不返回 deviceToken;

  • 授权结果存在系统里,入口是「设置 → 你的 App → 通知」;

  • 系统授权弹窗一个 App 安装生命周期内只弹一次。第一次调用弹窗,之后无论调用多少次,都是直接把已保存的状态回给你,不再弹。

所以它本质上是一个「展示开关」,而不是「推送通道开关」。这一点如果不先建立起来,后面所有的推论都会歪。

1.2 UIApplication.registerForRemoteNotifications:这才是通往 APNs 的入口


[[UIApplication sharedApplication] registerForRemoteNotifications];

调用它之后,系统框架才真正向苹果 APNs 服务器发起注册,协商出这台设备 + 这个 App(bundleId)+ 这个环境(sandbox / production)对应的唯一标识,也就是 deviceToken。结果通过两个 AppDelegate 回调返回:


- (void)application:(UIApplication *)application

didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken;

- (void)application:(UIApplication *)application

didFailToRegisterForRemoteNotificationsWithError:(NSError *)error;

关键结论,值得单独记一句:

didRegisterForRemoteNotificationsWithDeviceToken 的唯一触发前提,是你主动调用了 registerForRemoteNotifications。

用户很久以前就授权了、权限到现在一直是允许状态,只要你的代码这次没有调用 registerForRemoteNotifications,这个回调永远不会进来,你也拿不到 token。

而且这个注册过程不检查通知授权状态。用户点了「不允许」,你照样可以调用 registerForRemoteNotifications,照样成功拿到 deviceToken,只是后续推送没有弹窗能力,只能静默送达。

1.3 四种组合的真实效果

把两个开关做笛卡尔积,行为和效果是这样的:

| 场景 | 是否申请授权 | granted | 是否调用 register | 能否拿到 token | 推送效果 |

| --- | --- | --- | --- | --- | --- |

| A | 已申请 | YES | 调用 | 能拿到 | 正常弹横幅、响铃、角标 |

| B | 已申请 | NO | 调用 | 能拿到 | 只能静默送达,无任何可见提醒 |

| C | 从未申请 | 默认 NO | 调用 | 能拿到 | 同上,静默送达 |

| D | 已申请 | YES | 不调用 | 拿不到,回调不触发 | 后端没有 token,推不出去 |

D 这一行是最容易出线上事故的:版本迭代时有人把启动逻辑里的 registerForRemoteNotifications 删了,权限明明还开着,但新版本再也拿不到 token 传给后端,推送直接全线哑掉。


二、deviceToken 的生命周期

2.1 调用之后系统做了什么


registerForRemoteNotifications

│

▼

系统框架连接 APNs(HTTPS,异步,非主线程)

│

├── APNs 侧已有有效 token → 快速返回旧 token,回调 didRegister

├── 无有效 token(新装/重装/刷机/苹果轮换)→ 生成新 token,回调 didRegister

└── 网络不通、证书不匹配、环境错配 → 走 didFail

注意两点:

  • 回调是异步的,且不在主线程。 在里面更新 UI 或调用需要主线程的东西,必须切回 main queue。

  • 多次调用是安全的,也是官方推荐的做法。 苹果文档明确建议每次 App 启动都调用 registerForRemoteNotifications,不要把 token 当成一次拿到就永久有效的常量。系统内部有缓存,token 没变时回调很快,不会每次重新走完整协商。

2.2 token 会变的原因

  • App 卸载重装;

  • 设备抹掉所有内容 / 刷机;

  • 从备份恢复到另一台设备;

  • 苹果 APNs 后台策略主动轮换(不通知开发者,不定期发生)。

token 一变,后端手里的旧 token 就是死数据。所以每次 didRegister 回调都要把当前 token 传给后端,而不是「登录时传一次就完事」。

2.3 NSData 转十六进制

deviceToken 是 NSData,直接 description 或转 UTF-8 拿到的是带尖括号和空格的乱码,必须逐字节转十六进制小写字符串:


- (NSString *)hexStringFromDeviceToken:(NSData *)data {

const uint8_t *bytes = data.bytes;

NSMutableString *hex = [NSMutableString stringWithCapacity:data.length * 2];

for (NSUInteger i = 0; i < data.length; i++) {

[hex appendFormat:@"%02x", bytes[i]];

}

return [hex copy];

}


三、payload 决定投递链路:普通通知 vs 静默推送

APNs 收到你后端发来的推送,第一件事不是投递,而是看 aps 里有没有 content-available: 1。这一个字段把推送劈成两条完全独立的链路。

3.1 普通远程通知


{

"aps": {

"alert": { "title": "标题", "body": "内容" },

"sound": "default",

"badge": 1

}

}

这类推送的语义就是「一张给用户看通知卡片」,所以它强依赖通知授权:

  • granted = YES:进入 UNUserNotificationCenter,走通知代理体系;

  • granted = NO:系统直接把这条消息丢弃,不交付给 App。

这里有一个很多人心里默默希望但实际不成立的想法:「不让弹窗,但前台收到推送时偷偷执行点代码」。普通通知做不到。用户拒绝授权时,消息在系统层就被吃掉了,willPresentNotification 不回调,didReceiveNotificationResponse 不回调,didReceiveRemoteNotification:fetchCompletionHandler: 也不回调,App 任何地方都感知不到这条推送来过。

3.2 静默推送(Background Notification)


{

"aps": { "content-available": 1 },

"sync": "pull_unread"

}

  • 不依赖通知授权。granted 是 YES 还是 NO,都可以尝试投递;

  • 不会产生任何通知卡片。锁屏、通知中心、横幅、声音、角标全都没有,用户完全无感,这就是「静默」的含义;

  • 不走 UN 通知代理。willPresent、didReceiveResponse 都不会进;

  • 唯一的回调入口是 AppDelegate:


- (void)application:(UIApplication *)application

didReceiveRemoteNotification:(NSDictionary *)userInfo

fetchCompletionHandler:(void (^)(UIBackgroundFetchResult result))completionHandler;

  • 用途:后台唤醒 App,给大约 30 秒的运行时间做数据同步、拉未读、预加载、同步配置;

  • 前提:工程必须打开 Signing & Capabilities → Background Modes → Remote notifications,否则收不到;

  • 限制很硬:系统有频率限流,会结合电量、网络、用户使用习惯决定要不要唤醒你;App 被用户手动上滑杀死后,静默推送大概率唤不醒(不同系统版本表现略有差异)。

所以静默推送不能当可靠的消息通道用。 IM 类 App 想保证消息必达,只能引导用户打开通知授权走普通通知,静默推送顶多作为「前台/后台状态下的补充同步手段」。

3.3 两条链路对照

| 推送类型 | aps 关键字段 | granted=NO 能否抵达 App | 回调入口 | 有无通知卡片 |

| --- | --- | --- | --- | --- |

| 普通远程通知 | alert / sound / badge | 否,系统直接丢弃 | willPresent / didReceiveResponse | 有 |

| 静默推送 | content-available: 1 | 可以(受后台策略限制) | didReceiveRemoteNotification:fetchCompletionHandler: | 无 |

| 本地通知 | UNNotificationRequest,不走 APNs | 需 granted=YES | willPresent / didReceiveResponse | 有 |

再补一个常被忽略的点:权限开关不会改变 deviceToken。 同一个 token,用户之后在系统设置里手动打开通知,这个 token 立刻就具备弹窗能力;关掉又变回静默。不需要重新 register,也不会换新 token。token 标识的是「设备 + App + 环境」,权限控制的是「系统要不要展示」。


四、四个代理方法的触发条件

推送相关的回调容易记混,按「什么时候会进来」逐个钉死。

4.1 willPresentNotification:withCompletionHandler:

App 处于前台 Active,收到通知卡片时回调。

必要条件,缺一不可:

  1. 是普通远程通知或本地通知(静默推送不进);

  2. granted = YES;

  3. App 当前在前台 Active;

  4. APNs 成功把通知交付给 UNUserNotificationCenter。

它的作用很实际:前台默认不弹横幅,你在这个 completionHandler 里决定这次要不要展示:


- (void)userNotificationCenter:(UNUserNotificationCenter *)center

willPresentNotification:(UNNotification *)notification

withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler {

// 前台也想弹横幅、响铃、更新角标

completionHandler(UNNotificationPresentationOptionBanner

| UNNotificationPresentationOptionSound

| UNNotificationPresentationOptionBadge);

}

不进的场景:granted=NO(消息被系统拦截)、App 在后台挂起(系统直接出横幅,不走这个方法)、静默推送、App 已被杀死。

4.2 didReceiveNotificationResponse:withCompletionHandler:

用户主动点击通知卡片时才回调。

进入的场景:

  1. App 后台挂起,用户点击通知横幅;

  2. App 已被杀死,用户点通知冷启动;

  3. App 前台,willPresent 里选择了展示横幅,用户点了这条前台横幅。

不进的场景:通知送达但用户没点(放着不管);granted=NO 的普通推送(压根没有卡片可点);静默推送(没有卡片)。

所以要纠正一个常见说法:不是「前台点击 / 后台点击 / 冷启动点击」三种场景都进,而是所有场景的共同前提都是「用户点击了通知卡片」。送达本身不进这个代理,这也是很多「已读上报」逻辑做错的地方。

4.3 didReceiveRemoteNotification:fetchCompletionHandler:

静默推送(以及带 content-available: 1 的普通推送)的后台回调入口。静默推送场景下这是唯一入口。必须在 completionHandler 里回调 UIBackgroundFetchResult,否则系统会认为你这次后台任务没结束,影响后续唤醒优先级。

4.4 didFailToRegisterForRemoteNotificationsWithError:

注册失败的回调。常见原因:网络不可用、推送证书 / App ID 能力未开、开发环境用了 production 证书(反之亦然)、设备不支持。生产环境建议在这里打点上报,否则推送静默失效很难排查。


五、什么时候申请通知权限

苹果的建议很明确:不要一打开 App 就弹系统授权框。系统弹窗一个安装生命周期只有一次机会,用掉了就再也没有第二次,用户随手点「不允许」,你就永久失去了这个渠道。

5.1 推荐时序(有账号体系的 App)

  1. App 冷启动:配置 UNUserNotificationCenter.delegate,不主动申请权限;

  2. 在合适的业务场景(登录成功、进入消息页、下单完成)先弹自己的软提示,说明「为什么要推送、推什么内容」;

  3. 用户点了同意,再调用 requestAuthorization 弹系统框;

  4. 在 completionHandler 里,不管 granted 是 YES 还是 NO,都调用 registerForRemoteNotifications 拿 token。


- (void)requestPushPermissionIfNeeded {

UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];

[center getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings *settings) {

// 已经是明确拒绝或已授权,就不再打扰用户

if (settings.authorizationStatus == UNAuthorizationStatusNotDetermined) {

[center requestAuthorizationWithOptions:(UNAuthorizationOptionAlert

| UNAuthorizationOptionSound

| UNAuthorizationOptionBadge)

completionHandler:^(BOOL granted, NSError *error) {

dispatch_async(dispatch_get_main_queue(), ^{

[[UIApplication sharedApplication] registerForRemoteNotifications];

});

});

} else {

// 已授权 / 已拒绝,也要注册,保证 token 有效

dispatch_async(dispatch_get_main_queue(), ^{

[[UIApplication sharedApplication] registerForRemoteNotifications];

});

}

}];

}

先授权再注册的好处是:拿到 token 的那一刻推送就已经具备弹窗能力,不用等下一次授权。

反过来先 registerForRemoteNotifications 再申请授权,技术上完全能跑(token 立刻就能拿到),只是这个阶段推过去的消息只能静默,产品体验上不划算。

5.2 临时授权(Provisional,iOS 12+)


UNAuthorizationOptionProvisional

不弹系统框,通知先安静地投到通知中心,用户在通知上自己选择「保持安静 / 立即送达 / 关闭」。适合不想一次性消耗唯一弹窗机会的产品,转化率通常比硬弹窗高。

5.3 权限被拒之后的兜底

用户拒绝后不要反复弹窗(弹不出来,只是白调接口)。正确做法是:在 App 内的设置页做一条引导,检测 settings.authorizationStatus,如果是 UNAuthorizationStatusDenied,引导用户去系统设置里手动打开(UIApplicationOpenSettingsURLString)。用户在系统设置里打开后,不需要重启 App、不需要重新注册,原来的 token 立刻生效。


六、账号体系下 token 怎么绑、怎么解绑

这是整套设计里业务风险最高的部分。核心规则只有一条:

同一个 deviceToken(同一台设备)在同一时刻只应该归属一个登录账号。 一个 uid 可以有多个 token(多设备登录),但一个 token 不能同时属于多个 uid。

否则切号之后,A 账号的消息会推到已经登录 B 的设备上,是明确的串号事故。

6.1 上传时机

  1. didRegister 回调里拿到 token 就上传,但必须先判断当前是否已登录:
  • 已登录 → 直接 bindToken(uid, token);

  • 未登录 → 只把 token 存在内存和本地,不要上传绑定匿名 uid;

  1. 登录成功回调里再补一次绑定。这一步是兜底,因为 didRegister 可能发生在登录之前,那时没有 uid;

  2. token 变化时立刻上传新 token,后端以最新值为准;

  3. 不需要在每次启动都无脑上传,但每次启动都要调用 registerForRemoteNotifications——回调触发后按上面 1、2 的规则自然就走到了上传。

6.2 退出登录 / 切换账号

正确的顺序:

  1. 用户点退出 → 调用后端 unbindToken(token),后端把这个 token 对应的 uid 置空或删掉映射;

  2. 客户端清掉内存里的 uid,保留 token 字符串(设备没变,token 没变);

  3. 新账号登录成功 → 用当前 token 调 bindToken(newUid, token)。

两个坑必须避开:

  • 不要用 unregisterForRemoteNotifications 当解绑。 这个 API 是彻底注销 APNs 注册,之后你再也拿不到 token,一般只在用户主动关闭推送、注销账号时才用。

  • 不要只依赖客户端调用解绑。 客户端可能崩、可能网络失败。后端绑定接口要设计成幂等 + 互斥:以 deviceToken 为唯一键,新绑定自动踢掉旧 uid 的绑定。这样即使客户端漏调解绑,服务端也不会串号。

6.3 后端表结构建议


push_binding

uid bigint

device_token varchar(64) 唯一索引

platform varchar(16) ios/android

bundle_id varchar(64)

env varchar(16) sandbox / production

updated_at timestamp

  • device_token 建唯一索引,从数据库层面保证「一台设备只属于一个账号」;

  • 记录 env,避免拿 sandbox token 去 production 通道推送(这是新手最典型的「本地能推,线上收不到」);

  • 定期用 APNs Feedback / 推送回执清理失效 token,不要让它永远躺在表里。

6.4 完整时序


App 冷启动

├── 配置 UNUserNotificationCenter.delegate

├── 调用 registerForRemoteNotifications

│ └── didRegister 回调

│ ├── 已登录 → bindToken(uid, token)

│ └── 未登录 → 本地缓存 token,不上传

└── 按业务时机申请授权(软提示 → 系统框)

└── completionHandler 里再次 register(保证 token 有效)

游客 → 登录

└── 登录成功 → 取当前 token → bindToken(uid, token)

(若此时还没有 token,则 register 一次,回调里再绑)

已登录 → 每次启动

└── register → didRegister → bindToken(后端幂等覆盖)

退出登录 / 切换账号

└── unbindToken(token) → 清 uid、留 token → 新账号 login → bindToken

苹果轮换 token

└── didRegister 收到新 token

├── 已登录 → bind 新 token(后端覆盖旧值)

└── 未登录 → 更新本地缓存


七、踩坑清单

按我实际遇到的频率排序:

  1. 以为权限拒绝就拿不到 deviceToken。 错。拒绝只影响能否弹窗,token 照样能拿到。

  2. 把 token 当成永久常量缓存在本地。 错。token 会变,每次 didRegister 都要上传。

  3. 用 unregisterForRemoteNotifications 解绑账号。 错。这会把 APNs 注册一起注销。

  4. 一个 token 同时绑两个 uid。 切号串消息的根源。必须服务端唯一约束。

  5. didRegister 回调里直接更新 UI。 它是异步非主线程的。

  6. 把静默推送当 IM 消息通道。 限流 + 强杀后唤不醒,不可靠。

  7. sandbox token 打 production。 表现是「开发能推、线上静默失败」,一定要在表里区分 env。

  8. 删掉了启动时的 registerForRemoteNotifications。 权限还开着也拿不到 token,推送全线哑掉且不好排查。

  9. 用 willPresent 当「收到推送」的统一入口。 它只在 App 前台 + granted=YES + 普通通知时才进。

  10. 更新了 App 但没更新推送证书 / App ID 没勾 Push Notifications 能力。 走 didFail,建议上报打点。


八、可直接用的代码骨架


// AppDelegate.m

- (BOOL)application:(UIApplication *)application

didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {

UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];

center.delegate = self;

// 每次启动都注册一次,保证 token 有效(不等授权)

[[UIApplication sharedApplication] registerForRemoteNotifications];

return YES;

}

#pragma mark - APNs 注册结果

- (void)application:(UIApplication *)application

didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {

NSString *token = [self hexStringFromDeviceToken:deviceToken];

[PushManager sharedManager].currentDeviceToken = token;

if ([UserManager sharedManager].isLogin) {

[[PushManager sharedManager] bindToken:token uid:[UserManager sharedManager].uid];

}

// 未登录:只缓存,等登录成功后补绑

}

- (void)application:(UIApplication *)application

didFailToRegisterForRemoteNotificationsWithError:(NSError *)error {

NSLog(@"[Push] APNs 注册失败: %@", error);

// 生产环境在这里打点上报

}

#pragma mark - 静默推送

- (void)application:(UIApplication *)application

didReceiveRemoteNotification:(NSDictionary *)userInfo

fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler {

// 仅 content-available:1 的静默推送会进这里

[[PushManager sharedManager] syncFromRemotePayload:userInfo

completionHandler:completionHandler];

}

#pragma mark - UNUserNotificationCenterDelegate

- (void)userNotificationCenter:(UNUserNotificationCenter *)center

willPresentNotification:(UNNotification *)notification

withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler {

// 前台收到普通通知/本地通知

completionHandler(UNNotificationPresentationOptionBanner

| UNNotificationPresentationOptionSound

| UNNotificationPresentationOptionBadge);

}

- (void)userNotificationCenter:(UNUserNotificationCenter *)center

didReceiveNotificationResponse:(UNNotificationResponse *)response

withCompletionHandler:(void (^)(void))completionHandler {

// 用户点击了通知卡片(前台/后台/冷启动)

[[PushManager sharedManager] handleNotificationTap:response.notification.request.content.userInfo];

completionHandler();

}


// PushManager.m —— 绑定 / 解绑

- (void)bindToken:(NSString *)token uid:(NSString *)uid {

if (token.length == 0 || uid.length == 0) return;

// 后端接口内部保证:device_token 唯一,新绑定覆盖旧 uid

[[APIClient sharedClient] bindPushToken:token uid:uid completion:nil];

}

- (void)unbindToken:(NSString *)token {

if (token.length == 0) return;

[[APIClient sharedClient] unbindPushToken:token completion:nil];

}

// 登录成功后调用

- (void)handleLoginSuccessWithUid:(NSString *)uid {

NSString *token = [PushManager sharedManager].currentDeviceToken;

if (token.length > 0) {

[[PushManager sharedManager] bindToken:token uid:uid];

} else {

[[UIApplication sharedApplication] registerForRemoteNotifications];

// 回调里会走 bindToken,因为此时 isLogin 已为 YES

}

}

// 退出登录

- (void)handleLogout {

[[PushManager sharedManager] unbindToken:[PushManager sharedManager].currentDeviceToken];

[[UserManager sharedManager] clearUser]; // 保留 token

}


九、自测清单

发布前 / 每次改推送相关代码,按这个跑一遍:

  • 卸载重装,冷启动,确认 didRegister 有回调、token 已上传;

  • 权限选「不允许」,确认 token 仍能拿到,静默推送(content-available:1)能进 didReceiveRemoteNotification;

  • 权限允许,App 前台收普通推送,确认 willPresent 进、横幅按预期展示;

  • App 后台点通知,确认 didReceiveNotificationResponse 进,跳转参数正确;

  • App 强杀后点通知冷启动,确认同上且业务参数不丢;

  • A 账号登录 → 退出 → B 账号登录,用 A 的业务触发推送,确认 B 的设备收不到;

  • 在系统设置里手动关闭通知权限,确认 token 不变、静默推送仍可送达;

  • 强制 registerForRemoteNotifications 前断网,确认走 didFail 有日志可查。


结语

这套机制的复杂度,本质上来自苹果把「通道」和「展示」拆成了两个独立子系统:registerForRemoteNotifications 负责通道(token),requestAuthorization 负责展示(横幅)。理解了这条分界线,剩下的回调触发条件、静默推送边界、多账号绑定,都只是这条分界线的自然推论,而不是一堆需要死记的特殊情况。