Building a Weather Application From Scratch

Increasing Testability With Protocol-Oriented Programming

Resources

In the previous episode, we asked the Core Location framework for the location of the device and used that location to fetch weather data. That change has made the application much more useful.

At the end of the episode, I mentioned that the current implementation introduces a subtle problem. The RootViewModel class is responsible for fetching the location of the device, using the Core Location framework. The implementation of the RootViewModel class depends on the Core Location framework. Because Core Location is a framework we don't control, it impacts the testability of the RootViewModel class. Remember what I said earlier in this series. To write a fast and reliable test suite, you need to be in control of the environment the test suite runs in.

In this episode, we use protocol-oriented programming to decouple the RootViewModel class from the the Core Location framework. We used protocol-oriented programming earlier in this series to add a layer of abstraction. It's a powerful pattern that is easy to adopt.

Before We Start

Before we start, I'd like to create a lightweight type that represents a location. The project currently uses the CLLocation class for that purpose. Because CLLocation is defined in the Core Location framework, I'd like to replace it with a type that represents a location, a set of coordinates.

Create a new Swift file in the Models group and name it Location.swift. The implementation is straightforward. We define a structure, Location, with two constant properties, latitude of type Double and longitude of type Double. That's it. Because we don't define a custom initializer, the Location struct automatically receives a memberwise initializer that accepts a latitude and a longitude.

import Foundation

struct Location {

    // MARK: - Properties

    let latitude: Double
    let longitude: Double

}

The Location struct helps us remove the references to the CLLocation class. Let's start with Configuration.swift. We remove the CL class prefix of CLLocation. We can also remove the import statement for Core Location at the top.

import Foundation

enum Defaults {

    static let location = Location(latitude: 37.335114, longitude: -122.008928)

}

enum WeatherService {

    ...

}

Next is the WeatherRequest struct. We change the type of the location property to Location and update the latitude and longitude computed properties. We can also remove the import statement for Core Location at the top.

import Foundation

struct WeatherRequest {

    // MARK: - Properties

    let baseUrl: URL

    // MARK: -

    let location: Location

    // MARK: -

    var latitude: Double {
        return location.latitude
    }

    var longitude: Double {
        return location.longitude
    }

    // MARK: -

    var url: URL {
        return baseUrl.appendingPathComponent("\(latitude),\(longitude)")
    }

}

The RootViewModel class requires a number of changes. Open RootViewModel.swift and navigate to the fetchWeatherData(for:) method. Change the type of the location parameter to Location.

private func fetchWeatherData(for location: Location) {

    ...

}

To keep the compiler happy, we also update the implementation of locationManager(_:didUpdateLocations:). We use the CLLocation instance to create a Location instance. We extract the latitude and longitude of the CLLocation instance and use the values to create a Location instance. The Location instance is passed to the fetchWeatherData(for:) method we updated a moment ago.

func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
    guard let location = locations.first else {
        return
    }

    // Fetch Weather Data
    let latitude = location.coordinate.latitude
    let longitude = location.coordinate.longitude
    fetchWeatherData(for: Location(latitude: latitude, longitude: longitude))
}

Run the application to make sure we didn't break anything.

Defining an Interface

To extract the Core Location framework from the RootViewModel class, we need to replace the CLLocationManager instance with an object that is capable of fetching the location of the device. Let's start small and define the interface for that object. We do that by defining a protocol. Create a new Swift file in the Protocols group and name it LocationService.swift. Define a protocol with name LocationService.

import Foundation

protocol LocationService {

}

The interface isn't complicated. An object that can fetch the location of the device only needs to implement one method, fetchLocation(completion:). The method accepts one argument, a closure. The closure accepts two arguments, an optional Location object and an optional Error object.

import Foundation

protocol LocationService {

    // MARK: - Methods

    func fetchLocation(completion: @escaping (Location?, Error?) -> Void)

}

Notice that the closure is defined as escaping by marking it with the escaping attribute. If a closure is passed as an argument to a function and the closure is invoked after the function returns, then the closure is escaping. It is also said that the closure argument escapes the function body. A closure is non-escaping by default as of Swift 3. If a closure is escaping, it needs to be marked with the escaping attribute.

Let's clean up the method signature by defining a type alias. We define a type alias with name FetchLocationCompletion and use it in the fetchLocation(completion:) method. Type aliases are very convenient to keep your code readable and concise. This is especially true if you're dealing with complex closures or nested types.

import Foundation

protocol LocationService {

    // MARK: - Type Aliases

    typealias FetchLocationCompletion = (Location?, Error?) -> Void

    // MARK: - Methods

    func fetchLocation(completion: @escaping FetchLocationCompletion)

}

