Search code examples
swiftasync-awaitconcurrencytask

Threading problem with Swift search method when using Async Await


I have a search method for a search bar component in my Swift app. Here is the search method:

@MainActor
func searchLocalPopularTerms(for searchTerm: String, completionHandler: @escaping (() -> Void)) {
    // If timestamp is nil, this is our first search, so set it here
    if self.timestamp == nil {
        self.timestamp = Date()
    }
    
    guard let timestamp else { return }
    
    Task {
        // When we make a request we pass a timestamp into the request
        let searchResults = await self.searchManager.searchResults(searchTerm: searchTerm, gender: self.gender, timestamp: Date())
        
        // We then check if the timestamp returned by the results is great than the stored timestamp.
        // If it is, then these are new results and we can update the searchItems array
        if searchResults.results.isNotEmpty, searchResults.timestamp > timestamp {
            self.searchItems = searchResults.results
            // And then reset the local timestamp to the new current date
            self.timestamp = Date()
        }
        completionHandler()
    }
}

Because the SearchManager searchResults() method is with async await I am trying to make sure that there is no issue with searches i.e. that the searches are done in the correct order and that there are no threading issues. As such I have tried to add a check here whereby I pass a timestamp into the request which then is set against the results when they are returned from the API. When a successful call is made and results returned, I set the local timestamp variable. Every time I receive an API response, I am checking the local variable (which will be storing the last successful result timestamp) against the new results. If the new results timestamp is greater than the stored one, I know that this request was indeed made after the most successful one, and so only then do I update the results array.

However, there seems to be something wrong with the implementation as when I type into the search field, sometimes it is clearly not updating with the latest search. It works most of the time, but on occasions if I type very fast, if I have typed, for example, "Sandwiches" if fetches "Sandwi", omitting the last characters. I think there must be something wrong with the way I am implementing this.

Here is an example of where I call this method:

viewModel?.searchLocalPopularTerms(for: searchText, completionHandler: { [weak self] in
    onMain {
        self?.reloadSection(showKeyboard: true)
        self?.viewModel?.searchTerm = searchText
    }
})

And the service method itself:

private static func searchResults(_ query: String, gender: Gender, popularTerms: [String]? = nil,
                                  allowFredhopperSuggestions: Bool = ConfigManager.shared.allowFredhopperSuggestions, timestamp: Date) async -> SearchResults {
    let suggestionLimits = SearchSuggestionLimits()

    var suggestions: [SearchItem] = []
    suggestions.reserveCapacity(suggestionLimits.totalLimit)

    if allowFredhopperSuggestions {
        let results = await fredhopperSuggestions(query)
        suggestions.append(contentsOf: results)
    } else {
        let designers = designerSearchResults(query, gender: gender, fetchLimit: suggestionLimits.designerLimit)
        suggestions.append(contentsOf: designers)

        let categories = categorySearchResults(query, gender: gender, fetchLimit: suggestionLimits.categoryLimit)
        suggestions.append(contentsOf: categories)
    }

    if let popularTerms = popularTerms {
        let alreadyFoundList = suggestions.compactMap { $0.name }
        let popularSearches = popularTermsSearchResults(query, popularTerms: popularTerms, alreadyFoundList: alreadyFoundList, fetchLimit: suggestionLimits.popularLimit)
        suggestions.append(contentsOf: popularSearches)
    }

    return SearchResults(timestamp: timestamp, results: suggestions)
}

Solution

  • I've refactored now so that the search method in the viewModel is as follows:

    @MainActor
    func searchLocalPopularTerms(for searchTerm: String) async {
        let searchResults = await self.searchManager.searchResults(searchTerm: searchTerm, gender: self.gender, timestamp: Date())
        
        if searchResults.results.isNotEmpty {
            self.searchItems = searchResults.results
        }
    }
    

    And in the viewController we use the task:

    func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
            self.searchTask?.cancel()
            let task = Task { [weak self] in
                guard let self else { return }
                await self.viewModel?.searchLocalPopularTerms(for: searchText)
                self.reloadSection(showKeyboard: true)
                self.viewModel?.searchTerm = searchText
            }
            self.searchTask = task
    }
    

    For each new search, we cancel the previous task