Saturday, June 11, 2016

Completion handlers for NSURLSessionDataTask. Returning downloaded data on iOS

This tutorial picks up exactly where I left my previous iOS tutorial of "Downloading and parsing JSON data in Swift 2.2", so you may want to at least read over that one to see where we're starting from, and how we got here.

Refactor code

The very first step is refactoring our code, primarily to make more sense of how a real application would use completion handlers to pass downloaded data around.

We'll begin by creating a new Swift class called "Utilities.swift" which will inherit from NSObject:
import Foundation
class Utilities : NSObject
{
    
}
New file added, and the project structure

We will then create a new static function (didn't we use to call them method in obj-c?) called getJsonData, which will take 1 parameter, of type String, and returns Void.
The Utilities class should look like this now:
import Foundation
class Utilities : NSObject
{
    static func getJsonData(jsonUrlAsString:String) -> Void
    {
        
    }
}
We then go back to our ViewController class we were using on the first tutorial, and cut everything from the network configuration, to the resume() call. We will paste this inside of our getJsonData inside of our Utilities class.

The Utilities class should be complaining about the UILabels we set in the previous tutorial.
We will need the code for this UILabels later, but since its easy to create we will just delete it from Utilities.

At this point, our Utilities class should look like this:
import Foundation
class Utilities : NSObject
{
    static func getJsonData(jsonUrlAsString:String) -> Void
    {
        let configuration = NSURLSessionConfiguration.defaultSessionConfiguration()
        let headers: [NSObject : AnyObject] = ["Accept":"application/json", "Auth":"token"]
        configuration.HTTPAdditionalHeaders = headers
        let session = NSURLSession(configuration: configuration)
        
        let downloadTask = session.dataTaskWithURL(NSURL(string: jsonUrlAsString)!) { (dataReceived, response, error) in
            if (error == nil)
            {
                print("dataReceived = \(dataReceived)")
                do
                {
                    let dataDownloadedAsJson = try NSJSONSerialization.JSONObjectWithData(dataReceived!, options: .AllowFragments)
                    print("dataDownloadedAsJson = \(dataDownloadedAsJson)")
                    
                    let nameRead = dataDownloadedAsJson["name"] as? String
                    let countryRead = dataDownloadedAsJson["country"] as? String
                    let genderRead = dataDownloadedAsJson["gender"] as? String
                    let ageRead = dataDownloadedAsJson["age"] as? Int
                    
                    print("nameRead = \(nameRead!)")
                    print("countryRead = \(countryRead!)")
                    print("genderRead = \(genderRead!)")
                    print("ageRead = \(ageRead!)")
                }
                catch
                {
                    
                }
            }
            else
            {
                print("Error downloading data. Error = \(error)")
            }
        }
        
        // actually execute the task
        downloadTask.resume()
    }
}

On our ViewController we want to call the function getJsonData from the Utilities class, so we do this passing the url as a String.
At this point, the ViewController class should look like this:
import UIKit
class ViewController: UIViewController
{

    @IBOutlet weak var labelName: UILabel!
    @IBOutlet weak var labelAge: UILabel!
    @IBOutlet weak var labelGender: UILabel!
    @IBOutlet weak var labelCountry: UILabel!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let jsonUrlAsString = "https://api.myjson.com/bins/2c0aw"
        Utilities.getJsonData(jsonUrlAsString)        
    }
}
So now, if we were to run our application, we should not have any compiling errors, and we should see the output on the console displaying something like this:
dataReceived = Optional()
dataDownloadedAsJson = {
    age = 32;
    country = USA;
    gender = male;
    name = "Eduardo Flores";
}
nameRead = Eduardo Flores
countryRead = USA
genderRead = male
ageRead = 32
sdad

If the code transplant so far does the same thing as it does to me, then we're ready to move on.
Everything should work, except...well the app UI.
Before we fix that we need to create an object to return.

