mcpField
P
push-notifications

push-notifications

github.com/dpearson2699开发1.2k4.2k 次安装13 周前更新
代码仓库

关于 push-notifications

面向 iOS 和 macOS 开发者的推送通知技能,涵盖本地通知、远程通知及 APNs 的实现、审查和调试。内容从权限和令牌注册延伸到负载、操作、分组与富通知处理。

主要功能

  1. 01通知权限与状态检查
  2. 02APNs 令牌注册处理
  3. 03本地通知调度
  4. 04远程与静默推送负载
  5. 05通知操作和分类
  6. 06富通知与通知分组

适用场景

  1. 01实现设备端提醒和定时通知
  2. 02处理 APNs 令牌更新
  3. 03排查后台推送配置问题
  4. 04添加通知操作与分组

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/dpearson2699/swift-ios-skills --skill push-notifications

安装后在 Claude Code 中输入/push-notifications确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

evals/
evals.json
references/
notification-patterns.md
rich-notifications.md

详细介绍

推送通知

使用 UserNotifications 和 APNs 在 iOS/macOS 上实现、审核和调试本地及远程通知。涵盖权限流程、令牌注册、负载结构、前台处理、通知操作、分组和丰富通知。面向 iOS 26+ 和 Swift 6.3,除非另有说明,否则向后兼容 iOS 16。

请将相邻领域分开处理:Live Activity 的 content-state 负载属于 activitykit;PushKit/VoIP 通话推送属于 callkit;App Clip 临时通知设置属于 app-clips;静默推送后的长时间运行或计划后台任务属于 background-processing。

目录

修正审查

审查有缺陷的通知方案时,应明确指出违反了哪项约定。审查 APNs 令牌时,必须说明令牌注册独立于提醒授权、每次 didRegister 回调时都要上传令牌、避免将本地缓存视为真实状态、绝不假设令牌长度,并说明模拟器注册失败属于预期情况,但可使用 .apns 文件或 simctl push 模拟投递。审查后台推送时,必须说明只使用 content-available、设置 apns-push-type: background、设置 apns-priority: 5、启用 Remote notifications 后台模式、优先级较低、会受到节流限制、无法保证投递、不能每隔几分钟发送一次,并且 didReceiveRemoteNotification 必须有时间限制且返回正确的 UIBackgroundFetchResult。审查丰富通知时,必须说明服务扩展需要 mutable-content: 1 和提醒负载,静默推送不会触发服务扩展,附件必须是系统会验证并存储的受支持磁盘文件,机密信息应使用 Keychain Sharing,而 App Groups 用于共享文件/UserDefaults;通信通知需要 capability + NSUserActivityTypes + INInteraction 捐赠 + content.updating(from:);服务扩展的每条执行路径,包括附件或下载失败以及 serviceExtensionTimeWillExpire(),都必须且只能调用一次内容处理程序,并传入原始内容、尽力处理后的内容或已更新内容。

权限流程

在计划安排或显示面向用户的提醒、声音或标记之前,请先请求通知授权。系统提示只会出现一次;后续调用会返回已保存的决定。APNs 令牌注册是独立的:应用需要设备令牌时,即使用户尚未授予提醒授权,也要调用 registerForRemoteNotifications()。

import UserNotifications

@MainActor
func requestNotificationPermission() async -> Bool {
    let center = UNUserNotificationCenter.current()
    do {
        let granted = try await center.requestAuthorization(
            options: [.alert, .sound, .badge]
        )
        return granted
    } catch {
        print("Authorization request failed: \(error)")
        return false
    }
}
检查当前状态

始终先检查状态,再判断是否已获得权限。用户可以随时更改设置。

@MainActor
func checkNotificationStatus() async -> UNAuthorizationStatus {
    let settings = await UNUserNotificationCenter.current().notificationSettings()
    return settings.authorizationStatus
    // .notDetermined, .denied, .authorized, .provisional, .ephemeral
}
临时通知

临时通知会安静地送达通知中心,不会打扰用户。用户随后可以选择保留或关闭通知。可用于引导流程,以便在请求完整权限前先向用户展示通知的价值。

// Delivers silently -- no permission prompt shown to the user
try await center.requestAuthorization(options: [.alert, .sound, .badge, .provisional])
严重警报

严重警报可以绕过“勿扰模式”和静音开关。需要 Apple 提供特殊权限(通过开发者门户申请)。仅用于健康、安全或安保场景。

