Skip to content

Instantly share code, notes, and snippets.

@bcattle
Created November 2, 2016 22:07
Show Gist options
  • Save bcattle/ddf99159319f40972b062e5e75337740 to your computer and use it in GitHub Desktop.
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
//
// 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