3、有限长度后台任务:beginBackgroundTask方法详解、expirationHandler的使用、任务结束后的清理工作

好,咱们进入第三个核心知识点。

前面我们聊了后台任务的整体框架,也知道了iOS给我们的时间其实很有限。那具体怎么申请这段时间?怎么保证任务不被系统暴力杀死?任务超时了怎么办?

这一节,我把这些细节掰开揉碎讲清楚。

3.1 beginBackgroundTask:申请时间的入口

说白了,beginBackgroundTask(withName:expirationHandler:) 就是向系统喊一声:“嘿,我有个事没干完,再给我点时间。”

系统听到后,会给你一个 后台任务标识符(UIBackgroundTaskIdentifier)。这个标识符很重要,后面结束任务时要用它来“还债”。

我个人的习惯是,在开始一个可能被中断的耗时操作前,立刻调用这个方法。比如文件下载、数据同步、数据库写入等。

来看一个最基础的调用示例:

// 声明一个属性来存储任务标识符
var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid

// 开始后台任务
backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "数据同步任务") {
    // 这里是 expirationHandler,我们稍后细讲
    UIApplication.shared.endBackgroundTask(self.backgroundTaskID)
    self.backgroundTaskID = .invalid
}

// 执行你的耗时操作
syncData()

嗯,这里要注意:beginBackgroundTask 必须在主线程调用。虽然苹果文档没强制,但我在项目中遇到过在子线程调用导致崩溃的情况。所以,保险起见,放在主线程。

3.2 expirationHandler:系统给你的“最后通牒”

为什么要有 expirationHandler

你想想看,系统给你后台时间,但你不能无限占用。当时间快用完时,系统会调用这个闭包,告诉你:“兄弟,赶紧收拾东西,我要杀进程了。”

这个闭包是 强制性的。你不处理?那系统会在超时后直接终止你的应用,而且不会给你任何保存数据的机会。

我曾经在一个项目中,因为忘记在 expirationHandler 里结束任务,导致用户数据丢失。那次教训让我养成了一个习惯:每次写 beginBackgroundTask,立刻把 expirationHandler 写好

正确的做法是这样的:

backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "下载任务") {
    // 1. 停止当前正在进行的操作
    self.downloadTask?.cancel()
    
    // 2. 保存当前进度(如果有必要)
    self.saveProgress()
    
    // 3. 标记任务已过期
    self.isTaskExpired = true
    
    // 4. 结束后台任务
    UIApplication.shared.endBackgroundTask(self.backgroundTaskID)
    self.backgroundTaskID = .invalid
}
⚠️ 重要警告:

expirationHandler 里不要做耗时操作!系统只给你几秒钟的时间来清理。如果你在里面又开了一个网络请求,那大概率会 crash。

另外,expirationHandler 不一定在主线调用,所以 UI 操作要小心。

3.3 任务结束后的清理工作

任务正常完成了,或者被 expirationHandler 强制结束了,都需要做清理。

清理的核心就两个动作:

  1. 调用 endBackgroundTask(_:):告诉系统我完事了,你可以回收时间了。
  2. 将标识符重置为 .invalid:避免后续误用。

我见过不少新手犯的错误:只调用了 beginBackgroundTask,却忘了调用 endBackgroundTask。结果就是系统一直认为你有任务在执行,最终导致应用被杀死。

来看一个完整的生命周期管理示例:

class DataSyncManager {
    private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
    
    func startSync() {
        // 申请后台时间
        backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "数据同步") {
            // 超时处理
            self.cleanup()
        }
        
        // 执行同步
        performSync { [weak self] success in
            guard let self = self else { return }
            // 同步完成,清理
            self.cleanup()
        }
    }
    
    private func cleanup() {
        // 1. 取消所有未完成的操作
        // 2. 保存状态
        // 3. 结束后台任务
        if backgroundTaskID != .invalid {
            UIApplication.shared.endBackgroundTask(backgroundTaskID)
            backgroundTaskID = .invalid
        }
    }
}

💡 我的小技巧:

我会把清理逻辑封装成一个独立的方法(比如上面的 cleanup()),这样无论是正常结束还是超时结束,都调用同一个方法,避免遗漏。

3.4 几个容易踩的坑

常见问题 后果 解决方案
忘记调用 endBackgroundTask 应用被系统杀死 在 expirationHandler 和任务完成回调中都调用
在 expirationHandler 里做耗时操作 应用卡死或崩溃 只做必要的保存和清理,不要开新任务
多次调用 beginBackgroundTask 消耗系统资源,可能被拒绝 确保每次 begin 都有对应的 end
在子线程调用 beginBackgroundTask 偶发崩溃 强制在主线程调用

3.5 实际项目中的最佳实践

我个人习惯在 AppDelegate 里维护一个后台任务的管理器。这样所有后台任务的状态都集中管理,方便调试。

另外,我会在 applicationDidEnterBackground 里检查当前是否有未完成的任务。如果有,就申请后台时间继续执行;如果没有,就直接返回。

func applicationDidEnterBackground(_ application: UIApplication) {
    // 检查是否有未完成的任务
    guard hasPendingTasks else { return }
    
    // 申请后台时间
    backgroundTaskID = application.beginBackgroundTask(withName: "后台清理") {
        // 超时处理
        self.forceCleanup()
    }
    
    // 执行清理
    performCleanup()
}

嗯,这里还要提醒一点:不要过度依赖后台任务。iOS 给的时间很有限(大约30秒),如果你需要更长时间,就得考虑使用 URLSession 的后台下载或者 BGTaskScheduler。这些我们后面章节会详细讲。

总结一下这一节的核心:

  • beginBackgroundTask 是申请后台时间的入口,必须配对使用 endBackgroundTask
  • expirationHandler 是系统给你的最后机会,一定要处理
  • 清理工作要彻底,标识符要重置为 .invalid
  • 封装清理逻辑,避免重复代码

下一节,我们会深入 URLSession 的后台下载配置,那才是真正能处理大文件下载的利器。