Alamofire + AlamofireObjectMapper
This is an updated version of a talk I gave at CocoaHeads Sthlm in 2017, on how to use Alamofire to talk with an api, AlamofireObjectMapper to map responses, the Alamofire RequestRetrier
to retry failing requests and the RequestAdapter
to adapt all requests.
In this post, I’ll recreate the entire app from scratch, with some modifications. I have updated the original post to Swift 4.2 and to use some new code conventions as well.
Disclaimer
Since I gave this talk, Codable
has been released as a native part of Swift. I will update this blog post to Swift 4.2, but you should really be using Codable
instead of Mappable
.
Regarding the demo app structure, I normally prefer to extract as much logic and code as possible to separate libraries, which I then can use as decoupled blocks. For instance, I would keep my domain logic in a domain library that doesn’t know anything about the app.
I’d also keep all API logic in an API library that knows about the domain, but not the app. In this project, though, I will keep it simple. Think of the Api
folder as a separate library, and Auth
and Movies
as part of a Domain
library.
Update information
The original blog post was released in August 2017 and was updated about a week ago. The biggest differences between that post and this, is that this post changes the following:
- I use
struct
instead ofclass
for models. Using structs simplifies how you can use and extend your model, collections etc. so it’s something I really recommend. - I no longer use model protocols. I instead use API-specific models that can be mapped to domain-specific models. This is much more flexible, but requires additional code.
- The Swift 4.2 demo app has much more comments, to guide developers and explain what the various parts do.
Video
You can watch the original talk here. The talk focuses more on concepts than code, so that talk and this post complete eachother pretty well.
Prerequisites
For this article, I expect that you know about CocoaPods. I will use terms like podfile
and expect you to know what it means.
Source Code
I recommend that you create an app project and work through this tutorial by coding. You can also grab the code from GitHub. The master
branch contains code for the demo app and gh-pages
contains source code for the static api.
Why use a static api?
In the demo, we will use a static API to fetch movies. The API is a static Jekyll web site that lets us grab movies by ID, as well as top rated and top grossing movies.
The limited API lets us focus on Alamofire and Realm instead of having to understand an external API, set up a developer account, handle auth logic etc.
Defining the domain model
Start by creating a clean Xcode project. I went with a simple iOS storyboard app, but you can set it up in any way you like.
The app fetches movies from the API. A Movie
has basic info and a cast
of MovieActor
. MovieActor
only has a name to show how easy deep mapping is with Alamofire.
Let’s define this domain-specific model as two simple structs:
struct Movie {
let id: Int
let name: String
let year: Int
let releaseDate: Date
let grossing: Int
let rating: Double
let cast: [MovieActor]
}
struct MovieActor {
let name: String
}
We will later convert the api-specific models we receive from the API to these structs. The app should only know about these structs and be oblivious about the existence of an API.
Define the domain logic
Now let’s define how the app should fetch movies. Add this protocol to Movies
:
typealias MovieResult = (_ movie: Movie?, _ error: Error?) -> ()
typealias MoviesResult = (_ movies: [Movie], _ error: Error?) -> ()
protocol MovieService: class {
func getMovie(id: Int, completion: @escaping MovieResult)
func getTopGrossingMovies(year: Int, completion: @escaping MoviesResult)
func getTopRatedMovies(year: Int, completion: @escaping MoviesResult)
}
This service lets us fetch fetch single movies as well as top grossing and top rated movies. Having completion blocks open up for implementations to do this asynchronously.
Add Alamofire and AlamofireObjectMapper
Before we can add api-specific implementations to the app, we must set up CocoaPods to specify that the app needs Alamofire
and AlamofireObjectMapper
.
To do this, run pod init
to create a podfile
, add Alamofire
and AlamofireObjectMapper
to the file and run pod install
to download these libraries.
Once this is done, open the generated workspace instead of the project file.
Create an api specific domain model
With the dependencies in place, we can now add app-specific implementations to the app. Create an Api
folder in the project root, add a Movies
folder to it and add these two types:
import ObjectMapper
class ApiMovie {
required public init?(map: Map) {}
var id = 0
var name = ""
var year = 0
var releaseDate = Date(timeIntervalSince1970: 0)
var grossing = 0
var rating = 0.0
var cast = [ApiMovieActor]()
func convert() -> Movie {
return Movie(
id: id,
name: name,
year: year,
releaseDate: releaseDate,
grossing: grossing,
rating: rating,
cast: cast.map { $0.convert() }
)
}
}
// MARK: - Mappable
extension ApiMovie: Mappable {
func mapping(map: Map) {
id <- map["id"]
name <- map["name"]
year <- map["year"]
releaseDate <- map["releaseDate"]
grossing <- map["grossing"]
rating <- map["rating"]
cast <- map["cast"]
}
}
import ObjectMapper
class ApiMovieActor {
required public init?(map: Map) {}
var name = ""
func convert() -> MovieActor {
return MovieActor(name: name)
}
}
// MARK: - Mappable
extension ApiMovieActor: Mappable {
func mapping(map: Map) {
name <- map["name"]
}
}
These API-specific types have mapping logic that can automatically map API responses to these types. They also have a convert()
function to map them to domain-specific types.
Besides this, ApiMovie
uses a DateTransform
and has an ApiMovieActor
array that can be easily converted using map/convert
.
If we have set things up properly, we should now be able to point Alamofire to a valid url and recursively parse movie data with little effort.
Setup the core api logic
Before we create an API-specific MovieService
implementation, let’s setup some core API logic in the Api
folder, that our service implementation can use.
Managing API environments
Since real-world apps often have to switch between different API environments (e.g. test and production) I often use enums to specify available API environments.
I know we only have a single environment now, but I still prefer to have such an enum in place for later. Add this enum to the Api
folder:
import Foundation
enum ApiEnvironment: String { case
production = "http://danielsaidi.com/demo_Alamofire_AlamofireObjectMapper_Realm/api/"
var url: String {
return rawValue
}
}
Managing api routes
With the ApiEnvironment
enum in place, we can list available API routes in another enum. Add this enum to the Api
folder:
import Foundation
enum ApiRoute { case
auth,
movie(id: Int),
topGrossingMovies(year: Int),
topRatedMovies(year: Int)
var path: String {
switch self {
case .auth: "auth"
case .movie(let id): "movies/\(id)"
case .topGrossingMovies(let year): "movies/topGrossing/\(year)"
case .topRatedMovies(let year): "movies/topRated/\(year)"
}
}
func url(for environment: ApiEnvironment) -> String {
return "\(environment.url)/\(path)"
}
}
Since year
and id
are dynamic route segments, we use associated enum values. This enum can also provide complete route urls for specific API environments.
Managing API context
I usually have an ApiContext
class to manage and persist API-specific information, such as the current environment, authentication tokens etc.
This context can be used by services that need to communicate with the API. A singleton context ensures that all API specific services are properly affected whenever it changes.
Let’s create an ApiContext
protocol and a non-persisted implementation. Add a Context
folder to the Api
folder, then add these files to it:
protocol ApiContext {
var environment: ApiEnvironment { get set }
}
class NonPersistentApiContext: ApiContext {
init(environment: ApiEnvironment) {
self.environment = environment
}
var environment: ApiEnvironment
}
We can now inject this context into our api-specific service implementations, and add more properties later if we want to, e.g. authentication tokens.
If we later create a persistent context, e.g. one that stores data in UserDefault
, we just have to create another implementation and replace the implementation we use in our app.
Specifying basic API behavior
To simplify how an app communicates with the API, let’s create a base class for API-based services. Add an Alamofire
folder to the Api
folder, then add this file to it:
import Alamofire
class AlamofireService {
init(context: ApiContext) {
self.context = context
}
var context: ApiContext
func get(at route: ApiRoute) -> DataRequest {
return request(at: route, method: .get, encoding: URLEncoding.default)
}
func post(at route: ApiRoute) -> DataRequest {
return request(at: route, method: .post, encoding: JSONEncoding.default)
}
func put(at route: ApiRoute) -> DataRequest {
return request(at: route, method: .put, encoding: JSONEncoding.default)
}
func request(at route: ApiRoute, method: HTTPMethod, params: Parameters = [:], encoding: ParameterEncoding) -> DataRequest {
let url = route.url(for: context.environment)
return Alamofire
.request(url, method: method, parameters: params, encoding: encoding)
.validate()
}
}
Forcing services to use ApiRoute
ensures that the app can’t make unspecified requests. If the app needs to call any custom URLs later on, we could just add a .custom(url: String)
case to the ApiRoute
enum.
This was a pretty long setup, but we are now ready to fetch movies from the API!
Create an API-based movie service
Let’s create an API-based movie service that fetches movies from the API, by using the foundation that we have setup. Just add this file to the Api/Movies
folder:
import Alamofire
import AlamofireObjectMapper
class AlamofireMovieService: AlamofireService, MovieService {
func getMovie(id: Int, completion: @escaping MovieResult) {
get(at: .movie(id: id)).responseObject { (response: DataResponse<ApiMovie>) in
let result = response.result.value?.convert()
completion(result, response.result.error)
}
}
func getTopGrossingMovies(year: Int, completion: @escaping MoviesResult) {
get(at: .topGrossingMovies(year: year)).responseArray { (response: DataResponse<[ApiMovie]>) in
let result = response.result.value?.map { $0.convert() }
completion(result ?? [], response.result.error)
}
}
func getTopRatedMovies(year: Int, completion: @escaping MoviesResult) {
get(at: .topRatedMovies(year: year)).responseArray { (response: DataResponse<[ApiMovie]>) in
let result = response.result.value?.map { $0.convert() }
completion(result ?? [], response.result.error)
}
}
}
The service just performs requests and specifies API-specific return types that are mapped to by Alamofire and AlamofireObjectMapper, then uses convert()
to map the API-specific types to the domain-specific types that are used by the app.
The getMovie
request uses responseObject
since it returns an optional result object, while the other functions use responseArray
. since they return an array of objects.
I only use arrays to show both object and array mapping. Instead of having your API return arrays, I strongly recommend to add the arrays to a response object.
Returning response objects gives you more flexibility, where you can later add more things to the response object if needed. With arrays, you are stuck.
Fetch movies
We can now make our app fetch API results. Replace the ViewController
code with this:
override func viewDidLoad() {
super.viewDidLoad()
let env = ApiEnvironment.production
let context = NonPersistentApiContext(environment: env)
let service = AlamofireMovieService(context: context)
service.getTopGrossingMovies(year: 2016) { (movies, error) in
if let error = error { return print(error.localizedDescription) }
print("Found \(movies.count) movies:")
movies.forEach { print(" \($0.name)") }
}
}
IMPORTANT For this to work, you must allow the app to perform external requests. Add this to Info.plist
(in a real app, you should specify an exact list of trusted domains):
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
Now run the app. If everything is correctly setup, it should print the following:
Found 10 movies:
Finding Dory
Rouge One - A Star Wars Story
Captain America - Civil War
The Secret Life of Pets
The Jungle Book
Deadpool
Zootopia
Batman v Superman - Dawn of Justice
Suicide Squad
Doctor Strange
If you see this in Xcode’s log, the app now fetches movie data from the API and maps it to domain-specific models.
Now change the print format for each movie to look like this:
movies.forEach { print(" \($0.name) (\($0.releaseDate))") }
The app should now output the following:
Found 10 movies:
Finding Dory (1970-01-01 00:33:36 +0000)
Rouge One - A Star Wars Story (1970-01-01 00:33:36 +0000)
Captain America - Civil War (1970-01-01 00:33:36 +0000)
The Secret Life of Pets (1970-01-01 00:33:36 +0000)
The Jungle Book (1970-01-01 00:33:36 +0000)
Deadpool (1970-01-01 00:33:36 +0000)
Zootopia (1970-01-01 00:33:36 +0000)
Batman v Superman - Dawn of Justice (1970-01-01 00:33:36 +0000)
Suicide Squad (1970-01-01 00:33:36 +0000)
Doctor Strange (1970-01-01 00:33:36 +0000)
Oooops! Seems like the date parsing doesn’t work. I told you that we would have fix this. Let’s do it.
Fix date parsing
The problem is that the API uses a different date format than Alamofire expects. This can be solved by replacing the DateTransform
. Add a Date
folder to Api
and add this to it:
import ObjectMapper
public extension DateTransform {
public static var custom: DateFormatterTransform {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.timeZone = TimeZone(secondsFromGMT: 0)
return DateFormatterTransform(dateFormatter: formatter)
}
}
Now change the releaseDate
mapping in the ApiMovie
class to look like this:
releaseDate <- (map["releaseDate"], DateTransform.custom)
Problem solved! The app should now output the following instead:
Found 10 movies:
Finding Dory (2016-06-17 00:00:00 +0000)
Rouge One - A Star Wars Story (2016-12-16 00:00:00 +0000)
Captain America - Civil War (2016-05-06 00:00:00 +0000)
The Secret Life of Pets (2016-07-08 00:00:00 +0000)
The Jungle Book (2016-04-15 00:00:00 +0000)
Deadpool (2016-02-12 00:00:00 +0000)
Zootopia (2016-03-04 00:00:00 +0000)
Batman v Superman - Dawn of Justice (2016-03-25 00:00:00 +0000)
Suicide Squad (2016-08-05 00:00:00 +0000)
Doctor Strange (2016-11-05 00:00:00 +0000)
If you inspect the other properties, they should be correctly parsed. Time to celebrate, then to extend Alamofire with additional functionality.
Retry failing requests
In real apps, a user most often has to authenticate her/himself in order to use some parts of an API. Authentication APIs commonly return an auth token
and a refresh token
.
If an auth and refresh token is used, the authentication flow could look something like this:
- If no tokens exist and a request fails with an HTTP 401, the user may have to login (if the request is mandatory). If so, show a login screen/prompt.
- If tokens exist, the app should provide the
auth
token with each request. - If an
auth
token-based request fails with an HTTP 401, theauth
token has may have expired. The app should then save any requests that fail with 401 and use therefresh
token to request new tokens from the API. - If the refresh succeeds, the app should parse the new tokens and retry failed requests with these new tokens. The app should use these new tokens from now on.
- If the refresh request fails, the app should delete all tokens and logout the user. If the app requires a logged in user, the app should show a login screen.
Alamofire makes this logic easy to implement with a RequestRetrier
, which is a protocol that we can implement and inject into Alamofire. It’s automatically notified about all failing request and lets you determine if a request should be retried or not.
Let’s demonstrate this by faking a failing request. First, add an auth
route to ApiRoute
, using auth
as path. The static api will always give us the same auth token, but it’s good enough for demo purposes.
Second, add a new Auth
folder and add this protocol to it:
typealias AuthResult = (_ token: String?, _ error: Error?) -> ()
protocol AuthService: class {
func authorize(completion: @escaping AuthResult)
}
This is a very simple protocol that describes how the app authorizes itself. The app will be able to use this without having to care about how it’s implemented.
Before we implement it, we must add a way to store auth tokens. Remember what I told you about the ApiContext
? It’s a great place to store API tokens as well, so let’s do that.
Add an authToken
property to the ApiContext
protocol:
var authToken: String? { get set }
Also, add this property to NonPersistentApiContext
(if we had a persistent one, it would remember the token even if restarted the app, but that’s something you can build yourself):
var authToken: String?
Let’s create an Alamofire-based AuthService
. Create an Auth
folder to Api
and add this:
import Alamofire
import AlamofireObjectMapper
class ApiAuthService: AlamofireService, AuthService {
func authorize(completion: @escaping AuthResult) {
get(at: .auth).responseString { (response: DataResponse<String>) in
if let token = response.result.value {
self.context.authToken = token
}
completion(response.result.value, response.result.error)
}
}
}
If the request above succeeds, the token will be saved in our API context, which makes it available to all future api requests.
Now, let’s (finally) retry some requests by creating a custom request retrier! Add this retrier code to the Api/Alamofire
folder:
import Alamofire
class ApiRequestRetrier: RequestRetrier {
// MARK: - Initialization
init(context: ApiContext, authService: AuthService, statusCodeTrigger: Int = 404 /* 401 */) {
self.context = context
self.authService = authService
self.statusCodeTrigger = statusCodeTrigger
}
// MARK: - Dependencies
private let authService: AuthService
private var context: ApiContext
private let statusCodeTrigger: Int
// MARK: - Properties
private var isAuthorizing = false
private var retryQueue = [RequestRetryCompletion]()
// MARK: - RequestRetrier
func should(
_ manager: SessionManager,
retry request: Request,
with error: Error,
completion: @escaping RequestRetryCompletion) {
guard
shouldRetryRequest(with: request.request?.url),
shouldRetryResponse(with: request.response?.statusCode)
else { return completion(false, 0) }
authorize(with: completion)
}
}
// MARK: - Private Functions
private extension ApiRequestRetrier {
func authorize(with completion: @escaping RequestRetryCompletion) {
print("Authorizing application...")
retryQueue.append(completion)
guard !isAuthorizing else { return }
isAuthorizing = true
authService.authorize { (token, error) in
self.isAuthorizing = false
self.printAuthResult(token, error)
self.context.authToken = token
let success = token != nil
self.retryQueue.forEach { $0(success, 0) }
self.retryQueue.removeAll()
}
}
func printAuthResult(_ token: String?, _ error: Error?) {
if let error = error {
return print("Authorizing failed: \(error.localizedDescription)")
}
if let token = token {
return print("Authorizing succeded: \(token)")
}
print("No token received - failing!")
}
func shouldRetryRequest(with url: URL?) -> Bool {
guard let url = url?.absoluteString else { return false }
let authPath = ApiRoute.auth.path
return !url.contains(authPath)
}
func shouldRetryResponse(with statusCode: Int?) -> Bool {
return statusCode == statusCodeTrigger
}
}
Whenever a request fails, Alamofire will ask the retrier if it should be retried. The retrier will trigger a retry if the request is not a failing auth. If not, it just lets the request fail.
If a request should be retried, it’s added to a retry queue. The retrier then triggers an API authorization, after which it checks if it succeeded. If so, all queued requests are retried. If not, they are made to fail. The retry queue is then cleared.
Note that this is completely hidden to the user as well as the app itself. The retrier handles this under the hood, tightly built into to Alamofire’s internal workings. It just notifies the app if the authorization fails, by failing all requests.
You inject the retrier into Alamofire
by adding the following to our viewDidLoad
(note that you have to add import Alamofire
topmost as well):
let manager = SessionManager.default
manager.retrier = ApiRequestRetrier(context: context, authService: authService)
IMPORTANT A 401 status code is an indication that the auth token should be refreshed. If this fails, the refresh tokens are invalid. In this demo, we never receive a 401, since we use a static API. We thus have to trigger these mechanisms by doing the following:
- Kill your connection and perform a clean install, to remove all stored data.
- Add a breakpoint to the retrier’s
authService.authorizeApplication
call. - Run the app. The app should now fail the request and activate this breakpoint.
- Bring the connection back online and resume the app.
- This should make the auth request succeed and have Alamofire retry the request.
That’s it! Alamofire should now retry any failing request that are not auth ones.
Adapt all api requests
Sometimes, you have to add custom headers to your API requests. A common reason is to add Accept
information, auth tokens etc.
To adapt all requests before they are sent, you just have to implement the RequestAdapter
protocol and inject it into Alamofire.
Let’s give it a try! Add this file to the Api/Alamofire
folder:
import Alamofire
class ApiRequestAdapter: RequestAdapter {
public init(context: ApiContext) {
self.context = context
}
private let context: ApiContext
func adapt(_ request: URLRequest) throws -> URLRequest {
guard let token = context.authToken else { return request }
var request = request
request.setValue(token, forHTTPHeaderField: "AUTH_TOKEN")
return request
}
}
The adapter just adds any existing token to the request headers. Inject this adapter into Alamofire
by adding the following to our viewDidLoad
:
manager.adapter = ApiRequestAdapter(context: context)
That’s it! Alamofire should now add the auth token to all requests.
Adding offline support with Realm
Let’s add offline support to our app, so that we can use cached data when we are offline.
There’s a million ways to do this, but we’ll do it by adding Realm to our app and building a new services that stores movies to a local database.
When we create this new service, we’ll use the decorator pattern, where the new service will use a base service to fetch data, then add the database logic on top of this.
The decorator pattern is great when you want to compose services and let each service be responsible for its own scope. It makes it very easy to test each service and provides you with a flexible, composable code base.
Add Realm support
Before we can create a Realm-specific implementation of our service and domain model, we have to add Realm support to our app.
To do this, just add RealmSwift
to podfile
and run pod install
. When Realm has been installed, we can create proceed with creating a Realm-specific model.
Create a Realm-specific model
Unlike the old Swift 3 implementation of this app, I no longer use protocols for the domain model. Instead, I use structs and map other representations to that domain model.
As such, the Realm-specific model will not inherit any class nor implement any protocols. Instead, it will just define the properties it needs, as well as add some mapping functions.
Create a new Realm
folder in the app root and these two Realm classes to it:
import RealmSwift
class RealmMovieActor: Object {
convenience init(from actor: MovieActor) {
self.init()
self.name = actor.name
}
@objc dynamic var name = ""
func convert() -> MovieActor {
return MovieActor(name: name)
}
}
import RealmSwift
class RealmMovie: Object {
// MARK: - Initialization
convenience init(from: Movie) {
self.init()
self.id = from.id
self.name = from.name
self.year = from.year
self.releaseDate = from.releaseDate
self.grossing = from.grossing
self.rating = from.rating
from.cast
.map { RealmMovieActor(from: $0) }
.forEach { self.cast.append($0) }
}
// MARK: - Properties
@objc dynamic var id = 0
@objc dynamic var name = ""
@objc dynamic var year = 0
@objc dynamic var releaseDate = Date(timeIntervalSince1970: 0)
@objc dynamic var grossing = 0
@objc dynamic var rating = 0.0
let cast = List<RealmMovieActor>()
// MARK: - Primary Key
override class func primaryKey() -> String? {
return "id"
}
// MARK: - Functions
func convert() -> Movie {
return Movie(
id: id,
name: name,
year: year,
releaseDate: releaseDate,
grossing: grossing,
rating: rating,
cast: cast.map { $0.convert() }
)
}
}
Just like the API-specific models, these are regular Realm objects that can be mapped to our domain model. Both inherit the Realm
Object
class and have a convenience initializer that copies a domain model instance.
Create a Realm-specific movie service
Let’s add a Realm-specific movie service that lets us store movies and movie actors from the API into Realm. Add this file to the Realm
folder:
import RealmSwift
class RealmMovieService: MovieService {
// MARK: - Initialization
init(baseService: MovieService) {
self.baseService = baseService
}
// MARK: - Dependencies
private let baseService: MovieService
private var realm: Realm { return try! Realm() }
// MARK: - Functions
func getMovie(id: Int, completion: @escaping MovieResult) {
getMovieFromDb(id: id, completion: completion)
getMovieFromBaseService(id: id, completion: completion)
}
func getTopGrossingMovies(year: Int, completion: @escaping MoviesResult) {
getTopGrossingMoviesFromDb(year: year, completion: completion)
getTopGrossingMoviesFromBaseService(year: year, completion: completion)
}
func getTopRatedMovies(year: Int, completion: @escaping MoviesResult) {
getTopRatedMoviesFromDb(year: year, completion: completion)
getTopRatedMoviesFromBaseService(year: year, completion: completion)
}
}
// MARK: - Database Functions
private extension RealmMovieService {
func getMovieFromDb(id: Int, completion: @escaping MovieResult) {
let obj = realm.object(ofType: RealmMovie.self, forPrimaryKey: id)
guard let movie = obj?.convert() else { return }
completion(movie, nil)
}
func getTopGrossingMoviesFromDb(year: Int, completion: @escaping MoviesResult) {
let objs = realm.objects(RealmMovie.self).filter("year == \(year)")
let sorted = objs.sorted { $0.grossing > $1.grossing }.map { $0.convert() }
completion(sorted, nil)
}
func getTopRatedMoviesFromDb(year: Int, completion: @escaping MoviesResult) {
let objs = realm.objects(RealmMovie.self).filter("year == \(year)")
let sorted = objs.sorted { $0.rating > $1.rating }.map { $0.convert() }
completion(sorted, nil)
}
func persist(_ movie: Movie?) {
persist([movie].compactMap { $0 })
}
func persist(_ movies: [Movie]) {
let objs = movies.map { RealmMovie(from: $0) }
try! realm.write {
realm.add(objs, update: true)
}
}
}
// MARK: - Base Service Functions
private extension RealmMovieService {
func getMovieFromBaseService(id: Int, completion: @escaping MovieResult) {
baseService.getMovie(id: id) { [weak self] (movie, error) in
self?.persist(movie)
completion(movie, error)
}
}
func getTopGrossingMoviesFromBaseService(year: Int, completion: @escaping MoviesResult) {
baseService.getTopGrossingMovies(year: year) { [weak self] (movies, error) in
self?.persist(movies)
completion(movies, error)
}
}
func getTopRatedMoviesFromBaseService(year: Int, completion: @escaping MoviesResult) {
baseService.getTopRatedMovies(year: year) { [weak self] (movies, error) in
self?.persist(movies)
completion(movies, error)
}
}
}
The RealmMovieService
initializer requires another MovieService
. This is the decorator pattern in action, where RealmMovieService
uses another implementation of the same a protocol to extend the base implementation with Realm-specific logic.
In this case, baseService
will be an AlamofireMovieService
, but the decorator most not know anything about the base service, only what the protocol promises.
In this case, RealmMovieService
tries to get data from the database, but at the same time also tries to get data from the base service. When the base service completes, this service saves any data it receives, then calls the completion block with the data.
Disclaimer:
This is an intentionally simple design. RealmMovieService
always loads data from the database and from the base service. In a real app, you’d probably have logic to determine if calling the base service is needed.
Put Realm into action
Let’s give the new movie service a try. Modify viewDidLoad
to look like this:
override func viewDidLoad() {
super.viewDidLoad()
let env = ApiEnvironment.production
let context = NonPersistentApiContext(environment: env)
let baseService = AlamofireMovieService(context: context)
let service = RealmMovieService(baseService: baseService)
var invokeCount = 0
service.getTopGrossingMovies(year: 2016) { (movies, error) in
invokeCount += 1
if let error = error { return print(error.localizedDescription) }
print("Found \(movies.count) movies (callback #\(invokeCount))")
}
}
We rename AlamofireMovieService
to baseService
and create a RealmMovieService
, into which we inject the baseService
. The app still loads top grossing movies using a service
, but the instance will now first check the
database then call the API.
An important thing here is that the app doesn’t care about any of this. Just as the decorator doesn’t care about the internal workings of its base service, the app only uses the protocol, not the implementation.
The output will be the following, the first time we run the app with this setup:
Found 0 movies (callback #1)
Found 10 movies (callback #2)
This happens because the database has no data, while the API will load 10 movies. If you run the app again, the output should now be:
Found 10 movies (callback #1)
Found 10 movies (callback #2)
This happens because the database now has data, which means that both completions will return 10 movies.
It’s worth repeating that having multiple callbacks for a single function call is not good. We only have it here to visualize what’s going on. You should adjust the service to only call the completion block once.
Now get the app offline and call getTopRatedMovies
instead (Alamofire caches the previous result, so we have to fetch new data). If you then run the app again, the output should be:
Found 10 movies (callback #1)
ERROR: The Internet connection appears to be offline.
This happens because the database data can still be loaded, while the API cannot, since the Internet connection is now down.
We now have an app with offline support, that only refreshes its data whenever a call to the API provides new data. All we had to do was to change two lines that defines the service to use. The app doesn’t care about any of this.
Add Dependency Injection to the app
I won’t show the specifics here, since it just add even more complexity to an already long post. In the demo app, however, I have an IoC
folder, in which I use a library called Dip to resolve dependencies.
By adding Dip
to podfile
and running pod install
, we can make the app much cleaner and more robust, since we’ll register all dependencies when the app launches and resolve them with constructor injection, or by calling
IoC.resolve(...)
in storyboards.
Take a look at the demo app if you are interested in the specifics. In short, it lets us remove a lot of code from our view controller, which then looks like this:
import UIKit
import Alamofire
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
reloadData(self)
}
lazy var movieService: MovieService = IoC.resolve()
...
}
With this in place, the app no longer knows anything about which implementations we use. The only part that knows about the API, a database etc. is the IoC
. This makes it easy to change implementations, since we just have to change implementations at a single place.
Conclusion
We have created an app that uses Alamofire to fetch data from an API and that also injects a RequestRetrier
and a RequestAdapter
to Alamofire to change how it adapts all outgoing requests and handles any failing ones.
We also used Realm to implement offline support, and Dip to get an IoC container in place.
I hope this was helpful. Don’t hesistate to connect with me on Twitter to discuss this further.