avatarDrew Althage

Summary

The provided content outlines a method for building an offline-first iOS app using CoreData to manage local data and sync with a remote server.

Abstract

The article discusses a proof-of-concept approach for developing iOS applications that prioritize offline functionality. It emphasizes the use of CoreData for local data storage and management, ensuring that users have access to their data even without an internet connection. The author provides a detailed guide on how to define a data model, make CoreData entities conform to the Codable protocol, and create a network layer using Alamofire for interacting with a REST API. The article also includes instructions for setting up a simple server, handling data synchronization, and implementing a user interface with SwiftUI to demonstrate the offline-first design in practice. The goal is to enable seamless data access and synchronization, improving the user experience by reducing dependency on internet connectivity.

Opinions

  • The author advocates for a single definition of the data model to streamline the development process.
  • They highlight the importance of an offline-capable app to prevent user frustration when internet access is unavailable.
  • The use of the Codable protocol is presented as a "magical potion" to simplify JSON encoding and decoding for CoreData objects.
  • The CoreDataJSONDecoder helper is described as a "decoder ring" that translates JSON data into CoreData objects, emphasizing its utility.
  • The article suggests that making CoreData entities Codable-compliant allows for easy transformation between JSON and CoreData representations.
  • The author implies that using a network layer with Alamofire for REST API interactions is a straightforward and effective approach.
  • The importance of a user-friendly interface is underscored by the inclusion of a SwiftUI view for listing and managing CoreData entities.
  • The author expresses enthusiasm about the offline-first approach, concluding with a celebratory tone about the successful integration of CoreData with remote data synchronization.

Decodable CoreData: A Proof-of-Concept for Building Offline-First iOS Apps

Photo by Annie Spratt on Unsplash

You’re building an iOS app with a truckload of data coming in and out. You also want to make this data available even if your users are off the grid. Where do you put it? CoreData, baby!

But then, you have to think about syncing with the outside world — your remote server. You don’t want your users to pull their hair out when the internet takes a break, right? Let’s get them happy by building an offline-first app with CoreData.

I recommend cloning the project to help follow along.

/**
 * @note before getting started, this article assumes a general 
 * familiarity with Swift, CoreData and SwiftUI. If you are new to either of these, 
 * I HIGHLY recommend checking out the Swift course at codecademy.com,
 * and Paul Hudson's hackingwithswift.com
 */

Goals

  • Define the data model once. No defining our CoreData entities and mirror structs to decode JSON and then create/update entities.
  • Offline Capable

CoreData and Remote Data

CoreData is like your home — cozy, and all your stuff is there. Remote data is like the grocery store; you go there to get fresh stuff. Problem? Imagine having to go to the store every time you need a coffee. Painful! Plus, sometimes the store is closed (read: no internet), and you get caffeine-deprived. No bueno! So, let’s bridge this gap with a cool hack.

🔮 Magic Spell: Codable Protocol

The Codable protocol is like a magical potion that helps us decode and encode JSON. This is great for ephemeral data, but what if you want to save that data on a user's device? Making NSManagedObjects conform to Codable is a little tricky.

First, we must give our magical potion the correct context and a sense of direction.

extension CodingUserInfoKey {
    static let managedObjectContext = CodingUserInfoKey(rawValue: "managedObjectContext")!
}

Next, we’ll create a CoreDataJSONDecoder helper to decode incoming JSON in the CoreData container view context. Remember those decoder rings in cereal boxes? This is like that, but way cooler. Our CoreDataJSONDecoder is the decoder ring that knows how to read secret messages (JSON data) and magically turn them into CoreData objects.

struct CoreDataJSONDecoder {
    let decoder: JSONDecoder

    init() {
        let decoder = JSONDecoder()
        decoder.userInfo[CodingUserInfoKey.managedObjectContext] = PersistenceController.shared.container.viewContext
        self.decoder = decoder
    }

    enum DecoderConfigurationError: Error {
        case missingManagedObjectContext
    }
}

Our decoder is now tuned into CoreData’s frequency; thanks to that managedObjectContext we gave it.

Next, we need to create our CoreData entity and make it conform to Codable.

