HomeLearnHow-toBuild Offline-First Mobile Apps by Caching API Results in Realm

Build Offline-First Mobile Apps by Caching API Results in Realm

Updated: Sep 14, 2021 |

Published: Sep 13, 2021

  • Mobile
  • Realm
  • Swift
  • ...

By Andrew Morgan

Rate this article

#Introduction

When building a mobile app, there's a good chance that you want it to pull in data from a cloud service—whether from your own or from a third party. While other technologies are growing (e.g., GraphQL and MongoDB Realm Sync), REST APIs are still prevalent.

It's easy to make a call to a REST API endpoint from your mobile app, but what happens when you lose network connectivity? What if you want to slice and dice that data after you've received it? How many times will your app have to fetch the same data (consuming data bandwidth and battery capacity each time)? How will your users react to a sluggish app that's forever fetching data over the internet?

By caching the data from API calls in Realm, the data is always available to your app. This leads to higher availability, faster response times, and reduced network and battery consumption.

This article shows how the RCurrency mobile app fetches exchange rate data from a public API, and then caches it in Realm for always-on, local access.

#Is Using the API from Your Mobile App the Best Approach?

This app only reads data through the API. Writing an offline-first app that needs to reliably update cloud data via an API is a far more complex affair. If you need to update cloud data when offline, then I'd strongly recommend you consider MongoDB Realm Sync.

Many APIs throttle your request rate or charge per request. That can lead to issues as your user base grows. A more scalable approach is to have your backend Realm app fetch the data from the API and store it in Atlas. Realm Sync then makes that data available locally on every user's mobile device—without the need for any additional API calls.

#Prerequisites

#The RCurrency Mobile App

The RCurrency app is a simple exchange rate app. It's intended for uses such as converting currencies when traveling.

You choose a base currency and a list of other currencies you want to convert between.

When opened for the first time, RCurrency uses a REST API to retrieve exchange rates, and stores the data in Realm. From that point on, the app uses the data that's stored in Realm. Even if you force-close the app and reopen it, it uses the local data.

If the stored rates are older than today, the app will fetch the latest rates from the API and replace the Realm data.

The app supports pull-to-refresh to fetch and store the latest exchange rates from the API.

You can alter the amount of any currency, and the amounts for all other currencies are instantly recalculated.

Animation of the RCurrency app running on an iPhone. Includes the user selecting currencies, changing amounts and observing the amounts changing for other currencies

#The REST API

I'm using the API provided by exchangerate.host. The API is a free service that provides a simple API to fetch currency exchange rates.

One of the reasons I picked this API is that it doesn't require you to register and then manage access keys/tokens. It's not rocket science to handle that complexity, but I wanted this app to focus on when to fetch data, and what to do once you receive it.

The app uses a single endpoint (where you can replace USD and EUR with the currencies you want to convert between):

1https://api.exchangerate.host/convert?from=USD&to=EUR

You can try calling that endpoint directly from your browser.

The endpoint responds with a JSON document:

1{
2 "motd": {
3 "msg": "If you or your company use this project or like what we doing, please consider backing us so we can continue maintaining and evolving this project.",
4 "url": "https://exchangerate.host/#/donate"
5 },
6 "success": true,
7 "query": {
8 "from": "USD",
9 "to": "EUR",
10 "amount": 1
11 },
12 "info": {
13 "rate": 0.844542
14 },
15 "historical": false,
16 "date": "2021-09-02",
17 "result": 0.844542
18}

Note that the exchange rate for each currency is only updated once every 24 hours. That's fine for our app that's helping you decide whether you can afford that baseball cap when you're on vacation. If you're a currency day-trader, then you should look elsewhere.

#The RCurrency App Implementation

#Data Model

JSON is the language of APIs. That's great news as most modern programming languages (including Swift) make it super easy to convert between JSON strings and native objects.

The app stores the results from the API query in objects of type Rate. To make it as simple as possible to receive and store the results, I made the Rate class match the JSON format of the API results:

1class Rate: Object, ObjectKeyIdentifiable, Codable {
2 var motd = Motd()
3 var success = false
4 @Persisted var query: Query?
5 var info = Info()
6 @Persisted var date: String
7 @Persisted var result: Double
8}
9
10class Motd: Codable {
11 var msg = ""
12 var url = ""
13}
14
15class Query: EmbeddedObject, ObjectKeyIdentifiable, Codable {
16 @Persisted var from: String
17 @Persisted var to: String
18 var amount = 0
19}
20
21class Info: Codable {
22 var rate = 0.0
23}