Create return Object
I could pass a 4 variables back, but that'll be horrible and not what you'll do on a regular basis. (you don't do that, right?)
So let's create a new swift class and let's call it "Person.swift"
In here, we'll create 4 variables: name, age, gender and country.
The whole object should look like this:
import Foundation
class Person: NSObject
{
    var name : String?
    var age : Int?
    var gender : String?
    var country : String?
}
Now that we have the object data, let's add the Person object to the getJsonData function from the Utilities class. Now the code inside getJsonData looks like this:
do
static func getJsonData(jsonUrlAsString:String) -> Void
{
    let person = Person()

    let configuration = NSURLSessionConfiguration.defaultSessionConfiguration()
    let headers: [NSObject : AnyObject] = ["Accept":"application/json", "Auth":"token"]
    configuration.HTTPAdditionalHeaders = headers
    let session = NSURLSession(configuration: configuration)
    
    let downloadTask = session.dataTaskWithURL(NSURL(string: jsonUrlAsString)!) { (dataReceived, response, error) in
        if (error == nil)
        {
            print("dataReceived = \(dataReceived)")
        do
        {
            let dataDownloadedAsJson = try NSJSONSerialization.JSONObjectWithData(dataReceived!, options: .AllowFragments)
            print("dataDownloadedAsJson = \(dataDownloadedAsJson)")
            
            let nameRead = dataDownloadedAsJson["name"] as? String
            let countryRead = dataDownloadedAsJson["country"] as? String
            let genderRead = dataDownloadedAsJson["gender"] as? String
            let ageRead = dataDownloadedAsJson["age"] as? Int
            
            print("nameRead = \(nameRead!)")
            print("countryRead = \(countryRead!)")
            print("genderRead = \(genderRead!)")
            print("ageRead = \(ageRead!)")
            
            person.name = nameRead!
            person.age = ageRead!
            person.gender = genderRead!
            person.country = countryRead!
        }
        catch
        {
            
        }
        }
        else
        {
            print("Error downloading data. Error = \(error)")
        }
    }
    
    // actually execute the task
    downloadTask.resume()
}
And now we need to return the Person object.
This is not as simple as you expect...

Return data as return type

The issue we have is that we have data in one class, but we want to use it on another class. This is a fairly common affair in programming, and the initial approach would be to return Person instead of Void from the getJsonData function.

So let's try it!

In Utilities, change getJsonData the function to this:
static func getJsonData(jsonUrlAsString:String) -> Person
{
    let person = Person()

    let configuration = NSURLSessionConfiguration.defaultSessionConfiguration()
    let headers: [NSObject : AnyObject] = ["Accept":"application/json", "Auth":"token"]
    configuration.HTTPAdditionalHeaders = headers
    let session = NSURLSession(configuration: configuration)
    
    let downloadTask = session.dataTaskWithURL(NSURL(string: jsonUrlAsString)!) { (dataReceived, response, error) in
        if (error == nil)
        {
            print("dataReceived = \(dataReceived)")
        do
        {
            let dataDownloadedAsJson = try NSJSONSerialization.JSONObjectWithData(dataReceived!, options: .AllowFragments)
            print("dataDownloadedAsJson = \(dataDownloadedAsJson)")
            
            let nameRead = dataDownloadedAsJson["name"] as? String
            let countryRead = dataDownloadedAsJson["country"] as? String
            let genderRead = dataDownloadedAsJson["gender"] as? String
            let ageRead = dataDownloadedAsJson["age"] as? Int
            
            print("nameRead = \(nameRead!)")
            print("countryRead = \(countryRead!)")
            print("genderRead = \(genderRead!)")
            print("ageRead = \(ageRead!)")
            
            person.name = nameRead!
            person.age = ageRead!
            person.gender = genderRead!
            person.country = countryRead!
        }
        catch
        {
            
        }
        }
        else
        {
            print("Error downloading data. Error = \(error)")
        }
    }
    
    // actually execute the task
    downloadTask.resume()
    return person
}
and in the ViewController, change the way you call the getJsonData function, to this (and applying the result to the UI):
override func viewDidLoad() {
    super.viewDidLoad()
    
    let jsonUrlAsString = "https://api.myjson.com/bins/2c0aw"
    let person = Utilities.getJsonData(jsonUrlAsString)
    
    print("person.name = \(person.name!)")
    labelName.text = person.name!
}
If you run this, you'll be getting a runtime error of something like this:
fatal error: unexpectedly found nil while unwrapping an Optional value
(lldb) 

Why are we getting this?
Well, the issue is that the getJsonData is downloading data on a different thread, and while this is great, it also means that the return statement of the getJsonData function is getting called before the data finishes downloading.
So, because of this, you're returning an empty Person object, with just nil values. Therefore person.name is nil

In other words, we're returning nothing because we're reaching our return statement before we ever even make our network call.
And that's not good...

Returning data with completion handler

Since we cannot return the data as a good ol' return type, we need a different way to return the data, after we know the download has completed....whenever that is.

For this we use Completion Handlers, also known as Callbacks in other languages.

I won't get into details on this, so if you want to learn more about Completion Handlers there are tons of online resources for that.
In short, they are a parameter sent from whoever wants to be notified of something happening on a different class or function.
In this tutorial, I'm just teaching you how to use them with a specific example to achieve something.

1. The first thing we'll do is modify the getJsonData function to accept a completion handler parameter.
Change the getJsonData function declaration from this:
static func getJsonData(jsonUrlAsString:String) -> Person
to this:
static func getJsonData(jsonUrlAsString:String, completionHandlerPerson:(responsePerson:Person?, errorPerson:NSError?) -> ())

In here, we're passing a new parameter called completionHandlerPerson, which will return 2 elements: a Person object response (named responsePerson), and a NSError object (named errorPerson)
We're also just returning () or Void now, since the return will be handled by the completionHandlerPerson.
Since our completionHandlerPerson has a Person object and a NSError object, we must always use it retuning both elements. This is handy because if we do get data downloaded, the NSError element will be nil, which is easy to check.

2. With the function declaration modified, we now need to return our data with our completionHandlerPerson element.
Our entire getJsonData function will now look like this:
static func getJsonData(jsonUrlAsString:String, completionHandlerPerson:(responsePerson:Person?, errorPerson:NSError?) -> ())
{
    let person = Person()

    let configuration = NSURLSessionConfiguration.defaultSessionConfiguration()
    let headers: [NSObject : AnyObject] = ["Accept":"application/json", "Auth":"token"]
    configuration.HTTPAdditionalHeaders = headers
    let session = NSURLSession(configuration: configuration)
    
    let downloadTask = session.dataTaskWithURL(NSURL(string: jsonUrlAsString)!) { (dataReceived, response, error) in
        if (error == nil)
        {
            print("dataReceived = \(dataReceived)")
            do
            {
                let dataDownloadedAsJson = try NSJSONSerialization.JSONObjectWithData(dataReceived!, options: .AllowFragments)
                print("dataDownloadedAsJson = \(dataDownloadedAsJson)")
                
                let nameRead = dataDownloadedAsJson["name"] as? String
                let countryRead = dataDownloadedAsJson["country"] as? String
                let genderRead = dataDownloadedAsJson["gender"] as? String
                let ageRead = dataDownloadedAsJson["age"] as? Int
                
                print("nameRead = \(nameRead!)")
                print("countryRead = \(countryRead!)")
                print("genderRead = \(genderRead!)")
                print("ageRead = \(ageRead!)")
                
                person.name = nameRead!
                person.age = ageRead!
                person.gender = genderRead!
                person.country = countryRead!
                
                completionHandlerPerson(responsePerson: person, errorPerson: nil)
            }
            catch
            {
                
            }
        }
            else
            {
                print("Error downloading data. Error = \(error)")
                completionHandlerPerson(responsePerson: nil, errorPerson: error)

            }
        }
    
    // actually execute the task
    downloadTask.resume()
}
Notice how we call the completionHandlerPerson twice: once for when the response is valid, and once for when the error happens.
We should also return a third version for when the parser fails, but that'll be your homework.

And with that, the Utilities.getJsonData function is complete.
We need to modify the caller class of ViewController.

In ViewController, you need to change this:
let person = Utilities.getJsonData(jsonUrlAsString)
for this:
Utilities.getJsonData(jsonUrlAsString,
                              completionHandlerPerson: {(responsePerson, errorPerson) -> Void in
                          })
so now we're passing our custom completion handler as a parameter to Utilities, and whenever the download thread finishes, we will be notified to do whatever want to do.

With that, all we're missing is placing the UI elements inside the caller completion handler, and we'll be done.

Here's the final ViewController class:
import UIKit
class ViewController: UIViewController
{

    @IBOutlet weak var labelName: UILabel!
    @IBOutlet weak var labelAge: UILabel!
    @IBOutlet weak var labelGender: UILabel!
    @IBOutlet weak var labelCountry: UILabel!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let jsonUrlAsString = "https://api.myjson.com/bins/2c0aw"
        Utilities.getJsonData(jsonUrlAsString,
                              completionHandlerPerson: {(responsePerson, errorPerson) -> Void in
                                if (errorPerson == nil)
                                {
                                    // don't forget to update the UI in the main thread!
                                    dispatch_async(dispatch_get_main_queue()) { () -> Void in
                                        self.labelName.text = responsePerson!.name!
                                        self.labelAge.text = String(responsePerson!.age!)
                                        self.labelGender.text = responsePerson!.gender!
                                        self.labelCountry.text = responsePerson!.country!
                                    }
                                }
                                
                            })
    }
}

