Search code examples
swiftswiftuicore-location

Swift concurrency seems to be random when using MainActor?


I've been trying to figure out a way to call corelocation's requestLocation on the main thread (which apparently is required).

Consider this MRE

import CoreLocation
import MapKit
import SwiftUI

struct ContentView: View {
    var locationManager = LocationManager()

    var body: some View {
        Button {
            Task {
                let location = try await locationManager.currentLocation // works
                print(location)
                let location2 = try await locationManager.work() // works, no mainactor needed
                print(location2)
                 let location3 = try await APIService.shared.test() // doesnt work
                print(location3)
                let location4 = try await APIService.shared.test2() // works, mainactor needed
                print(location4)
                let location5 = try await APIService.shared.test3() // doesnt work even with mainactor
                print(location5)
            }
        } label: {
            Text("Get Location")
        }.task {
            // 1. Check if the app is authorized to access the location services of the device
            locationManager.checkAuthorization()
        }
    }
}

class LocationManager: NSObject, CLLocationManagerDelegate {
    // MARK: Object to Access Location Services

    private let locationManager = CLLocationManager()

    // MARK: Set up the Location Manager Delegate

    override init() {
        super.init()
        locationManager.delegate = self
    }

    // MARK: Request Authorization to access the User Location

    func checkAuthorization() {
        switch locationManager.authorizationStatus {
        case .notDetermined:
            locationManager.requestWhenInUseAuthorization()
        default:
            return
        }
    }

    // MARK: Continuation Object for the User Location

    private var continuation: CheckedContinuation<CLLocation, Error>?

    // MARK: Async Request the Current Location

    var currentLocation: CLLocation {
        get async throws {
            return try await withCheckedThrowingContinuation { continuation in
                // 1. Set up the continuation object
                self.continuation = continuation
                // 2. Triggers the update of the current location
                locationManager.requestLocation()
            }
        }
    }

    @MainActor
    var currentLocation2: CLLocation {
        get async throws {
            return try await withCheckedThrowingContinuation { continuation in
                // 1. Set up the continuation object
                self.continuation = continuation
                // 2. Triggers the update of the current location
                locationManager.requestLocation()
            }
        }
    }

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        // 4. If there is a location available
        if let lastLocation = locations.last {
            // 5. Resumes the continuation object with the user location as result
            continuation?.resume(returning: lastLocation)
            // Resets the continuation object
            continuation = nil
        }
    }

    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        // 6. If not possible to retrieve a location, resumes with an error
        continuation?.resume(throwing: error)
        // Resets the continuation object
        continuation = nil
    }

    func work() async throws -> CLLocation {
        return try await currentLocation
    }
}

class APIService {
    static let shared = APIService()

    // Private initializer to prevent the creation of additional instances
    private init() {
    }

    func test() async throws -> String {
        return try await String(describing: LocationManager().currentLocation)
    }

    @MainActor
    func test2() async throws -> String {
        return try await String(describing: LocationManager().currentLocation)
    }

    func test3() async throws -> String {
        return try await String(describing: LocationManager().currentLocation2)
    }
}

Test1 works as expected because Task from the view is inherited as mainactor

Test2 works for the same reason I assume

Test3 doesnt work not sure why when test2 worked? I guess if it goes to another class, you lose the actor?

Test4 works as expected because you force it to be mainactor

Test5 doesnt work mysteriously even though you force it to be mainactor again So what is the rule for main thread in swift concurrency?

I'm trying to get Test5 to work but the other test cases explained will help towards understanding how to get Test5 to work.


Solution

  • I've been trying to figure out a way to call corelocation's requestLocation on the main thread (which apparently is required).

    It is not required to call requestLocation on the main thread. You can call it from any thread.

    The problem here is that you are creating the CLLocationManager on the wrong thread. From the docs,

    Core Location calls the methods of your delegate object using the RunLoop of the thread on which you initialized the CLLocationManager object. That thread must itself have an active RunLoop, like the one found in your app’s main thread.

    In the cases that fail, you are creating LocationManager (which in turns creates a CLLocationManager) in a non isolated async method. This will be run on some thread from the cooperative thread pool, which most certainly doesn't have a RunLoop. Therefore the delegate methods are not called.

    // locationManager is initialised in the View initialiser, which is run on the main thread
    // so these work
    let location = try await locationManager.currentLocation
    let location2 = try await locationManager.work()
    
    // test2 is isolated to the main actor, so "LocationManager()" is run on the main thread too
    let location4 = try await APIService.shared.test2()
    
    // neither test nor test3 are isolated to the main actor, so LocationManager is not created on the main thread
    // the fact that 'currentLocation2' is isolated to the main thread doesn't matter
    let location3 = try await APIService.shared.test()
    let location5 = try await APIService.shared.test3()
    

    As for which thread is calling requestLocation (not relevant to the issue - just for your information), the main thread will always call the call in currentLocation2 because it is isolated to the main actor, and a non-main thread will always call the call in currentLocation because it is not isolated. You can check this with MainActor.shared.assertIsolated(). If it crashes, you are not on the main actor.


    Note that your code has many concurrency-related warnings. Try turning on complete concurrency checking and see for yourself.

    I'd make LocationManager an actor so that it is Sendable, and therefore safe to call its properties/methods from anywhere. I would also make APIService a final class, so it can be made Sendable too. This makes the shared instance safe.

    Note that currently if you get currentLocation while an ongoing continuation has not resumed, you would overwrite the existing continuation, causing the resume to never be called on that overwritten continuation.

    Here I have fixed the code to remove the concurrency-related warnings:

    actor LocationManager: NSObject, CLLocationManagerDelegate {
    
        private let locationManager = CLLocationManager()
        
        override init() {
            super.init()
            locationManager.delegate = self
        }
    
        func checkAuthorization() {
            switch locationManager.authorizationStatus {
            case .notDetermined:
                locationManager.requestWhenInUseAuthorization()
            default:
                return
            }
        }
        
        private var continuation: CheckedContinuation<CLLocation, Error>?
    
        enum LocationError: Error {
            case locationInProgress
        }
        
        var currentLocation: CLLocation {
            get async throws {
                return try await withCheckedThrowingContinuation { continuation in
                    if self.continuation != nil {
                        continuation.resume(throwing: LocationError.locationInProgress)
                    } else {
                        self.continuation = continuation
                        locationManager.requestLocation()
                    }
                }
            }
        }
    
        func updateLocation(_ location: CLLocation) {
            continuation?.resume(returning: location)
            continuation = nil
        }
        
        func locationError(_ error: Error) {
            continuation?.resume(throwing: error)
            continuation = nil
        }
        
        nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
            if let lastLocation = locations.last {
                Task {
                    await updateLocation(lastLocation)
                }
            }
        }
    
        nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
            Task {
                await locationError(error)
            }
        }
    
        func work() async throws -> CLLocation {
            return try await currentLocation
        }
    }
    
    // also consider making this a struct
    final class APIService: Sendable {
        static let shared = APIService()
        
        let locationManager = LocationManager()
        private init() {
        }
    
        func test() async throws -> String {
            return try await String(describing: locationManager.currentLocation)
        }
    }
    

    Now at the start of the app, you just need to access APIService.shared on the main thread, this will cause a CLLocationManager to be created on the main thread, and any subsequent accesses can be made from any thread.


    There are a few improvements to be made in the SwiftUI code as well, such as creating an unstructured top-level Task when you can use the .task(id:) modifier instead, but that's out of the scope of this question.