Even though we haven't created a type that conforms to the LocationService protocol, we can already put the protocol to use. Open RootViewModel.swift and navigate to the initializer of the RootViewModel class. Remove the override keyword and define a parameter, locationService, of type LocationService.

init(locationService: LocationService) {
    super.init()

    // Fetch Weather Data
    fetchWeatherData(for: Defaults.location)

    // Fetch Location
    fetchLocation()
}

Define a private, constant property, locationService, of type LocationService.

private let locationService: LocationService

In the initializer, we store a reference to the location service that is passed to the initializer in the locationService property.

init(locationService: LocationService) {
    // Set Location Service
    self.locationService = locationService

    super.init()

    // Fetch Weather Data
    fetchWeatherData(for: Defaults.location)

    // Fetch Location
    fetchLocation()
}

Most of the hard work of this episode is completed. Before we remove the Core Location framework from the RootViewModel class, we need to put the location service to use. The only method we need to modify is fetchLocation(). We ask the location service for the location of the device by invoking the fetchLocation(completion:) method.

private func fetchLocation() {
    locationService.fetchLocation { [weak self] (location, error) in
        if let location = location {
            // Invoke Completion Handler
            self?.fetchWeatherData(for: location)

        } else {
            print("Unable to Fetch Location")

            // Invoke Completion Handler
            self?.didFetchWeatherData?(nil, .notAuthorizedToRequestLocation)
        }
    }
}

The Location instance that is returned by the location service is passed to the fetchWeatherData(for:) method. If something went wrong, we print a message to the console and invoke the didFetchWeatherData closure, passing in notAuthorizedToRequestLocation as the error. The error doesn't cover every scenario. We update the implementation later in this episode.

We can't run the application because we broke it. The initializer of the RootViewModel class expects an object that conforms to the LocationService protocol. That's the last piece of the puzzle.

Adopting the Protocol

Create a new group, Managers, and add a Swift file to the group. Name the file LocationManager.swift. We define a class, LocationManager, that inherits from NSObject. This is important because the LocationManager class will conform to the CLLocationManagerDelegate protocol.

import Foundation

class LocationManager: NSObject {

}

The LocationManager class should also conform to the LocationService protocol. The compiler helps us by offering to add a stub for the only method of the LocationService protocol.

import Foundation

class LocationManager: NSObject, LocationService {

    func fetchLocation(completion: @escaping LocationService.FetchLocationCompletion) {

    }

}

The LocationManager class takes advantage of the Core Location framework to obtain the location of the device, identical to how it's currently implemented in the RootViewModel class. In other words, we're moving this responsibility from the RootViewModel class to the LocationManager class.

Open RootViewModel.swift in the assistant editor on the right. That will make the implementation easier. Move the import statement for the Core Location framework from RootViewModel.swift to LocationManager.swift.

import Foundation
import CoreLocation

class LocationManager: NSObject, LocationService {

    // MARK: - Location Service

    func fetchLocation(completion: @escaping LocationService.FetchLocationCompletion) {

    }

}

Move the locationManager property from the RootViewModel class to the LocationManager class.

import Foundation
import CoreLocation

class LocationManager: NSObject, LocationService {

    // MARK: - Properties

    private lazy var locationManager: CLLocationManager = {
        // Initialize Location Manager
        let locationManager = CLLocationManager()

        // Configure Location Manager
        locationManager.delegate = self

        return locationManager
    }()

    // MARK: - Location Service

    func fetchLocation(completion: @escaping LocationService.FetchLocationCompletion) {

    }

}

This also means LocationManager needs to adopt the CLLocationManagerDelegate protocol. Move the extension for the RootViewModel class to the LocationManager class and replace RootViewModel with LocationManager in the extension definition. The compiler won't be happy, but we fix any errors in a few moments.

extension LocationManager: CLLocationManagerDelegate {

    func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
        if status == .notDetermined {
            // Request Authorization
            locationManager.requestWhenInUseAuthorization()

        } else if status == .authorizedWhenInUse {
            // Fetch Location
            fetchLocation()
        } else {
            // Invoke Completion Handler
            didFetchWeatherData?(nil, .notAuthorizedToRequestLocation)
        }
    }

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        guard let location = locations.first else {
            return
        }

        // Fetch Weather Data
        let latitude = location.coordinate.latitude
        let longitude = location.coordinate.longitude
        fetchWeatherData(for: Location(latitude: latitude, longitude: longitude))
    }

    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        print("Unable to Fetch Location (\(error))")
    }

}

We first need to start with the implementation of the fetchLocation(completion:) method of the LocationManager class. We start by defining a property, didFetchLocation, of type FetchLocationCompletion?. As I mentioned earlier, type aliases make your life as a developer much easier.

import Foundation
import CoreLocation

