Search code examples
swifturlsession

Using data from a URLSession and understanding Completion Handlers in Swift 5


I have code where I am creating a URLSession and trying to return the data obtained from this so it can be used outside of the function that the URLSession is in. My code looks like:

var nameStr = ""
var ingStr = ""
let task = URLSession.shared.dataTask(with: url) {(data, response, error) in
    guard let data = data else { return }
    let str = String(data: data, encoding: .utf8)!
    let str2 = convertStringToDictionary(text: str)

    nameStr = "Name: \(str2!["title"] as! NSString)"
    ingStr = "Ingredients: \((str2!["ingredientList"] as! NSString).components(separatedBy: ", "))"
    print("First ", nameStr)
    print(ingStr)
    
}
task.resume()

print("Second ", nameStr)
print(ingStr)

The first print statement works as expected, but the second only prints "Second ". I looked at Swift return data from URLSession and although I realize it does have a viable solution, I do not understand the whole completion block part and have a feeling others might be on the same boat. Any suggestions?


Solution

  • You said:

    The first print statement works as expected, but the second only prints "Second ". I looked at Swift return data from URLSession

    The accepted answer to that question is the correct answer here, too.

    What you’ve labeled as “first” and “second” in your code snippet are actually backwards. If you look carefully, you will see that “second” print statement (with no results yet) is actually happening before you see your “first” print statement! More to the point, your execution path will reach that “second” print statement before you’ve received and parsed the response from the network call.

    That is the root of the issue, namely that the code inside the closure passed to dataTask method happens “asynchronously”, i.e. later. You can’t print your strings right after the resume statement (your “second” print statement) because those strings have not been set yet! Likewise, obviously cannot return these strings, either, because, again, they are not populated until well after you’ve returned from this function. This is all because the dataTask closure has not run yet! The completion handler closure, described in that other answer, is the solution.

    Consider this code snippet:

    let task = URLSession.shared.dataTask(with: url) { data, _, _ in
        guard
            let data = data,
            let string = String(data: data, encoding: .utf8),
            let dictionary = convertStringToDictionary(text: string),
            let title = dictionary["title"] as? String,
            let ingredientsString = dictionary["ingredientList"] as? String
        else { return }
    
        let ingredients = ingredientsString.components(separatedBy: ", ")
    
        // you’ve got `title` and `ingredients` here …
    }
    task.resume()
    
    // … but not here, because the above runs asynchronously
    

    This is basically your code, refactored to eliminate the ! forced unwrapping operators and the NSString references. But that’s not the relevant piece of the story. It is the those two comments, where I’m showing where you have results (i.e., in the closure) and where you do not (i.e., immediately after the resume). If you’re not familiar with asynchronous programming, this might look deeply confusing. But it is the important lesson, to understand exactly what “asynchronous” means.

    … but I do not understand the whole completion block part. Any suggestions?

    Yep, the answer to your question rests in understanding this “completion handler” pattern. The dataTask method has a completion handler closure parameter, a pattern that you have to repeat in your own method.

    So, going back to my code snippet above, how precisely do you “return” something that is retrieved later? The answer: You technically do not return anything. Instead, add a completion handler closure parameter to your method and call that method with a Result, which is either some model object upon success (e.g., perhaps a Recipe object), or an Error upon failure. And to keep it simple for the caller, we will call that completion handler on the main queue.

    Thus, perhaps:

    func fetchRecipe(with url: URL, completion: @escaping (Result<Recipe, Error>) -> Void) {
        let task = URLSession.shared.dataTask(with: url) { data, _, _ in
            guard
                let data = data,
                let string = String(data: data, encoding: .utf8),
                let dictionary = convertStringToDictionary(text: string),
                let title = dictionary["title"] as? String,
                let ingredientsString = dictionary["ingredientList"] as? String
            else { 
                DispatchQueue.main.async {
                    completion(.failure(error ?? URLError(.badServerResponse)))
                }
                return
            }
        
            let ingredients = ingredientsString.components(separatedBy: ", ")
            let recipe = Recipe(title: title, ingredients: ingredients)
    
            DispatchQueue.main.async {
                completion(.success(recipe))
            }
        }
        task.resume()
    }
    

    This is basically the same as my above code snippet, except that I:

    • Wrapped it in a fetchRecipe method;
    • I don’t return anything;
    • I gave that method a completion parameter, which is a completion handler closure;
    • I call this completion closure when the network request completes, passing back either a .failure() or a .success(), obviously depending upon whether it failed or succeeded.

    For the sake of completeness, this is the Recipe model object that I used, to wrap everything returned from the server in a nice, simple object:

    struct Recipe {
        let title: String
        let ingredients: [String]
    }
    

    And you would call it like so:

    fetchRecipe(with: url) { result in
        // use `result` here …
        switch result {
        case .failure(let error):
            print(error)
        case .success(let recipe):
            print(recipe)
            // update your model and UI using `result` here …
        }
    }
    
    // … but not here, because, again, the above runs asynchronously
    

    I have to say, it feels deeply wrong to convert the Data to a String and then to a dictionary and then use dictionary key string literals. Usually, for example, we’d have a server that returned JSON and we would just parse the Data directly with JSONDecoder. But you did not share this convertStringToDictionary, so I wasn’t able to offer any meaningful advice on that score. But that’s something for you to tackle once you have your immediate problem behind you.