-
Notifications
You must be signed in to change notification settings - Fork 0
Loading Remote Resources: A Complete Guide
Many applications pull content from online servers, and this app is no exception. The following guide will step you through the design and implementation used in this app for loading resources from remote locations.
In this guide, we'll be loading Announcements. Announcements are one of several different types of information presented to the user in this application. An announcement is equipped with the following fields:
- An identifer, assigned to it by the server's database.
- A school identifier, so it can be filtered by school ID.
- A title.
- A description, or body of text with markdown formatting.
- A date of creation, in an ISO string format.
All data models get their own class, which must conform to the RGSDataModelDelegate
protocol. The protocol specifies the following must be defined:
-
static var entityKey: [String: String]
: A dictionary mapping a property name to that used in the ManagedObject representation in Core Data. Core Data is the persistent storage solution used with this app. Usually, key-value pairs share the same value. Some words are reserved though, and so differences crop up. Finally, everyentityKey
dictionary must contain aentityName
key matched to the string name of the entity used with Core Data. See more about Core Data here. -
func saveTo (managedObject: NSManagedObject)
: When saving an instance of a model to persistent storage. All properties must be saved to anNSManagedObject
instance. -
init? (from json: [String: Any], with keys: [String: String])
: All models must be able to initialize themselves from JSON representations with help from a key-map. -
init? (from managedObject: NSManagedObject)
: All models must be able to initialize themselves from aNSManagedObject
representation.
Let's start by adding announcement properties to the class.
class RGSAnnouncementDataModel: RGSDataModelDelegate {
/// MARK: - Properties.
var id, schoolId, title, description: String?
var date: Date?
}
Next, we define our announcements entityKey
:
class RGSAnnouncementDataModel: RGSDataModelDelegate {
//// MARK: - Properties.
...
/// The model entity key.
static var entityKey: [String : String] = [
"entityName" : "AnnouncementEntity",
"title" : "title",
"description" : "announcementDescription",
"dateString" : "dateString",
"schoolId" : "schoolId",
"id" : "id"
]
}
Next, we'll implement the required init (from json: [String: Any], with keys: [String: String])
initializer. This method is supposed to take a JSON encoded data model and initialize the class from the given fields.
Parsing the JSON form of the Announcement data model is basically the same as indexing a dictionary. The JSON is cast as a dictionary of strings to objects of type Any
, and then we coerce them into the type we want. This type is almost always a string, or another dictionary of form [String: Any]
.
class RGSAnnouncementDataModel: RGSDataModelDelegate {
/// MARK: - Properties.
...
/// The model entity key.
...
/// Initializes the data model from JSON.
/// - json: Data in JSON format.
required init? (from json: [String: Any]) {
/// Mandatory fields.
guard
let id = json[keys["id"]!] as? String,
let title = json[keys["title"]!] as? String,
let description = json[keys["body"]!] as? String,
let schoolId = json[keys["schoolId"]!] as? String,
let dateString = json[keys["dateString"]!] as? String
else { return nil }
self.id = id
self.schoolId = schoolId
self.title = title
self.description = description
self.author = author
self.date = DateManager.sharedInstance.ISOStringToDate(dateString, format: .JSONGeneralDateFormat)
}
}
Mandatory fields are processed in the guard ... else { return nil }
clause. If any such fields cannot be initialized, the initialization fails with nil
return.
The keys with which the JSON dictionary is indexed are provided in the method name. They are stored persistently inside applicationConfig.plist
, and loaded in by DataManager.swift
before being passed in. You could hardcore them for simplicity of course, but when server JSON data keys change frequently, it's far easier to head over to the applicationConfig.plist
file and update the key names there.
After this, we implement the initializer for NSManagedObject
. This object is the representation of the model returned by Core Data when the DataManager
singleton extracts all entities carrying the entityName
defined earlier. Extracting values is not too much different from that of initializing from JSON.
class RGSAnnouncementDataModel: RGSDataModelDelegate {
/// MARK: - Properties.
...
/// The model entity key.
...
/// Initializes the data model from JSON.
/// - json: Data in JSON format.
...
/// Initializes the data model from NSManagedObject.
/// - managedObject: NSManagedObject instance.
required init? (from managedObject: NSManagedObject) {
let entityKey = RGSAnnouncementDataModel.entityKey
// Mandatory fields.
guard
let id = managedObject.value(forKey: entityKey["id"]!) as? String,
let schoolId = managedObject.value(forKey: entityKey["schoolId"]!) as? String,
let title = managedObject.value(forKey: entityKey["title"]!) as? String,
let description = managedObject.value(forKey: entityKey["description"]!) as? String,
let author = managedObject.value(forKey: entityKey["author"]!) as? String,
let dateString = managedObject.value(forKey: entityKey["dateString"]!) as? String
else { return nil }
self.id = id
self.schoolId = schoolId
self.title = title
self.description = description
self.date = DateManager.sharedInstance.ISOStringToDate(dateString, format: .JSONGeneralDateFormat)
}
}
With our initializers complete, we conclude with the implementation of the saveTo
method.
The saveTo
method effectively does the opposite of the NSManagedObject
initializer. We simply save the properties of the class to the managedObject
instance using the keys defined in the entityKey
dictionary.
/// Saves all fields to the given NSManagedObject.
/// - managedObject: The NSManagedObject representation.
func saveTo (managedObject: NSManagedObject) {
let entityKey = RGSAnnouncementDataModel.entityKey
managedObject.setValue(title, forKey: entityKey["title"]!)
managedObject.setValue(description, forKey: entityKey["description"]!)
managedObject.setValue(id, forKey: entityKey["id"]!)
managedObject.setValue(schoolId, forKey: entityKey["schoolId"]!)
let dateString = DateManager.sharedInstance.dateToISOString(date, format: .JSONGeneralDateFormat)
managedObject.setValue(dateString, forKey: entityKey["dateString"]!)
}
For the sake of brevity, this isn't shown inside the class here, but rest assured it should be simply placed amongst the other methods we already defined.
The next section will cover fetching the models from the remote location.
Once you have created the appropriate classes for the data model you wish to present, you must then write the supporting methods for parsing, loading, and saving the data. Convention in this project is to attach these methods as static methods in a class extension, written in the same file. The structure is shown below.
class RGSAnnouncementDataModel: RGSDataModelDelegate {
...
}
extension RGSAnnouncementDataModel {
/// Parses a array of JSON objects into an array of data model instances.
/// - data: Data to be parsed as JSON.
/// - keys: The JSON data keys.
/// - sort: Sorting method.
static func parseDataModel (from data: Data, with keys: [String: String], sort: (RGSAnnouncementDataModel, RGSAnnouncementDataModel) -> Bool) -> [RGSAnnouncementDataModel]? {
...
}
/// Retrieves all model entities from Core Data, and returns them in an array
/// sorted using the provided sort method.
/// - context: The managed object context.
/// - sort: The mandatory sorting method.
static func loadDataModel (context: NSManagedObjectContext, sort: (RGSAnnouncementDataModel, RGSAnnouncementDataModel) -> Bool) -> [RGSAnnouncementDataModel]? {
...
}
/// Saves all given model representations in Core Data. All existing entries are
/// removed prior.
/// - model: The array of data models to be archived.
/// - context: The managed object context.
static func saveDataModel (_ model: [RGSAnnouncementDataModel], context: NSManagedObjectContext) {
...
}
}
Now, none of these method names are enforced by a protocol. This is because they must return instances specific to each class. However, it is strongly encouraged to follow this convention if adding more content or performing maintenance.
We will begin with writing parseDataModel
for our announcements.
/// Parses a array of JSON objects into an array of data model instances.
/// - data: Data to be parsed as JSON.
/// - sort: Sorting method.
static func parseDataModel (from data: Data, with keys: [String: String], sort: (RGSAnnouncementDataModel, RGSAnnouncementDataModel) -> Bool) -> [RGSAnnouncementDataModel]? {
// Extract the JSON array.
guard
let json = try? JSONSerialization.jsonObject(with: data, options: []),
let jsonArray = json as? [Any]
else { return nil }
// Map JSON representations to data model instances.
let models = jsonArray.map({(object: Any) -> RGSAnnouncementDataModel in
return RGSAnnouncementDataModel(from: object as! [String: Any], with: keys)!
})
// Return sorted models.
return models.sorted(by: sort)
}
Method parseDataModel
is responsible for initializing a vector of internal class objects from a given JSON vector. You essentially initialize your data model using this method. When our remote server sends us a set of announcements, each serialized announcement will be located in an array of serialized JSON announcements. We need to parse all elements of this serialized JSON array and initialize our own internal representations off of them. This method does exactly that using a mapping method.
The sort
method parameter lets the data be sorted according to the given method. This is useful for providing a consistent user experience regardless of how the server decides to sort the data.
Loading a data model from storage is another situation in which you must initialize your custom class from another representation. In this case, we'll have to initialize our objects using a Core Data NSManagedObjectContext
instance. The idea is to design a fetch-request to provide the managed context which will extract all archived entities of a certain type.
Our previously defined entityKey
comes in handy here, as we'll have the internal entity name of our announcements entity saved as the value for key "entityName". This makes it easy to extract all our entities as NSManagedObject
instances . Once we've done that, we can map our custom class's NSManagedObject initializer method to the vector to get a corresponding vector of our internal representation. Very useful!
/// Retrieves all model entities from Core Data, and returns them in an array
/// sorted using the provided sort method.
/// - context: The managed object context.
/// - sort: The mandatory sorting method.
static func loadDataModel (context: NSManagedObjectContext, sort: (RGSAnnouncementDataModel, RGSAnnouncementDataModel) -> Bool) -> [RGSAnnouncementDataModel]? {
let entityKey = RGSAnnouncementDataModel.entityKey
var entities: [NSManagedObject]
// Construct request, extract entities.
do {
let request = NSFetchRequest<NSFetchRequestResult>(entityName: entityKey["entityName"]!)
entities = try context.fetch(request) as! [NSManagedObject]
} catch {
print("Error: loadDataModel: Couldn't extract announcement data!")
return nil
}
// Convert entities to models.
let models = entities.map({(object: NSManagedObject) -> RGSAnnouncementDataModel in
return RGSAnnouncementDataModel(from: object)!
})
// Return sorted models.
return models.sorted(by: sort)
}
As usual, a sorting method must be supplied. You should use exactly the same sorting method on all parsing methods in order to provide a consistent experience for the user. You may also apply a .filter
method to the data. In the production code, announcements are filtered by schoolId
. So this is an example of an applied filter.
This method is by far the last and least elegant of those defined thus far. The purpose of saveDataModel
is to archive the existing data model in persistent storage. What makes it ugly is that I have all entities for the given model in question (I.E. Announcements) extracted and deleted from storage before putting in the new ones. I believe it is possible to update certain entities with their new values. But I determined it would be too risky to try to anticipate how and when entities should be modified/deleted/added. It's much easier to just destroy the existing entries and enter them fresh as they are on the server whenever new data is obtained.
/// Saves all given model representations in Core Data. All existing entries are
/// removed prior.
/// - model: The array of data models to be archived.
/// - context: The managed object context.
static func saveDataModel (_ model: [RGSAnnouncementDataModel], context: NSManagedObjectContext) {
let entityKey = RGSAnnouncementDataModel.entityKey
var entities: [NSManagedObject]
// Extract all existing entities.
do {
let request = NSFetchRequest<NSFetchRequestResult>(entityName: entityKey["entityName"]!)
entities = try context.fetch(request) as! [NSManagedObject]
} catch {
print("Error: saveDataModel: Couldn't extract announcement data!")
return
}
// Delete all existing entities.
for entity in entities {
let objectContext = entity.managedObjectContext!
objectContext.delete(entity)
}
// Insert new entities.
for object in model {
let entity = NSEntityDescription.insertNewObject(forEntityName: entityKey["entityName"]!, into: context) as NSManagedObject
object.saveTo(managedObject: entity)
}
}
As before, we use our class's methods to help save each object to a persistent representation. I believe most of the code here is self explanatory.
This concludes the construction of our Data Structure Representation!
If you've managed to actually read up to this point of the document and didn't just skip through to this part, then congratulations! It's a damn long document and I'll probably split it up into dedicated pages to make it less intimidating. Anyways, this final section is about representing our model in an actual view which users will see, so lets get right to it!