瞭解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