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.