OneBusAway / onebusaway-ios

OneBusAway for iOS, written in Swift.
Other
81 stars 33 forks source link

Cache stops as they get loaded #62

Open aaronbrethorst opened 4 years ago

aaronbrethorst commented 4 years ago

A few points about this:

Database Choices

We're going to use SQLite. Core Data is too bulky and easy to mess up. Realm seems like an unnecessary third party dependency. SQLite is already installed on the user's device, plus it is lightweight and incredibly well-tested. That said, SQLite lacks a lot of niceties that Core Data and Realm offer, and so it behooves us to use a layer on top of it. I think that this project looks like it will fit our needs well: https://github.com/groue/GRDB.swift

Displaying Map Data

Stops displayed on the map should exclusively be loaded from a sqlite database. Even though sqlite doesn't have built-in support for geospatial queries, we can do a simple bounding box search for stops based on lat/lon coordinates, and show those results on the map.

How do new stops get displayed?

As the user scrolls around the map, the app will continue fetching data from the server, just like it does today. However, results returned from the server will no longer be rendered directly on the map. Instead, they will be stored in the database, and a callback will alert the map region manager that new data is available. A quick search of available resources for making this straightforward reveals this project: https://github.com/groue/GRDB.swift#database-changes-observation

Schema

ualch9 commented 4 years ago

I've been playing around with a core-data powered OBA for a bit now and my [experimental] implementation is very similar to the way you are describing a potential implementation of caching support.

The idea

Prerequisites

Step 1: Setting up the model

In the model declaration

class OBARegion: NSManagedObject, Decodable {
    public enum CodingKeys: String, CodingKey {
        case identifier = "id"
        // other keys...
    }

    public convenience required init(from decoder: Decoder) throws {
        guard let context = decoder.userInfo[.context] as? NSManagedObjectContext else {
            throw OBADecodeError.contextNotFound
        }

        guard let entity = NSEntityDescription.entity(forEntityName: OBARegion.entityName, in: context) else {
            throw OBADecodeError.entityNotFound
        }

        /// Initialize but don't insert into the context yet. Leave inserting until after decoding keys, in case we throw.
        self.init(entity: entity, insertInto: nil)

        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.identifier = container.decode(Int.self, forKey: .identifier)

        // decode as normal...

        self.lastUpdated = Date()
        context.insert(self)
    }
}

In the core data model

Set the unique constraint Screen Shot 2020-01-26 at 8 16 31 PM

Step 2: Fetching from remote and saving into cache

Branch away from the main context and make the changes on a background context to avoid blocking the main thread.

let workingContext = coreDataContainer.newBackgroundContext()
workingContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy

let decoder = JSONDecoder()
decoder.userInfo[.context] = workingContext

let regionsData: Data = OBAWebService.getRegions()    // pretend this is async

// Option A - update the cache and use/modify the results immediately.
let regions = decoder.decode([OBARegion].self, from: regionsData)

// Option B - only update the cache.
_ = try decoder.decode([OBARegion].self, from: regionsData)

try self.workingContext.save()      // Push the changes into the view context.

Regardless of which option you choose, core data will notice that an object with the same identifier already exists so instead of creating a new object, it will merge the changes by replacing the old data with the new data. When we save the changes into the view context, any references to that object will receive an update notification.

Step 3: Fetching from cache

Fetch once

// Type-safety included
let regions: [OBARegion] = try coreDataContainer.viewContext.fetch(OBARegion.fetchRequest)

Fetch and listen to changes

class OBARegionsTableViewController: UITableViewController, NSFetchedResultsControllerDelegate {
    var fetchedResultsController: NSFetchedResultsController<OBARegion>!

    override func viewDidLoad() {
        let fetchRequest = OBARegion.fetchRequest
        fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest,
            managedObjectContext:coreDataContainer.viewContext,
            sectionNameKeyPath: "isExperimental",
            cacheName: nil)
        fetchedResultsController.delegate = self

        try self.fetchedResultsController.performFetch()
    }

    func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
       // Free diffing support courtesy of core data
    }
}

Implementation Example

aaronbrethorst commented 4 years ago

OneBusAway for iPhone started with a Core Data-powered backend. I've had to excise Core Data from an app at a previous job. And now I'm maintaining a Core Data-powered app at my current job. I am extremely leery of the software because of the sheer complexity of it and the relative dearth of best practices around the web.