Note that only the fields annotated with @Persisted will be stored in Realm.

Swift can automatically convert between Rate objects and the JSON strings returned by the API because we make the class comply with the Codable protocol.

There are two other top-level classes used by the app.

Symbols stores all of the supported currency symbols. In the app, the list is bootstrapped from a fixed list. For future-proofing, it would be better to fetch them from an API:

1class Symbols {
2 var symbols = Dictionary<String, String>()
3}
4
5extension Symbols {
6 static var data = Symbols()
7
8
9 static func loadData() {
10 data.symbols["AED"] = "United Arab Emirates Dirham"
11 data.symbols["AFN"] = "Afghan Afghani"
12 data.symbols["ALL"] = "Albanian Lek"
13 ...
14 }
15}

UserSymbols is used to store the user's chosen base currency and the list of currencies they'd like to see exchange rates for:

1class UserSymbols: Object, ObjectKeyIdentifiable {
2 @Persisted var baseSymbol: String
3 @Persisted var symbols: List<String>
4}

An instance of UserSymbols is stored in Realm so that the user gets the same list whenever they open the app.

#Rate Data Lifecycle

This flowchart shows how the exchange rate for a single currency (represented by the symbol string) is managed when the CurrencyRowContainerView is used to render data for that currency:

Flowchart showing how the app fetches data from the API and stored in in Realm. The mobile app's UI always renders what's stored in MongoDB. The following sections will describe each block in the flow diagram.

Note that the actual behavior is a little more subtle than the diagram suggests. SwiftUI ties the Realm data to the UI. If stage #2 finds the data in Realm, then it will immediately get displayed in the view (stage #8). The code will then make the extra checks and refresh the Realm data if needed. If and when the Realm data is updated, SwiftUI will automatically refresh the UI to render it.

Let's look at each of those steps in turn.

##1 CurrencyContainerView loaded for currency represented by symbol

CurrencyListContainerView iterates over each of the currencies that the user has selected. For each currency, it creates a CurrencyRowContainerView and passes in strings representing the base currency (baseSymbol) and the currency we want an exchange rate for (symbol):

1List {
2 ForEach(userSymbols.symbols, id: \.self) { symbol in
3 CurrencyRowContainerView(baseSymbol: userSymbols.baseSymbol,
4 baseAmount: $baseAmount,
5 symbol: symbol,
6 refreshNeeded: refreshNeeded)
7 }
8 .onDelete(perform: deleteSymbol)
9}

##2 rate = FetchFromRealm(symbol)

CurrencyRowContainerView then uses the @ObservedResults property wrapper to query all Rate objects that are already stored in Realm:

1struct CurrencyRowContainerView: View {
2 @ObservedResults(Rate.self) var rates
3 ...
4}

The view then filters those results to find one for the requested baseSymbol/symbol pair:

1var rate: Rate? {
2 rates.filter(
3 NSPredicate(format: "query.from = %@ AND query.to = %@",
4 baseSymbol, symbol)).first
5}

##3 rate found?

The view checks whether rate is set or not (i.e., whether a matching object was found in Realm). If rate is set, then it's passed to CurrencyRowDataView to render the details (step #8). If rate is nil, then a placeholder "Loading Data..." TextView is rendered, and loadData is called to fetch the data using the API (step #4-3):

1var body: some View {
2 if let rate = rate {
3 HStack {
4 CurrencyRowDataView(rate: rate, baseAmount: $baseAmount, action: action)
5 ...
6 }
7 } else {
8 Text("Loading Data...")
9 .onAppear(perform: loadData)
10 }
11}

##4-3 Fetch rate from API — No matching object found in Realm

The API URL is formed by inserting the base currency (baseSymbol) and the target currency (symbol) into a template string. loadData then sends the request to the API endpoint and handles the response:

1private func loadData() {
2 guard let url = URL(string: "https://api.exchangerate.host/convert?from=\(baseSymbol)&to=\(symbol)") else {
3 print("Invalid URL")
4 return
5 }
6 let request = URLRequest(url: url)
7 print("Network request: \(url.description)")
8 URLSession.shared.dataTask(with: request) { data, response, error in
9 guard let data = data else {
10 print("Error fetching data: \(error?.localizedDescription ?? "Unknown error")")
11 return
12 }
13 if let decodedResponse = try? JSONDecoder().decode(Rate.self, from: data) {
14 // TODO: Step #5-3
15 } else {
16 print("No data received")
17 }
18 }
19 .resume()
20}

