关于 push-notifications
面向 iOS 和 macOS 开发者的推送通知技能,涵盖本地通知、远程通知及 APNs 的实现、审查和调试。内容从权限和令牌注册延伸到负载、操作、分组与富通知处理。
主要功能
- 01通知权限与状态检查
- 02APNs 令牌注册处理
- 03本地通知调度
- 04远程与静默推送负载
- 05通知操作和分类
- 06富通知与通知分组
适用场景
- 01实现设备端提醒和定时通知
- 02处理 APNs 令牌更新
- 03排查后台推送配置问题
- 04添加通知操作与分组
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dpearson2699/ swift-ios-skills --skill push-notifications安装后在 Claude Code 中输入/push-notifications确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
evals/evals.jsonreferences/notification-patterns.mdrich-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
参考资料
- references/notification-patterns.md — AppDelegate 设置、APNs 回调、深层链接路由器、静默推送、调试
- references/rich-notifications.md — 服务扩展、内容扩展、附件、通信通知
- Apple 文档:APNs 注册、权限、负载、后台推送