ToyPhotoGallery implements the design for a photo gallery transitioning into a preview view.
The design goals of the original v1.0 object map include:
- Provide abstract protocols for common services so that libraries can be swapped if needed
- Hide API interfaces behind model controllers
- Provide launch safety for reachability or other issues
- Provide thread safe access to network fetching and local storage
- Provide robust non-fatal error handling for debugging
- Use the Model View View Model design pattern
- Use abstract models within model controllers so that content can be expanded without rearchitecture
- Offer the ability to swap out views as much as possible
- Provide convenience structures for configuring requests and appearances
A description of the v1.0 object graph diagram below can be found at voxels.github.io
Version 1.1 has a slightly improved object graph. In general terms, model objects have been simplified for handling images.
More detail about the examples of techniques is offered in the section below.
ToyPhotoGallery requires Carthage) for a run script build phase that copies in the frameworks for Bugsnag and Parse. Carthage is a lightweight alternative to Cocoapods. Carthage can be installed with Homebrew. In order to compile the project on a machine that does not have Carthage installed, follow the steps below:
- Install Homebrew with the follwing command in the Terminal:
/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
- Install Carthage with the following command in the Terminal:
brew install carthage
- Optionally, run the following commands to update the dependencies, however this should not be necessary to get the project to build from a clean checkout:
carthage update --platform iOS
NOTE: At the time of writing, the FBSDKIntegrationTests are misconfigured for building in Carthage. If checking out the framework dependencies source again, remove the dependency from the FacebookSDK.xcworkspace schemes to pass through the build.
This project requires setting up a back end to serve images. Parse was chosen as the remote store API because it's relatively easy to configure. The sections below describe how content was generated and installed into the Parse instance, located on an AWS box.
- Choose photos
- Export as original size, JPG format into ./assets/originals/
- Fetch mozjpeg from Github
- Copy and run the following shell script from ./assets/originals/
# Convert.sh
# Uses cjpeg (https://github.com/kornelski/mozjpeg/releases) to convert a folder of
# unmodified original images into progressive JPEG
INDEX=0
for FILENAME in *.jpg; do
echo $FILENAME
../cjpeg/cjpeg -quant-table 2 -quality 70 -outfile "../converted/$FILENAME" $FILENAME
done
- Create thumbnails from the converted files
- Upload folders to s3 bucket
- Setup an S3 bucket on AWS
- Synchronize thumbnails and full-res folders to the bucket using
aws s3 sync ./resources s3://<BUCKET_NAME>/path/to/resources
- Setup a Parse service on AWS using Bitnami or something else
- SSH into the box and grab the application ID from /apps/parse/htdocs/server.js
- Create the classes and columns for Resource tables
- Upload the resource links using Python:
import json,httplib,os
start_path = './converted/' # current directory
connection = httplib.HTTPConnection('<IP_ADDRESS>', 80)
for path,dirs,files in os.walk(start_path):
for filename in files:
name = filename
thumbnailURLString = "https://s3.amazonaws.com/<AWS_BUCKET_NAME>/path/to/resources/thumbnails/" + filename
fileURLString = "https://s3.amazonaws.com/<AWS_BUCKET_NAME>/path/to/resources/converted/" + filename
connection.connect()
connection.request('POST', '/parse/classes/Resource', json.dumps({"filename":name,
"thumbnailURLString":thumbnailURLString,
"fileURLString":fileURLString}), {
"X-Parse-Application-Id": "<APPLICATION_ID",
"Content-Type": "application/json"
})
results = json.loads(connection.getresponse().read())
print(results)
- Set the allowClientClassCreation parse server configuration setting to FALSE
- Create an Administrator Role and an admin user with that role
- Set the ACL for the Resource classes to 'public>read', 'administrator>read+write'
- Configure for HTTPS if necessary
ToyPhotoGallery includes code that demonstrates the following techniques:
ImageRepositoryTests.swift Line 33 - 55
func testExtractImageResourcesExtractsExpectedEntries() {
let waitExpectation = expectation(description: "Wait for completion")
let rawResourceArray = [ImageRepositoryTests.imageResourceRawObject]
ImageResource.extractImageResources(from: rawResourceArray) { (repository, errors) in
if let errors = errors, errors.count > 0 {
XCTFail("Found unexpected errors")
return
}
guard let first = repository.map.first else {
XCTFail("Did not find expected resource")
return
}
XCTAssertEqual(first.key, ImageRepositoryTests.imageResourceRawObject["objectId"] as! String)
waitExpectation.fulfill()
}
let actual = register(expectations: [waitExpectation], duration: XCTestCase.defaultWaitDuration)
XCTAssertTrue(actual)
}
ResourceModelController.swift Line 110 - 119
/**
Checks the existing number of resources in the repository and fills in entries for indexes between the skip and limit, if necessary
- parameter repository: the *Repository* that needs to be filled
- parameter skip: the number of items to skip when finding new resources
- parameter limit: the number of items we want to fetch
- parameter timeoutDuration: the *TimeInterval* to wait before timing out the request
- parameter completion: a callback used to pass back the filled repository
- Throws: Throws any error surfaced from *tableMap*
- Returns: void
*/
LaunchController.swift Line 88
try service.launch(with:service.launchControlKey?.decoded(), with:center)
ResourceModelController.swift Line 67 - 97
do {
try strongSelf.fill(repository: strongSelf.imageRepository, skip: 0, limit: strongSelf.remoteStoreController.defaultQuerySize, timeoutDuration:timeoutDuration, on:queue, completion:{ [weak self] (repository) in
guard let strongSelf = self else {
return
}
let writeQueue = DispatchQueue(label: "\(strongSelf.writeQueueLabel)")
writeQueue.async { [weak self] in
self?.imageRepository = repository
DispatchQueue.main.async { [weak self] in
self?.delegate?.didUpdateModel()
}
}
})
}
catch {
errorHandler.report(error)
DispatchQueue.main.async { [weak self] in
self?.delegate?.didFailToUpdateModel(with: error.localizedDescription)
}
}
ParseInterface.swift Line 77 - 99
/**
Finds the objects in the given schemaClass, sorted by the given String, with the expected fetch skip and limit constants. Calls a completion block when completed
- parameter table: a *RemoteStoreTable* table that should be queried on the remote store
- parameter sortBy: the *String* of the column name to sort by, or nil if no sorting is needed
- parameter skip: an *Int* of the number of records to skip in the query
- parameter limit: an *Int* of the limit of the number of records returned by the query
- parameter queue: The *DispatchQueue* we need to call the completion block on
- parameter errorHandler: The *ErrorHandlerDelegate* that will report the error
- parameter completion: the *FindCompletion* callback executed when the query is complete
- Returns: void
*/
func find(table: RemoteStoreTableMap, sortBy: String?, skip: Int, limit: Int, on queue:DispatchQueue, errorHandler: ErrorHandlerDelegate, completion: @escaping RawResourceArrayCompletion) {
let wrappedCompletion = parseFindCompletion(with:errorHandler, for: completion)
do {
let pfQuery = try query(for: table, sortBy: sortBy, skip: skip, limit: limit)
find(query: pfQuery, on:queue, completion: wrappedCompletion)
} catch {
errorHandler.report(error)
completion(RawResourceArray())
}
}
Extractor.swift Line 12 - 33
static func extractValue<T>(named key:String, from dictionary:[String:AnyObject]) throws -> T {
guard var value = dictionary[key] else {
if key == RemoteStoreTableMap.CommonColumn.objectId.rawValue {
throw ModelError.EmptyObjectId
} else {
throw ModelError.MissingValue
}
}
// We need to convert the string to an URL type
if T.self is URL.Type{
value = try Extractor.constructURL(from: value) as AnyObject
}
// We need to make sure we have the type of variable we expect to have
guard let castValue = value as? T else {
throw ModelError.IncorrectType
}
return castValue
}
NetworkSesionInterface Line 25 - 70
/**
Uses a one-off URLSession, NOT the interface's session, to perform a quick fetch of a data task for the given URL
- parameter url: the URL being fetched
- parameter queue: The queue that the fetch should be returned on
- parameter timeout: The number of seconds before timing out the request
- parameter cachePolicy: The *URLRequest.CachePolicy* for handling the request. Defaults to *.returnCacheDataEleseLoad*.
- parameter completion: a callback used to pass through the optional fetched *Data*
- Returns: void
*/
func fetch(url:URL, on queue:DispatchQueue?, timeout:TimeInterval, cachePolicy:URLRequest.CachePolicy = .returnCacheDataElseLoad, completion:@escaping (Data?)->Void) {
// Using a default session here may crash because of a potential bug in Foundation.
// Ephemeral and Shared sessions don't crash.
// See: https://forums.developer.apple.com/thread/66874
var fetchQueue:DispatchQueue = .main
if let otherQueue = queue {
fetchQueue = otherQueue
}
if AWSBucketHandler.isAWS(url: url) {
bucketHandler.fetchWithAWS(url: url, on:fetchQueue, with:errorHandler, completion: completion)
return
}
// We are handling the cacheing ourselves
let session = URLSession(configuration: .ephemeral)
let request = URLRequest(url: url, cachePolicy: cachePolicy, timeoutInterval: timeout)
let taskCompletion:((Data?, URLResponse?, Error?) -> Void) = { [weak self] (data, response, error) in
if let e = error {
fetchQueue.async {
self?.errorHandler.report(e)
completion(nil)
}
return
}
fetchQueue.async {
completion(data)
}
}
let task = session.dataTask(with: request, completionHandler: taskCompletion)
task.resume()
}
}
GalleryCollectionViewLayout.swift Line 100 - 110
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
var relativeSize = CGSize.zero
guard let configuration = configuration, let delegate = sizeDelegate else {
return relativeSize
}
relativeSize = delegate.sizeForItemAt(indexPath: indexPath, layout:self, currentConfiguration: configuration)
return relativeSize
}
GalleryViewController.swift Line 253 - 267
override func updateViewConstraints() {
if customConstraints.count > 0 {
NSLayoutConstraint.deactivate(customConstraints)
view.removeConstraints(customConstraints)
}
customConstraints.removeAll()
if let currentCollectionView = collectionView, let collectionViewConstraints = constraints(for: currentCollectionView) {
customConstraints.append(contentsOf: collectionViewConstraints)
}
NSLayoutConstraint.activate(customConstraints)
super.updateViewConstraints()
}
ImageRespository.swift Line 13 - 19
/// Implementation of the *Repository* protocol for images
class ImageRepository : Repository {
typealias AssociatedType = ImageResource
/// A map of image resources
var map: [String : ImageResource] = [:]
}
ResourceModelController.swift Line 278 - 298
/**
Sorts the given repository with records between the skip and limit indexes, and calls a callback with the resources that exist between the indexes
- parameter repository: the *Repository* that needs to be filled
- parameter skip: the number of items to skip when finding new resources
- parameter limit: the number of items we want to fetch
- parameter queue: The *DispatchQueue* we need to call the completion block on
- parameter completion: a callback used to pass back the filled resources
- Returns: void
*/
func sort<T>(repository:T, skip:Int, limit:Int, on queue:DispatchQueue, completion:@escaping ([T.AssociatedType])->Void) where T:Repository, T.AssociatedType:Resource {
let sortQueue = DispatchQueue(label: "\(readQueueLabel).sort")
sortQueue.async {
let values = Array(repository.map.values).sorted { $0.updatedAt > $1.updatedAt }
let endSlice = skip + limit < values.count ? skip + limit : values.count
let resources = Array(values[skip..<(endSlice)])
queue.async {
completion(resources)
}
}
}
ResourceModelController+GalleryCollectionViewModelDelegate Line 17 - 74
/**
Fetches the image resources from the local repository contained in the *ResourceModelController*
- parameter currentCount: the current number of image resource items in the collection view model
- parameter skip: the number of items to skip when finding new resources
- parameter limit: the number of items we want to fetch
- parameter timeoutDuration: the *TimeInterval* to wait before timing out the request
- parameter completion: a callback used to pass back the filled resources
- Returns: void
*/
func imageResources(currentCount:Int, skip: Int, limit: Int, timeoutDuration:TimeInterval = ResourceModelController.defaultTimeout, completion:ImageResourceCompletion?) -> Void {
if currentCount == totalImageRecords {
DispatchQueue.main.async {
completion?([ImageResource]())
}
}
// We need to make sure we don't skip fetching any images for this purpose
let readQueue = DispatchQueue(label: readQueueLabel)
var checkCount = 0
readQueue.sync { checkCount = imageRepository.map.values.count }
let finalSkip = skip > checkCount ? checkCount : skip
// We also need to make sure we still get the requested number of images
let finalLimit = abs(finalSkip - skip) + limit
// FillAndSort returns on the main queue but we are doing this for safety
let wrappedCompletion:([Resource])->Void = {[weak self] (sortedResources) in
guard let imageResources = sortedResources as? [ImageResource] else {
self?.errorHandler.report(ModelError.IncorrectType)
DispatchQueue.main.async {
completion?([ImageResource]())
}
return
}
DispatchQueue.main.async {
completion?(imageResources)
}
}
var copyImageRepository = ImageRepository()
readQueue.sync {
copyImageRepository = imageRepository
}
let fetchQueue = DispatchQueue(label: "\(readQueueLabel).fetch")
fetchQueue.async { [weak self] in
do {
try self?.fillAndSort(repository: copyImageRepository, skip: finalSkip, limit: finalLimit, timeoutDuration:timeoutDuration, on:fetchQueue, completion: wrappedCompletion)
} catch {
self?.errorHandler.report(error)
DispatchQueue.main.async {
completion?([ImageResource]())
}
}
}
}
GalleryCollectionViewModel.swift Line 11 - 17
/// Protocol to fetch the image resources for the model and get an error handler if necessary
protocol GalleryCollectionViewModelDelegate : class {
var networkSessionInterface:NetworkSessionInterface { get }
var errorHandler:ErrorHandlerDelegate { get }
var timeoutDuration:TimeInterval { get }
func imageResources(skip: Int, limit: Int, timeoutDuration:TimeInterval, completion:ImageResourceCompletion?)
}