onebusaway-ios
onebusaway-ios copied to clipboard
Cache stops as they get loaded
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
- In addition to the data that we are already storing for
Stop
objects, we should also include a couple extra fields, like a creation date, so that we can purge stale data, along with a unique identifier. - Stops should be uniquely identified by a combination of their region ID and stop ID.
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
- The object must conform to Decodable.
- The decoder must have
decoder.userInfo[.context]
set to a validNSManagedObjectContext
. - The object must have a
unique constraint
set in the core data model. The unique constraint is like an identifier, only one object with that identifier may exist on the stack. (a nice explanation and gif) - The context should be a background context.
- The context should set its
mergePolicy
toNSMergeByPropertyObjectTrumpMergePolicy
to automatically merge changes.
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
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
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.