Reputation: 702
I'm trying to schedule a background task to run in the background using BGTaskScheduler.shared.register(identifier:queue:launchHandler:)
, but the launchHandler
block is never executed.
SceneDelegate
on sceneDidEnterBackground(:)
using BGTaskScheduler.shared.submit(:)
.Info.plist
.Debug > Simulate Background Fetch
.func scene(_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
let isRegistered = BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.example.bgTaskIdentifier",
using: nil) { task in
print("Executed") // Does not print
self.handleAppRefresh(task: task as! BGAppRefreshTask)
}
print("isRegistered: \(isRegistered)") // Prints "isRegistered: true"
}
func sceneDidEnterBackground(_ scene: UIScene) {
scheduleAppRefresh()
print("Entered Background") // Prints "Entered Background"
}
func scheduleAppRefresh() {
let request = BGAppRefreshTaskRequest(identifier: "com.example.bgTaskIdentifier")
request.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60)
do {
try BGTaskScheduler.shared.submit(request)
} catch {
print("Could not schedule app refresh: \(error)") // Does not reach the catch block
}
}
Upvotes: 3
Views: 1167
Reputation: 437542
Two observations:
On my Simulator, when I reach the BGTaskScheduler.shared.submit(request)
line, it throws BGTaskScheduler.Error.invalid
error, whose documentation informs us that this can happen if:
The app is running on Simulator which doesn’t support background processing.
When I launch the app on an actual device from the Xcode debugger, I leave the app to have it go into background and I see scheduleAppRefresh
is called). At that point I tap the Xcode debugger “Pause program execution” button, and then at the (lldb)
prompt I enter:
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.example.bgTaskIdentifier"]
I then tap on the “Continue program execution” button, and my handleAppRefresh
method is called.
This debugging process is outlined in Starting and Terminating Tasks During Development.
You are registering the BGAppRefreshTask
task identifier in the SceneDelegate
. You really want to do that in the AppDelegate
. As the register
documentation says:
Registration of all launch handlers must be complete before the end of
applicationDidFinishLaunching(_:)
.
There are three additional things I would check:
Go to the “Settings” app on your device, and pull up the settings for your app, and make sure that “Background App Refresh” is turned on.
Take a look at your handleAppRefresh(task:)
implementation and make sure all paths of execution call setTaskCompleted(success:)
. As the docs say:
Not calling
setTaskCompleted(success:)
before the time for the task expires may result in the system killing your app.
Note, if this happens, you may not be eligible for future fetch requests. I have not empirically confirmed this, though this is common in iOS background execution patterns.
Make sure your app refresh task actually performs a network operation. If you do not, the OS can detect that and may opt the app out of future background app refreshes. (The idea is that they are trying to detect/prevent misuse of the background fetch capability.)
For what it is worth, here is an example of my background task manager object:
// BackgroundTaskManager.swift
//
// Created by Robert Ryan on 5/14/24.
import Foundation
import os.log
import BackgroundTasks
import UserNotifications
@MainActor
class BackgroundTaskManager {
static let shared = BackgroundTaskManager()
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "BackgroundTaskManager")
private let appRefreshIdentifier = "com.example.bgTaskIdentifier"
private init() { }
@discardableResult
func register() -> Bool {
let isRegistered = BGTaskScheduler.shared.register(
forTaskWithIdentifier: appRefreshIdentifier,
using: nil
) { [weak self, logger] task in
guard let self else { return }
logger.notice("\(#function, privacy: .public): register closure called")
Task { [task] in
let processTask = Task { await self.handleAppRefresh() }
task.expirationHandler = { processTask.cancel() }
task.setTaskCompleted(success: await processTask.value)
}
}
logger.notice("\(#function, privacy: .public): isRegistered = \(isRegistered)")
return isRegistered
}
func scheduleAppRefresh() {
logger.notice(#function)
let request = BGAppRefreshTaskRequest(identifier: appRefreshIdentifier)
request.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60)
do {
try BGTaskScheduler.shared.submit(request)
} catch {
logger.error("\(#function, privacy: .public): Could not schedule app refresh: \(error)")
}
}
}
// MARK: - Private methods
private extension BackgroundTaskManager {
func handleAppRefresh() async -> Bool {
logger.notice("\(#function, privacy: .public): starting")
// make sure to schedule again, so that this continues to enjoy future background fetch
scheduleAppRefresh()
// now fetch data
do {
try await DataRepositoryService.shared.fetchNewData()
notifyUserInDebugBuild(message: "\(#function): success")
logger.notice("\(#function, privacy: .public): success")
return true
} catch {
notifyUserInDebugBuild(message: "\(#function): failed")
logger.error("\(#function, privacy: .public): failed \(error, privacy: .public)")
return false
}
}
func notifyUserInDebugBuild(message: String) {
#if DEBUG
logger.notice("\(#function, privacy: .public): message: \(message, privacy: .public)")
let content = UNMutableNotificationContent()
content.title = "BackgroundTaskManager"
content.body = message
let request = UNNotificationRequest(identifier: Bundle.main.bundleIdentifier!, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
#endif
}
}
I then register
in the AppDelegate
’s didFinishLaunchingWithOptions
:
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "AppDelegate")
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
logger.notice(#function)
BackgroundTaskManager.shared.register()
BackgroundTaskManager.shared.scheduleAppRefresh()
return true
}
…
}
While I schedule in didFinishLaunchingWithOptions
, I also register in the SceneDelegate
’s sceneDidEnterBackground
:
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "SceneDelegate")
var window: UIWindow?
…
func sceneDidEnterBackground(_ scene: UIScene) {
logger.notice(#function)
BackgroundTaskManager.shared.scheduleAppRefresh()
}
}
A few debugging observations:
I use Logger
to record messages to the console, rather than print
statements. That way, I can not only see it in the Xcode console, but even I run the app independently of Xcode, I can watch the messages in the macOS Console.
I can even download these Logger
messages stored on my device, after the fact:
sudo log collect --device --start '2024-05-13 10:00:00' --output background-fetch.logarchive
open background-fetch.logarchive
With the .logarchive
extension, it will open
this in the macOS Console app (and you can then filter by subsystem, category, time range, etc.). For more information, see WWDC 2020 video Explore logging in Swift
As an aside, this is why I used Logger
’s notice
, rather than debug
. The debug
messages are not persisted, while the notice
messages are.
Another good diagnostic tool is to use “user notifications” (which I implement only in debug builds). This way, even when the user has left the app, when the background fetch is completed, there is a notification on the device, so I have external verification that the background fetch was performed.
Note, just remember to ask for authorization to present notifications:
import UIKit
import UserNotifications
import os.log
class ViewController: UIViewController {
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ViewController")
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
UNUserNotificationCenter.current().requestAuthorization(options: .alert) { [logger] granted, error in
logger.notice("\(granted), \(String(describing: error))")
}
}
}
But when the app is launched in the background, I will see a notification on my device:
Upvotes: 3