and here's the final Utilities class:
import Foundation
class Utilities : NSObject
{
    static func getJsonData(jsonUrlAsString:String, completionHandlerPerson:(responsePerson:Person?, errorPerson:NSError?) -> ())
    {
        let person = Person()

        let configuration = NSURLSessionConfiguration.defaultSessionConfiguration()
        let headers: [NSObject : AnyObject] = ["Accept":"application/json", "Auth":"token"]
        configuration.HTTPAdditionalHeaders = headers
        let session = NSURLSession(configuration: configuration)
        
        let downloadTask = session.dataTaskWithURL(NSURL(string: jsonUrlAsString)!) { (dataReceived, response, error) in
            if (error == nil)
            {
                print("dataReceived = \(dataReceived)")
                do
                {
                    let dataDownloadedAsJson = try NSJSONSerialization.JSONObjectWithData(dataReceived!, options: .AllowFragments)
                    print("dataDownloadedAsJson = \(dataDownloadedAsJson)")
                    
                    let nameRead = dataDownloadedAsJson["name"] as? String
                    let countryRead = dataDownloadedAsJson["country"] as? String
                    let genderRead = dataDownloadedAsJson["gender"] as? String
                    let ageRead = dataDownloadedAsJson["age"] as? Int
                    
                    print("nameRead = \(nameRead!)")
                    print("countryRead = \(countryRead!)")
                    print("genderRead = \(genderRead!)")
                    print("ageRead = \(ageRead!)")
                    
                    person.name = nameRead!
                    person.age = ageRead!
                    person.gender = genderRead!
                    person.country = countryRead!
                    
                    completionHandlerPerson(responsePerson: person, errorPerson: nil)
                }
                catch
                {
                    
                }
            }
                else
                {
                    print("Error downloading data. Error = \(error)")
                    completionHandlerPerson(responsePerson: nil, errorPerson: error)

                }
            }
        
        // actually execute the task
        downloadTask.resume()
    }
}

And that should be all!
Your app should now be ready to download data in some class, and consume it on another one.

Eduardo

(it's midnight so let me know if I missed something, or something doesn't make sense)

1 comment:

  1. Hey!
    I really love your blog about coding with Swift. You rock!

    Thanks you in advance for your blog! Keep coding and writing here ;)

    ReplyDelete