mcpField
A
axiom-push-notifications

axiom-push-notifications

github.com/charleswiltgen开发1.2k70 次安装今天更新
代码仓库

关于 axiom-push-notifications

面向 iOS 开发者的通知技能,涵盖本地通知与 APNs 推送,从权限和令牌管理到富通知及 Live Activity 更新,并说明常见实现陷阱。

主要功能

  1. 01权限请求与状态处理
  2. 02APNs 令牌注册和服务器同步
  3. 03推送内容设计与大小限制
  4. 04可操作通知与富通知
  5. 05通信通知和专注模式交互
  6. 06Live Activity 推送传输

适用场景

  1. 01选择请求通知权限的时机
  2. 02排查推送未送达或环境错误
  3. 03添加通知操作或媒体附件
  4. 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 Activity4KB

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-typeliveactivity
apns-topic{bundleID}.push-type.liveactivity
apns-priority5(常规)或 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+)

面向大量受众的基于频道的推送(体育赛事比分、航班状态、突发新闻)。

设置
  1. 在 Apple Developer Portal 中启用 Broadcast Push Notifications 功能
  2. 通过 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% 的用户永远不会这样做。“之后再修复”意味着选择加入率会永久降低。

正确做法:

  1. 先实现推送注册和发送,不请求权限
  2. 在用户执行某项操作、明显感受到通知价值后,再添加情境式权限请求
  3. 端到端测试授予和拒绝两种流程

应对话术:“请求权限的时机直接影响我们的选择加入率。现在投入 2 小时,就能避免通知触达率永久降低 30%。让我来实现情境式提示——代码量相同,只是放在了正确的位置。”

场景 2:“通知在开发环境中正常,但在生产环境中失效”

背景:App Store 版本无法收到推送通知。

压力:“APNs 出了问题,我们提交 radar 吧。”

现实:95% 的情况下,问题出在沙盒环境和生产环境的令牌不匹配。开发版本使用 api.sandbox.push.apple.com,生产版本使用 api.push.apple.com。不同环境的令牌也不同。将同一个令牌发送到错误的端点会静默失败。

正确做法:

  1. 确认服务端使用的 APNs 端点与构建类型相符
  2. 检查令牌是否来自生产版本(而不是发送到生产端点的开发版或 TestFlight 令牌)
  3. 如果使用 FCM,确认已将 APNs 身份验证密钥(.p8)上传到 Firebase Console

应对话术:“提交 radar 之前,让我先核实令牌和环境配置。这是‘开发环境正常、生产环境失效’最常见的原因,检查只需 5 分钟。”

场景 3:“所有通知都设为 Time Sensitive 就行”

背景:产品团队希望通知获得最大程度的曝光。

压力:“用户需要立即看到我们的通知。”

现实:iOS 会向用户显示哪些应用过度使用 Time Sensitive。感到受打扰的用户会关闭你们应用的所有通知——而不只是 Time Sensitive 通知。Apple 也可能因滥用而撤销相关权限。

正确做法:

  1. 根据实际紧急程度对通知分类
  2. 信息类通知使用 passive,一般互动通知使用 active(默认值),只有真正有时效性的事件才使用 time-sensitive
  3. 为团队记录分类规则,确保后端工程师应用正确的级别

应对话术:“过度使用 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

评论

支持 Markdown