了解iOS的后台任务执行的各种方式

语言: CN / TW / HK

本文主要内容来自WWDC 2019: Advances in App Background Execution

Apple 中很多后台执行都是用户从前台进入后台之后,依然保持了一段时间的活跃,最常见的比如使用UIApplication.shared.beginBackgroundTask() 来申请更长的代码执行时间,同时不同的线程后台申请执行任务的时间也不同,这一点在我的另一篇文章中亦有深入的探索。

在iOS 13.0之后,Apple出了新的框架 BackgroundTasks ,这个和前者是有很大的不同的,那就是它并不会从前台到后台之后立马执行,而是会规划后台任务执行的时间,系统自动选择合适的时间来执行该任务,比如手机充电或者闲置的时候

它一共提供了两个Task来执行,分别是 BGProcessingTaskBGAppRefreshTask

纵观iOS的后台任务的机制,基本可以分为两类,一类是立即执行的后台任务,比如从前台到后台申请后台执行时间完成前台任务、收到后台推送处理内容等等,这一类是立即执行后台任务的类型,还有一类就是延时执行的后台任务,由系统选择合适的时间来执行任务。

立即执行的后台任务

App如何进入立即执行后台任务的状态呢?也就是立即进入 Background 状态,一般是两种方式:

  • App请求:App想完成某些任务比如下载等等,所以向系统申请后台执行时间
  • 事件触发:App需要执行后台任务来响应某些事件,比如消息推送等等

下面以Message App为例,它涉及到诸多场景都是这种立即执行后台任务的情况。

Send Messages

当服务器响应很慢的时候,用户可能发送了消息之后就将手机锁屏了,这种情况需要去确保消息在后台状态下也可以成功发送。 这种在后台完成前台的任务还有一些场景,比如保存文件到磁盘中、完成用户请求等等。

这种在前台进入后台后需要额外的时间来执行任务的场景需要使用 beginBackgroundTask(expirationHandler:) 方法,如果app是在Extension中运行的话,那就需要使用 ProcessInfo.performExpiringActivity(withReason:using:) 方法,代码实例如下:

```swift func send(_ message: Message) { let sendOperation = SendOperation(message: message) var identifier: UIBackgroundTaskIdentifier!

identifier = UIApplication.shared.beginBackgroundTask(expirationHandler: {
    sendOperation.cancel()
    postUserNotification("Message not sent, please resend")
 // Background task will be ended in the operation's completion block below
 })

sendOperation.completionBlock = {
    UIApplication.shared.endBackgroundTask(identifier)
}
operationQueue.addOperation(sendOperation)

} ```

注意 beginBackgroundTaskendBackgroundTask 需要成对使用。也有可能在系统分配到时间内依然无法完成改任务,那么这个时候就会执行 expirationHandler ,在这里将做失败处理,在样例代码中发送了一条本地通知,提醒用户消息并未成功发送!

Phone Calls

当有人给你打电话的时候需要向用户呈现来电提醒,这个场景使用了 VoIP push notifications

这个API,这是一种特殊的推送可以启动App,来让用户接听电话,需要在PK推送注册中注册VoIP类型:

swift func registerForVoIPPushs() { self.voipRegistry = PKPushRegistry(queue: nil) self.voipRegistry.delegate = self self.voipRegistry.desiredPushTypes = [.voIP] }

但是在2019年有一个新的改进,那就是在 didReceiveIncomingPush 回调中必须使用 CallKit 框架来报告来电,不然系统将停止杀死App。如果一直无法处理该通知,那么系统可能在接收到 VoIP 推送之后再也不会启动App了。那么新的改变如下:

```swift let provider = CXProvider(configuration: providerConfiguration)

func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { if type == .voIP { if let handle = payload.dictionaryPayload["handle"] as? String { let callUpdate = CXCallUpdate() callUpdate.remoteHandle = CXHandle(type: .phoneNumber, value: handle)

        let callUUID = UUID()
        provider.reportNewIncomingCall(with: callUUID,
                                       update: callUpdate) { _ in
            completion()
        }
        establishConnection(for: callUUID)
    }
}

} ```

有几点需要注意的细节:

  • 推送中带有足够的来电人的信息,可以用来展示UI界面。
  • apns-expiration 推送设置为0或者很小的值,这样来电之后的通知也会是相关通话的通知,而不是几分钟甚至更久之后,来电结束了才收到通知。
  • 使用标准推送(standard push),不用全屏推送,无需在呼叫UI中全屏展现通知。
  • 还可以使用 Notification Service Extension 来修改内容。

Muted Threads (静音群组)

