Files
gh-glittercowboy-taches-cc-…/skills/expertise/iphone-apps/references/background-tasks.md
2025-11-29 18:28:37 +08:00

11 KiB

Background Tasks

BGTaskScheduler, background fetch, and silent push for background processing.

BGTaskScheduler

Setup

  1. Add capability: Background Modes
  2. Enable: Background fetch, Background processing
  3. Register identifiers in Info.plist:
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
    <string>com.app.refresh</string>
    <string>com.app.processing</string>
</array>

Registration

import BackgroundTasks

@main
struct MyApp: App {
    init() {
        registerBackgroundTasks()
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }

    private func registerBackgroundTasks() {
        // App Refresh - for frequent, short updates
        BGTaskScheduler.shared.register(
            forTaskWithIdentifier: "com.app.refresh",
            using: nil
        ) { task in
            guard let task = task as? BGAppRefreshTask else { return }
            handleAppRefresh(task: task)
        }

        // Processing - for longer, deferrable work
        BGTaskScheduler.shared.register(
            forTaskWithIdentifier: "com.app.processing",
            using: nil
        ) { task in
            guard let task = task as? BGProcessingTask else { return }
            handleProcessing(task: task)
        }
    }
}

App Refresh Task

Short tasks that need to run frequently:

func handleAppRefresh(task: BGAppRefreshTask) {
    // Schedule next refresh
    scheduleAppRefresh()

    // Create task
    let refreshTask = Task {
        do {
            try await syncLatestData()
            task.setTaskCompleted(success: true)
        } catch {
            task.setTaskCompleted(success: false)
        }
    }

    // Handle expiration
    task.expirationHandler = {
        refreshTask.cancel()
    }
}

func scheduleAppRefresh() {
    let request = BGAppRefreshTaskRequest(identifier: "com.app.refresh")
    request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)  // 15 minutes

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

private func syncLatestData() async throws {
    // Fetch new data from server
    // Update local database
    // Badge update if needed
}

Processing Task

Longer tasks that can be deferred:

func handleProcessing(task: BGProcessingTask) {
    // Schedule next
    scheduleProcessing()

    let processingTask = Task {
        do {
            try await performHeavyWork()
            task.setTaskCompleted(success: true)
        } catch {
            task.setTaskCompleted(success: false)
        }
    }

    task.expirationHandler = {
        processingTask.cancel()
    }
}

func scheduleProcessing() {
    let request = BGProcessingTaskRequest(identifier: "com.app.processing")
    request.earliestBeginDate = Date(timeIntervalSinceNow: 60 * 60)  // 1 hour
    request.requiresNetworkConnectivity = true
    request.requiresExternalPower = false

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

private func performHeavyWork() async throws {
    // Database maintenance
    // Large file uploads
    // ML model training
    // Cache cleanup
}

Background URLSession

For large uploads/downloads that continue when app is suspended:

class BackgroundDownloadService: NSObject {
    static let shared = BackgroundDownloadService()

    private lazy var session: URLSession = {
        let config = URLSessionConfiguration.background(
            withIdentifier: "com.app.background.download"
        )
        config.isDiscretionary = true  // System chooses best time
        config.sessionSendsLaunchEvents = true  // Wake app on completion

        return URLSession(
            configuration: config,
            delegate: self,
            delegateQueue: nil
        )
    }()

    private var completionHandler: (() -> Void)?

    func download(from url: URL) {
        let task = session.downloadTask(with: url)
        task.resume()
    }

    func handleEventsForBackgroundURLSession(
        identifier: String,
        completionHandler: @escaping () -> Void
    ) {
        self.completionHandler = completionHandler
    }
}

extension BackgroundDownloadService: URLSessionDownloadDelegate {
    func urlSession(
        _ session: URLSession,
        downloadTask: URLSessionDownloadTask,
        didFinishDownloadingTo location: URL
    ) {
        // Move file to permanent location
        let documentsURL = FileManager.default.urls(
            for: .documentDirectory,
            in: .userDomainMask
        ).first!
        let destinationURL = documentsURL.appendingPathComponent("downloaded.file")

        try? FileManager.default.moveItem(at: location, to: destinationURL)
    }

    func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
        DispatchQueue.main.async {
            self.completionHandler?()
            self.completionHandler = nil
        }
    }
}

// In AppDelegate
func application(
    _ application: UIApplication,
    handleEventsForBackgroundURLSession identifier: String,
    completionHandler: @escaping () -> Void
) {
    BackgroundDownloadService.shared.handleEventsForBackgroundURLSession(
        identifier: identifier,
        completionHandler: completionHandler
    )
}

Silent Push Notifications

Trigger background work from server:

Configuration

Entitlements:

<key>UIBackgroundModes</key>
<array>
    <string>remote-notification</string>
</array>

Handling

