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.
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 theCLLocationManager
object. That thread must itself have an activeRunLoop
, 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.