class ItemEntity: NSManagedObject, Codable {
    required convenience init(from decoder: Decoder) throws {
        guard let context = decoder.userInfo[CodingUserInfoKey.managedObjectContext] as? NSManagedObjectContext else {
            throw CoreDataJSONDecoder.DecoderConfigurationError.missingManagedObjectContext
        }
        self.init(context: context)
        
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let uuidString = try container.decode(String.self, forKey: .id)
        id = UUID(uuidString: uuidString)
        title = try container.decode(String.self, forKey: .title)
        subtitle = try container.decode(String.self, forKey: .subtitle)
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(title, forKey: .title)
        try container.encode(subtitle, forKey: .subtitle)
        try container.encode(id, forKey: .id)
    }
}

Our CoreData model now wears the Codable cape. It can change into JSON and back like it’s no big deal.

It's also important to note the configuration of our CoreData entity. See the inspector on the right side of the following screenshot.

Let's set up a super simple network layer to interact with our REST API. We’re going to use Alamofire in this example.

import Alamofire
import Foundation

typealias NetworkError = AFError

enum RequestStatus {
    case loading, success, failure
}

struct NetworkManager {
    private let baseUrl = "http://localhost:3000"
    private let decoder = CoreDataJSONDecoder().decoder

    struct ResponseMessage: Decodable {
        let message: String
    }

    func get<Output: Decodable>(_ url: String,
                                output _: Output.Type,
                                completion: @escaping (Result<Output, NetworkError>) -> Void)
    {
        AF.request(baseUrl + url).validate().responseDecodable(of: Output.self, decoder: decoder) { response in

            completion(response.result)
        }
    }

    func post<Input: Encodable, Output: Decodable>(_ url: String,
                                                   input: Input,
                                                   output _: Output.Type,
                                                   completion: @escaping (Result<Output, NetworkError>) -> Void)
    {
        AF.request(baseUrl + url,
                   method: .post,
                   parameters: input,
                   encoder: .json,
                   headers: [
                       .init(name: "Content-Type", value: "application/json"),
                   ])
                   .validate()
                   .responseDecodable(of: Output.self, decoder: decoder) { response in
                       completion(response.result)
                   }
    }
}

Next, let's create a view to see a list of our ItemEntities.

struct ItemListView: View {
    @StateObject var viewModel = ViewModel()
    var body: some View {
        NavigationStack {
            List {
                ForEach(viewModel.results, id: \.id) { item in
                    NavigationLink(destination: ItemDetailView(item: item)) {
                        VStack(alignment: .leading) {
                            Text(item.title ?? "-").font(.title3)
                            Text(item.subtitle ?? "-").font(.caption).foregroundColor(.secondary)
                        }
                    }
                }.onDelete(perform: viewModel.delete)
                Section {
                    Button {
                        viewModel.refreshItems()
                    } label: {
                        HStack {
                            Label("", systemImage: "arrow.clockwise").labelStyle(.iconOnly)
                            Text("Refresh")
                        }
                    }.listRowBackground(Color.clear)
                        .buttonStyle(.borderedProminent)
                }
            }.onAppear {
                viewModel.loadItems()
            }.navigationTitle("Items")
                .toolbar {
                    #if os(iOS)
                        ToolbarItem(placement: .navigationBarTrailing) {
                            EditButton()
                        }
                    #endif
                    ToolbarItem {
                        Button(action: viewModel.toggleForm) {
                            Label("Add Item", systemImage: "plus")
                        }
                    }
                }.sheet(isPresented: $viewModel.formVisible) {
                    NavigationStack {
                        Form {
                            TextField("Title", text: $viewModel.newItemTitle)
                            TextField("Subtitle", text: $viewModel.newItemSubtitle)
                        }.navigationTitle("New Item")
                            .toolbar {
                                ToolbarItem(placement: .cancellationAction) {
                                    Button {
                                        viewModel.formVisible = false
                                    } label: {
                                        Text("Cancel")
                                    }
                                }
                                ToolbarItem(placement: .confirmationAction) {
                                    Button {
                                        viewModel.createNewItem()
                                    } label: {
                                        Text("Save")
                                    }.disabled(!viewModel.formValid)
                                }
                            }
                    }
                }
        }
    }
}