// In AppDelegate
func application(
    _ application: UIApplication,
    didReceiveRemoteNotification userInfo: [AnyHashable: Any]
) async -> UIBackgroundFetchResult {
    guard let action = userInfo["action"] as? String else {
        return .noData
    }

    do {
        switch action {
        case "sync":
            try await syncData()
            return .newData
        case "refresh":
            try await refreshContent()
            return .newData
        default:
            return .noData
        }
    } catch {
        return .failed
    }
}

Payload

{
    "aps": {
        "content-available": 1
    },
    "action": "sync",
    "data": {
        "lastUpdate": "2025-01-01T00:00:00Z"
    }
}

Location Updates

Background location monitoring:

import CoreLocation

class LocationService: NSObject, CLLocationManagerDelegate {
    private let manager = CLLocationManager()

    override init() {
        super.init()
        manager.delegate = self
        manager.allowsBackgroundLocationUpdates = true
        manager.pausesLocationUpdatesAutomatically = true
    }

    // Significant location changes (battery efficient)
    func startMonitoringSignificantChanges() {
        manager.startMonitoringSignificantLocationChanges()
    }

    // Region monitoring
    func monitorRegion(_ region: CLCircularRegion) {
        manager.startMonitoring(for: region)
    }

    // Continuous updates (high battery usage)
    func startContinuousUpdates() {
        manager.desiredAccuracy = kCLLocationAccuracyBest
        manager.startUpdatingLocation()
    }

    func locationManager(
        _ manager: CLLocationManager,
        didUpdateLocations locations: [CLLocation]
    ) {
        guard let location = locations.last else { return }

        // Process location update
        Task {
            try? await uploadLocation(location)
        }
    }

    func locationManager(
        _ manager: CLLocationManager,
        didEnterRegion region: CLRegion
    ) {
        // Handle region entry
    }
}

Background Audio

For audio playback while app is in background:

import AVFoundation

class AudioService {
    private var player: AVAudioPlayer?

    func configureAudioSession() throws {
        let session = AVAudioSession.sharedInstance()
        try session.setCategory(.playback, mode: .default)
        try session.setActive(true)
    }

    func play(url: URL) throws {
        player = try AVAudioPlayer(contentsOf: url)
        player?.play()
    }
}

Testing Background Tasks

Simulate in Debugger

// Pause in debugger, then:
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.app.refresh"]

Force Early Execution

#if DEBUG
func debugScheduleRefresh() {
    let request = BGAppRefreshTaskRequest(identifier: "com.app.refresh")
    request.earliestBeginDate = Date(timeIntervalSinceNow: 1)  // 1 second for testing

    try? BGTaskScheduler.shared.submit(request)
}
#endif

Best Practices

Battery Efficiency

// Use discretionary for non-urgent work
let config = URLSessionConfiguration.background(withIdentifier: "com.app.upload")
config.isDiscretionary = true  // Wait for good network/power conditions

// Require power for heavy work
let request = BGProcessingTaskRequest(identifier: "com.app.process")
request.requiresExternalPower = true

Respect User Settings

func scheduleRefreshIfAllowed() {
    // Check if user has Low Power Mode
    if ProcessInfo.processInfo.isLowPowerModeEnabled {
        // Reduce frequency or skip
        return
    }

    // Check background refresh status
    switch UIApplication.shared.backgroundRefreshStatus {
    case .available:
        scheduleAppRefresh()
    case .denied, .restricted:
        // Inform user if needed
        break
    @unknown default:
        break
    }
}

Handle Expiration

Always handle task expiration:

func handleTask(_ task: BGTask) {
    let operation = Task {
        // Long running work
    }

    // CRITICAL: Always set expiration handler
    task.expirationHandler = {
        operation.cancel()
        // Clean up
        // Save progress
    }
}

Progress Persistence

Save progress so you can resume:

func performIncrementalSync(task: BGTask) async {
    // Load progress
    let lastSyncDate = UserDefaults.standard.object(forKey: "lastSyncDate") as? Date ?? .distantPast

    do {
        // Sync from last position
        let newDate = try await syncSince(lastSyncDate)

        // Save progress
        UserDefaults.standard.set(newDate, forKey: "lastSyncDate")

        task.setTaskCompleted(success: true)
    } catch {
        task.setTaskCompleted(success: false)
    }
}

Debugging

Check Scheduled Tasks

BGTaskScheduler.shared.getPendingTaskRequests { requests in
    for request in requests {
        print("Pending: \(request.identifier)")
        print("Earliest: \(request.earliestBeginDate ?? Date())")
    }
}

Cancel Tasks

// Cancel specific
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: "com.app.refresh")

// Cancel all
BGTaskScheduler.shared.cancelAllTaskRequests()

Console Logs

# View background task logs
log stream --predicate 'subsystem == "com.apple.BackgroundTasks"' --level debug