【SwiftUI入门练中学】第17课 通知与后台任务

通知与后台任务

主要目标: 系统掌握 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 的架构模式:

  1. 前台 :用户打开 App → .active 阶段触发数据刷新 → 更新 UI
  2. 后台刷新 :系统调度 BGAppRefreshTask → 在后台拉取最新数据 → 更新本地缓存
  3. 实时推送:服务器通过 APNs 发送静默推送 → 后台唤醒 App → 同步数据 → 更新角标
  4. 用户触达:如果数据有关键更新 → 调度本地通知或通过远程推送展示提醒
  5. 交互处理 :用户点击通知或自定义操作 → didReceive 代理处理 → 路由到对应页面
  6. 大文件传输 :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 视图:

  1. 创建一个 @Observable 类(如 DeepLinkRouter)作为共享路由状态。
  2. 在 AppDelegate 的 didReceive 代理方法中,解析 userInfo,更新路由状态。
  3. 在 SwiftUI 的 App 结构体中,通过 @State 持有该路由对象,并通过 .environment() 注入视图层。
  4. 在 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 根据多维度因素综合决定。

开发者的应对策略:

  1. 不依赖 BGTask 做时间敏感的事情。 闹钟、提醒、定时任务应该用 UNCalendarNotificationTrigger。
  2. 使用静默推送替代 BGTask 做实时同步。 静默推送由 APNs 直接触发。
  3. 使用 URLSession 后台传输处理大文件。 由系统进程外管理,无硬性时间上限。
  4. 为 BGTask 设计"可降级"的任务。 如果任务没有在期望的时间执行,App 仍然应该正常工作。
  5. 始终在任务开始时调度下一次。 避免任务链断裂。
  6. 正确处理 expirationHandler。 系统可能在任何时候终止你的任务。
  7. 适配低电量模式。 检测 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 + UIBackgroundModes
    • BGAppRefreshTask → 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. 行动清单

  1. 将通知权限请求从首次启动移到有上下文收益的用户动作之后
  2. 在 App.init() 中设置 UNUserNotificationCenterDelegate
  3. 为关键通知类型注册 UNNotificationCategory 和自定义操作
  4. 在 .background 场景阶段调度 BGAppRefreshTask
  5. 为所有 BGTask 设置 expirationHandler
  6. 在 BGAppRefreshTask 开始时调度下一次刷新
  7. 用静默推送处理实时数据同步
  8. 用 URLSessionConfiguration.background 处理大文件传输
  9. 检测并适配低电量模式
  10. 用 LLDB 命令调试后台任务
  11. 考虑 iOS 26 的 BGContinuedProcessingTask 处理前台任务延续
  12. 使用决策矩阵选择正确的机制组合

9. 一句话总结

SwiftUI 通知与后台任务的核心是"组合多个机制覆盖不同场景":前台用 scenePhase 主动刷新,后台用 BGTaskScheduler 被动刷新,大文件用 URLSession 后台传输,实时用静默推送同步,用户触达用本地/远程通知,交互响应用代理方法处理------每种机制各司其职,共同构成完整的后台数据与用户触达体系。

相关推荐
承渊政道2 小时前
Linux系统学习【线程概念与控制核心知识详解】
linux·运维·vscode·学习·ubuntu·线程创建 终止 等待
励志不掉头发的内向程序员2 小时前
【从零写一个CAD 04】中键拖动平移:抓住一个点,让它一直待在鼠标底下
开发语言·c++·qt·学习·系统架构·计算机外设
UIU1142 小时前
同一个变量在两个 .c 文件里类型不一致,程序会怎样?
c++·学习·c#
GEO从入门到精通10 小时前
课程怎么选?2026年GEO学习资源全梳理
学习
优化Henry11 小时前
LTE 载波频率与频点配置详解
运维·网络·学习·5g·信息与通信
xiaomu0012311 小时前
高湿环境床垫湿度管理技术框架:湿度阈值、排湿结构与材料选型
经验分享·学习·学习方法
zhangrelay11 小时前
ROS项目设计案例智能大模型正经乱答案例
linux·笔记·学习·ubuntu·机器人
probex_11 小时前
学习笔记:知识图谱是什么,在测试里能干什么
笔记·学习·知识图谱
ii_best12 小时前
按键精灵手机端开发安卓版实战:手写一个可最小化、可拖动的「运行日志悬浮窗」(附完整 源码 + 踩坑记录)
android·ios·智能手机·自动化·ai编程·按键精灵