class LocationManager: NSObject, LocationService {

    // MARK: - Properties

    private var didFetchLocation: FetchLocationCompletion?

    ...

}

Fetching the location of the device is an asynchronous operation and we need to keep a reference to the completion handler that is passed to fetchLocation(completion:). We store a reference to the closure in the didFetchLocation property. That's the first thing we do in the fetchLocation(completion:) method.

func fetchLocation(completion: @escaping LocationService.FetchLocationCompletion) {
    // Store Reference to Completion
    self.didFetchLocation = completion
}

To fetch the location, we invoke requestLocation() on the CLLocationManager instance. That should look familiar.

func fetchLocation(completion: @escaping LocationService.FetchLocationCompletion) {
    // Store Reference to Completion
    self.didFetchLocation = completion

    // Fetch Location
    locationManager.requestLocation()
}

We can now update the implementation of the CLLocationManagerDelegate protocol. Let's start with locationManager(_:didChangeAuthorization:). If the authorization status is equal to authorizedWhenInUse, we invoke requestLocation() on the CLLocationManager instance.

func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
    if status == .notDetermined {
        ...
    } else if status == .authorizedWhenInUse {
        // Fetch Location
        locationManager.requestLocation()

    } else {
        ...
    }
}

If the user hasn't granted the application access to the location of their device, the else clause is executed. In the else clause, we invoke the didFetchLocation closure.

There's a problem, though. We need to pass an error to the closure. The question is "Where should the error be defined?" Two options come to mind. We can define the error in a separate file. That's a fine solution. The other option is to define the error in the same file as the LocationService protocol, associating it with the protocol. That's the option I like best. Let me show you what I mean. Open LocationService.swift. Below the import statement, define an error with name LocationSerivceError. We define one case, notAuthorizedToRequestLocation.

import Foundation

enum LocationServiceError: Error {
    case notAuthorizedToRequestLocation
}

protocol LocationService {

    // MARK: - Type Aliases

    typealias FetchLocationCompletion = (Location?, Error?) -> Void

    // MARK: - Methods

    func fetchLocation(completion: @escaping FetchLocationCompletion)

}

The name implies that the error is associated with the LocationService protocol. I'd like to take it one step further and update the type alias. We replace the type of the second parameter of the closure with LocationSerivceError. This illustrates another benefit of type aliases. We need to make this change in one location.

import Foundation

enum LocationServiceError: Error {
    case notAuthorizedToRequestLocation
}

protocol LocationService {

    // MARK: - Type Aliases

    typealias FetchLocationCompletion = (Location?, LocationServiceError?) -> Void

    // MARK: - Methods

    func fetchLocation(completion: @escaping FetchLocationCompletion)

}

Revisit LocationManager.swift and pass notAuthorizedToRequestLocation as the second argument of the didFetchLocation closure. We set the didFetchLocation property to nil to make sure it can't be invoked more than once.

func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
    if status == .notDetermined {
        // Request Authorization
        locationManager.requestWhenInUseAuthorization()

    } else if status == .authorizedWhenInUse {
        // Fetch Location
        locationManager.requestLocation()

    } else {
        // Invoke Completion Handler
        didFetchLocation?(nil, .notAuthorizedToRequestLocation)

        // Reset Completion Handler
        didFetchLocation = nil
    }
}

We also need to update locationManager(_:didUpdateLocations:). Before we do, I'd like to show you a nice benefit of extensions. At the bottom of LocationManager.swift, we define an extension for Location. We define an initializer that accepts a CLLocation instance. By defining an initializer in an extension, the memberwise initializer remains available and we have access to a convenient initializer to convert a CLLocation instance to a Location instance.

extension Location {

    // MARK: - Initialization

    init(location: CLLocation) {
        latitude = location.coordinate.latitude
        longitude = location.coordinate.longitude
    }

}

We prefix the extension with the fileprivate keyword to ensure the initializer is only accessible from within LocationManager.swift.

fileprivate extension Location {

    // MARK: - Initialization

    init(location: CLLocation) {
        latitude = location.coordinate.latitude
        longitude = location.coordinate.longitude
    }

}

In locationManager(_:didUpdateLocations:), we invoke the didFetchLocation closure. We use the initializer we implemented a moment ago to create a Location instance from a CLLocation instance. We set the didFetchLocation property to nil to make sure it can't be invoked more than once.

func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
    guard let location = locations.first else {
        return
    }

    // Invoke Completion Handler
    didFetchLocation?(Location(location: location), nil)

    // Reset Completion Handler
    didFetchLocation = nil
}

Handling Errors

We're almost ready to run the application. We need to handle a few more errors first. Open RootViewModel.swift and define a new case for WeatherDataError, failedToRequestLocation.

