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.