Search code examples
ioskotlincore-locationkotlin-multiplatform

Why does iOS delegate not work properly in KMM?


I have a KMM project that requires access to the user's location. My locationClient is written in Kotlin.

It was all working as expected, but I'm seeing strange behavior after updating Kotlin from 1.8.20 to 1.9.20.

I get the first few locations as expected, but then the delegate just stops sending locations back. I usually get 5 or 6 locations before it stops, and never more than 10. I've tested this on both the simulator and a real device, and get the same results.

On the Swift side the trigger to start sending locations is in an "onAppear" block, and navigating away and back triggers another few locations to come through, but then they stop again.

When the locations stop, the phone still thinks the app is listening to locations, and continues to show the appropriate privacy notification. This is correctly removed when I navigate away and stop listening to locations.

LocationClient:

actual class LocationProviderImpl : LocationProvider {
     private val locationManager = CLLocationManager()

    private class LocationDelegate : NSObject(), CLLocationManagerDelegateProtocol {
        var onLocationUpdate: ((Location?) -> Unit)? = null
        override fun locationManager(manager: CLLocationManager, didUpdateLocations: List<*>) {
//This is correctly called for the first few locations then stops
            didUpdateLocations.firstOrNull()?.let {
                val location = it as CLLocation
                location.coordinate.useContents {
                    onLocationUpdate?.invoke(Location(latitude, longitude, it.altitude))
                }
            }
        }

        override fun locationManager(manager: CLLocationManager, didFailWithError: NSError) {
//This never gets called
            onLocationUpdate?.invoke(null)
        }
    }
override fun getCurrentLocationAsFlow(): Flow<Location?> = callbackFlow {
        locationManager.requestAlwaysAuthorization()
        locationManager.desiredAccuracy = kCLLocationAccuracyBest
        locationManager.distanceFilter = kCLDistanceFilterNone
        val locationDelegate = LocationDelegate()
        locationDelegate.onLocationUpdate = { location ->
            trySend(
                location
            ).isSuccess
        }
        locationManager.delegate = locationDelegate
         locationManager.startUpdatingLocation()
       awaitClose {
//This is never called
            locationManager.stopUpdatingLocation()
        }
    }.flowOn(Dispatchers.IO)

    fun requestBackground() {
        locationManager.allowsBackgroundLocationUpdates = true
    }

    fun stopBackground() {
        locationManager.allowsBackgroundLocationUpdates = false
    }
    fun stopLocationUpdates() {
        locationManager.stopUpdatingLocation()
    }
}

This is used as follows:

object GpsService {

    private lateinit var locationClient: LocationProviderImpl
    private var initialized = false
    private var serviceScope = CoroutineScope(Dispatchers.IO)
 
 fun initialize(appModule: AppModule) {
        if (!initialized) {
            locationClient = LocationProviderImpl()
}
}
    

  fun startLocationListener() {
  locationClient.getCurrentLocationAsFlow().cancellable()
            .catch { e ->
                e.printStackTrace()
            }
            .mapNotNull { it }
            .onEach { location ->
                // send location to view
                _state.update {
                    it.copy(
                        currentLocation = location,
                        formattedAltitude = getFormattedAltitude(location.altitude)
                    )
                }
     }.launchIn(serviceScope)
    }

I get no errors or warnings (and have set breakpoints / println()s all over the place) and no error or catch blocks ever get hit, I just stop getting any locations back after the first few. It was all working fine with Kotlin 1.8.20!


Solution

  • Kotlin has it's own memory model, but when you create ObjC objects even from Kotlin code, they follow the ObjC memory model.

    In the iOS world, the field named delegate usually (always with system frameworks) stores a weak reference to an object - it means that this object is not responsible for the object's lifecycle, so when the object is destroyed, the property is automatically set to null. This is done to prevent retain cycles. See this question for more details. If you're planning to implement more iOS features from Kotlin, check out some articles about Automatic Reference Counting (ARC).

    In your code, locationDelegate will be released as soon as awaitClose pauses the function execution.

    So you need to store it somewhere. For example, in a class variable.

    With your current code this can be tricky, as getCurrentLocationAsFlow can be called multiple times from different places, so the old delegate will be overwritten by the new call - you might consider having a single delegate created during init and stored as let, and adding onLocationUpdate handlers to this object.