// Requires com.apple.developer.usernotifications.critical-alerts entitlement
try await center.requestAuthorization(
    options: [.alert, .sound, .badge, .criticalAlert]
)
处理被拒绝的权限

用户拒绝通知后,应引导他们前往“设置”,使用 UIApplication.openSettingsURLString。不要反复请求或催促用户。

APNs 注册

在 SwiftUI 应用中,使用 UIApplicationDelegateAdaptor 接收设备令牌。只有 AppDelegate 回调可以接收 APNs 令牌。

@main
struct MyApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

class AppDelegate: NSObject, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
    ) -> Bool {
        UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
        return true
    }

    func application(
        _ application: UIApplication,
        didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
    ) {
        let token = deviceToken.map { String(format: "%02x", $0) }.joined()
        print("APNs token: \(token)")
        // Send token to your server
        Task { await TokenService.shared.upload(token: token) }
    }

    func application(
        _ application: UIApplication,
        didFailToRegisterForRemoteNotificationsWithError error: Error
    ) {
        print("APNs registration failed: \(error.localizedDescription)")
        // Simulator can simulate pushes, but it does not register with APNs.
    }
}
注册顺序

在启动时配置代理和类别。然后在适当的场景中请求用户通知授权,以显示通知;当应用需要设备令牌时,注册 APNs。不要将 APNs 注册限制在 .authorized 状态下;即使未获得提醒授权,远程通知也会以静默方式送达。

@MainActor
func configureNotifications() async {
    let center = UNUserNotificationCenter.current()
    let settings = await center.notificationSettings()

    if settings.authorizationStatus == .notDetermined {
        _ = await requestNotificationPermission()
    }

    // Needed for APNs token delivery and silent remote notifications.
    UIApplication.shared.registerForRemoteNotifications()
}
令牌处理

设备令牌会发生变化。每次触发 didRegisterForRemoteNotificationsWithDeviceToken 时,都要将令牌重新发送到服务器,而不只是首次触发时。不要将令牌作为权威数据来源保存在本地,也不要假设令牌长度固定。

本地通知

无需服务器,直接从设备安排通知。适用于提醒、计时器和基于位置的警报。

创建内容
let content = UNMutableNotificationContent()
content.title = "Workout Reminder"
content.subtitle = "Time to move"
content.body = "You have a scheduled workout in 15 minutes."
content.sound = .default
content.badge = NSNumber(value: 1)
content.userInfo = ["workoutId": "abc123"]
content.threadIdentifier = "workouts"  // groups in notification center
触发器类型
// Fire after a time interval (minimum 60 seconds for repeating)
let timeTrigger = UNTimeIntervalNotificationTrigger(timeInterval: 300, repeats: false)

// Fire at a specific date/time
var dateComponents = DateComponents()
dateComponents.hour = 8
dateComponents.minute = 30
let calendarTrigger = UNCalendarNotificationTrigger(
    dateMatching: dateComponents, repeats: true  // daily at 8:30 AM
)

// Fire when entering a geographic region
let region = CLCircularRegion(
    center: CLLocationCoordinate2D(latitude: 37.33, longitude: -122.01),
    radius: 100,
    identifier: "gym"
)
region.notifyOnEntry = true
region.notifyOnExit = false
let locationTrigger = UNLocationNotificationTrigger(region: region, repeats: false)
// Requires "When In Use" location permission at minimum
安排和管理
let request = UNNotificationRequest(
    identifier: "workout-reminder-abc123",
    content: content,
    trigger: timeTrigger
)

let center = UNUserNotificationCenter.current()
try await center.add(request)

// Remove specific pending notifications
center.removePendingNotificationRequests(withIdentifiers: ["workout-reminder-abc123"])

// Remove all pending
center.removeAllPendingNotificationRequests()

// Remove delivered notifications from notification center
center.removeDeliveredNotifications(withIdentifiers: ["workout-reminder-abc123"])
center.removeAllDeliveredNotifications()

// List all pending requests
let pending = await center.pendingNotificationRequests()

远程通知负载

标准 APNs 负载
{
    "aps": {
        "alert": {
            "title": "New Message",
            "subtitle": "From Alice",
            "body": "Hey, are you free for lunch?"
        },
        "badge": 3,
        "sound": "default",
        "thread-id": "chat-alice",
        "category": "MESSAGE_CATEGORY"
    },
    "messageId": "msg-789",
    "senderId": "user-alice"
}
静默/后台推送