import Foundation

class RootViewModel: NSObject {

    // MARK: - Types

    enum WeatherDataError: Error {
        case notAuthorizedToRequestLocation
        case failedToRequestLocation
        case noWeatherDataAvailable
    }

    ...

}

Navigate to the fetchLocation() method. We need to update the implementation slightly. If the error isn't equal to nil, then it means the user hasn't granted the application access to the location of their device because that's the only error the LocationServiceError enum defines at the moment.

private func fetchLocation() {
    locationService.fetchLocation { [weak self] (location, error) in
        if let error = error {
            print("Unable to Fetch Location (\(error))")

            // Invoke Completion Handler
            self?.didFetchWeatherData?(nil, .notAuthorizedToRequestLocation)

        } else if let location = location {
            // Invoke Completion Handler
            self?.fetchWeatherData(for: location)

        } else {
            print("Unable to Fetch Location")

            // Invoke Completion Handler
            self?.didFetchWeatherData?(nil, .notAuthorizedToRequestLocation)
        }
    }
}

We also update the error we pass to the didFetchWeatherData closure in the else clause. Because we know that the application should never enter the else clause, we could throw a fatal error. Instead of throwing a fatal error, however, we pass failedToRequestLocation as the second argument of the didFetchWeatherData closure.

This example illustrates the downside of defining a closure with two optional parameters. In another episode, I show you an elegant solution to resolve this annoying issue.

private func fetchLocation() {
    locationService.fetchLocation { [weak self] (location, error) in
        if let error = error {
            print("Unable to Fetch Location (\(error))")

            // Invoke Completion Handler
            self?.didFetchWeatherData?(nil, .notAuthorizedToRequestLocation)

        } else if let location = location {
            // Invoke Completion Handler
            self?.fetchWeatherData(for: location)

        } else {
            print("Unable to Fetch Location")

            // Invoke Completion Handler
            self?.didFetchWeatherData?(nil, .failedToRequestLocation)
        }
    }
}

We also need to update the RootViewController class. It should be able to handle the failedToRequestLocation error. At the top, we add a case to the AlertType enum, failedToRequestLocation.

import UIKit

class RootViewController: UIViewController {

    // MARK: - Types

    private enum AlertType {
        case notAuthorizedToRequestLocation
        case failedToRequestLocation
        case noWeatherDataAvailable
    }

    ...

}

We update the implementation of setupViewModel(with:) by adding a case to the switch statement.

private func setupViewModel(with viewModel: RootViewModel) {
    // Configure View Model
    viewModel.didFetchWeatherData = { [weak self] (weatherData, error) in
        if let error = error {
            let alertType: AlertType

            switch error {
            case .notAuthorizedToRequestLocation:
                alertType = .notAuthorizedToRequestLocation
            case .failedToRequestLocation:
                alertType = .failedToRequestLocation
            case .noWeatherDataAvailable:
                alertType = .noWeatherDataAvailable
            }

    ...

}

And we also update presentAlert(of:). We add a case to the switch statement. It's important that we notify the user if anything's goes wrong.

private func presentAlert(of alertType: AlertType) {
    // Helpers
    let title: String
    let message: String

    switch alertType {
    case .notAuthorizedToRequestLocation:
        ...
    case .failedToRequestLocation:
        title = "Unable to Fetch Weather Data for Your Location"
        message = "Rainstorm is not able to fetch your current location due to a technical issue."
    case .noWeatherDataAvailable:
        ...
    }

    ...

}

If you try to run the application, you'll notice that the build fails. We need to update the instantiation of the RootViewModel instance in the application delegate. That's easy to do. Open AppDelegate.swift and pass a LocationManager instance to the initializer of the RootViewModel class in application(_:didFinishLaunchingWithOptions:).

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
    guard let rootViewController = window?.rootViewController as? RootViewController else {
        fatalError("Unexpected Root View Controller")
    }

    // Initialize Root View Model
    let rootViewModel = RootViewModel(locationService: LocationManager())

    // Configure Root View Controller
    rootViewController.viewModel = rootViewModel

    return true
}

Build and run the application to see the result. Nothing has changed visually, but we reap the benefits of this episode later in this series.

What's Next?

Protocol-oriented programming is a powerful pattern that you need to understand as a Swift developer. Even though it may seem as if we simply moved code from the RootViewModel class to the LocationManager class, the benefits become clear once we start unit testing the RootViewModel class.

The addition of the LocationService protocol also adds flexibility to the project. We can easily replace the LocationManager class with another type as long as it conforms to the LocationService protocol.

Resources
Next Episode "Replacing Optionals with Enums and Associated Values"

Stop Writing Swift That Sucks

Download the Swift Patterns I Swear By