关于 axiom-push-notifications
面向 iOS 开发者的通知技能,涵盖本地通知与 APNs 推送,从权限和令牌管理到富通知及 Live Activity 更新,并说明常见实现陷阱。
主要功能
- 01权限请求与状态处理
- 02APNs 令牌注册和服务器同步
- 03推送内容设计与大小限制
- 04可操作通知与富通知
- 05通信通知和专注模式交互
- 06Live Activity 推送传输
适用场景
- 01选择请求通知权限的时机
- 02排查推送未送达或环境错误
- 03添加通知操作或媒体附件
- 04通过远程推送更新 Live Activity
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-push-notifications安装后在 Claude Code 中输入/axiom-push-notifications确认已加载。
详细介绍
推送通知
适用于 iOS 的远程和本地通知模式。涵盖权限流程、APNs 注册、令牌管理、负载设计、可交互通知、带服务扩展的丰富通知、通信通知、与专注模式的交互,以及实时活动推送传输。
何时使用此技能
适用于以下情况:
- ☑ 实现远程(APNs)推送通知
- ☑ 请求通知权限
- ☑ 管理设备令牌并与服务器同步
- ☑ 添加带类别和操作的可交互通知
- ☑ 使用服务扩展构建丰富通知
- ☑ 添加带头像的通信通知(iOS 15+)
- ☑ 发送时效性或关键提醒
- ☑ 通过推送更新实时活动(传输层)
- ☑ 面向大量用户群体发送广播推送(iOS 18+)
- ☑ 安排本地通知
示例提示词
“如何设置推送通知?” “我应该什么时候请求通知权限?” “我的推送通知没有送达” “如何在通知中添加按钮?” “如何在推送通知中显示图片?” “如何向实时活动发送推送更新?” “APNs 令牌和 FCM 令牌有什么区别?” “如何让通知穿透专注模式?” “我的通知在开发环境中有效,但在生产环境中无效” “如何处理通知点击并打开特定页面?”
风险信号
以下迹象表明你把事情弄得比实际需要的更复杂:
- ❌ 在用户了解通知价值之前,就在首次启动时请求权限
- ❌ 在本地缓存设备令牌,而不是在每次启动时重新获取
- ❌ 将同一个令牌同时发送到沙盒和生产 APNs
- ❌ 不了解静默推送的节流机制(约每小时 2-3 次)就使用
content-available: 1 - ❌ 未实现
serviceExtensionTimeWillExpire回退处理 - ❌ 强制解包设备令牌,或假设注册总会成功
- ❌ 将 APNs 主机硬编码,而不是根据环境切换沙盒和生产环境
- ❌ 对所有通知都设置
apns-priority: 10(耗费电量,并会触发节流) - ❌ 超出 4KB 负载限制,却没意识到 APNs 会静默拒绝该请求
- ❌ 使用 FCM 时,在自定义委托处理的情况下没有禁用方法交换
- ❌ 未处理前台通知的显示(通知会被静默丢弃)
- ❌ 过度使用时效性中断级别(会损害用户信任,导致用户关闭所有通知)
必须先完成的步骤
实现任何推送通知功能之前:
1. 启用推送通知能力
- Xcode:Target → Signing & Capabilities → + Push Notifications
- 添加
aps-environment权限 - 在 Apple Developer Portal 中确认 App ID 已启用推送通知
2. 注册远程通知
// AppDelegate
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
UNUserNotificationCenter.current().delegate = self
UIApplication.shared.registerForRemoteNotifications()
return true
}
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
sendTokenToServer(token) // Never cache locally — tokens change
}
func application(_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error) {
// Simulator cannot register. Log, don't crash.
}
3. 请求授权(在合适的情境中,而不是启动时)
let center = UNUserNotificationCenter.current()
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
if granted {
await MainActor.run { UIApplication.shared.registerForRemoteNotifications() }
}
当用户执行的操作能让通知价值显而易见时再请求(例如,安排提醒后或订阅更新后)。系统只会提示一次——时机不当会导致用户永久拒绝。
权限流程
模式 1:标准授权
在用户了解其价值后,于合适的情境中请求:
func subscribeToUpdates() async {
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
switch settings.authorizationStatus {
case .notDetermined:
let granted = try? await center.requestAuthorization(options: [.alert, .sound, .badge])
if granted == true {
await MainActor.run { UIApplication.shared.registerForRemoteNotifications() }
}
case .authorized, .provisional:
// Already have permission
break
case .denied:
// Redirect to Settings
promptToOpenSettings()
case .ephemeral:
break
@unknown default:
break
}
}
模式 2:临时授权(iOS 12+)
通知会以静默方式显示在通知中心,并提供“保留”和“关闭”按钮。系统不会向用户显示权限对话框。
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge, .provisional])
适用于用户尚未发现通知价值的应用。用户会静默看到通知,并可选择提升通知权限。
模式 3:检查授权状态
安排通知或假定已获授权之前,务必先检查:
let settings = await UNUserNotificationCenter.current().notificationSettings()
guard settings.authorizationStatus == .authorized else {
// Handle missing permission
return
}
模式 4:处理拒绝授权
用户拒绝授权后,引导其前往“设置”:
func promptToOpenSettings() {
// iOS 16+
if let url = URL(string: UIApplication.openNotificationSettingsURLString) {
UIApplication.shared.open(url)
} else {
// Fallback: general app settings
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
}
令牌管理
令牌格式
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
sendTokenToServer(token)
}
关键规则
- 切勿在本地缓存——每次应用启动时都通过
registerForRemoteNotifications()获取最新信息 - 沙盒环境 ≠ 生产环境——APNs 的令牌因环境而异,端点也不同(
api.sandbox.push.apple.com与api.push.apple.com) - 与服务端同步——将令牌、Bundle ID、用户 ID 和环境(沙盒/生产)发送到你的服务端
- 备份恢复、设备迁移、重新安装或系统更新后,令牌可能会发生变化
通知类型决策树
What type of notification?
│
├─ Alert (user-visible)
│ ├─ Passive — informational, no sound, appears in history
│ │ interruption-level: "passive"
│ │
│ ├─ Active — default, sound + banner
│ │ interruption-level: "active" (or omit, it's default)
│ │
│ ├─ Time Sensitive — breaks through scheduled summary, not Focus
│ │ interruption-level: "time-sensitive"
│ │ Requires: Time Sensitive Notifications capability
│ │
│ └─ Critical — breaks through Do Not Disturb and mute switch
│ interruption-level: "critical"
│ Requires: Apple entitlement approval (medical, safety, security)
│
├─ Communication (iOS 15+)
│ Shows sender avatar, name, breaks Focus for allowed contacts
│ Requires: INSendMessageIntent + Communication Notifications capability
│ Configured in service extension via content.updating(from: intent)
│
├─ Silent / Background
│ content-available: 1, no alert/sound/badge
│ Throttled to ~2-3 per hour
│ apns-priority: 5 (MUST be 5, not 10)
│ App gets ~30 seconds background execution
│
└─ Live Activity
apns-push-type: liveactivity
apns-topic: {bundleID}.push-type.liveactivity
Updates/starts/ends Live Activities remotely
负载设计模式
基本提醒
{
"aps": {
"alert": {
"title": "New Message",
"subtitle": "From Alice",
"body": "Hey, are you free for lunch?"
},
"sound": "default",
"badge": 3
}
}
声音选项
{
"aps": {
"alert": { "title": "Notification", "body": "With custom sound" },
"sound": "custom-sound.aiff"
}
}
关键提醒(需要 Apple 授权):
{
"aps": {
"alert": { "title": "Emergency", "body": "Critical alert" },
"sound": {
"critical": 1,
"name": "alarm.aiff",
"volume": 0.8
}
}
}
角标
{
"aps": {
"badge": 5
}
}
设置为 0 可移除角标。
本地化内容
{
"aps": {
"alert": {
"loc-key": "NEW_MESSAGE_FORMAT",
"loc-args": ["Alice", "lunch"]
}
}
}
自定义数据
将自定义数据放在 aps 字典之外:
{
"aps": {
"alert": { "title": "Order Update", "body": "Your order shipped" }
},
"orderId": "12345",
"deepLink": "/orders/12345"
}
相关性分数和线程 ID
{
"aps": {
"alert": { "title": "Breaking News", "body": "..." },
"relevance-score": 0.8,
"thread-id": "news-breaking",
"interruption-level": "time-sensitive"
}
}
relevance-score(0.0–1.0):通知摘要的排序依据(iOS 15+)thread-id:在通知中心将通知归入不同对话
负载大小限制
| 类型 | 最大大小 |
|---|---|
| 标准推送 | 4KB |
| VoIP 推送 | 5KB |
| Live Activity | 4KB |
APNs 会静默拒绝超出大小限制的负载,不会向发送方返回错误。
类别和操作
在启动时注册类别
func registerNotificationCategories() {
let replyAction = UNTextInputNotificationAction(
identifier: "REPLY_ACTION",
title: "Reply",
options: [])
// iOS 15+: actions with icons
let likeIcon = UNNotificationActionIcon(systemImageName: "hand.thumbsup")
let likeAction = UNNotificationAction(
identifier: "LIKE_ACTION",
title: "Like",
options: [],
icon: likeIcon)
let messageCategory = UNNotificationCategory(
identifier: "MESSAGE",
actions: [replyAction, likeAction],
intentIdentifiers: [],
options: [.customDismissAction])
UNUserNotificationCenter.current().setNotificationCategories([messageCategory])
}
在负载中设置类别
{
"aps": {
"alert": { "title": "Alice", "body": "Are you free?" },
"category": "MESSAGE"
}
}
处理操作响应
extension AppDelegate: UNUserNotificationCenterDelegate {
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void) {
let userInfo = response.notification.request.content.userInfo
switch response.actionIdentifier {
case "REPLY_ACTION":
if let textResponse = response as? UNTextInputNotificationResponse {
handleReply(text: textResponse.userText, userInfo: userInfo)
}
case "LIKE_ACTION":
handleLike(userInfo: userInfo)
case UNNotificationDefaultActionIdentifier:
// User tapped the notification itself
handleNotificationTap(userInfo: userInfo)
case UNNotificationDismissActionIdentifier:
// User dismissed (requires .customDismissAction on category)
handleDismiss(userInfo: userInfo)
default:
break
}
completionHandler()
}
}
服务扩展模式
模式 1:媒体增强
下载图片、音频或视频并将其附加到通知中。
负载要求:必须包含 "mutable-content": 1:
{
"aps": {
"alert": { "title": "Photo", "body": "Alice sent a photo" },
"mutable-content": 1
},
"imageURL": "https://example.com/photo.jpg"
}
服务扩展:
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest,
withContentHandler contentHandler:
@escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
guard let bestAttemptContent,
let imageURLString = bestAttemptContent.userInfo["imageURL"] as? String,
let imageURL = URL(string: imageURLString) else {
contentHandler(request.content)
return
}
// Download image
let task = URLSession.shared.downloadTask(with: imageURL) { [weak self] url, _, error in
guard let self, let url, error == nil else {
contentHandler(self?.bestAttemptContent ?? request.content)
return
}
// Move to tmp with proper extension
let tmpURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("jpg")
try? FileManager.default.moveItem(at: url, to: tmpURL)
if let attachment = try? UNNotificationAttachment(identifier: "image",
url: tmpURL,
options: nil) {
bestAttemptContent.attachments = [attachment]
}
contentHandler(bestAttemptContent)
}
task.resume()
}
override func serviceExtensionTimeWillExpire() {
// 30-second window exceeded — deliver what we have
if let contentHandler, let bestAttemptContent {
contentHandler(bestAttemptContent)
}
}
}
模式 2:端到端解密
在显示前于服务扩展中解密负载:
override func didReceive(_ request: UNNotificationRequest,
withContentHandler contentHandler:
@escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
guard let bestAttemptContent,
let encryptedBody = bestAttemptContent.userInfo["encryptedBody"] as? String else {
contentHandler(request.content)
return
}
if let decrypted = decrypt(encryptedBody) {
bestAttemptContent.body = decrypted
} else {
bestAttemptContent.body = "(Encrypted message)"
}
contentHandler(bestAttemptContent)
}
override func serviceExtensionTimeWillExpire() {
if let contentHandler, let bestAttemptContent {
bestAttemptContent.body = "(Encrypted message)"
contentHandler(bestAttemptContent)
}
}
30 秒处理时限:如果 didReceive 未在约 30 秒内调用 contentHandler,系统会调用 serviceExtensionTimeWillExpire。始终将 bestAttemptContent 作为备用方案发送——如果这两个方法都没有调用处理程序,通知就会完全消失。
通信通知(iOS 15+)
显示发送者的头像和姓名。对于允许的联系人,通知可以穿透专注模式。
// In your Notification Service Extension
import Intents
override func didReceive(_ request: UNNotificationRequest,
withContentHandler contentHandler:
@escaping (UNNotificationContent) -> Void) {
guard let bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent else {
contentHandler(request.content)
return
}
// 1. Create sender persona
let senderImage = INImage(url: avatarURL) // or INImage(imageData:)
let sender = INPerson(
personHandle: INPersonHandle(value: "[email protected]", type: .emailAddress),
nameComponents: nil,
displayName: "Alice",
image: senderImage,
contactIdentifier: nil,
customIdentifier: "user-alice-123"
)
// 2. Create message intent
let intent = INSendMessageIntent(
recipients: nil, // nil for 1:1, set for group
outgoingMessageType: .outgoingMessageText,
content: bestAttemptContent.body,
speakableGroupName: nil, // set for group conversations
conversationIdentifier: "conversation-123",
serviceName: nil,
sender: sender,
attachments: nil
)
// 3. Donate interaction
let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .incoming
interaction.donate(completion: nil)
// 4. Update content with intent
do {
let updatedContent = try bestAttemptContent.updating(from: intent)
contentHandler(updatedContent)
} catch {
contentHandler(bestAttemptContent)
}
}
要求:
- 在 Xcode 中启用 Communication Notifications 功能
- 添加 Notification Service Extension 目标
- 负载中包含
mutable-content: 1
穿透专注模式:用户在专注模式设置中允许的联系人的通信通知可以穿透专注模式。请谨慎使用——过度使用会损害信任。
前台通知处理
如果没有此代理方法,应用在前台时收到的通知会被静默丢弃:
extension AppDelegate: UNUserNotificationCenterDelegate {
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler:
@escaping (UNNotificationPresentationOptions) -> Void) {
// Show notification even when app is in foreground
completionHandler([.banner, .sound, .badge])
}
}
在 didFinishLaunchingWithOptions 中设置 UNUserNotificationCenter.current().delegate = self——必须在应用完成启动之前设置。
Live Activity 推送传输
通过 APNs 远程推送 Live Activity 更新。
监听推送令牌
let activity = try Activity<OrderAttributes>.request(
attributes: attributes,
content: initialContent,
pushType: .token
)
Task {
for await pushToken in activity.pushTokenUpdates {
let pushTokenString = pushToken.reduce("") {
$0 + String(format: "%02x", $1)
}
try await sendPushToken(pushTokenString: pushTokenString)
}
}
Live Activity 的 APNs 标头
| 标头 | 值 |
|---|---|
| apns-push-type | liveactivity |
| apns-topic | {bundleID}.push-type.liveactivity |
| apns-priority | 5(常规)或 10(时间敏感) |
更新负载
{
"aps": {
"timestamp": 1234567890,
"event": "update",
"content-state": {
"currentStep": "outForDelivery",
"estimatedArrival": "2:30 PM"
},
"stale-date": 1234571490,
"dismissal-date": 1234575090,
"relevance-score": 75.0
}
}
事件类型
| 事件 | 用途 |
|---|---|
update | 更新 content-state |
start | 远程启动新的 Live Activity(iOS 17.2+) |
end | 结束该活动 |
关键规则
content-state必须与ActivityAttributes.ContentState完全匹配——不能使用自定义 JSON 编码策略,属性名称必须完全相同- 必须提供
timestamp——APNs 使用它来丢弃过时的更新 stale-date会显示数据已过时的视觉指示dismissal-date控制已结束的活动何时从锁定屏幕消失relevance-score用于排列多个活动中的 Live Activity- 系统会强制执行优先级额度——过度使用
apns-priority: 10会导致请求被限流 - 对高频更新应用(体育、导航),请将
NSSupportsLiveActivitiesFrequentUpdates添加到 Info.plist
有关 ActivityKit UI、属性和灵动岛布局,请参阅 axiom-extensions-widgets。
广播推送(iOS 18+)
面向大量受众的基于频道的推送(体育赛事比分、航班状态、突发新闻)。
设置
- 在 Apple Developer Portal 中启用 Broadcast Push Notifications 功能
- 通过 Apple 的 Broadcast Push API 创建频道
订阅频道
let activity = try Activity<ScoreAttributes>.request(
attributes: attributes,
content: initialContent,
pushType: .channel(channelId)
)
服务端向频道发送消息
POST /4/broadcasts/apps/{TOPIC}
关键规则
- 仅适用于实时活动(不适用于常规推送)
- 频道存储策略:不存储(预算更高)与最新消息(存储最后一次更新)
- 管理频道生命周期——删除不再使用的频道(活跃频道总数有限)
- 频道由不透明 ID 标识,不使用面向用户的名称
- 在一对多场景中,比逐设备令牌推送更高效
将 FCM 用作推送提供方
如果使用 Firebase Cloud Messaging 作为推送提供方,请留意以下问题:
1. Swizzling 陷阱
FCM 默认会对 UNUserNotificationCenterDelegate 方法和 didRegisterForRemoteNotifications 进行 swizzling。如果你有自定义的代理处理逻辑,就会发生冲突。
解决方法:在 Info.plist 中设置:
<key>FirebaseAppDelegateProxyEnabled</key>
<false/>
然后手动将 APNs 令牌传递给 FCM:
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
Messaging.messaging().apnsToken = deviceToken
}
2. 双令牌混淆
FCM 令牌 ≠ APNs 设备令牌。两者完全不同。请将正确的令牌发送到对应的后端。
- FCM 令牌 → 你的服务端(用于通过 FCM 发送)
- APNs 令牌 → 仅在直接通过 APNs 发送时需要
3. 上传 APNs 身份验证密钥
将你的 .p8 APNs 身份验证密钥上传到 Firebase Console → Project Settings → Cloud Messaging。没有此密钥,开发构建可以正常工作(FCM 会自动使用沙盒环境),但生产构建会静默失败。
4. 静默推送载荷大小
FCM 的 content_available 会映射到 APNs 的 content-available,但 FCM 可能会向载荷中添加额外字段。请监控总大小,避免超过 4KB 的限制。
反模式
反模式 1:在应用启动时请求权限
错误:
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in }
return true
}
正确:
// After user taps "Subscribe to updates" or completes onboarding
func subscribeButtonTapped() async {
let granted = try? await UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .sound, .badge])
if granted == true {
await MainActor.run { UIApplication.shared.registerForRemoteNotifications() }
}
}
原因:系统只会显示一次权限对话框。如果用户还没有体验到价值,就会下意识地点按“不要允许”。拒绝后不再前往“设置”中重新启用的用户约占 60%。你只有一次机会。
反模式 2:缓存设备令牌
错误:
func application(_ app: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken token: Data) {
let tokenString = token.map { String(format: "%02x", $0) }.joined()
UserDefaults.standard.set(tokenString, forKey: "pushToken") // Stale after restore
}
正确:
func application(_ app: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken token: Data) {
let tokenString = token.map { String(format: "%02x", $0) }.joined()
sendTokenToServer(tokenString) // Fresh every launch
}
原因:备份恢复、设备迁移、重新安装后,令牌会发生变化,有时 OS 更新后也会变化。缓存的令牌过期后,服务端会向 APNs 不再识别的令牌发送消息——通知会悄无声息地消失。
反模式 3:忽略服务扩展超时
错误:
override func didReceive(_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
// Download large image, no timeout handling
downloadImage(from: url) { image in
// If this takes >30 seconds, notification vanishes entirely
contentHandler(modifiedContent)
}
}
// serviceExtensionTimeWillExpire not implemented
正确:
override func serviceExtensionTimeWillExpire() {
if let contentHandler, let bestAttemptContent {
// Deliver whatever we have — text without image is better than nothing
contentHandler(bestAttemptContent)
}
}
原因:服务扩展的运行时间约为 30 秒。如果 didReceive 和 serviceExtensionTimeWillExpire 都没有调用内容处理程序,通知就会彻底消失,用户完全看不到。
反模式 4:所有通知都使用 Time Sensitive
错误:
{
"aps": {
"alert": { "title": "Weekly Newsletter", "body": "Check out this week's articles" },
"interruption-level": "time-sensitive"
}
}
正确:
{
"aps": {
"alert": { "title": "Weekly Newsletter", "body": "Check out this week's articles" },
"interruption-level": "passive"
}
}
原因:iOS 会向用户显示哪些应用过度使用 Time Sensitive。觉得受到打扰的用户会关闭你应用的所有通知,而不只是 Time Sensitive 通知。请仅将其用于真正有时效性的事件(配送即将送达、会议即将开始、安全警报)。Apple 也可能撤销此功能的使用权限。
压力场景
场景 1:“周五前上线推送通知”
背景:产品经理需要推送通知功能,以用于演示。
压力:“启动时直接请求权限,之后再修复。”
现实:系统只会提示一次。如果用户拒绝,你就需要让他们手动在“设置”中启用。约 60% 的用户永远不会这样做。“之后再修复”意味着选择加入率会永久降低。
正确做法:
- 先实现推送注册和发送,不请求权限
- 在用户执行某项操作、明显感受到通知价值后,再添加情境式权限请求
- 端到端测试授予和拒绝两种流程
应对话术:“请求权限的时机直接影响我们的选择加入率。现在投入 2 小时,就能避免通知触达率永久降低 30%。让我来实现情境式提示——代码量相同,只是放在了正确的位置。”
场景 2:“通知在开发环境中正常,但在生产环境中失效”
背景:App Store 版本无法收到推送通知。
压力:“APNs 出了问题,我们提交 radar 吧。”
现实:95% 的情况下,问题出在沙盒环境和生产环境的令牌不匹配。开发版本使用 api.sandbox.push.apple.com,生产版本使用 api.push.apple.com。不同环境的令牌也不同。将同一个令牌发送到错误的端点会静默失败。
正确做法:
- 确认服务端使用的 APNs 端点与构建类型相符
- 检查令牌是否来自生产版本(而不是发送到生产端点的开发版或 TestFlight 令牌)
- 如果使用 FCM,确认已将 APNs 身份验证密钥(.p8)上传到 Firebase Console
应对话术:“提交 radar 之前,让我先核实令牌和环境配置。这是‘开发环境正常、生产环境失效’最常见的原因,检查只需 5 分钟。”
场景 3:“所有通知都设为 Time Sensitive 就行”
背景:产品团队希望通知获得最大程度的曝光。
压力:“用户需要立即看到我们的通知。”
现实:iOS 会向用户显示哪些应用过度使用 Time Sensitive。感到受打扰的用户会关闭你们应用的所有通知——而不只是 Time Sensitive 通知。Apple 也可能因滥用而撤销相关权限。
正确做法:
- 根据实际紧急程度对通知分类
- 信息类通知使用
passive,一般互动通知使用active(默认值),只有真正有时效性的事件才使用time-sensitive - 为团队记录分类规则,确保后端工程师应用正确的级别
应对话术:“过度使用 Time Sensitive 会导致用户彻底关闭我们的通知。我们按紧急程度分类吧——大多数通知应设为 active,time-sensitive 则留给真正有时效性的事件,例如配送到达或优惠即将过期。”
检查清单
发送推送通知前:
Entitlements:
- ☑ 已在 Xcode 中添加推送通知功能
- ☑ Provisioning profile 包含 aps-environment
- ☑ Communication Notifications 功能(如使用通信类型)
权限:
- ☑ 在合适的情境下请求授权(而非在启动时)
- ☑ 妥善处理用户拒绝授权的情况(引导至“设置”或提供降级体验)
- ☑ 在安排通知前检查授权状态
- ☑ 考虑在试用期使用临时授权
令牌管理:
- ☑ 每次启动时都将令牌发送至服务端(绝不缓存)
- ☑ 服务端按环境存储令牌(sandbox/production)
- ☑ 处理令牌刷新(Live Activities 使用 pushTokenUpdates)
有效负载:
- ☑ 有效负载小于 4KB(VoIP 为 5KB)
- ☑ 类别标识符与已注册的类别一致
- ☑ 根据内容紧急程度设置适当的中断级别
- ☑ 将自定义数据放在 aps 字典之外
服务扩展(如适用):
- ☑ 在有效负载中将 mutable-content: 1 设为 1
- ☑ serviceExtensionTimeWillExpire 提供备用内容
- ☑ 配置 App group,以便共享数据
测试:
- ☑ 使用 Push Notifications Console 或 curl 进行测试
- ☑ 测试前台和后台送达
- ☑ 在实体设备上测试(模拟器没有 APNs 令牌)
资源
WWDC:2021-10091、2023-10025、2023-10185、2024-10069
文档:/usernotifications、/usernotifications/unusernotificationcenter、/activitykit
技能:axiom-push-notifications-ref、axiom-push-notifications-diag、axiom-extensions-widgets、axiom-background-processing