设置 content-available: 1,且不包含提醒、声音或徽标。需要启用“Background Modes > Remote notifications”,并设置 APNs 标头 apns-push-type: background 和 apns-priority: 5。系统会将此类推送视为低优先级,进行限流,且不保证送达;不要每隔几分钟就发送,也不要依赖它们实现即时更新。在 didReceiveRemoteNotification 中执行有界工作,并在后台执行时间窗口内及时返回 UIBackgroundFetchResult。

{
    "aps": {
        "content-available": 1
    },
    "updateType": "new-data"
}

在 AppDelegate 中处理:

func application(
    _ application: UIApplication,
    didReceiveRemoteNotification userInfo: [AnyHashable: Any]
) async -> UIBackgroundFetchResult {
    guard let updateType = userInfo["updateType"] as? String else {
        return .noData
    }
    do {
        try await DataSyncService.shared.sync(trigger: updateType)
        return .newData
    } catch {
        return .failed
    }
}
可变内容

设置 mutable-content: 1,并添加 alert 字典,以便通知服务扩展在显示提醒型远程通知之前对其进行修改。静默推送不会触发通知服务扩展。通知服务扩展适用于有界工作,例如下载支持的磁盘附件、解密显示文本或配置通信通知;在所有成功、失败和超时路径中都要调用内容处理器。对于通信通知,启用相应功能,添加 NSUserActivityTypes,捐赠 INInteraction,然后调用 content.updating(from:)。

{
    "aps": {
        "alert": { "title": "Photo", "body": "Alice sent a photo" },
        "mutable-content": 1
    },
    "imageUrl": "https://example.com/photo.jpg"
}
本地化通知

使用本地化键,让通知以用户的语言显示:

{
    "aps": {
        "alert": {
            "title-loc-key": "NEW_MESSAGE_TITLE",
            "loc-key": "NEW_MESSAGE_BODY",
            "loc-args": ["Alice"]
        }
    }
}

通知处理

UNUserNotificationCenterDelegate

实现代理以控制前台显示并处理用户点击。尽早设置代理——在 application(_:didFinishLaunchingWithOptions:) 或 App.init 中设置。

@MainActor
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
    static let shared = NotificationDelegate()

    // Called when notification arrives while app is in FOREGROUND
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification
    ) async -> UNNotificationPresentationOptions {
        // Return which presentation elements to show
        // Without this, foreground notifications are silently suppressed
        return [.banner, .sound, .badge]
    }

    // Called when user TAPS the notification
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse
    ) async {
        let userInfo = response.notification.request.content.userInfo
        let actionIdentifier = response.actionIdentifier

        switch actionIdentifier {
        case UNNotificationDefaultActionIdentifier:
            // User tapped the notification body
            await handleNotificationTap(userInfo: userInfo)
        case UNNotificationDismissActionIdentifier:
            // User dismissed the notification
            break
        default:
            // Custom action button tapped
            await handleCustomAction(actionIdentifier, userInfo: userInfo)
        }
    }
}
从通知进行深层链接

使用共享的 @Observable 路由器,将通知点击导航到正确的屏幕。代理写入待处理的目标位置;SwiftUI 视图会观察并处理该目标位置。

@Observable @MainActor
final class DeepLinkRouter {
    static let shared = DeepLinkRouter()

    var pendingDestination: AppDestination?
}

// In NotificationDelegate:
func handleNotificationTap(userInfo: [AnyHashable: Any]) async {
    guard let id = userInfo["messageId"] as? String else { return }
    DeepLinkRouter.shared.pendingDestination = .chat(id: id)
}

// In SwiftUI -- observe and consume:
.onChange(of: router.pendingDestination) { _, destination in
    if let destination {
        path.append(destination)
        router.pendingDestination = nil
    }
}

有关包含标签页切换的完整深层链接处理器,请参阅 references/notification-patterns.md。

通知操作和类别

定义在通知上以按钮形式显示的交互操作。在启动时注册类别。