像微信一样,有多个联系人,以及群聊的时候,有时用户不想让某些群聊的消息有提醒,但是进入App中之后又想要立即查看信息,只是不想每次都震动设备并收到通知。为了达到这一点,我们需要 Background Pushes 后台推送机制。这个机制可以告诉设备有新数据可用而无需提醒用户。

这就需要设置推送 content-available: 1, 而不是 alert, sound, 或者 badge ,从而实现静默推送的目的,系统收到通知之后会选择一个合适的时间来启动app来下载相关内容,时间线如下:

%E6%88%AA%E5%B1%8F2023-03-24_00.28.04.png

同时后台推送功能增加了一些新的机制:

  • apns-priority = 5 ,必须优先级设置为5,不然系统无法后台启动app。
  • 比如设置 apns-push-type = background ,这对 watchOS 是必须的,但是Apple建议所有平台对于后台静默推送都采用这种方式。

Download Past Attachments(下载之前的附件)

如果用户在一台新的设备上登录了它的账户,需要立即下载回话列表以及最近的消息记录,但是对玉一些很老的内容,如果可以在设备充电或者闲置时下载的话,何必在前台下载呢?所以这就需要推迟后台执行下载的时间,实现方式是 Discretionary Background URL Session

```swift let config = URLSessionConfiguration.background(withIdentifier: "com.app.attachments") let session = URLSession(configuration: config, delegate: ..., delegateQueue: ...)

// 设置系统自主性:依据性能来决定开始时间 config.discretionary = true

// 设置时间间隔 config.timeoutIntervalForResource = 24 * 60 * 60 config.timeoutIntervalForRequest = 60

// 创建请求 var request = URLRequest(url: url) request.addValue("...", forHTTPHeaderField: "...") let task = session.downloadTask(with: request)

// 设置请求安排的最早时间,这里是两小时后 task.earliestBeginDate = Date(timeIntervalSinceNow: 2 * 60 * 60)

// 设置预计的工作量 task.countOfBytesClientExpectsToSend = 160 task.countOfBytesClientExpectsToReceive = 4096 task.resume() ```

延时的后台任务

以上都是一些立即执行的后台任务,接下来要介绍的不是马上就执行的后台任务,而是延迟执行的后台任务,会在设备闲置或者充电的时候统一来进行任务的处理:

%E6%88%AA%E5%B1%8F2023-03-23_23.23.25.png

Background Processing Task的特点

注意,这里是的数据来自Apple的WWDC视频,所以应该要相信它的准确性。

  • 系统会在合适的时候分配几分钟的运行时间
    • 执行可推迟的可维护性工作:同步数据、备份、本地数据库清理等等
    • Core ML的训练等等
  • 对于计算密集型的操作可以关掉 CPU 的监控使后台任务充分利用硬件性能
其实这就是为了后台进行模型训练来特意整出来的!!!
  • 在前台申请过,那么在后台就可以执行

在使用 BGProcessingTaskRequest 时有几个属性需要注意:

  • requiresNetworkConnectivity

    如果在执行后台任务的时候需要使用网络,而不仅仅是本地的操作,那属性就要设置为 true 。

  • requiresExternalPower

    后台任务执行计算密集型的操作的时候,想要取消 CPU 的监控,可以设置改属性为 true 来实现这一点。

Background App Refresh Task的特点

新的API,后台刷新任务。

  • 该任务提供30秒的运行时间
  • 用于获取新内容使App保持最新的数据状态
  • 后台刷新任务执行的时机取决于用户使用App的方式

    如果用户在早中晚使用App,那么它可以在使用之前启动该App的后台任务来获取最新数据。

%E6%88%AA%E5%B1%8F2023-03-23_18.39.41.png

使用频率不高的情况:会在启动之前,调用后台刷新任务

还有一点要注意的是使用新的API之后,不要使用旧的API了,旧的API已经被废弃了:

swift UIApplication.setMinimumBackgroundFetchInterval(_:) UIApplicationDelegate.application(_:performFetchWithCompletionHandler:)

使用BackgroundTasks的原理

%E6%88%AA%E5%B1%8F2023-03-23_19.22.14.png

App以及它的Extension都可以创建 BGTask ,并将其提交给 BGTaskScheduler ,它是一个全局的管理后台任务的进程,它会在合适的时候选择执行相应的Task,唤醒App并在后台启动它执行对应的任务,完成任务之后,需要调用 setTaskCompleted 方法,将任务标记为完成并挂起App。

同时Extension提交的任务只会唤醒主App,也就是说 BackgroundTask 永远由主App来执行。 系统也可能选择后台启动App来同时执行多个任务,但是系统只会按照每次启动来分配一定的时间来同时执行任务,并不会按照任务来单独分配时间。

使用Background Task的流程