##5-3 StoreInRealm(rate) — No matching object found in Realm

Rate objects stored in Realm are displayed in our SwiftUI views. Any data changes that impact the UI must be done on the main thread. When the API endpoint sends back results, our code receives them in a callback thread, and so we must use DispatchQueue to run our closure in the main thread so that we can add the resulting Rate object to Realm:

1if let decodedResponse = try? JSONDecoder().decode(Rate.self, from: data) {
2 DispatchQueue.main.async {
3 $rates.append(decodedResponse)
4 }
5} else {
6 print("No data received")
7}

Notice how simple it is to convert the JSON response into a Realm Rate object and store it in our local realm!

##6 Refresh Requested?

RCurrency includes a pull-to-refresh feature which will fetch fresh exchange rate data for each of the user's currency symbols. We add the refresh functionality by appending the .refreshable modifier to the List of rates in CurrencyListContainerView:

1List {
2 ...
3}
4.refreshable(action: refreshAll)

refreshAll sets the refreshNeeded variable to true, waits a second to allow SwiftUI to react to the change, and then sets it back to false:

1private func refreshAll() {
2 refreshNeeded = true
3 DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
4 refreshNeeded = false
5 }
6}

refreshNeeded is passed to each instance of CurrencyRowContainerView:

1CurrencyRowContainerView(baseSymbol: userSymbols.baseSymbol,
2 baseAmount: $baseAmount,
3 symbol: symbol,
4 refreshNeeded: refreshNeeded)

