withTaskGroup does not work with CLGeocoder

45 views Asked by At

I need to implement search functionality for addresses based on user input. For address suggestions I am using MKLocalSearchCompleter. After suggestions are retrieved I need to get placemarks based on the title of the address. To get the placemarks I use CLGeocoder function geocodeAddressString. Since this is an async function and I need to do multiple requests, calling it separately with each address one after another is too slow, therefore I need to combine the requests and perform them at the same time. withTaskGroup seems like a perfect way to do this, but the problem occurs that for some reason the task group simply stops without any errors or exceptions. Only the first task goes through and the Task keeps hanging forever.

I tried rewriting the same thing in multiple different ways but nothing helped. In order to make this as simple as possible I created a separate Playgrounds project just to keep this as isolated as possible. To my surprise this issue still persists! Here is the code from Playgrounds:

import CoreLocation
import MapKit

class MapManager: NSObject, MKLocalSearchCompleterDelegate {
    
    let geocoder = CLGeocoder()
    
    private lazy var localSearchCompleter: MKLocalSearchCompleter = {
        let completer = MKLocalSearchCompleter()
        completer.delegate = self
        return completer
    }()
    
    func searchAddress(_ query: String) {
        localSearchCompleter.queryFragment = query
    }
    
    func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
        Task {
            let placemarks = try! await withThrowingTaskGroup(of: [CLPlacemark].self) { group in
                
                var placemarks: [CLPlacemark] = []
                
                print(completer.results.count) // 15
                
                for result in completer.results {
                    print("Task was added to the group")
                    group.addTask { try! await self.geocoder.geocodeAddressString(result.title) }
                }
                
                
                for try await placemark in group {
                    print("Task appended to a list")
                    placemarks.append(contentsOf: placemark)
                }
                
                // This is never called
                return placemarks
            }
            
            print(placemarks)
        }
    }
}

let manager = MapManager()
manager.searchAddress("wall street")

When searching for addresses based on query "wall street", completer returns 15 completions. I need to add 15 tasks to the task group, one for each address. Then each of those addresses are geocoded to get their placemarks. The problem is that all tasks are added to the group but only the first one is appended to the list. Here is the console output:

15
Task was added to the group
Task was added to the group
Task was added to the group
Task was added to the group
Task was added to the group
Task was added to the group
Task was added to the group
Task was added to the group
Task was added to the group
Task was added to the group
Task was added to the group
Task was added to the group
Task was added to the group
Task was added to the group
Task was added to the group
Task appended to a list

I wonder if this is an issue with CLGeocoder, MKLocalSearchCompleter, TaskGroup or I am simply doing something wrong. This seems like a really simple thing to do and it works perfectly with other mocked types and values, yet for some reason the combination of Location services and task group completely breaks the project.

Thanks for the help!

1

There are 1 answers

2
Rob On BEST ANSWER

You are performing geocoding requests in parallel. Apple’s geocoding API is not intended for parallel queries. If you run them consecutively, it does not hang:

func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
    Task {
        var placemarks: [CLPlacemark] = []

        for result in completer.results {
            let searchString = [result.title, result.subtitle]
                .filter { !$0.isEmpty }
                .joined(separator: ", ")

            print("searchString =", searchString)

            do {
                let placemark = try await geocoder.geocodeAddressString(searchString)
                print(placemark)
                placemarks.append(contentsOf: placemark)
            } catch let error as CLError {
                print(error)
            } catch {
                print(error)
            }
        }
    }
}

FYI, the documentation warns us:

Tips for Using a Geocoder Object

Apps must be conscious of how they use geocoding. Geocoding requests are rate-limited for each app, so making too many requests in a short period of time may cause some of the requests to fail. (When the maximum rate is exceeded, the geocoder returns an error object with the CLError.Code.network error to the associated completion handler.) Here are some rules of thumb for using this class effectively:

  • Send at most one geocoding request for any one user action.

  • If the user performs multiple actions that involve geocoding the same location, reuse the results from the initial geocoding request instead of starting individual requests for each action.

  • When you want to update the user’s current location automatically (such as when the user is moving), issue new geocoding requests only when the user has moved a significant distance and after a reasonable amount of time has passed. For example, in a typical situation, you should not send more than one geocoding request per minute.

  • Do not start a geocoding request at a time when the user will not see the results immediately. For example, do not start a request if your application is inactive or in the background.

So, having shown you how to get around the geocoding tasks not completing, you really should not be geocoding all the individual MKLocalSearchCompleter results at all. As the user types, we debounce the input and then call the completer to present some text field completion options. But that is it. We should not geocode all of these.

So, the user is typing and we call the completer (probably after a little debouncing). The user sees the completer UI (with no additional geocoding requests), either picks one or keeps typing, and only when they hit enter or pick one should the app then do an actual MKLocalSearch on the one they picked.

And when you perform that final MKLocalSearch, the results include the detailed mapItems with all the necessary geocoded data.

Bottom line, one should decouple the completer UX from the searching UX.