在 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-available,apns-push-type: background,apns-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: 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": "照片", "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 特定的通知规则。要: 将它们路由到 activitykit、callkit 和 app-clips。
审查清单
- [ ] 在可见提醒/声音/角标之前请求授权;处理拒绝情况(设置链接)
- [ ] APNs 注册未被提醒授权状态错误阻止
- [ ] 设备令牌转换为十六进制,每次回调上传,不被视为本地缓存或固定长度常量
- [ ]
UNUserNotificationCenterDelegate在App.init或application(_:didFinishLaunching:)中设置 - [ ] 实现了前台(
willPresent)和点击(didReceive)处理 - [ ] 如果需要交互式通知,在启动时注册类别/操作
- [ ] 静默推送使用
content-available: 1,无提醒/声音/角标,apns-push-type: background,apns-priority: 5,后台模式 > 远程通知,限流注意事项,以及正确的UIBackgroundFetchResult
参考
- references/notification-patterns.md —— AppDelegate 设置、APNs 回调、深度链接路由器、静默推送、调试
- references/rich-notifications.md —— 服务扩展、内容扩展、附件、通信通知
- Apple 文档:APNs 注册、权限、负载、后台推送