CurrencyRowContainerView checks refreshNeeded. If true, it displays a temporary refresh image and invokes refreshData (step #4-6):

1if refreshNeeded {
2 Image(systemName: "arrow.clockwise.icloud")
3 .onAppear(perform: refreshData)
4}

##4-6 Fetch rate from API — Refresh requested

refreshData fetches the data in exactly the same way as loadData in step #4-3:

1private func refreshData() {
2 guard let url = URL(string: "https://api.exchangerate.host/convert?from=\(baseSymbol)&to=\(symbol)") else {
3 print("Invalid URL")
4 return
5 }
6 let request = URLRequest(url: url)
7 print("Network request: \(url.description)")
8 URLSession.shared.dataTask(with: request) { data, response, error in
9 guard let data = data else {
10 print("Error fetching data: \(error?.localizedDescription ?? "Unknown error")")
11 return
12 }
13 if let decodedResponse = try? JSONDecoder().decode(Rate.self, from: data) {
14 DispatchQueue.main.async {
15 // TODO: #5-5
16 }
17 } else {
18 print("No data received")
19 }
20 }
21 .resume()
22}

The difference is that in this case, there may already be a Rate object in Realm for this currency pair, and so the results are handled differently...

##5-6 StoreInRealm(rate) — Refresh requested

If the Rate object for this currency pair had been found in Realm, then we reference it with existingRate. existingRate is then updated with the API results:

1if let decodedResponse = try? JSONDecoder().decode(Rate.self, from: data) {
2 DispatchQueue.main.async {
3 if let existingRate = rate {
4 do {
5 let realm = try Realm()
6 try realm.write() {
7 guard let thawedrate = existingRate.thaw() else {
8 print("Couldn't thaw existingRate")
9 return
10 }
11 thawedrate.date = decodedResponse.date
12 thawedrate.result = decodedResponse.result
13 }
14 } catch {
15 print("Unable to update existing rate in Realm")
16 }
17 }
18 }
19}

##7 rate stale?

The exchange rates available through the API are updated daily. The date that the rate applies to is included in the API response, and it’s stored in the Realm Rate object. When displaying the exchange rate data, CurrencyRowDataView invokes loadData:

1var body: some View {
2 CurrencyRowView(value: (rate.result) * baseAmount,
3 symbol: rate.query?.to ?? "",
4 baseValue: $baseAmount,
5 action: action)
6 .onAppear(perform: loadData)
7}

loadData checks that the existing Realm Rate object applies to today. If not, then it will refresh the data (stage 4-7):

1private func loadData() {
2 if !rate.isToday {
3 // TODO: 4-7
4 }
5}

isToday is a Rate method to check whether the stored data matches the current date:

1extension Rate {
2 var isToday: Bool {
3 let today = Date().description.prefix(10)
4 return date == today
5 }
6}

##4-7 Fetch rate from API — rate stale

By now, the code to fetch the data from the API should be familiar:

1private func loadData() {
2 if !rate.isToday {
3 guard let query = rate.query else {
4 print("Query data is missing")
5 return
6 }
7 guard let url = URL(string: "https://api.exchangerate.host/convert?from=\(query.from)&to=\(query.to)") else {
8 print("Invalid URL")
9 return
10 }
11 let request = URLRequest(url: url)
12 URLSession.shared.dataTask(with: request) { data, response, error in
13 guard let data = data else {
14 print("Error fetching data: \(error?.localizedDescription ?? "Unknown error")")
15 return
16 }
17 if let decodedResponse = try? JSONDecoder().decode(Rate.self, from: data) {
18 DispatchQueue.main.async {
19 // TODO: #5.7
20 }
21 } else {
22 print("No data received")
23 }
24 }
25 .resume()
26 }
27}

##5-7 StoreInRealm(rate) — rate stale

loadData copies the new date and exchange rate (result) to the stored Realm Rate object:

1if let decodedResponse = try? JSONDecoder().decode(Rate.self, from: data) {
2 DispatchQueue.main.async {
3 $rate.date.wrappedValue = decodedResponse.date
4 $rate.result.wrappedValue = decodedResponse.result
5 }
6}

##8 View rendered with rate

CurrencyRowView receives the raw exchange rate data, and the amount to convert. It’s responsible for calculating and rendering the results:

Screen capture showing the row for a single currency. In this case it shows the US flag, the "USD" symbol and the amount (3.23...)

The number shown in this view is part of a TextField, which the user can overwrite:

1@Binding var baseValue: Double
2...
3TextField("Amount", text: $amount)
4 .keyboardType(.decimalPad)
5 .onChange(of: amount, perform: updateValue)
6 .font(.largeTitle)

When the user overwrites the number, the onChange function is called which recalculates baseValue (the value of the base currency that the user wants to convert):

1private func updateValue(newAmount: String) {
2 guard let newValue = Double(newAmount) else {
3 print("\(newAmount) cannot be converted to a Double")
4 return
5 }
6 baseValue = newValue / rate
7}

As baseValue was passed in as a binding, the new value percolates up the view hierarchy, and all of the currency values are updated. As the exchange rates are held in Realm, all of the currency values are recalculated without needing to use the API:

Animation showing the app running on an iPhone. When the user changes the amount for 1 currency, the amounts for all of the others changes immediately

#Conclusion

REST APIs let your mobile apps act on a vast variety of cloud data. The downside is that APIs can't help you when you don't have access to the internet. They can also make your app seem sluggish, and your users may get frustrated when they have to wait for data to be downloaded.

A common solution is to use Realm to cache data from the API so that it's always available and can be accessed locally in an instant.

This article has shown you a typical data lifecycle that you can reuse in your own apps. You've also seen how easy it is to store the JSON results from an API call in your Realm database:

1if let decodedResponse = try? JSONDecoder().decode(Rate.self, from: data) {
2 DispatchQueue.main.async {
3 $rates.append(decodedResponse)
4 }
5}

We've focussed on using a read-only API. Things get complicated very quickly when your app starts modifying data through the API. What should your app do when your device is offline?

  • Don't allow users to do anything that requires an update?
  • Allow local updates and maintain a list of changes that you iterate through when back online?

    • Will some changes you accept from the user have to be backed out once back online and you discover conflicting changes from other users?

If you need to modify data that's accessed by other users or devices, consider MongoDB Realm Sync as an alternative to accessing APIs directly from your app. It will save you thousands of lines of tricky code!

The API you're using may throttle access or charge per request. You can create a backend MongoDB Realm app to fetch the data from the API just once, and then use Realm Sync to handle the fan-out to all instances of your mobile app.

If you have any questions or comments on this post (or anything else Realm-related), then please raise them on our community forum. To keep up with the latest Realm news, follow @realm on Twitter and join the Realm global community.

Rate this article
MongoDB Icon
  • Developer Hub
  • Documentation
  • University
  • Community Forums

© MongoDB, Inc.