Created
November 2, 2016 22:07
-
-
Save bcattle/ddf99159319f40972b062e5e75337740 to your computer and use it in GitHub Desktop.
Photo Library Asset Manager, a code sample illustrating an asynchronous queue loading assets from Apple's iCloud service
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// PhotoLibraryAssetManager.swift | |
// Koowalla | |
// | |
// Created by Bryan Cattle on 8/22/16. | |
// Copyright © 2016 Koowalla, Inc. All rights reserved. | |
// | |
import UIKit | |
import Bolts | |
let ErrorCodeUnableToLoadAsset = -16 // a Koowalla, not iOS error code | |
// Abstract Class | |
class PhotoLibraryBaseAssetRequest: NSObject { | |
var phAssetLocalIdentifier: String! | |
var showUserFacingErrorMessage:Bool = false | |
var lastProgress:Double = 0.0 | |
private var imageRequestID:PHImageRequestID = PHInvalidImageRequestID | |
override func isEqual(object: AnyObject?) -> Bool { | |
var equal: Bool = false; | |
if let otherRequest = object as? PhotoLibraryBaseAssetRequest { | |
equal = self.phAssetLocalIdentifier == otherRequest.phAssetLocalIdentifier // && | |
// self.showUserFacingErrorMessage == otherRequest.showUserFacingErrorMessage | |
} | |
// NSLog("Returning equal = \(equal) for \(self) and \(object)") | |
return equal | |
} | |
override var hashValue: Int { | |
get { | |
return phAssetLocalIdentifier.hashValue | |
} | |
} | |
override var description: String { | |
return super.description + " request for PHAsset: " + phAssetLocalIdentifier | |
} | |
} | |
// Concrete Classes | |
class PhotoLibraryVideoAssetRequest: PhotoLibraryBaseAssetRequest { | |
var deliveryMode = PHVideoRequestOptionsDeliveryMode.MediumQualityFormat | |
} | |
class PhotoLibraryPhotoAssetRequest: PhotoLibraryBaseAssetRequest { | |
var imageSizePx = CGSizeZero | |
var aspectFill = false | |
var deliveryMode = PHImageRequestOptionsDeliveryMode.HighQualityFormat | |
} | |
// This should really be an enum, but | |
// we need a class so we can access it from ObjC | |
class PhotoLibraryAssetRequestResult: NSObject { | |
var localIdentifier: String? | |
var asset: AVAsset? | |
var audioMix: AVAudioMix? | |
var image: UIImage? | |
} | |
class PhotoLibraryAssetManager: NSObject, TaskDelegate { | |
// MARK: Class properties | |
private static let MaxSimultaneousDownloads = 3 | |
static let ErrorCodeLowDiskSpace = 256 | |
static let ErrorCodeNoInternet = 81 | |
static let ErrorCodeMediaDamaged = -11829 | |
// static let ImageDeliveryMode = PHImageRequestOptionsDeliveryMode.HighQualityFormat | |
// static let VideoDeliveryMode = PHVideoRequestOptionsDeliveryMode.MediumQualityFormat | |
static let ProgressNotificationName = "PhotoLibraryAssetManagerProgressNotification" | |
static let NotificationAssetRequestKey = "PhotoLibraryAssetManagerProgressNotificationAssetRequest" | |
static let TaskFinishedNotificationName = "PhotoLibraryAssetManagerTaskFinishedNotification" | |
static let TaskFinishedWasCancelledKey = "PhotoLibraryAssetManagerTaskFinishedWasCancelled" | |
static let sharedManager = PhotoLibraryAssetManager() | |
// Uncommenting this *forces* the object to be a singleton | |
//private override init() { } | |
// MARK: Instance properties | |
var logAssetLoading = false | |
// We keep track of these so we know how many assets are being loaded at any given time | |
// and so we can cancel 1 of them if a cancel request comes in. | |
private var runningTasks = Dictionary<PhotoLibraryBaseAssetRequest, BaseTask>() | |
private var queuedTasks = Dictionary<PhotoLibraryBaseAssetRequest, BaseTask>() | |
private var cancellationSourcesByRequest = Dictionary<PhotoLibraryBaseAssetRequest, BFCancellationTokenSource>() | |
private lazy var taskListQueue: dispatch_queue_t = dispatch_queue_create("co.Koowalla.PhotoLibraryAssetManager.taskList", DISPATCH_QUEUE_SERIAL) | |
// MARK: Public methods | |
func numberOfRemainingTasks() -> Int { | |
var count:Int? | |
dispatch_sync(taskListQueue) { | |
count = self.runningTasks.count + self.queuedTasks.count | |
} | |
return count! | |
} | |
// We can't use swift generics from ObjC, but these BFTasks | |
// are assumed to return PhotoLibraryAssetRequestResult | |
private func makeOrReturnTask<T:BaseTask>(asset:PHAsset, withAssetRequest assetRequest:PhotoLibraryBaseAssetRequest, returningTaskOfType TaskType: T.Type) -> BFTask { | |
assetRequest.phAssetLocalIdentifier = asset.localIdentifier | |
var task: BFTask! | |
dispatch_sync(taskListQueue) { | |
// If this request matches a task that's already running, return that | |
if let runningTask = self.runningTasks[assetRequest] { | |
task = runningTask.bfTask | |
if (logAssetLoading) { | |
NSLog("Task is already running, returning current BFTask") | |
} | |
} else if let queuedTask = self.queuedTasks[assetRequest] { | |
task = queuedTask.bfTask | |
if (logAssetLoading) { | |
NSLog("Task is already queued, returning current BFTask") | |
} | |
} else { | |
// Make a new task | |
let assetTask = TaskType.init(asset: asset, assetRequest: assetRequest) | |
assetTask.delegate = self | |
assetTask.logAssetLoading = logAssetLoading | |
self.cancellationSourcesByRequest[assetRequest] = assetTask.cancellationSource | |
if (self.runningTasks.count < PhotoLibraryAssetManager.MaxSimultaneousDownloads) { | |
assetTask.run() | |
self.runningTasks[assetRequest] = assetTask | |
if (logAssetLoading) { | |
NSLog("Started new task. Now \(self.runningTasks.count) running, \(self.queuedTasks.count) queued") | |
} | |
} else { | |
// Enqueue it | |
self.queuedTasks[assetRequest] = assetTask | |
if (logAssetLoading) { | |
NSLog("Enqueued new task. Now \(self.runningTasks.count) running, \(self.queuedTasks.count) queued") | |
} | |
} | |
// Add a completion handler that updates the queue | |
task = assetTask.bfTask.continueWithBlock { | |
[weak self] (task:BFTask) -> AnyObject? in | |
self?.handleDone(assetTask) | |
return task | |
} | |
} | |
} | |
return task | |
} | |
func requestAVAssetforAsset(asset:PHAsset, withAssetRequest assetRequest:PhotoLibraryVideoAssetRequest) -> BFTask { | |
return makeOrReturnTask(asset, withAssetRequest: assetRequest, returningTaskOfType: AVAssetTask.self) | |
} | |
func requestImageForAsset(asset:PHAsset, withAssetRequest assetRequest:PhotoLibraryPhotoAssetRequest) -> BFTask { | |
return makeOrReturnTask(asset, withAssetRequest: assetRequest, returningTaskOfType: ImageTask.self) | |
} | |
func cancelAllPendingAssetRequests() { | |
dispatch_sync(taskListQueue) { | |
self.queuedTasks.removeAll() | |
for (assetRequest, _) in self.runningTasks { | |
self._cancelAssetRequest(assetRequest) | |
} | |
} | |
} | |
func cancelAssetRequest(assetRequest:PhotoLibraryBaseAssetRequest) { | |
dispatch_sync(taskListQueue) { | |
self._cancelAssetRequest(assetRequest) | |
} | |
} | |
func _cancelAssetRequest(assetRequest:PhotoLibraryBaseAssetRequest) { | |
if let cancellationSource = self.cancellationSourcesByRequest[assetRequest] { | |
if (logAssetLoading) { | |
NSLog("Cancelling asset request \(assetRequest)") | |
} | |
cancellationSource.cancel() | |
PHImageManager.defaultManager().cancelImageRequest(assetRequest.imageRequestID) | |
} else { | |
NSLog("No task found for asset request \(assetRequest)") | |
} | |
} | |
// MARK: Private Methods | |
private func handleProgress(task:BaseTask, assetRequest:PhotoLibraryBaseAssetRequest, progress:Double, error:NSError?, stop:UnsafeMutablePointer<ObjCBool>, info:[NSObject : AnyObject]?) { | |
// This is called on an arbitrary queue | |
dispatch_async(dispatch_get_main_queue()) { | |
// If there's an error, stop | |
if (error != nil) { | |
NSLog("(progress handler) requestImageForAsset returned error: \(error)") | |
stop.memory = true | |
assetRequest.lastProgress = 1.0 | |
} else { | |
// NSLog("iCloud progress: \(progress) asset: \(assetRequest.phAssetLocalIdentifier)") | |
assetRequest.lastProgress = progress | |
} | |
// Send the progress notification | |
let userInfo = [PhotoLibraryAssetManager.NotificationAssetRequestKey: assetRequest]; | |
NSNotificationCenter.defaultCenter().postNotificationName(PhotoLibraryAssetManager.ProgressNotificationName, object: self, userInfo: userInfo) | |
} | |
} | |
private func handleDone(task: BaseTask) { | |
// Task and cancellation source are done, remove from the manager | |
dispatch_async(taskListQueue) { | |
// [weak self] in | |
self.runningTasks.removeValueForKey(task.assetRequest) | |
self.cancellationSourcesByRequest.removeValueForKey(task.assetRequest) | |
// If there are tasks enqueued, run the next... | |
if let (_, assetTask) = self.queuedTasks.popFirst() { | |
assetTask.run() | |
self.runningTasks[assetTask.assetRequest] = assetTask | |
if (self.logAssetLoading) { | |
NSLog("Finished task. Starting another. Now \(self.runningTasks.count) running, \(self.queuedTasks.count) queued") | |
} | |
} | |
else { | |
if (self.logAssetLoading) { | |
NSLog("Task finished.") | |
} | |
} | |
// Send the task finished notification | |
dispatch_async(dispatch_get_main_queue()) { | |
let userInfo = [PhotoLibraryAssetManager.NotificationAssetRequestKey: task.assetRequest, | |
PhotoLibraryAssetManager.TaskFinishedWasCancelledKey: task.bfTask.cancelled]; | |
NSNotificationCenter.defaultCenter().postNotificationName(PhotoLibraryAssetManager.TaskFinishedNotificationName, object: self, userInfo: userInfo) | |
} | |
} | |
} | |
} | |
// MARK: Private classes | |
// This class is a container that holds the completionSource | |
// and lets us actually run the task at a later time. This allows us to | |
// enqueue a task and still return a reference to it | |
private protocol TaskDelegate: class { | |
func handleProgress(task:BaseTask, assetRequest:PhotoLibraryBaseAssetRequest, progress:Double, error:NSError?, stop:UnsafeMutablePointer<ObjCBool>, info:[NSObject : AnyObject]?) | |
} | |
private class BaseTask { | |
var logAssetLoading = false | |
let completionSource = BFTaskCompletionSource() | |
let cancellationSource = BFCancellationTokenSource() | |
let asset:PHAsset | |
let assetRequest:PhotoLibraryBaseAssetRequest | |
weak var delegate: TaskDelegate? | |
required init(asset:PHAsset, assetRequest:PhotoLibraryBaseAssetRequest) { | |
self.asset = asset | |
self.assetRequest = assetRequest | |
} | |
var bfTask: BFTask { get { | |
return completionSource.task | |
} | |
} | |
func run() { | |
assert(false, "Abstract method") | |
} | |
func showErrorMessageForAssetFetchError(error: NSError) { | |
dispatch_async(dispatch_get_main_queue()) { | |
if (error.code == PhotoLibraryAssetManager.ErrorCodeLowDiskSpace) { | |
UIAlertControllerShowWithOK(NSLocalizedString("There is not enough free disk space to download this " + | |
"clip from iCloud.", comment: ""), | |
NSLocalizedString("Uh oh", comment: ""), | |
nil) | |
} else if (error.code == PhotoLibraryAssetManager.ErrorCodeNoInternet) { | |
UIAlertControllerShowWithOK(NSLocalizedString("We need internet to download this clip from " + | |
"iCloud. You aren't connected right now.", comment: ""), | |
NSLocalizedString("Uh oh", comment: ""), | |
nil) | |
} else { | |
UIAlertControllerShowWithOK(NSLocalizedString("Sorry, there was a problem downloading this clip from " + | |
"iCloud: ", comment: "") | |
.stringByAppendingString(error.localizedDescription), | |
NSLocalizedString("Uh oh", comment: ""), | |
nil) | |
} | |
} | |
} | |
func logNonfatalErrorToHockeyAppIfUnknown(error: NSError) { | |
let knownErrors = Set([ | |
PhotoLibraryAssetManager.ErrorCodeLowDiskSpace, | |
PhotoLibraryAssetManager.ErrorCodeNoInternet, | |
PhotoLibraryAssetManager.ErrorCodeMediaDamaged, | |
ErrorCodeUnableToLoadAsset | |
]) | |
if (!knownErrors.contains(error.code)) { | |
ISKoowallaDiagnosticDataManager.sharedManager().logNonfatalError(error) | |
} | |
} | |
} | |
private class AVAssetTask : BaseTask { | |
override func run() { | |
let options = PHVideoRequestOptions() | |
// options.deliveryMode = PhotoLibraryAssetManager.VideoDeliveryMode | |
let videoAssetRequest = assetRequest as! PhotoLibraryVideoAssetRequest | |
options.deliveryMode = videoAssetRequest.deliveryMode | |
options.networkAccessAllowed = true | |
options.progressHandler = { | |
// [weak self] | |
(progress:Double, error:NSError?, stop:UnsafeMutablePointer<ObjCBool>, info:[NSObject : AnyObject]?) in | |
self.delegate?.handleProgress(self, assetRequest: self.assetRequest, progress: progress, error: error, stop: stop, info: info) | |
} | |
// An AVAsset request is completely defined by (1) what asset is being fetched, and (2) the options class | |
if (logAssetLoading) { | |
NSLog("requestAVAssetForVideo: getting asset \(asset.localIdentifier) with options \(options)") | |
} | |
assetRequest.imageRequestID = PHImageManager.defaultManager().requestAVAssetForVideo(asset, options: options) { | |
[weak self] (avAsset:AVAsset?, audioMix:AVAudioMix?, info:[NSObject : AnyObject]?) in | |
// Normally, audio mix is nil. | |
// But slow mo clips return an audio mix that looks like this: | |
// _ ( slow mo period ) _ | |
// \ ____________________ / | |
// \/ \/ | |
// Slo-mo clips return an AVComposition | |
// other clips (including time-lapse) return an AVURLAsset. | |
// For slow-mo, this AVComposition accomplishes the time dialation | |
// with series of track segments that map source asset time to output time. | |
// These begin at 1:1 time mapping, then do a six-step interpolation down to 8x | |
// 1x input time = 8x output time: | |
// 1x, 1.2x, 1.4x, 1.8x, 2.5x, 3.8x, 8x | |
// We then hold there for the duration of the slo-mo period, | |
// then the steps reverse, to bring us back up. | |
// data: https://docs.google.com/spreadsheets/d/17WMo4XqeNiIXod70vXiDP1tlJai4CPknmucCkrqxHRw/edit#gid=0 | |
var error = info?[PHImageErrorKey] as? NSError | |
if let cancelled = info?[PHImageCancelledKey] as? Bool { | |
if (cancelled == true) { | |
self?.completionSource.trySetCancelled() | |
} | |
} | |
else if error != nil || avAsset == nil { | |
if error != nil { | |
NSLog("requestAVAssetForVideo returned error: \(error)"); | |
} else { | |
NSLog("requestAVAssetForVideo returned nil asset"); | |
error = NSError(domain: "co.koowalla.PhotoLibraryAssetManager", | |
code: ErrorCodeUnableToLoadAsset, | |
userInfo: [NSLocalizedDescriptionKey: NSLocalizedString("Failed to load one of your video clips", | |
comment: "Message when requestAVAssetForVideo returned nil asset")]) | |
} | |
// Log the error and show it to the user if necessary | |
self?.logNonfatalErrorToHockeyAppIfUnknown(error!) | |
if (self?.assetRequest.showUserFacingErrorMessage == true) { | |
self?.showErrorMessageForAssetFetchError(error!) | |
} | |
// Finish the task with an error | |
self?.completionSource.setError(error!) | |
} | |
else { | |
// Load the asset's tracks | |
avAsset!.loadTracksWithCompletionHandler { (status:AVKeyValueStatus, error:NSError?) in | |
if (error != nil) { | |
self?.logNonfatalErrorToHockeyAppIfUnknown(error!) | |
if (self?.assetRequest.showUserFacingErrorMessage == true) { | |
self?.showErrorMessageForAssetFetchError(error!) | |
} | |
self?.completionSource.setError(error!) | |
} else { | |
// Make the result and finish the task | |
let result = PhotoLibraryAssetRequestResult() | |
result.localIdentifier = self?.asset.localIdentifier | |
result.asset = avAsset | |
result.audioMix = audioMix | |
self?.completionSource.setResult(result) | |
} | |
} | |
} | |
} | |
} | |
} | |
private class ImageTask : BaseTask { | |
override func run() { | |
// Note: this is called on `taskListQueue` | |
let assetRequest = self.assetRequest as! PhotoLibraryPhotoAssetRequest | |
assert(!CGSizeEqualToSize(assetRequest.imageSizePx, CGSizeZero), "Image size is zero") | |
let contentMode = assetRequest.aspectFill ? PHImageContentMode.AspectFill : PHImageContentMode.AspectFit | |
let options = PHImageRequestOptions() | |
options.deliveryMode = assetRequest.deliveryMode | |
options.networkAccessAllowed = true | |
options.progressHandler = { | |
[weak self] | |
(progress:Double, error:NSError?, stop:UnsafeMutablePointer<ObjCBool>, info:[NSObject : AnyObject]?) in | |
self?.delegate?.handleProgress(self!, assetRequest: self!.assetRequest, progress: progress, error: error, stop: stop, info: info) | |
} | |
options.resizeMode = .Fast | |
if (logAssetLoading) { | |
NSLog("Requesting image: \(asset.localIdentifier) \(options)") | |
} | |
assetRequest.imageRequestID = PHImageManager.defaultManager().requestImageForAsset(asset, targetSize: assetRequest.imageSizePx, contentMode: contentMode, options: options) { | |
[weak self] (image:UIImage?, info:[NSObject : AnyObject]?) in | |
var error = info?[PHImageErrorKey] as? NSError | |
if error != nil || image == nil { | |
if error != nil { | |
NSLog("requestImageForAsset returned error: \(error)"); | |
} else { | |
NSLog("requestImageForAsset returned nil asset"); | |
error = NSError(domain: "co.koowalla.PhotoLibraryAssetManager", | |
code: ErrorCodeUnableToLoadAsset, | |
userInfo: [NSLocalizedDescriptionKey: NSLocalizedString("Failed to load one of your video clips", | |
comment: "Message when requestAVAssetForVideo returned nil asset")]) | |
} | |
// Log the error and show it to the user if necessary | |
self?.logNonfatalErrorToHockeyAppIfUnknown(error!) | |
if assetRequest.showUserFacingErrorMessage { | |
self?.showErrorMessageForAssetFetchError(error!) | |
} | |
// Finish the task with an error | |
self?.completionSource.setError(error!) | |
} | |
else { | |
// Finish the task with a result | |
let result = PhotoLibraryAssetRequestResult() | |
result.localIdentifier = self?.asset.localIdentifier | |
result.image = image | |
self?.completionSource.setResult(result) | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment