push-notifications

push-notifications

热门

在 iOS/macOS 应用中实现、审查或调试推送通知——本地通知、远程(APNs)通知、富通知、通知操作、静默推送以及通知服务/内容扩展。适用于使用 UNUserNotificationCenter、注册远程通知、处理通知负载、设置通知类别和操作、创建富通知内容或调试通知投递。也适用于在 Swift 应用中处理提醒、角标、声音、后台推送或用户通知权限。

931Star
0Fork
更新于 2026/7/26
SKILL.md
readonly只读
name
push-notifications
description

在 iOS/macOS 应用中实现、审查或调试推送通知——本地通知、远程(APNs)通知、富通知、通知操作、静默推送以及通知服务/内容扩展。适用于使用 UNUserNotificationCenter、注册远程通知、处理通知负载、设置通知类别和操作、创建富通知内容或调试通知投递。也适用于在 Swift 应用中处理提醒、角标、声音、后台推送或用户通知权限。

推送通知

使用 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-availableapns-push-type: backgroundapns-priority: 5,远程通知后台模式,低优先级,限流,不保证,不是每隔几分钟,并且 didReceiveRemoteNotification 返回正确的 UIBackgroundFetchResult。富通知审查必须说明服务扩展需要 mutable-content: 1 加上提醒负载,静默推送不会触发它们,附件是系统验证和存储的磁盘文件,秘密使用钥匙串共享而应用组用于共享文件/UserDefaults,通信通知需要能力 + 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("授权请求失败:\(error)")
        return false
    }
}

检查当前状态

在假设权限之前始终检查状态。用户可以随时更改设置。

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

临时通知

临时通知会静默地投递到通知中心,不会打扰用户。用户随后可以选择保留或关闭。适用于在请求完全权限之前展示价值的引导流程。

// 静默投递——不向用户显示权限提示
try await center.requestAuthorization(options: [.alert, .sound, .badge, .provisional])

关键提醒

关键提醒会绕过勿扰模式和静音开关。需要 Apple 的特殊授权(通过开发者门户申请)。仅用于健康、安全或安全场景。

// 需要 com.apple.developer.usernotifications.critical-alerts 授权
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)")
        // 将令牌发送到你的服务器
        Task { await TokenService.shared.upload(token: token) }
    }

    func application(
        _ application: UIApplication,
        didFailToRegisterForRemoteNotificationsWithError error: Error
    ) {
        print("APNs 注册失败:\(error.localizedDescription)")
        // 模拟器可以模拟推送,但不会向 APNs 注册。
    }
}

注册顺序

在启动时配置委托和类别。然后在上下文中请求用户通知授权以显示可见通知,并在应用需要设备令牌时向 APNs 注册。不要将 APNs 注册与 .authorized 绑定;没有提醒授权时,远程通知会静默投递。

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

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

    // 需要 APNs 令牌投递和静默远程通知。
    UIApplication.shared.registerForRemoteNotifications()
}

令牌处理

设备令牌会变化。每次 didRegisterForRemoteNotificationsWithDeviceToken 触发时都重新发送令牌到服务器,而不仅仅是第一次。不要将令牌本地持久化作为真相来源,也不要假设固定的令牌长度。

本地通知

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

创建内容

let content = UNMutableNotificationContent()
content.title = "锻炼提醒"
content.subtitle = "该活动了"
content.body = "你有一个计划中的锻炼,15 分钟后开始。"
content.sound = .default
content.badge = NSNumber(value: 1)
content.userInfo = ["workoutId": "abc123"]
content.threadIdentifier = "workouts"  // 在通知中心分组

触发器类型

// 在时间间隔后触发(重复至少 60 秒)
let timeTrigger = UNTimeIntervalNotificationTrigger(timeInterval: 300, repeats: false)

// 在特定日期/时间触发
var dateComponents = DateComponents()
dateComponents.hour = 8
dateComponents.minute = 30
let calendarTrigger = UNCalendarNotificationTrigger(
    dateMatching: dateComponents, repeats: true  // 每天上午 8:30
)

// 进入地理区域时触发
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)
// 至少需要“使用期间”位置权限

调度和管理

let request = UNNotificationRequest(
    identifier: "workout-reminder-abc123",
    content: content,
    trigger: timeTrigger
)

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

