通知与后台任务
主要目标: 系统掌握 SwiftUI 中通知体系的完整实现链路(权限策略、内容构建、触发器、分类操作、代理处理、APNs 注册),深入理解 iOS 后台执行的"机会主义"调度模型(BGTaskScheduler、静默推送、beginBackgroundTask、BGContinuedProcessingTask、URLSession 后台传输),并掌握通知与后台任务协同工作的生产级架构设计。
第一部分 建立心智模型:iOS 后台执行的全景图
主要目标: 在深入 API 细节之前,先建立 iOS 后台执行的完整心智模型,理解通知与后台任务在整个系统中的位置与关系。
1.1 一个核心问题:App 不在前台时,它能做什么?
iOS 对后台执行的管理哲学可以用一句话概括:前台优先,后台机会主义。 系统始终将用户体验和电池续航放在第一位。当 App 不在前台时,系统会根据当前设备状态、用户行为模式和任务紧急程度,决定是否给 App 执行机会。
iOS 提供的后台执行能力可以分为五类:
| 能力类型 | 代表 API | 触发方式 | 时间预算 | 适用场景 |
|---|---|---|---|---|
| 通知展示 | UNUserNotificationCenter |
定时/位置/远程 | 不执行代码 | 用户触达 |
| 短暂延续 | beginBackgroundTask |
App 进入后台时 | ~30 秒 | 保存状态、完成写入 |
| 机会主义刷新 | BGTaskScheduler |
系统决定 | 30秒-2分钟 | 内容刷新、维护任务 |
| 系统进程外传输 | URLSessionConfiguration.background |
系统管理 | 无硬性上限 | 大文件下载/上传 |
| 实时同步 | 静默推送 | 服务器触发 | ~30 秒 | 消息到达、数据更新 |
这五种能力不是互斥的,而是互补的 。一个生产级 App 通常会组合使用它们:用 BGAppRefreshTask 做周期性内容预取,用静默推送做实时数据同步,用本地通知做用户触达,用 beginBackgroundTask 在进入后台时保存关键状态,用 URLSession 后台传输处理大文件。
1.2 通知与后台任务的关系
通知和后台任务经常被放在一起讨论,但它们解决的是不同层次的问题:
- 通知解决"如何触达用户" ------即使 App 不在前台,也能让用户感知到信息。
- 后台任务解决"如何保持数据新鲜" ------即使 App 不在前台,也能更新数据、执行维护。
两者的协同点在于:后台任务或静默推送更新数据后,可以触发本地通知或远程推送来触达用户。用户在通知上的交互(点击、自定义操作)又可能触发新的后台任务。这形成了一个完整的闭环。
1.3 本课的知识地图
通知与后台任务
├── 通知体系
│ ├── 权限请求(requestAuthorization / provisional)
│ ├── 本地通知(UNNotificationRequest + 三种触发器)
│ ├── 远程推送(APNs 注册 + device token)
│ ├── 通知分类(UNNotificationCategory + 自定义操作)
│ └── 代理处理(willPresent / didReceive)
├── 后台任务
│ ├── BGTaskScheduler(BGAppRefreshTask / BGProcessingTask)
│ ├── BGContinuedProcessingTask(iOS 26+)
│ ├── URLSession 后台传输(大文件)
│ ├── 静默推送(content-available: 1)
│ ├── beginBackgroundTask(短暂延续)
│ └── scenePhase(生命周期协同)
└── 协同架构
├── 前台刷新 + 后台刷新 + 静默推送
├── 通知触达 + 交互响应
└── 调试与测试
第二部分 通知体系:从权限到交互的完整链路
主要目标: 系统掌握通知权限策略、本地通知构建、远程推送注册、通知分类与自定义操作、代理处理的完整实现路径。
2.1 通知的两种形态与底层架构
iOS 通知分为两种形态:
本地通知(Local Notification) 由 App 自身创建并调度,无需服务器参与。适合闹钟提醒、待办事项、定时任务等场景。核心 API 是 UNUserNotificationCenter,开发者构建 UNNotificationRequest(包含内容和触发器),提交给系统后由系统按时投递。
远程推送(Remote Notification) 由服务器通过 Apple Push Notification service(APNs)发送到设备。适合即时消息、新闻推送、社交互动等场景。核心流程是:App 向 APNs 注册获取 device token,将 token 上传到自己的服务器,服务器通过 APNs HTTP/2 API 发送推送。
两种通知共享同一套展示和交互体系------都通过 UNUserNotificationCenter 管理,都经过 UNUserNotificationCenterDelegate 处理,都支持通知分类和自定义操作。区别仅在于"谁创建了通知"和"通过什么渠道投递"。
UNUserNotificationCenter 是通知体系的中枢对象,负责:请求授权、声明通知类型和自定义操作、调度本地通知、处理远程通知负载、管理已投递通知、获取通知设置。它的核心设计是始终使用 shared 单例 (UNUserNotificationCenter.current()),可以跨线程安全调用,对象按系统发起请求的顺序串行处理。
2.2 SwiftUI 中通知代理的接入方式
SwiftUI 的 App 生命周期没有 AppDelegate,但通知代理 UNUserNotificationCenterDelegate 仍然是一个 Objective-C 协议,需要一个 NSObject 子类来实现。@UIApplicationDelegateAdaptor 是桥接的关键:
swift
@main
struct MyApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@State private var router = DeepLinkRouter.shared
init() {
// 尽早设置代理,避免遗漏通知。
// App.init 比 application(_:didFinishLaunchingWithOptions:) 更早执行。
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
}
var body: some Scene {
WindowGroup {
ContentView()
.environment(router)
.task { await setupNotifications() }
}
}
private func setupNotifications() async {
registerNotificationCategories()
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
if settings.authorizationStatus == .notDetermined {
_ = try? await center.requestAuthorization(options: [.alert, .sound, .badge])
}
// APNs 注册与通知授权独立。即使用户拒绝展示权限,远程通知仍可静默到达。
await MainActor.run {
UIApplication.shared.registerForRemoteNotifications()
}
}
}
代理设置时机的关键设计: App.init() 比 application(_:didFinishLaunchingWithOptions:) 更早执行,在 init() 中设置代理可以确保任何通知都不会被遗漏。
冷启动陷阱。 如果 App 在用户点击通知之前已被系统终止,didReceive 代理方法不会在冷启动时自动触发。这是 SwiftUI 生命周期下一个常见的坑。通知响应数据可能通过 launchOptions 传递,需要通过 AppDelegate 的启动回调桥接到 SwiftUI 视图层。
代理的两个核心方法(Swift 6 并发适配):
在 Swift 6 中,UNUserNotificationCenterDelegate 的并发隔离策略有明确区分:didReceive 必须标记为 nonisolated(系统在任意后台线程回调),willPresent 不应标记为 nonisolated(需要运行在 MainActor 上)。Apple 工程师在开发者论坛中明确建议:"Do not change willPresent to nonisolated."
swift
@MainActor
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
static let shared = NotificationDelegate()
private override init() { super.init() }
// 前台通知展示:保持在 MainActor 上,不做 nonisolated。
// 不实现此方法,前台收到的通知会被静默抑制。
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
return [.banner, .sound, .badge]
}
// 通知响应:必须 nonisolated(系统在任意线程回调)。
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse
) async {
// 内部跳回 MainActor 做 UI 相关操作。
await MainActor.run {
let userInfo = response.notification.request.content.userInfo
// 根据 response.actionIdentifier 和 userInfo 做路由。
}
}
}
2.3 权限请求:时机与策略
通知权限是用户的一次性决策------系统对话框只弹出一次。如果用户冷启动时看到弹窗并点击"不允许",这个决定是永久的,后续无法再次弹出系统对话框。
核心原则:永远不要在首次启动时直接调用 requestAuthorization。 Apple 的官方指导明确指出:"在帮助用户理解为什么你的 App 需要授权的上下文中发出请求"。应该将权限请求放在一个有明确通知收益的用户动作之后(如"当内容准备好时通知我"),在系统对话框之前用一个自定义的预提示(pre-prompt)解释通知的具体用途。
Provisional 授权(临时授权) 是一个折中策略:使用 .provisional 选项请求授权时,系统不会弹出对话框,通知会静默投递到通知中心而非锁屏。用户在通知中心可以对通知进行"保持投递"或"关闭"的操作。
权限状态的完整处理:
swift
private func setupNotifications() async {
registerNotificationCategories()
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
switch settings.authorizationStatus {
case .notDetermined:
// 首次请求------建议在上下文明确时调用。
_ = try? await center.requestAuthorization(options: [.alert, .sound, .badge])
case .denied:
// 用户拒绝------引导到系统设置页面。
break
case .authorized:
// 用户已授权。
break
case .provisional:
// 临时授权------通知静默投递到通知中心。
break
case .ephemeral:
// App Clips 专用------在有限时间内接收通知的权限。
// 普通 App 不会收到此状态。
break
@unknown default:
break
}
}
注意 .ephemeral 的归属。 .ephemeral 授权是 App Clips 专用的------它授予 App 在有限时间内发送通知的权限,普通 App 不会进入此状态。
APNs 注册与通知授权是独立的。 即使用户拒绝了通知展示权限,App 仍然可以注册远程通知并接收静默推送(content-available: 1),用于后台数据同步。
2.4 本地通知的构建与调度
本地通知的核心是构建一个 UNNotificationRequest,包含三部分:内容(UNMutableNotificationContent)、触发器(UNNotificationTrigger)、唯一标识符(identifier)。
swift
func scheduleNotification(for item: Item) {
let content = UNMutableNotificationContent()
content.title = "待办提醒"
content.body = "\(item.name) 即将到期"
content.sound = .default
content.badge = NSNumber(value: 1)
content.userInfo = ["itemId": item.id.uuidString]
content.threadIdentifier = "todo-reminders"
content.interruptionLevel = .timeSensitive
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)
let request = UNNotificationRequest(
identifier: item.notificationId.uuidString,
content: content,
trigger: trigger
)
UNUserNotificationCenter.current().add(request) { error in
if let error = error {
print("通知调度失败: \(error.localizedDescription)")
}
}
}
三种触发器:
| 触发器 | 类名 | 适用场景 | 时间限制 |
|---|---|---|---|
| 时间间隔 | UNTimeIntervalNotificationTrigger |
延迟 N 秒后触发 | 非重复:> 0;重复:≥ 60 秒 |
| 日历 | UNCalendarNotificationTrigger |
指定日期/时间触发 | 支持 DateComponents 灵活配置 |
| 位置 | UNLocationNotificationTrigger |
进入/离开地理围栏时触发 | 需要定位权限配合 |
根据 Apple 官方文档,UNTimeIntervalNotificationTrigger 在 repeats: true 时,timeInterval 必须大于或等于 60 秒 ;在 repeats: false 时,只需大于 0 即可。
swift
// 非重复:timeInterval 需大于 0。
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
// 重复:timeInterval 必须 ≥ 60 秒。
let repeatingTrigger = UNTimeIntervalNotificationTrigger(timeInterval: 60, repeats: true)
内容配置的进阶能力:
threadIdentifier 用于通知中心的分组------相同 threadIdentifier 的通知会自动归为一组,用户可以展开查看全部。
userInfo 字典是承载业务数据的核心通道。当用户点击通知时,通过 response.notification.request.content.userInfo 获取这些数据,驱动页面导航或业务逻辑。
interruptionLevel(iOS 15+)控制通知的打断级别:.passive 静默投递到通知中心,.active 默认提醒,.timeSensitive 可穿透专注模式,.critical 需要特殊权限。
通知的管理操作:
swift
// 移除待投递的通知(尚未触发的)。
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [id])
// 移除已投递的通知(已出现在通知中心的)。
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [id])
使用稳定的 identifier(如 item.notificationId.uuidString)管理通知,确保可以对特定通知进行更新或取消。
2.5 通知分类与自定义操作
通知分类(Category)允许开发者为通知添加可交互的按钮。用户可以在通知横幅上直接执行操作(如"标记已完成"、"稍后提醒"),无需打开 App。
swift
func registerNotificationCategories() {
let markDone = UNNotificationAction(
identifier: "MARK_DONE",
title: "标记完成",
options: [] // 后台处理,不打开 App。
)
let snooze = UNNotificationAction(
identifier: "SNOOZE",
title: "稍后提醒",
options: []
)
let reply = UNTextInputNotificationAction(
identifier: "REPLY",
title: "回复",
options: [.foreground] // 需要打开 App。
)
let category = UNNotificationCategory(
identifier: "TODO_ACTIONS",
actions: [markDone, snooze, reply],
intentIdentifiers: [],
options: []
)
UNUserNotificationCenter.current().setNotificationCategories([category])
}
关键设计决策:options 的选择。
[](空):操作在后台执行,App 不会进入前台。适合"标记已读"、"删除"等不需要界面交互的操作。.foreground:操作会打开 App 并进入前台。适合需要展示界面或复杂交互的操作。.destructive:以红色显示按钮,表示破坏性操作(如删除)。.authenticationRequired:执行前需要设备解锁。
注册时机与匹配规则。 分类必须在 App 启动时(App.init() 或 didFinishLaunchingWithOptions 中)注册。通知负载中的 categoryIdentifier(本地通知)或 category(远程推送)必须与注册的 identifier 完全匹配。
在代理的 didReceive 方法中处理自定义操作:
swift
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse
) async {
let userInfo = response.notification.request.content.userInfo
await MainActor.run {
switch response.actionIdentifier {
case "MARK_DONE":
if let itemId = userInfo["itemId"] as? String {
Task { await ItemStore.shared.markDone(id: itemId) }
}
case "SNOOZE":
scheduleSnooze(from: userInfo)
case "REPLY":
if let textResponse = response as? UNTextInputNotificationResponse {
Task { await MessageService.shared.reply(with: textResponse.userText) }
}
case UNNotificationDefaultActionIdentifier:
// 用户直接点击通知(非自定义按钮)。
navigateToDetail(userInfo: userInfo)
case UNNotificationDismissActionIdentifier:
// 用户关闭了通知。
break
default:
break
}
}
}
UNNotificationDefaultActionIdentifier 和 UNNotificationDismissActionIdentifier 是两个系统常量,分别表示"用户点击了通知本体"和"用户关闭了通知"。这两个 case 容易被遗漏。
2.6 远程推送:APNs 注册与处理
远程推送的第一步是向 APNs 注册以获取 device token:
swift
class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
// 始终将 token 上传到服务器------token 在每次启动时都可能变化。
Task { @MainActor in
await TokenService.shared.upload(token: token)
}
}
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
print("APNs 注册失败: \(error.localizedDescription)")
}
}
关键细节: device token 在每次 App 启动时都可能变化,必须在 didRegisterForRemoteNotificationsWithDeviceToken 中始终将最新 token 上传到服务器,而不是只上传一次。
注册调用:
swift
await MainActor.run {
UIApplication.shared.registerForRemoteNotifications()
}
静默推送(Silent Push)。 当推送负载中包含 content-available: 1 且不包含 alert 字段时,推送不会展示任何 UI,而是在后台唤醒 App 执行数据刷新。处理方式:
swift
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any]
) async -> UIBackgroundFetchResult {
await BackgroundNotificationHandler.shared.handle(userInfo: userInfo)
return .newData // 或 .noData / .failed
}
静默推送是实时数据同步(如消息到达、数据更新)的最佳方案------它不依赖 BGTaskScheduler 的机会主义调度,而是由 APNs 直接触发。但需要用户开启"后台 App 刷新",且系统对静默推送的频率有节流限制。
Live Activity 与通知的协同。 通过 Activity.request 启动的实时活动,可以在 App 不在前台时由服务器通过 APNs 直接更新甚至结束。实时活动使用 WidgetKit 和 SwiftUI 渲染 UI,ActivityKit 负责生命周期管理。通知与实时活动的组合是 iOS 实时信息展示的现代范式:通知负责"提醒",实时活动负责"持续展示"。
第三部分 后台任务:机制、类型与限制
主要目标: 深入理解 iOS 后台执行的"机会主义"调度模型,掌握 BGTaskScheduler、URLSession 后台传输、静默推送、beginBackgroundTask、BGContinuedProcessingTask 的使用方法与适用场景。
3.1 iOS 后台执行的本质约束
iOS 对后台执行的管理远比 Android 严格。理解一个核心事实:iOS 默认行为是在用户将 App 移到后台后不久就挂起它。 后台任务不是"保证执行",而是"尽可能在系统认为合适的时机执行"。
BGTaskScheduler 的调度是机会主义的 ------当你调用 submit(request) 时,iOS 不是在那个时间点安排唤醒,而是将一个"提示"加入队列。是否真正唤醒 App 由 dasd(Duet Activity Scheduler Daemon)决定,它考虑的因素包括:
- App 使用模式(Siri 预测模型):用户在多长时间内、在什么网络、什么时段打开此 App
- 电池电量和充电状态:电量低于约 20% 时,BGTask 请求会被静默丢弃
- 低电量模式 :丢弃所有 BGTask 请求,没有回退方案
- 热状态 :
ProcessInfo.thermalState >= .serious时丢弃 BGTask - 后台 App 刷新开关:用户在设置中可以全局或按 App 关闭
- Wi-Fi vs 蜂窝网络:影响需要网络连接的任务
earliestBeginDate 是一个下界提示,不是保证。系统可能在该时间之后很久才执行,甚至可能永远不执行。
不要用 BGTask 做这些事:
- 闹钟/提醒------BGAppRefreshTask 永远不会准时触发,应该使用
UNUserNotificationCenter配合UNCalendarNotificationTrigger - 实时同步("消息应在 10 秒内到达")------应该使用静默推送
- 用户期望在特定时间"自然发生"的任何事情
适合用 BGTask 做的事: 相册备份、内容预取、清理任务、分析/遥测数据的最佳努力上传。
3.2 BGTaskScheduler 的配置与注册
Info.plist 配置。 每个后台任务标识符必须在 Info.plist 的 BGTaskSchedulerPermittedIdentifiers 数组中声明,否则 submit(_:) 会抛出 BGTaskScheduler.Error.Code.notPermitted 错误。
xml
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.example.app.refresh</string>
<string>com.example.app.db-cleanup</string>
</array>
同时需要在 UIBackgroundModes 中声明对应的模式:
xml
<key>UIBackgroundModes</key>
<array>
<string>fetch</string> <!-- BGAppRefreshTask 必需 -->
<string>processing</string> <!-- BGProcessingTask 必需 -->
</array>
BGAppRefreshTask 对应 "Background fetch",BGProcessingTask 对应 "Background processing"。在 Xcode 中通过 target → Signing & Capabilities → Background Modes 勾选对应选项。
注册时机。 处理器必须在 App 启动完成之前注册。在 SwiftUI 中是 App.init()。
swift
@main
struct MyApp: App {
init() {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.example.app.refresh",
using: nil // nil = 默认后台队列。
) { task in
BackgroundTaskManager.shared.handleAppRefresh(
task: task as! BGAppRefreshTask
)
}
}
var body: some Scene {
WindowGroup { ContentView() }
}
}
SwiftUI 还提供了更简洁的声明式 API------.backgroundTask 场景修饰符。注意:该修饰符是对 BGTaskScheduler.register 的声明式封装,Info.plist 中的标识符声明和 BGTaskScheduler.submit 调度仍然必须存在。
swift
@main
struct ColorFeed: App {
var body: some Scene {
WindowGroup { /* ... */ }
.backgroundTask(.appRefresh("com.colorfeed.appRefresh")) {
await handleAppRefreshTask()
}
}
}
3.3 BGAppRefreshTask:轻量级后台刷新
BGAppRefreshTask 用于短时间的后台刷新任务,如获取最新的股价、新闻标题、消息摘要等。实际时间预算约为 25-30 秒(具体由系统动态决定)。
swift
func scheduleAppRefresh() {
let request = BGAppRefreshTaskRequest(identifier: "com.example.app.refresh")
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
do {
try BGTaskScheduler.shared.submit(request)
} catch {
print("调度 App 刷新失败: \(error)")
}
}
func handleAppRefresh(task: BGAppRefreshTask) {
// 关键:立即调度下一次刷新,避免任务链断裂。
scheduleAppRefresh()
let fetchTask = Task {
do {
let data = try await APIClient.shared.fetchLatestFeed()
await FeedStore.shared.update(with: data)
task.setTaskCompleted(success: true)
} catch {
task.setTaskCompleted(success: false)
}
}
// 设置过期处理器------系统可能在任务完成前终止它。
task.expirationHandler = {
fetchTask.cancel()
task.setTaskCompleted(success: false)
}
}
三个关键模式:
链式调度。 在 handleAppRefresh 的最开始 就调度下一次刷新。因为 BGAppRefreshTask 不会自动重复------每次执行后必须重新提交请求。如果放在任务末尾调度,当任务被系统提前终止时,下次刷新就不会被调度。
expirationHandler。 系统可能在任务完成前终止它。expirationHandler 让你优雅地取消进行中的工作并标记任务完成。不设置这个处理器可能导致系统在未来降低你的 App 的后台优先级。
异步包装。 应该将实际工作包装在 Task 中,利用 Swift 并发的异步能力。永远不要在任务处理器中执行同步阻塞操作。
3.4 BGProcessingTask:长时间后台处理
BGProcessingTask 用于可能需要较长时间的后台处理任务,如数据库维护、机器学习模型训练、大文件下载与同步。时间预算通常在 60 秒到 2-3 分钟 之间。Apple 曾指出任务获得的是 300 秒的 CPU 时间(而非钟表时间),多线程执行时每个线程分配的时间会更短。
对于真正长时间的后台任务 (如大文件传输),应使用 URLSessionConfiguration.background,由系统在 App 进程外管理,没有硬性时间上限。
与 BGAppRefreshTask 的关键区别:
| 维度 | BGAppRefreshTask | BGProcessingTask |
|---|---|---|
| 用途 | 轻量级内容刷新 | 耗时的维护/处理任务 |
| 时间预算 | ~30 秒 | ~60 秒至 2-3 分钟 |
| 网络需求 | 可选 | 可通过 requiresNetworkConnectivity 声明 |
| 电源需求 | 无 | 可通过 requiresExternalPower 声明 |
| 系统调度频率 | 较高 | 较低(更重的资源消耗) |
swift
func submitProcessingTaskRequest() {
let request = BGProcessingTaskRequest(
identifier: "com.example.app.db-cleanup"
)
request.requiresNetworkConnectivity = true
request.requiresExternalPower = true // 仅在充电时执行。
do {
try BGTaskScheduler.shared.submit(request)
} catch {
print("调度处理任务失败: \(error)")
}
}
requiresExternalPower = true 是一个重要的优化------对于计算密集型的维护任务,要求设备在充电状态下执行,既保证任务的完成率,也避免消耗用户电量。
3.5 URLSession 后台传输:大文件的系统级管理
URLSessionConfiguration.background 是 iOS 后台执行体系中一个极其重要的机制。它由系统在 App 进程外管理传输,没有硬性时间限制,可以跨 App 重启完成传输。
核心特点:
- 系统进程外执行 :传输由
nsurlsessiond守护进程管理,即使 App 被挂起或终止,传输仍然继续 - 自动唤醒 App:传输完成后,系统会自动在后台唤醒 App 并调用 AppDelegate 的 completion handler
- 系统调度时机:后台会话只会在条件有利时启动传输------例如设备连接 Wi-Fi 和电源时
- 需要唯一标识符:每个后台会话需要一个在 App 内唯一的标识符
swift
let config = URLSessionConfiguration.background(withIdentifier: "com.example.app.download")
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
let task = session.downloadTask(with: url)
task.resume()
重要提示: 不要为每个请求创建新的后台会话------后台会话对象比标准会话重得多,应该复用同一个会话。
3.6 BGContinuedProcessingTask:前台任务的后台延续(iOS 26+)
iOS 和 iPadOS 26 新增了 BGContinuedProcessingTask API,它让 App 即使在后台运行,也能完成用户从前台开始的任务 。这是对现有后台任务体系的重要补充------之前的 BGAppRefreshTask 和 BGProcessingTask 都是由系统决定何时启动的"后台自主任务",而 BGContinuedProcessingTask 是由用户在前台触发、系统允许其在后台继续执行的任务。
系统会在 Live Activity 中显示这个任务的进度,用户可以通过界面取消它。系统可能在资源受限时突然终止任务,因此实现必须通过 Progress 协议报告进度。
swift
BGTaskScheduler.shared.register("com.example.app.userTask") { task in
let task = task as! BGContinuedProcessingTask
var shouldContinue = true
task.expirationHandler = { shouldContinue = false }
task.progress.totalUnitCount = 100
task.progress.completedUnitCount = 0
while shouldContinue {
// 执行工作。
task.progress.completedUnitCount += 1
}
task.setTaskCompleted(success: true)
}
提交请求时需要提供标题和副标题(会展示给用户),以及策略配置:
swift
let request = BGContinuedProcessingTaskRequest(
identifier: "com.example.app.userTask",
title: "处理中",
subtitle: "正在导出照片..."
)
request.strategy = .fail
BGTaskScheduler.shared.submit(request)!
BGContinuedProcessingTask 的核心价值在于它支持进度报告 (task.progress),用户可以在系统 UI 中看到任务进度。这填补了"用户发起的前台任务"与"系统调度的后台任务"之间的空白。
3.7 beginBackgroundTask / endBackgroundTask:短暂后台时间
除了 BGTaskScheduler,iOS 还提供了 beginBackgroundTask API,为 App 在过渡到后台时提供额外的执行时间来完成关键工作。
swift
func handlePersistence() {
let app = UIApplication.shared
var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
backgroundTaskID = app.beginBackgroundTask(withName: "Finish Export") {
// 超时回调:系统即将终止你的后台时间。
app.endBackgroundTask(backgroundTaskID)
backgroundTaskID = .invalid
}
// 执行需要完成的关键工作。
saveState()
app.endBackgroundTask(backgroundTaskID)
backgroundTaskID = .invalid
}
适用场景: 这个 API 适合"短时间内必须完成的关键工作"------通常只有约 30 秒的时间窗口。如果未在到期前调用 endBackgroundTask,App 会被系统直接终止,而非挂起。这意味着 expirationHandler 不是"可选优化",而是强制要求 。必须在这个处理器中调用 endBackgroundTask,否则 App 会被系统杀掉。
3.8 SwiftUI 场景阶段与后台任务协同
SwiftUI 通过 @Environment(\.scenePhase) 提供了 App 生命周期状态的观察能力。三个阶段:
.active:App 正在前台运行,用户可见.inactive:过渡状态(如用户下拉通知中心、进入 App 切换器).background:App 不可见,系统可能在任何时候终止它
swift
struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase
var body: some View {
// ...
.onChange(of: scenePhase) { oldPhase, newPhase in
switch newPhase {
case .background:
store.persist() // 保存状态。
scheduleAppRefresh() // 调度下次后台刷新。
case .active:
Task { await store.refresh() } // 恢复工作。
default:
break
}
}
}
}
关键模式: 在 .background 阶段保存状态并调度后台任务,在 .active 阶段刷新数据。这与 BGAppRefreshTask 形成互补------前台主动刷新保证实时性,后台被动刷新保证数据不会太旧。
节能模式的处理。 低电量模式下,系统会自动禁用后台 App 刷新,可用的后台时间也会减少。App 应该检测这一状态并调整行为:
swift
func adaptToLowPowerMode() {
if ProcessInfo.processInfo.isLowPowerModeEnabled {
// 降低刷新频率,减少网络请求,推迟非关键任务。
}
}
第四部分 通知与后台任务的协同实践
主要目标: 理解通知与后台任务如何协同构建完整的数据同步与用户触达体系。
4.1 典型架构模式
一个典型的数据驱动 App 的架构模式:
- 前台 :用户打开 App →
.active阶段触发数据刷新 → 更新 UI - 后台刷新 :系统调度
BGAppRefreshTask→ 在后台拉取最新数据 → 更新本地缓存 - 实时推送:服务器通过 APNs 发送静默推送 → 后台唤醒 App → 同步数据 → 更新角标
- 用户触达:如果数据有关键更新 → 调度本地通知或通过远程推送展示提醒
- 交互处理 :用户点击通知或自定义操作 →
didReceive代理处理 → 路由到对应页面 - 大文件传输 :
URLSession后台传输 → 系统进程外管理 → 完成后自动唤醒 App 处理
这种架构确保:即使 App 不在前台,数据也能保持相对新鲜(后台刷新 + 静默推送 + URLSession 三重保障);用户不会错过关键信息(本地通知 + 远程推送双重触达);用户可以直接在通知上操作而无需打开 App(自定义操作)。
4.2 决策矩阵:何时使用哪种机制
| 需求 | 推荐机制 | 原因 | 前提条件 |
|---|---|---|---|
| 定时提醒用户 | UNCalendarNotificationTrigger |
精确准时 | 通知权限已授权 |
| 实时消息到达 | 静默推送 | APNs 直接触发 | 用户开启后台 App 刷新 |
| 周期性内容预取 | BGAppRefreshTask |
系统调度,省电 | 后台 App 刷新开启 |
| 重维护任务 | BGProcessingTask |
较长时间预算 | 设备充电且空闲 |
| 前台任务延续 | BGContinuedProcessingTask |
用户触发,进度报告 | iOS 26+ |
| 大文件下载/上传 | URLSessionConfiguration.background |
系统进程外,无硬性上限 | 系统在有利条件时执行 |
| 进入后台时保存 | beginBackgroundTask |
立即执行 | 约 30 秒窗口 |
| 用户触达 | 本地通知 / 远程推送 | 展示在锁屏/通知中心 | 通知权限已授权 |
| 交互响应 | didReceive 代理 |
处理点击、自定义操作 | 代理已设置 |
4.3 调试后台任务
后台任务的调试是开发中的常见痛点------你无法等待系统自然触发。Xcode 提供了 LLDB 命令来强制模拟后台任务的启动:
// 在 LLDB 中执行
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.example.app.refresh"]
这个命令会立即触发指定标识符的后台任务处理器,让你验证代码逻辑是否正确。注意这只是在调试环境下的模拟,不改变系统在真实环境下的调度决策。
第五部分 本课小结
通知体系的核心链路: 权限请求(在上下文中请求,考虑 provisional 授权)→ 内容构建(title/body/userInfo/threadIdentifier/interruptionLevel)→ 触发器选择(TimeInterval / Calendar / Location)→ 调度提交 → 代理处理(willPresent 前台展示 / didReceive 交互响应)→ 分类与自定义操作(注意 options 的前后台行为差异)。
后台任务的核心机制: BGTaskScheduler 的机会主义调度模型 → 系统根据电池、使用模式、热状态等决定是否执行 → BGAppRefreshTask(~30 秒)适合轻量刷新,BGProcessingTask(~60 秒至 2-3 分钟)适合重处理,URLSession 后台传输适合大文件,BGContinuedProcessingTask(iOS 26+)适合前台任务延续 → 必须在 App.init() 中注册 → 必须设置 expirationHandler → 必须链式调度下一次任务。
核心原则: 永远不要在首次启动时请求通知权限;永远不要在 BGTask 处理器中直接做工作(用 Task 包装);永远不要依赖 BGTask 准时执行(用 UNCalendarNotificationTrigger 做闹钟);永远在 BGAppRefreshTask 开始时调度下一次;永远在 beginBackgroundTask 到期前调用 endBackgroundTask(否则 App 会被终止)。
第六部分 习题与答案解读
每题后紧跟答案和解读,共15题。
习题1【单选】通知代理的设置时机
在 SwiftUI App 中,UNUserNotificationCenter.current().delegate 应该在什么时机设置?
A. 在 ContentView 的 onAppear 中
B. 在 App.init() 或 AppDelegate 的 didFinishLaunchingWithOptions 中
C. 在用户点击通知后的 didReceive 回调中
D. 在请求通知权限之后
答案:B
解读: 代理应尽早设置,避免遗漏通知。App.init() 比 application(_:didFinishLaunchingWithOptions:) 更早执行,是 SwiftUI 中设置代理的首选位置。在 onAppear 中设置太晚,在权限请求后设置也不够早。
习题2【多选】通知权限请求的正确做法
以下哪些是通知权限请求的正确做法?
A. 在 App 首次启动时立即调用 requestAuthorization
B. 将权限请求放在有明确通知收益的用户动作之后
C. 使用 .provisional 选项进行临时授权
D. 在系统对话框之前使用自定义预提示解释通知用途
E. APNs 注册应在通知授权被拒绝后停止
答案:B、C、D
解读: A 是常见错误------首次启动时请求权限,用户容易因为不理解用途而拒绝,且系统对话框只弹出一次。B 和 D 是推荐做法。C 是正确的折中策略。E 错误------APNs 注册与通知授权独立,即使用户拒绝展示权限,仍然可以接收静默推送。
习题3【判断】BGTaskScheduler 会在 earliestBeginDate 准时唤醒 App
答案:错误
解读: earliestBeginDate 只是一个下界提示,不是保证。iOS 的 dasd 服务根据 App 使用模式、电量、热状态、低电量模式等因素决定是否以及何时唤醒 App。任务可能在该时间之后很久才执行,甚至永远不执行。
习题4【单选】BGAppRefreshTask 的合理时间预算
BGAppRefreshTask 的时间预算大约是多久?
A. 5 秒
B. 30 秒
C. 5 分钟
D. 30 分钟
答案:B
解读: BGAppRefreshTask 用于短时间的后台刷新,时间预算约 30 秒。BGProcessingTask 的时间预算约 60 秒至 2-3 分钟,适合数据库维护、数据同步等重处理任务。
习题5【简答】为什么 BGAppRefreshTask 必须在任务开始时调度下一次刷新?
答案: 因为 BGAppRefreshTask 不会自动重复。每次执行后必须重新调用 BGTaskScheduler.shared.submit(request) 来提交下一次请求。如果放在任务末尾调度,当任务被系统提前终止(通过 expirationHandler)时,下一次刷新就不会被调度,后台刷新链就会断裂。
解读: 这是一个容易忽视但非常重要的实践模式。系统的 expirationHandler 可能在任何时候触发,放在最前面可以确保无论任务是否被中断,下次刷新都会被调度。
习题6【多选】适合使用 BGProcessingTask 的场景
以下哪些场景适合使用 BGProcessingTask?
A. 闹钟在特定时间触发
B. 数据库清理和日志轮转
C. 大文件下载与同步
D. 实时消息在 10 秒内到达
E. 机器学习模型训练
答案:B、C、E
解读: BGProcessingTask 适合耗时的后台处理任务。A 错误------闹钟应该用 UNCalendarNotificationTrigger。D 错误------实时同步应该用静默推送。注意: 如果是超大文件传输(如视频文件),应优先使用 URLSessionConfiguration.background,它没有硬性时间上限。
习题7【代码补全】设置 BGAppRefreshTask 的过期处理器
补全代码,为后台刷新任务添加过期处理器。
swift
func handleAppRefresh(task: BGAppRefreshTask) {
scheduleAppRefresh()
let fetchTask = Task {
do {
let data = try await APIClient.shared.fetchLatestFeed()
await FeedStore.shared.update(with: data)
task.setTaskCompleted(success: true)
} catch {
task.setTaskCompleted(success: false)
}
}
// 在此处补充过期处理器
task.____________________ = {
fetchTask.__________()
task.setTaskCompleted(success: __________)
}
}
答案:
swift
task.expirationHandler = {
fetchTask.cancel()
task.setTaskCompleted(success: false)
}
解读: expirationHandler 是 BGTask 的重要安全机制。当系统决定终止你的后台任务时,这个闭包会被调用。应该取消进行中的异步工作并标记任务完成,否则系统可能在未来降低你的 App 的后台优先级。
习题8【简答】通知分类中 options: [] 和 options: [.foreground] 的区别是什么?
答案: options: [](空选项)表示操作在后台执行,App 不会进入前台------适合"标记已读"、"删除"等不需要界面交互的操作。options: [.foreground] 表示操作会打开 App 并进入前台------适合需要展示界面或进行复杂交互的操作。
解读: 这个选择影响用户的操作体验。如果"标记完成"这样的简单操作需要打开 App,会增加不必要的步骤。反之,如果"回复消息"这样的操作在后台静默完成,用户无法确认操作结果。
习题9【判断】APNs 注册和通知展示授权是同一件事
答案:错误
解读: 两者是独立的。通知展示授权(requestAuthorization)控制的是"是否允许通知出现在用户界面上"。APNs 注册(registerForRemoteNotifications)控制的是"是否向 APNs 注册以接收远程推送"。即使用户拒绝了通知展示权限,App 仍然可以注册 APNs 并接收静默推送。
习题10【简答】SwiftUI 中如何将通知响应数据传递到视图层进行页面导航?
答案: 核心思路是通过一个共享的可观察对象桥接 AppDelegate 的代理回调和 SwiftUI 视图:
- 创建一个
@Observable类(如DeepLinkRouter)作为共享路由状态。 - 在 AppDelegate 的
didReceive代理方法中,解析userInfo,更新路由状态。 - 在 SwiftUI 的
App结构体中,通过@State持有该路由对象,并通过.environment()注入视图层。 - 在 ContentView 中通过
@Environment读取路由状态,驱动导航。
解读: 这个问题涉及 SwiftUI 与 UIKit 桥接的核心模式。AppDelegate 负责接收系统回调,SwiftUI 的 @Observable 负责状态管理,@Environment 负责在视图树中传递状态。
习题11【多选】影响 BGTaskScheduler 调度的系统因素
以下哪些因素会影响 BGTaskScheduler 是否以及何时执行后台任务?
A. App 的使用频率和时段
B. 电池电量和充电状态
C. 低电量模式
D. 设备热状态
E. 用户是否开启了"后台 App 刷新"
答案:A、B、C、D、E
解读: 所有这些因素都由 dasd 服务评估。电量低于约 20% 时任务被静默丢弃;低电量模式丢弃所有 BGTask 请求;thermalState >= .serious 时丢弃任务;用户可以在设置中全局或按 App 关闭后台刷新。
习题12【简答】beginBackgroundTask 和 BGTaskScheduler 的区别是什么?各自适用什么场景?
答案:
beginBackgroundTask/endBackgroundTask:
- 为 App 在过渡到后台时提供短暂的额外执行时间
- 通常只有约 30 秒的时间窗口
- 适合"短时间内必须完成的关键工作"------保存状态、断开连接、完成文件写入
- 如果未在到期前调用
endBackgroundTask,App 会被系统终止
BGTaskScheduler:
- 由系统在合适的时机调度执行,不是立即执行
- 时间预算从 30 秒(BGAppRefreshTask)到 2-3 分钟(BGProcessingTask)
- 适合"可以在后台稍后执行"的周期性任务
解读: 两者的核心区别在于"谁决定何时执行"。选择时考虑:工作是"必须现在完成"还是"可以稍后完成"。
习题13【代码补全】注册通知分类
补全代码,注册一个包含"标记完成"和"稍后提醒"两个操作的"待办"通知分类。
swift
func registerTodoCategory() {
let markDone = UNNotificationAction(
identifier: "MARK_DONE", title: "标记完成", options: []
)
let snooze = UNNotificationAction(
identifier: "SNOOZE", title: "稍后提醒", options: []
)
let category = UNNotificationCategory(
identifier: ______________,
actions: ______________,
intentIdentifiers: [],
options: []
)
UNUserNotificationCenter.current().setNotificationCategories([category])
}
答案:
swift
let category = UNNotificationCategory(
identifier: "TODO_ACTIONS",
actions: [markDone, snooze],
intentIdentifiers: [],
options: []
)
解读: 分类的 identifier 在创建本地通知时通过 content.categoryIdentifier 引用。通知负载中的 category 值必须与注册的 identifier 完全匹配。分类应在 App 启动时注册。
习题14【设计题】设计一个"新闻阅读器"App 的通知与后台任务方案
请为"新闻阅读器"App 设计一套完整的通知与后台任务方案。
答案示例:
权限请求时机: 在用户第一次收藏文章或设置"关注话题"之后请求。先用自定义预提示解释"当您关注的话题有新文章时通知您",再触发系统权限对话框。
后台任务类型: 使用 BGAppRefreshTask(标识符 com.newreader.app.refresh),在 .background 阶段调度,earliestBeginDate 设为 30 分钟后。任务中调用 API 拉取已关注话题的最新文章列表,更新本地缓存和 badge 数字。
实时新闻推送: 对于突发的重大新闻,使用远程推送展示通知。对于非紧急的文章更新,使用静默推送在后台同步数据。
通知自定义操作: "稍后阅读"(options: [])、"分享"(options: [.foreground])。使用 threadIdentifier: "news-updates" 按话题分组。使用 .timeSensitive 仅对突发事件。
解读: 这道题综合考察了通知权限策略、后台任务类型选择、远程推送与静默推送的区分。核心设计原则是"尊重用户"。
习题15【综合论述】为什么 iOS 的后台执行被称为"机会主义"的?开发者应该如何应对这种约束?
答案: iOS 的后台执行被称为"机会主义"的,是因为 BGTaskScheduler 不对任务的执行时间做出任何保证。当你提交一个 BGTaskRequest 并设置 earliestBeginDate 时,这只是向系统提供了一个"建议执行时间"的提示。实际是否执行、何时执行,由 dasd 根据多维度因素综合决定。
开发者的应对策略:
- 不依赖 BGTask 做时间敏感的事情。 闹钟、提醒、定时任务应该用
UNCalendarNotificationTrigger。 - 使用静默推送替代 BGTask 做实时同步。 静默推送由 APNs 直接触发。
- 使用 URLSession 后台传输处理大文件。 由系统进程外管理,无硬性时间上限。
- 为 BGTask 设计"可降级"的任务。 如果任务没有在期望的时间执行,App 仍然应该正常工作。
- 始终在任务开始时调度下一次。 避免任务链断裂。
- 正确处理 expirationHandler。 系统可能在任何时候终止你的任务。
- 适配低电量模式。 检测
ProcessInfo.processInfo.isLowPowerModeEnabled,降低刷新频率。
解读: iOS 的后台限制不是缺陷,而是设计选择------它保护了用户体验和电池续航。开发者的任务是在这些约束下找到最佳方案。
第七部分 知识点总结
1. 通知体系核心链路
| 环节 | 核心 API | 关键要点 |
|---|---|---|
| 权限请求 | requestAuthorization(options:) |
上下文请求,考虑 provisional |
| 内容构建 | UNMutableNotificationContent |
title/body/userInfo/threadIdentifier/interruptionLevel |
| 触发器 | UNTimeIntervalNotificationTrigger 等 |
时间间隔 / 日历 / 位置 |
| 调度提交 | UNUserNotificationCenter.add(request) |
使用稳定 identifier 管理 |
| 前台展示 | willPresent 代理方法 |
不实现则前台通知被静默抑制 |
| 交互处理 | didReceive 代理方法 |
处理默认点击、关闭、自定义操作 |
| 分类注册 | UNNotificationCategory |
在 App 启动时注册,identifier 精确匹配 |
| 移除管理 | removePending/DeliveredNotifications |
待投递 vs. 已投递 |
2. 后台任务类型对比
| 类型 | 时间预算 | 适用场景 | 调度方式 |
|---|---|---|---|
BGAppRefreshTask |
~30 秒 | 内容刷新、预取 | 机会主义 |
BGProcessingTask |
~60 秒至 2-3 分钟 | 数据库维护、数据同步 | 机会主义 |
BGContinuedProcessingTask (iOS 26+) |
不定 | 前台任务的延续 | 用户触发 |
URLSessionConfiguration.background |
无硬性上限 | 大文件下载/上传 | 系统进程外管理 |
beginBackgroundTask |
~30 秒 | 保存状态、完成关键写入 | 立即 |
| 静默推送 | ~30 秒 | 实时数据同步 | APNs 触发 |
3. Swift 6 通知代理的并发策略
| 方法 | 隔离策略 | 原因 |
|---|---|---|
willPresent |
MainActor(不标记 nonisolated) | 涉及 UI 展示决策,需运行在 MainActor 上 |
didReceive |
nonisolated,内部跳回 MainActor |
系统在任意后台线程回调 |
4. BGTaskScheduler 使用要点
- Info.plist 配置 :
BGTaskSchedulerPermittedIdentifiers+UIBackgroundModesBGAppRefreshTask→fetch模式BGProcessingTask→processing模式
- 注册时机 :
App.init()或didFinishLaunchingWithOptions - 调度模式:在任务开始时调度下一次
- 过期处理 :必须设置
expirationHandler - 异步包装 :用
Task包装实际工作,不阻塞处理器
5. 影响后台执行的关键因素
| 因素 | 影响 |
|---|---|
| 电量 < ~20% | BGTask 请求被静默丢弃 |
| 低电量模式 | 丢弃所有 BGTask 请求 |
| 热状态 >= .serious | 丢弃 BGTask |
| App 使用频率低 | dasd 优先级权重降低 |
| 后台 App 刷新关闭 | 不执行任何 BGTask |
6. 决策矩阵:何时使用哪种机制
| 需求 | 推荐机制 | 原因 | 前提条件 |
|---|---|---|---|
| 定时提醒用户 | UNCalendarNotificationTrigger |
精确准时 | 通知权限已授权 |
| 实时消息到达 | 静默推送 | APNs 直接触发 | 用户开启后台 App 刷新 |
| 周期性内容预取 | BGAppRefreshTask |
系统调度,省电 | 后台 App 刷新开启 |
| 重维护任务 | BGProcessingTask |
较长时间预算 | 设备充电且空闲 |
| 前台任务延续 | BGContinuedProcessingTask |
用户触发,进度报告 | iOS 26+ |
| 大文件下载/上传 | URLSessionConfiguration.background |
系统进程外,无硬性上限 | 系统在有利条件时执行 |
| 进入后台时保存 | beginBackgroundTask |
立即执行 | 约 30 秒窗口 |
| 用户触达 | 本地通知 / 远程推送 | 展示在锁屏/通知中心 | 通知权限已授权 |
| 交互响应 | didReceive 代理 |
处理点击、自定义操作 | 代理已设置 |
7. 调试技巧
// 在 LLDB 中强制模拟后台任务触发
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.example.app.refresh"]
8. 行动清单
- 将通知权限请求从首次启动移到有上下文收益的用户动作之后
- 在
App.init()中设置UNUserNotificationCenterDelegate - 为关键通知类型注册
UNNotificationCategory和自定义操作 - 在
.background场景阶段调度BGAppRefreshTask - 为所有 BGTask 设置
expirationHandler - 在 BGAppRefreshTask 开始时调度下一次刷新
- 用静默推送处理实时数据同步
- 用
URLSessionConfiguration.background处理大文件传输 - 检测并适配低电量模式
- 用 LLDB 命令调试后台任务
- 考虑 iOS 26 的
BGContinuedProcessingTask处理前台任务延续 - 使用决策矩阵选择正确的机制组合
9. 一句话总结
SwiftUI 通知与后台任务的核心是"组合多个机制覆盖不同场景":前台用 scenePhase 主动刷新,后台用 BGTaskScheduler 被动刷新,大文件用 URLSession 后台传输,实时用静默推送同步,用户触达用本地/远程通知,交互响应用代理方法处理------每种机制各司其职,共同构成完整的后台数据与用户触达体系。