定义类别和操作
func registerNotificationCategories() {
    let replyAction = UNTextInputNotificationAction(
        identifier: "REPLY_ACTION",
        title: "Reply",
        options: [],
        textInputButtonTitle: "Send",
        textInputPlaceholder: "Type a reply..."
    )

    let likeAction = UNNotificationAction(
        identifier: "LIKE_ACTION",
        title: "Like",
        options: []
    )

    let deleteAction = UNNotificationAction(
        identifier: "DELETE_ACTION",
        title: "Delete",
        options: [.destructive, .authenticationRequired]
    )

    let messageCategory = UNNotificationCategory(
        identifier: "MESSAGE_CATEGORY",
        actions: [replyAction, likeAction, deleteAction],
        intentIdentifiers: [],
        options: [.customDismissAction]  // fires didReceive on dismiss too
    )

    UNUserNotificationCenter.current().setNotificationCategories([messageCategory])
}
处理操作响应
func handleCustomAction(_ identifier: String, userInfo: [AnyHashable: Any]) async {
    switch identifier {
    case "REPLY_ACTION":
        // response is UNTextInputNotificationResponse for text input actions
        break
    case "LIKE_ACTION":
        guard let messageId = userInfo["messageId"] as? String else { return }
        await MessageService.shared.likeMessage(id: messageId)
    case "DELETE_ACTION":
        guard let messageId = userInfo["messageId"] as? String else { return }
        await MessageService.shared.deleteMessage(id: messageId)
    default:
        break
    }
}

操作选项:

  • .authenticationRequired -- 设备必须解锁才能执行操作
  • .destructive -- 以红色显示;用于删除/移除操作
  • .foreground -- 点击时将应用启动到前台

通知分组

使用 threadIdentifier(或 APNs 负载中的 thread-id)对相关通知进行分组。每个唯一线程都会在通知中心中成为一个单独的组。

content.threadIdentifier = "chat-alice"  // all messages from Alice group together
content.summaryArgument = "Alice"
content.summaryArgumentCount = 3         // "3 more notifications from Alice"

在类别中自定义摘要格式字符串:

let category = UNNotificationCategory(
    identifier: "MESSAGE_CATEGORY",
    actions: [replyAction],
    intentIdentifiers: [],
    categorySummaryFormat: "%u more messages from %@",
    options: []
)

常见错误

不要: 当应用需要静默推送或服务器端令牌绑定时,将 APNs 令牌注册限制在提醒授权之后。 应该: 请求提醒、声音和标记的授权;只要需要设备令牌,就向 APNs 注册。 不要: 使用 String(data: deviceToken, encoding: .utf8) 转换设备令牌。 应该: 使用十六进制:deviceToken.map { String(format: "%02x", $0) }.joined()。 不要: 承诺每隔几分钟进行一次静默刷新,或保证立即在后台送达。 应该: 说明后台推送优先级低、会受到限制、不保证送达;实际中每小时只有少量推送,并且需要在 didReceiveRemoteNotification 中执行有界工作并返回正确的 UIBackgroundFetchResult。 不要: 期望静默推送运行通知服务扩展,或让扩展未调用其内容处理程序就结束。 应该: 在提醒负载中使用 mutable-content: 1;使用系统会验证并存储的受支持磁盘附件;对于通信通知,使用 INInteraction 捐赠以及 content.updating(from:);并确保成功、失败和超时路径都会提供原始内容或尽力生成的内容。 不要: 忘记前台处理。未实现 willPresent 时,通知会被静默抑制。 应该: 实现 willPresent 并返回 .banner、.sound、.badge。 不要: 太晚设置代理,或在没有 AppDelegate 适配器的情况下从 SwiftUI 视图注册。 应该: 在 App.init 中设置代理;使用 UIApplicationDelegateAdaptor 处理 APNs。 不要: 只在令牌“发生变化”时才上传 APNs 令牌,或假设令牌长度固定。应该: 每次 didRegister 回调时都上传,并将令牌视为转换为十六进制的不透明数据。 不要: 在这里放入 Live Activity、VoIP 或 App Clip 专属通知规则。应该: 将这些规则分别归入 activitykit、callkit 和 app-clips。

审核清单

  • 在显示提醒、声音和标记之前请求授权;处理拒绝授权的情况(提供“设置”链接)
  • 不因提醒授权状态而错误地阻止 APNs 注册
  • 将设备令牌转换为十六进制、在每次回调时上传,且不将其视为本地缓存值或固定长度常量
  • 在 App.init 或 application(_:didFinishLaunching:) 中设置 UNUserNotificationCenterDelegate
  • 实现前台(willPresent)和点击(didReceive)处理
  • 如需交互式通知,在启动时注册类别和操作
  • 静默推送使用 content-available: 1,不包含提醒、声音或标记,设置 apns-push-type: background、apns-priority: 5,启用 Background Modes > Remote notifications,并说明节流限制,且返回正确的 UIBackgroundFetchResult

参考资料

评论

支持 Markdown