// 移除特定的待处理通知
center.removePendingNotificationRequests(withIdentifiers: ["workout-reminder-abc123"])

// 移除所有待处理通知
center.removeAllPendingNotificationRequests()

// 从通知中心移除已投递的通知
center.removeDeliveredNotifications(withIdentifiers: ["workout-reminder-abc123"])
center.removeAllDeliveredNotifications()

// 列出所有待处理请求
let pending = await center.pendingNotificationRequests()

远程通知负载

标准 APNs 负载

{
    "aps": {
        "alert": {
            "title": "新消息",
            "subtitle": "来自 Alice",
            "body": "嘿,你有空吃午饭吗?"
        },
        "badge": 3,
        "sound": "default",
        "thread-id": "chat-alice",
        "category": "MESSAGE_CATEGORY"
    },
    "messageId": "msg-789",
    "senderId": "user-alice"
}

静默/后台推送

设置 content-available: 1,不包含提醒、声音或角标。需要“后台模式 > 远程通知”以及 APNs 头部 apns-push-type: backgroundapns-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": "照片", "body": "Alice 发送了一张照片" },
        "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()

    // 当应用在前台时收到通知调用
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification
    ) async -> UNNotificationPresentationOptions {
        // 返回要显示的呈现元素
        // 如果没有此方法,前台通知会被静默抑制
        return [.banner, .sound, .badge]
    }

    // 当用户点击通知时调用
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse
    ) async {
        let userInfo = response.notification.request.content.userInfo
        let actionIdentifier = response.actionIdentifier

        switch actionIdentifier {
        case UNNotificationDefaultActionIdentifier:
            // 用户点击了通知主体
            await handleNotificationTap(userInfo: userInfo)
        case UNNotificationDismissActionIdentifier:
            // 用户关闭了通知
            break
        default:
            // 点击了自定义操作按钮
            await handleCustomAction(actionIdentifier, userInfo: userInfo)
        }
    }
}

从通知深度链接

使用共享的 @Observable 路由器将通知点击路由到正确的屏幕。委托写入待处理目标;SwiftUI 视图观察并消费它。

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

    var pendingDestination: AppDestination?
}

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

// 在 SwiftUI 中——观察并消费:
.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: "回复",
        options: [],
        textInputButtonTitle: "发送",
        textInputPlaceholder: "输入回复..."
    )

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

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

    let messageCategory = UNNotificationCategory(
        identifier: "MESSAGE_CATEGORY",
        actions: [replyAction, likeAction, deleteAction],
        intentIdentifiers: [],
        options: [.customDismissAction]  // 关闭时也会触发 didReceive
    )

    UNUserNotificationCenter.current().setNotificationCategories([messageCategory])
}

处理操作响应

func handleCustomAction(_ identifier: String, userInfo: [AnyHashable: Any]) async {
    switch identifier {
    case "REPLY_ACTION":
        // response 是 UNTextInputNotificationResponse 用于文本输入操作
        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"  // 所有来自 Alice 的消息分组在一起
content.summaryArgument = "Alice"
content.summaryArgumentCount = 3         // "来自 Alice 的 3 条更多通知"

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

let category = UNNotificationCategory(
    identifier: "MESSAGE_CATEGORY",
    actions: [replyAction],
    intentIdentifiers: [],
    categorySummaryFormat: "%u 条来自 %@ 的更多消息",
    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 特定的通知规则。要: 将它们路由到 activitykitcallkitapp-clips

审查清单

  • [ ] 在可见提醒/声音/角标之前请求授权;处理拒绝情况(设置链接)
  • [ ] APNs 注册未被提醒授权状态错误阻止
  • [ ] 设备令牌转换为十六进制,每次回调上传,不被视为本地缓存或固定长度常量
  • [ ] UNUserNotificationCenterDelegateApp.initapplication(_:didFinishLaunching:) 中设置
  • [ ] 实现了前台(willPresent)和点击(didReceive)处理
  • [ ] 如果需要交互式通知,在启动时注册类别/操作
  • [ ] 静默推送使用 content-available: 1,无提醒/声音/角标,apns-push-type: backgroundapns-priority: 5,后台模式 > 远程通知,限流注意事项,以及正确的 UIBackgroundFetchResult

参考