这个在Apple的文档中讲述的非常得清楚:**Using background tasks to update your app主要是有几个重点的步骤:

  1. 在项目的 capabilities 中开启想要的后台任务: BGAppRefreshTask 以及 BGProcessingTask

Untitled.png

  1. 在Target的Info中添加[BGTaskSchedulerPermittedIdentifiers](http://developer.apple.com/documentation/bundleresources/information_property_list/bgtaskschedulerpermittedidentifiers) 中相应的identifier字符串来标识task,后续需要在代码中注册

Untitled 1.png

  1. 使用设置好的Identifier注册BGTaskScheduler

```swift func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { let feedVC = (window?.rootViewController as? UINavigationController)?.viewControllers.first as? FeedTableViewController feedVC?.server = server

PersistentContainer.shared.loadInitialData()

// MARK: Registering Launch Handlers for Tasks
BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.example.apple-samplecode.ColorFeed.refresh", using: nil) { task in
    // Downcast the parameter to an app refresh task as this identifier is used for a refresh request.
    self.handleAppRefresh(task: task as! BGAppRefreshTask)
}

BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.example.apple-samplecode.ColorFeed.db_cleaning", using: nil) { task in
    // Downcast the parameter to a processing task as this identifier is used for a processing request.
    self.handleDatabaseCleaning(task: task as! BGProcessingTask)
}

return true

} ```

  1. 在合适的时机提交相应的Request

```swift func applicationDidEnterBackground(_ application: UIApplication) { scheduleAppRefresh() }

// MARK: - Scheduling Tasks

func scheduleAppRefresh() { let request = BGAppRefreshTaskRequest(identifier: "com.example.apple-samplecode.ColorFeed.refresh") request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // Fetch no earlier than 15 minutes from now

do {
    try BGTaskScheduler.shared.submit(request)
} catch {
    print("Could not schedule app refresh: \(error)")
}

} ```

另外还有一些额外需要注意的点:

  • 不要设置 earliestBeginDate 太远,最好在一周之内
  • 确保在设备被锁住的时候,依然可以访问文件

    swift FileProectionType.completeUntilFirstUserAuthentication

  • UIScene apps较为特殊,需要用到UIApplication.requestSceneSessionRefresh(_:) API

  • BGTaskScheduler.submit 为了使用的简洁是设置为一个阻塞的同步调用,所以如果要在启动的时候提交,那么应该在 background queue 中使用, 而非 main queue。

总结:后台任务如何选择?

既然上面已经描述了这么多的后台任务,那么究竟该如何选择呢?以及是几种常见的场景下的选择,具体Case来自Apple的官方文档:Choosing Background Strategies for Your App

1、在后台继续前台任务

使用[beginBackgroundTask(withName:expirationHandler:)] 申请时间继续执行前台的任务。

2、延迟执行计算密集型工作

使用 [BGProcessingTask] ,由系统来决定最佳的任务执行时间点。

3、更新App中的内容

如果App是周期性的从服务器拉取数据,那就可以使用 [BGAppRefreshTask] ,由系统选择最佳的任务执行时间点,并且这种方式可以提供最多30秒的后台执行时间。

4、使用后台推送唤醒App

使用后台推送在后台静默唤醒App,不涉及 alert、sound 以及 badge。这个上述已经介绍过了,就不赘述了。

5、使用后台推送通知用户

如果app需要在后台执行任务,并且还要向用户展示通知,那么可以使用 Notification Serverce Extension 。在收到推送通知之后,这个 service extension 会被唤醒,并且通过 [didReceive(_:withContentHandler:)] 来申请后台执行时间。

当 extension 完成任务之后,它必须调用 content handler 闭包来处理给用户的内容。extension 的执行时间也是有限的。

问题:App想长时间在后台运行怎么办?

在Apple官方文档中Preparing your UI to run in the background中总结了App在进入后台之后还可以执行任务的几种情况:

  • Audio communication using AirPlay, or Picture in Picture video.
  • Location-sensitive services for users.
  • Voice over IP.
  • Communication with an external accessory.
  • Communication with Bluetooth LE accessories, or conversion of the device into a Bluetooth LE accessory.
  • Regular updates from a server.
  • Support for Apple Push Notification service (APNs).

而如果想一直在后台运行,那就需要持续的在后台执行任务,占据系统资源,一般来说有以下三种情况:

  • 播放音频或者视频
  • 后台持续定位
  • 连接Bluetooth LE accessories

要注意的是,这三种情况都需要在开发的时候设置 Background Modes。

引用

[1] Apple 文档 Preparing your UI to run in the background

[2] Apple 文档 Choosing Background Strategies for Your App

[3] Apple 文档 Using background tasks to update your app

[4] WWDC 2019: Advances in App Background Execution