Now the view model:

extension ItemListView {
    @MainActor class ViewModel: ObservableObject {
        private let coreDataManager = PersistenceController.shared
        private let repo = CoreDataRepository<ItemEntity>(context: PersistenceController.shared.container.viewContext)

        @AppStorage("initial-load") var initialLoad = true
        @Published var results: [ItemEntity] = []
        @Published var formVisible = false
        @Published var newItemTitle = ""
        @Published var newItemSubtitle = ""

        var formValid: Bool {
            !newItemTitle.isEmpty && !newItemSubtitle.isEmpty
        }

        init() {
            getInitialData()
        }

        func toggleForm() {
            formVisible.toggle()
        }

        func loadItems() {
            do {
                results.removeAll()
                results = try repo.fetch().get()
            } catch {
                print(error)
            }
        }

        func createNewItem() {
            let result = repo.create { item in
                item.id = UUID()
                item.title = self.newItemTitle
                item.subtitle = self.newItemSubtitle
            }

            do {
                let newItem = try result.get()
                NetworkManager().post("/items", input: newItem, output: NetworkManager.ResponseMessage.self) { result in
                    do {
                        _ = try result.get()
                        self.formVisible = false
                        self.loadItems()
                    } catch {
                        print(error)
                    }
                }
            } catch {
                print(error)
            }
        }

        func refreshItems() {
            NetworkManager().get("/items", output: [ItemEntity].self) { result in
                do {
                    _ = try result.get()
                    try self.coreDataManager.saveContext()
                    self.initialLoad = false
                    self.loadItems()
                } catch {
                    print(error)
                }
            }
        }

        func delete(at offsets: IndexSet) {
            guard let index = offsets.first
            else { return }
            let el = results[index]
            let result = repo.delete(el)
            switch result {
            case .success:
                loadItems()
            case let .failure(error):
                print(error)
            }
        }

        private func getInitialData() {
            if !initialLoad { return }
            refreshItems()
        }
    }
}

You may have noticed the CoreDataRepository . This quick helper provides methods for working with our CoreData entities.

struct CoreDataRepository<Entity: NSManagedObject> {
    private let context: NSManagedObjectContext

    init(context: NSManagedObjectContext) {
        self.context = context
    }

    func fetch(sortDescriptors: [NSSortDescriptor] = [],
               predicate: NSPredicate? = nil) -> Result<[Entity], Error>
    {
        let request = Entity.fetchRequest()
        request.sortDescriptors = sortDescriptors
        request.predicate = predicate

        do {
            let results = try context.fetch(request) as! [Entity]
            return .success(results)
        } catch {
            return .failure(error)
        }
    }

    func create(_ body: @escaping (inout Entity) -> Void) -> Result<Entity, Error> {
        var entity = Entity(context: context)
        body(&entity)
        do {
            try context.save()
            return .success(entity)
        } catch {
            return .failure(error)
        }
    }

    func update(_ entity: Entity) -> Result<Entity, Error> {
        do {
            try context.save()
            return .success(entity)
        } catch {
            return .failure(error)
        }
    }

    func delete(_ entity: Entity) -> Result<Void, Error> {
        do {
            context.delete(entity)
            try context.save()
            return .success(())
        } catch {
            return .failure(error)
        }
    }

    enum Errors: Error {
        case objectNotFound
    }
}

Now let's fire up our “remote” server. From the project root, navigate to the server directory, install dependencies, and start listening for requests.

cd server/
npm install
node index.js

You're ready to run the app and see our offline-first approach in action. From Xcode, run the app in your preferred iOS simulator. If all goes according to plan, you should see a screen like this:

Play around creating entities and disabling the server to see the local-first data presented.

There you have it, folks! CoreData and remote data are now buds. Your users can use the app offline and sync when they return to the grid. Coffee’s ready without the grocery store trip! Cheers! 🎉

Swift
Swiftui
iOS
Software Development
Programming
Recommended from ReadMedium