本课目标
入门第17课你学过本地通知和后台任务。本课把通知做到生产级:远程推送、通知服务扩展、通知内容扩展、通信通知、专注过滤、分组与摘要、时间敏感与重要警报。完成后,你的"我的书库"能通过服务器推送新书提醒、显示带封面的通知、在专注模式下智能过滤,并支持用户不打开 App 就能操作。
一、核心概念
- APNs:Apple Push Notification service,苹果推送服务,服务器通过它发推送。
- Device Token:设备令牌,每次启动可能变化,必须上传服务器。
- Payload :推送内容,JSON 格式,包含
aps字典和自定义字段。 - 通知服务扩展:在通知显示前修改内容,如解密、下载图片、添加附件。
- 通知内容扩展:自定义通知展开后的界面。
- 通信通知 :
INSendMessageIntent,让通知显示为某人发来的消息,带头像。 - 专注过滤 :
SetFocusFilterIntent,让 App 在用户开启专注模式时调整行为。 - 通知分组 :
threadIdentifier把相关通知归组。 - 通知摘要:系统延迟非紧急通知,集中展示。
- 时间敏感 :
interruptionLevel = .timeSensitive,突破专注模式。 - 重要警报 :
criticalAlert,需要苹果特别授权,声音无法静音。 - 临时授权 :
provisional,先静默推送,用户可选择保留。
二、配置推送能力
1. 开启 Push Notifications
Xcode → App Target → Signing & Capabilities → + Capability → Push Notifications。
2. 开启 Background Modes
勾选 Remote notifications。
3. Info.plist
xml
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
4. 创建 APNs 密钥
在 Apple Developer 后台创建 APNs Auth Key,下载 .p8 文件,记录 Key ID 和 Team ID,交给服务器使用。
三、注册远程通知与获取 Token
swift
import SwiftUI
import UserNotifications
// AppDelegate:处理远程通知注册
class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
// 设置通知代理
UNUserNotificationCenter.current().delegate = self
// 注册通知分类
registerNotificationCategories()
return true
}
// 注册远程通知成功,拿到 device token
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
// 转成十六进制字符串
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
print("APNs token: \(token)")
// 上传到你的服务器
Task {
await uploadToken(token)
}
}
// 注册失败
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
print("注册远程通知失败:\(error)")
}
// 上传 token 到服务器
func uploadToken(_ token: String) async {
guard let url = URL(string: "https://api.example.com/device-token") else { return }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONEncoder().encode(["token": token])
_ = try? await URLSession.shared.data(for: request)
}
// 注册通知分类和操作
func registerNotificationCategories() {
// 新书通知分类
let readAction = UNNotificationAction(
identifier: "READ_NOW",
title: "立即阅读",
options: [.foreground]
)
let laterAction = UNNotificationAction(
identifier: "LATER",
title: "稍后提醒",
options: []
)
let newBookCategory = UNNotificationCategory(
identifier: "NEW_BOOK",
actions: [readAction, laterAction],
intentIdentifiers: [],
options: [.customDismissAction]
)
UNUserNotificationCenter.current().setNotificationCategories([newBookCategory])
}
}
// SwiftUI App 入口接入 AppDelegate
@main
struct MyLibraryApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
.task {
// 请求通知权限
let granted = try? await UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .sound, .badge])
if granted == true {
// 在主线程注册远程通知
await MainActor.run {
UIApplication.shared.registerForRemoteNotifications()
}
}
}
}
}
}
解读:registerForRemoteNotifications() 必须在主线程调用;token 每次启动都可能变化,必须重新上传;requestAuthorization 先请求用户授权。
四、APNs Payload 结构
标准 payload:
json
{
"aps": {
"alert": {
"title": "新书上架",
"subtitle": "你关注的作者有新书",
"body": "《SwiftUI 进阶》已加入书库"
},
"badge": 1,
"sound": "default",
"thread-id": "new-books",
"category": "NEW_BOOK",
"interruption-level": "time-sensitive",
"mutable-content": 1,
"content-available": 1
},
"bookID": "12345",
"coverURL": "https://example.com/cover.jpg"
}
字段说明:
alert:标题、副标题、正文。badge:角标数字。sound:声音,"default"或自定义。thread-id:分组标识。category:关联通知分类,显示操作按钮。interruption-level:passive、active、time-sensitive、critical。mutable-content:允许通知服务扩展修改内容。content-available:静默推送,唤醒 App 后台处理。- 自定义字段:
bookID、coverURL,供 App 和服务扩展使用。
五、处理通知:前台、点击、后台
swift
extension AppDelegate {
// App 在前台时收到通知
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
switch response.actionIdentifier {
case "READ_NOW":
// 打开 App 并跳转到书籍详情
if let bookID = userInfo["bookID"] as? String {
await MainActor.run {
NotificationCenter.default.post(
name: .openBook,
object: nil,
userInfo: ["bookID": bookID]
)
}
}
case "LATER":
// 重新调度本地通知
scheduleLaterReminder()
case UNNotificationDefaultActionIdentifier:
// 用户点击通知本体
print("用户点击了通知")
case UNNotificationDismissActionIdentifier:
// 用户滑掉了通知
print("用户关闭了通知")
default:
break
}
}
func scheduleLaterReminder() {
let content = UNMutableNotificationContent()
content.title = "阅读提醒"
content.body = "你之前想稍后阅读的书还在等你"
content.sound = .default
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 3600, repeats: false)
let request = UNNotificationRequest(
identifier: UUID().uuidString,
content: content,
trigger: trigger
)
UNUserNotificationCenter.current().add(request)
}
}
// 通知名称
extension Notification.Name {
static let openBook = Notification.Name("openBook")
}
解读:willPresent 决定前台是否显示;didReceive 处理点击和操作按钮;自定义 userInfo 传递数据。
六、通知服务扩展:修改内容
File → New → Target → Notification Service Extension。
swift
import UserNotifications
import UIKit
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
// 收到通知,有机会修改内容
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler
self.bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
guard let content = bestAttemptContent else {
contentHandler(request.content)
return
}
// 下载封面图并添加为附件
if let coverURLString = content.userInfo["coverURL"] as? String,
let coverURL = URL(string: coverURLString) {
downloadImage(from: coverURL) { imageURL in
if let imageURL,
let attachment = try? UNNotificationAttachment(
identifier: "cover",
url: imageURL,
options: nil
) {
content.attachments = [attachment]
}
contentHandler(content)
}
} else {
contentHandler(content)
}
}
// 超时未完成时调用
override func serviceExtensionTimeWillExpire() {
if let contentHandler, let content = bestAttemptContent {
contentHandler(content)
}
}
// 下载图片
func downloadImage(from url: URL, completion: @escaping (URL?) -> Void) {
URLSession.shared.downloadTask(with: url) { localURL, _, _ in
guard let localURL else {
completion(nil)
return
}
// 移动到临时目录并加扩展名
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("jpg")
try? FileManager.default.moveItem(at: localURL, to: tempURL)
completion(tempURL)
}.resume()
}
}
解读:服务扩展在通知显示前运行,可以解密、下载图片、修改文案。mutable-content: 1 才会触发。
七、通知内容扩展:自定义界面
File → New → Target → Notification Content Extension。
swift
import UserNotifications
import UIKit
import SwiftUI
class NotificationViewController: UIViewController, UNNotificationContentExtension {
var hostingController: UIHostingController<BookNotificationView>?
func didReceive(_ notification: UNNotification) {
let userInfo = notification.request.content.userInfo
let title = notification.request.content.title
let body = notification.request.content.body
let bookID = userInfo["bookID"] as? String ?? ""
// 用 SwiftUI 渲染自定义通知界面
let view = BookNotificationView(
title: title,
body: body,
bookID: bookID
)
let hosting = UIHostingController(rootView: view)
hostingController = hosting
addChild(hosting)
view.addSubview(hosting.view)
hosting.didMove(toParent: self)
hosting.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hosting.view.topAnchor.constraint(equalTo: view.topAnchor),
hosting.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hosting.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hosting.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
}
struct BookNotificationView: View {
let title: String
let body: String
let bookID: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(title).font(.headline)
Text(body).font(.subheadline)
HStack {
Button("查看") {
// 打开 App
}
Button("收藏") {
// 收藏逻辑
}
}
.buttonStyle(.bordered)
}
.padding()
}
}
解读:内容扩展在用户长按或下拉通知时显示自定义界面,适合展示封面、按钮和更多信息。
八、通信通知:显示为消息
通信通知让通知显示为"某人发来消息",带头像,适合社交、协作类 App。
swift
import Intents
import UserNotifications
// 发送通信通知
func sendCommunicationNotification(from sender: String, body: String) async throws {
// 1. 创建发送者头像
let avatar = INImage(named: "avatar") // 或从 URL 创建
// 2. 创建发送者
let handle = INPersonHandle(value: sender, type: .unknown)
let person = INPerson(
personHandle: handle,
nameComponents: nil,
displayName: sender,
image: avatar,
contactIdentifier: nil,
customIdentifier: sender
)
// 3. 创建消息意图
let intent = INSendMessageIntent(
recipients: nil,
outgoingMessageType: .outgoingMessageText,
content: body,
speakableGroupName: nil,
conversationIdentifier: "chat_\(sender)",
serviceName: nil,
sender: person,
attachments: nil
)
// 4. 生成通知内容
let interaction = INInteraction(intent: intent, response: nil)
let content = try await UNMutableNotificationContent.updating(from: intent)
// 5. 发送通知
let request = UNNotificationRequest(
identifier: UUID().uuidString,
content: content,
trigger: nil
)
try await UNUserNotificationCenter.current().add(request)
}
解读:INSendMessageIntent 让通知显示为消息,系统自动处理头像和名称。需要在 Info.plist 中声明 NSUserActivityTypes 包含 INSendMessageIntent。
九、专注过滤:Focus Filter
专注过滤让 App 在用户开启"工作""睡眠"等专注模式时调整行为。
swift
import AppIntents
import SwiftUI
// 专注过滤器
struct LibraryFocusFilter: SetFocusFilterIntent {
static var title: LocalizedStringResource = "书库专注过滤"
static var description = IntentDescription("在专注模式下只显示收藏的书籍")
// 可配置参数
@Parameter(title: "只显示收藏", default: true)
var onlyFavorites: Bool
@Parameter(title: "隐藏已读", default: false)
var hideRead: Bool
// 应用过滤
func perform() async throws -> some IntentResult {
// 保存过滤设置
UserDefaults.standard.set(onlyFavorites, forKey: "focus_onlyFavorites")
UserDefaults.standard.set(hideRead, forKey: "focus_hideRead")
// 通知界面刷新
await MainActor.run {
NotificationCenter.default.post(name: .focusFilterChanged, object: nil)
}
return .result()
}
}
extension Notification.Name {
static let focusFilterChanged = Notification.Name("focusFilterChanged")
}
在视图中响应:
swift
struct LibraryView: View {
@AppStorage("focus_onlyFavorites") private var onlyFavorites = false
@AppStorage("focus_hideRead") private var hideRead = false
@Query private var books: [Book]
var filteredBooks: [Book] {
var result = books
if onlyFavorites {
result = result.filter { $0.isFavorite }
}
if hideRead {
result = result.filter { !$0.isRead }
}
return result
}
var body: some View {
List(filteredBooks) { book in
BookRow(book: book)
}
.onReceive(NotificationCenter.default.publisher(for: .focusFilterChanged)) { _ in
// 刷新界面
}
}
}
解读:SetFocusFilterIntent 让 App 出现在专注模式设置中,用户可以为不同专注模式配置不同行为。
十、分组、摘要、时间敏感与重要警报
1. 分组
swift
content.threadIdentifier = "new-books" // 相同线程归为一组
content.summaryArgument = "新书" // 摘要中显示
content.summaryArgumentCount = 3 // 数量
2. 时间敏感
swift
if #available(iOS 15.0, *) {
content.interruptionLevel = .timeSensitive // 突破专注模式
}
3. 重要警报
swift
// 需要苹果特别授权
content.interruptionLevel = .critical
content.sound = UNNotificationSound.defaultCritical
4. 临时授权
swift
let granted = try await UNUserNotificationCenter.current()
.requestAuthorization(options: [.provisional, .alert, .sound])
// 静默推送,用户在通知中心可以选择"保持"
十一、测试与调试
1. 模拟器推送
创建 push.apns 文件:
json
{
"Simulator Target Bundle": "com.yourname.mylibrary",
"aps": {
"alert": {
"title": "测试推送",
"body": "这是一条测试通知"
},
"sound": "default",
"badge": 1
}
}
拖到模拟器即可触发。
2. 命令行推送
bash
xcrun simctl push booted com.yourname.mylibrary push.apns
3. 真机测试
真机推送需要 APNs 证书或密钥,服务器通过 HTTP/2 发送。可以用 Xcode 的 Push Notifications 控制台测试,或使用第三方工具如 Pusher。
4. 常见错误
- 未注册远程通知 :检查
registerForRemoteNotifications是否调用。 - token 未上传:服务器没有 token 发不了。
- payload 格式错误 :
aps必须存在,字段类型正确。 - 服务扩展未触发 :检查
mutable-content: 1。 - 权限被拒:引导用户去设置开启。
十二、习题、答案与解读
选择题
题1
题目:注册远程通知使用哪个方法?
A. registerForRemoteNotifications() B. requestAuthorization() C. add(request) D. setNotificationCategories()
要求:选出正确选项。
答案:A
解读:在主线程调用 UIApplication.shared.registerForRemoteNotifications()。
题2
题目:获取 device token 的代理方法是?
A. didRegisterForRemoteNotificationsWithDeviceToken B. didReceive C. willPresent D. didFinishLaunching
要求:选出正确选项。
答案:A
解读:在 AppDelegate 中实现,拿到 token 后上传服务器。
题3
题目:让通知服务扩展修改内容,payload 需要哪个字段?
A. mutable-content: 1 B. content-available: 1 C. category D. thread-id
要求:选出正确选项。
答案:A
解读:mutable-content 为 1 时触发服务扩展。
题4
题目:通信通知使用哪个 intent?
A. INSendMessageIntent B. INStartCallIntent C. INSearchForMessagesIntent D. INSetMessageAttributeIntent
要求:选出正确选项。
答案:A
解读:INSendMessageIntent 让通知显示为消息。
题5
题目:专注过滤使用哪个协议?
A. SetFocusFilterIntent B. AppIntent C. WidgetConfigurationIntent D. TimelineProvider
要求:选出正确选项。
答案:A
解读:SetFocusFilterIntent 让 App 接入专注模式。
判断题
题1
题目:device token 获取一次后可以永久使用。
要求:判断对错。
答案:错
解读:token 每次启动可能变化,必须重新上传。
题2
题目:willPresent 决定 App 在前台时是否显示通知。
要求:判断对错。
答案:对
解读:返回 [.banner, .sound, .badge] 显示,返回 [] 静默。
题3
题目:通知服务扩展可以下载图片并添加为附件。
要求:判断对错。
答案:对
解读:服务扩展在通知显示前运行,适合富媒体处理。
题4
题目:重要警报不需要苹果特别授权。
要求:判断对错。
答案:错
解读:criticalAlert 需要苹果特别批准。
题5
题目:content-available: 1 是静默推送,唤醒 App 后台处理。
要求:判断对错。
答案:对
解读:静默推送不显示通知,用于后台同步。
填空题
题1
题目:注册远程通知使用 ______()。
要求:填入空白处。
答案:registerForRemoteNotifications
解读:在主线程调用。
题2
题目:获取 token 的代理方法是 didRegisterForRemoteNotificationsWith______。
要求:填入空白处。
答案:DeviceToken
解读:参数是 Data。
题3
题目:让服务扩展修改内容设置 mutable-content: ______。
要求:填入空白处。
答案:1
解读:为 1 时触发。
题4
题目:通信通知使用 ______ intent。
要求:填入空白处。
答案:INSendMessageIntent
解读:Intents 框架。
题5
题目:专注过滤实现 ______ 协议。
要求:填入空白处。
答案:SetFocusFilterIntent
解读:AppIntents 框架。
改错题
题1
题目:
swift
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.description
print(token)
}
要求:找出 token 转换问题并改正。
答案:
swift
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
print(token)
}
解读:Data.description 不是标准 token 格式,需要转十六进制字符串。
题2
题目:
swift
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in
UIApplication.shared.registerForRemoteNotifications()
}
要求:找出线程问题并改正。
答案:
swift
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
}
解读:registerForRemoteNotifications 必须在主线程调用。
题3
题目:
swift
let content = UNMutableNotificationContent()
content.title = "新书"
content.body = "上架了"
// 期望显示操作按钮,但没有
要求:找出缺少的配置。
答案:
swift
content.categoryIdentifier = "NEW_BOOK" // 关联分类
// 并确保已注册 UNNotificationCategory
解读:通知需要设置 categoryIdentifier 并注册对应分类,才会显示操作按钮。
代码预测题
题目:
swift
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
return []
}
要求:App 在前台收到通知时会怎样?
答案:不显示任何横幅、声音或角标。
解读:返回空数组表示前台静默。
编程题
题1
题目:注册远程通知并上传 token。
要求:请求权限后注册,拿到 token 后打印并上传。
参考代码:
swift
func requestAndRegister() async {
let center = UNUserNotificationCenter.current()
let granted = try? await center.requestAuthorization(options: [.alert, .sound, .badge])
guard granted == true else { return }
await MainActor.run {
UIApplication.shared.registerForRemoteNotifications()
}
}
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
Task { await uploadToken(token) }
}
解读:权限 → 注册 → 拿 token → 上传,完整流程。
题2
题目:写通知服务扩展,下载图片并添加附件。
要求:从 coverURL 下载,添加为通知附件。
参考代码:
swift
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
guard let content = bestAttemptContent,
let urlString = content.userInfo["coverURL"] as? String,
let url = URL(string: urlString) else {
contentHandler(request.content)
return
}
URLSession.shared.downloadTask(with: url) { localURL, _, _ in
if let localURL {
let temp = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("jpg")
try? FileManager.default.moveItem(at: localURL, to: temp)
if let attachment = try? UNNotificationAttachment(identifier: "cover", url: temp) {
content.attachments = [attachment]
}
}
contentHandler(content)
}.resume()
}
解读:服务扩展下载图片,添加附件,通知会显示图片。
题3
题目:挑战------实现专注过滤器。
要求:用户在专注模式中配置"只显示收藏",App 保存并刷新。
参考代码:
swift
struct LibraryFocusFilter: SetFocusFilterIntent {
static var title: LocalizedStringResource = "书库专注过滤"
@Parameter(title: "只显示收藏", default: true)
var onlyFavorites: Bool
func perform() async throws -> some IntentResult {
UserDefaults.standard.set(onlyFavorites, forKey: "focus_onlyFavorites")
await MainActor.run {
NotificationCenter.default.post(name: .focusFilterChanged, object: nil)
}
return .result()
}
}
在视图中读取 @AppStorage("focus_onlyFavorites") 并过滤。
解读:专注过滤器让 App 随用户状态变化。
开放思考题
题1
题目:本地通知和远程推送有什么区别?
要求:简要回答。
参考:本地通知由 App 在设备上调度;远程推送由服务器通过 APNs 发送。
解读:提醒用本地,消息和营销用远程。
题2
题目:为什么 device token 必须上传服务器?
要求:简要回答。
参考:服务器需要 token 才能通过 APNs 定位到具体设备发送推送。
解读:token 是设备的推送地址。
题3
题目:通知服务扩展和内容扩展有什么区别?
要求:简要回答。
参考:服务扩展在显示前修改内容;内容扩展自定义展开后的界面。
解读:服务扩展处理数据,内容扩展处理展示。
十三、全课知识点总结
- APNs:苹果推送服务,服务器通过 HTTP/2 发送。
- Device Token:设备令牌,每次启动可能变化,必须上传。
- 注册流程:请求权限 →
registerForRemoteNotifications→ 获取 token → 上传。 - Payload:
aps字典 + 自定义字段。 mutable-content: 1:触发通知服务扩展。content-available: 1:静默推送,后台处理。thread-id:通知分组。category:关联通知分类,显示操作按钮。interruption-level:passive、active、time-sensitive、critical。- 通知服务扩展:修改内容、下载图片、添加附件。
- 通知内容扩展:自定义通知展开界面。
- 通信通知:
INSendMessageIntent,显示头像和消息样式。 - 专注过滤:
SetFocusFilterIntent,随专注模式调整行为。 - 时间敏感突破专注模式,重要警报需苹果授权。
- 临时授权:
.provisional静默推送。 - 测试:
simctl push、Xcode 控制台、真机 + 服务器。 - 常见错误:token 未上传、payload 格式、权限被拒、扩展未触发。
本课达标标准 :
能配置推送能力并注册远程通知,能获取并上传 device token,能构造和理解 APNs payload,能处理前台展示和用户点击,能写通知服务扩展修改内容,能写通知内容扩展自定义界面,能发送通信通知,能实现专注过滤器,能使用分组、时间敏感和重要警报,并能通过 simctl 和真机测试推送。