Proyectos de Subversion Iphone Microlearning

Rev

Autoría | Ultima modificación | Ver Log |

//
//  ImageDownloader.swift
//
//  Copyright (c) 2015 Alamofire Software Foundation (http://alamofire.org/)
//
//  Permission is hereby granted, free of charge, to any person obtaining a copy
//  of this software and associated documentation files (the "Software"), to deal
//  in the Software without restriction, including without limitation the rights
//  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
//  copies of the Software, and to permit persons to whom the Software is
//  furnished to do so, subject to the following conditions:
//
//  The above copyright notice and this permission notice shall be included in
//  all copies or substantial portions of the Software.
//
//  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
//  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
//  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
//  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
//  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
//  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
//  THE SOFTWARE.
//

import Alamofire
import Foundation

#if os(iOS) || os(tvOS) || os(watchOS)
import UIKit
#elseif os(macOS)
import Cocoa
#endif

/// Alias for `DataResponse<T, AFIError>`.
public typealias AFIDataResponse<T> = DataResponse<T, AFIError>

/// Alias for `Result<T, AFIError>`.
public typealias AFIResult<T> = Result<T, AFIError>

/// The `RequestReceipt` is an object vended by the `ImageDownloader` when starting a download request. It can be used
/// to cancel active requests running on the `ImageDownloader` session. As a general rule, image download requests
/// should be cancelled using the `RequestReceipt` instead of calling `cancel` directly on the `request` itself. The
/// `ImageDownloader` is optimized to handle duplicate request scenarios as well as pending versus active downloads.
open class RequestReceipt {
    /// The download request created by the `ImageDownloader`.
    public let request: DataRequest

    /// The unique identifier for the image filters and completion handlers when duplicate requests are made.
    public let receiptID: String

    init(request: DataRequest, receiptID: String) {
        self.request = request
        self.receiptID = receiptID
    }
}

// MARK: -

/// The `ImageDownloader` class is responsible for downloading images in parallel on a prioritized queue. Incoming
/// downloads are added to the front or back of the queue depending on the download prioritization. Each downloaded
/// image is cached in the underlying `NSURLCache` as well as the in-memory image cache that supports image filters.
/// By default, any download request with a cached image equivalent in the image cache will automatically be served the
/// cached image representation. Additional advanced features include supporting multiple image filters and completion
/// handlers for a single request.
open class ImageDownloader {
    /// The completion handler closure used when an image download completes.
    public typealias CompletionHandler = (AFIDataResponse<Image>) -> Void

    /// The progress handler closure called periodically during an image download.
    public typealias ProgressHandler = DataRequest.ProgressHandler

    // MARK: Helper Types

    /// Defines the order prioritization of incoming download requests being inserted into the queue.
    ///
    /// - fifo: All incoming downloads are added to the back of the queue.
    /// - lifo: All incoming downloads are added to the front of the queue.
    public enum DownloadPrioritization {
        case fifo, lifo
    }

    final class ResponseHandler {
        let urlID: String
        let handlerID: String
        let request: DataRequest
        var operations: [(receiptID: String, filter: ImageFilter?, completion: CompletionHandler?)]

        init(request: DataRequest,
             handlerID: String,
             receiptID: String,
             filter: ImageFilter?,
             completion: CompletionHandler?) {
            self.request = request
            urlID = ImageDownloader.urlIdentifier(for: request.convertible)
            self.handlerID = handlerID
            operations = [(receiptID: receiptID, filter: filter, completion: completion)]
        }
    }

    // MARK: Properties

    /// The image cache used to store all downloaded images in.
    public let imageCache: ImageRequestCache?

    /// The credential used for authenticating each download request.
    open private(set) var credential: URLCredential?

    /// Response serializer used to convert the image data to UIImage.
    public var imageResponseSerializer = ImageResponseSerializer()

    /// The underlying Alamofire `Session` instance used to handle all download requests.
    public let session: Session

    let downloadPrioritization: DownloadPrioritization
    let maximumActiveDownloads: Int

    var activeRequestCount = 0
    var queuedRequests: [Request] = []
    var responseHandlers: [String: ResponseHandler] = [:]

    private let synchronizationQueue: DispatchQueue = {
        let name = String(format: "org.alamofire.imagedownloader.synchronizationqueue-%08x%08x", arc4random(), arc4random())
        return DispatchQueue(label: name)
    }()

    private let responseQueue: DispatchQueue = {
        let name = String(format: "org.alamofire.imagedownloader.responsequeue-%08x%08x", arc4random(), arc4random())
        return DispatchQueue(label: name, attributes: .concurrent)
    }()

    // MARK: Initialization

    /// The default instance of `ImageDownloader` initialized with default values.
    public static let `default` = ImageDownloader()

    /// Creates a default `URLSessionConfiguration` with common usage parameter values.
    ///
    /// - returns: The default `URLSessionConfiguration` instance.
    open class func defaultURLSessionConfiguration() -> URLSessionConfiguration {
        let configuration = URLSessionConfiguration.default

        configuration.headers = .default
        configuration.httpShouldSetCookies = true
        configuration.httpShouldUsePipelining = false

        configuration.requestCachePolicy = .useProtocolCachePolicy
        configuration.allowsCellularAccess = true
        configuration.timeoutIntervalForRequest = 60

        configuration.urlCache = ImageDownloader.defaultURLCache()

        return configuration
    }

    /// Creates a default `URLCache` with common usage parameter values.
    ///
    /// - returns: The default `URLCache` instance.
    open class func defaultURLCache() -> URLCache {
        let memoryCapacity = 20 * 1024 * 1024
        let diskCapacity = 150 * 1024 * 1024
        let cacheDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
        let imageDownloaderPath = "org.alamofire.imagedownloader"

        #if targetEnvironment(macCatalyst)
        return URLCache(memoryCapacity: memoryCapacity,
                        diskCapacity: diskCapacity,
                        directory: cacheDirectory?.appendingPathComponent(imageDownloaderPath))
        #else
        #if os(macOS)
        return URLCache(memoryCapacity: memoryCapacity,
                        diskCapacity: diskCapacity,
                        diskPath: cacheDirectory?.appendingPathComponent(imageDownloaderPath).path)
        #else
        return URLCache(memoryCapacity: memoryCapacity,
                        diskCapacity: diskCapacity,
                        diskPath: imageDownloaderPath)
        #endif
        #endif
    }

    /// Initializes the `ImageDownloader` instance with the given configuration, download prioritization, maximum active
    /// download count and image cache.
    ///
    /// - parameter configuration:          The `URLSessionConfiguration` to use to create the underlying Alamofire
    ///                                     `SessionManager` instance.
    /// - parameter downloadPrioritization: The download prioritization of the download queue. `.fifo` by default.
    /// - parameter maximumActiveDownloads: The maximum number of active downloads allowed at any given time.
    /// - parameter imageCache:             The image cache used to store all downloaded images in.
    ///
    /// - returns: The new `ImageDownloader` instance.
    public init(configuration: URLSessionConfiguration = ImageDownloader.defaultURLSessionConfiguration(),
                downloadPrioritization: DownloadPrioritization = .fifo,
                maximumActiveDownloads: Int = 4,
                imageCache: ImageRequestCache? = AutoPurgingImageCache()) {
        session = Session(configuration: configuration, startRequestsImmediately: false)
        self.downloadPrioritization = downloadPrioritization
        self.maximumActiveDownloads = maximumActiveDownloads
        self.imageCache = imageCache
    }

    /// Initializes the `ImageDownloader` instance with the given session manager, download prioritization, maximum
    /// active download count and image cache.
    ///
    /// - parameter session:                The Alamofire `Session` instance to handle all download requests.
    /// - parameter downloadPrioritization: The download prioritization of the download queue. `.fifo` by default.
    /// - parameter maximumActiveDownloads: The maximum number of active downloads allowed at any given time.
    /// - parameter imageCache:             The image cache used to store all downloaded images in.
    ///
    /// - returns: The new `ImageDownloader` instance.
    public init(session: Session,
                downloadPrioritization: DownloadPrioritization = .fifo,
                maximumActiveDownloads: Int = 4,
                imageCache: ImageRequestCache? = AutoPurgingImageCache()) {
        precondition(!session.startRequestsImmediately, "Session must set `startRequestsImmediately` to `false`.")

        self.session = session
        self.downloadPrioritization = downloadPrioritization
        self.maximumActiveDownloads = maximumActiveDownloads
        self.imageCache = imageCache
    }

    // MARK: Authentication

    /// Associates an HTTP Basic Auth credential with all future download requests.
    ///
    /// - parameter user:        The user.
    /// - parameter password:    The password.
    /// - parameter persistence: The URL credential persistence. `.forSession` by default.
    open func addAuthentication(user: String,
                                password: String,
                                persistence: URLCredential.Persistence = .forSession) {
        let credential = URLCredential(user: user, password: password, persistence: persistence)
        addAuthentication(usingCredential: credential)
    }

    /// Associates the specified credential with all future download requests.
    ///
    /// - parameter credential: The credential.
    open func addAuthentication(usingCredential credential: URLCredential) {
        synchronizationQueue.sync {
            self.credential = credential
        }
    }

    // MARK: Download

    /// Creates a download request using the internal Alamofire `SessionManager` instance for the specified URL request.
    ///
    /// If the same download request is already in the queue or currently being downloaded, the filter and completion
    /// handler are appended to the already existing request. Once the request completes, all filters and completion
    /// handlers attached to the request are executed in the order they were added. Additionally, any filters attached
    /// to the request with the same identifiers are only executed once. The resulting image is then passed into each
    /// completion handler paired with the filter.
    ///
    /// You should not attempt to directly cancel the `request` inside the request receipt since other callers may be
    /// relying on the completion of that request. Instead, you should call `cancelRequestForRequestReceipt` with the
    /// returned request receipt to allow the `ImageDownloader` to optimize the cancellation on behalf of all active
    /// callers.
    ///
    /// - parameter urlRequest:     The URL request.
    /// - parameter cacheKey:       An optional key used to identify the image in the cache. Defaults to `nil`.
    /// - parameter receiptID:      The `identifier` for the `RequestReceipt` returned. Defaults to a new, randomly
    ///                             generated UUID.
    /// - parameter serializer:     Image response serializer used to convert the image data to `UIImage`. Defaults
    ///                             to `nil` which will fall back to the instance `imageResponseSerializer`.
    /// - parameter filter:         The image filter to apply to the image after the download is complete. Defaults
    ///                             to `nil`.
    /// - parameter progress:       The closure to be executed periodically during the lifecycle of the request.
    ///                             Defaults to `nil`.
    /// - parameter progressQueue:  The dispatch queue to call the progress closure on. Defaults to the main queue.
    /// - parameter completion:     The closure called when the download request is complete. Defaults to `nil`.
    ///
    /// - returns: The request receipt for the download request if available. `nil` if the image is stored in the image
    ///            cache and the URL request cache policy allows the cache to be used.
    @discardableResult
    open func download(_ urlRequest: URLRequestConvertible,
                       cacheKey: String? = nil,
                       receiptID: String = UUID().uuidString,
                       serializer: ImageResponseSerializer? = nil,
                       filter: ImageFilter? = nil,
                       progress: ProgressHandler? = nil,
                       progressQueue: DispatchQueue = DispatchQueue.main,
                       completion: CompletionHandler? = nil)
        -> RequestReceipt? {
        var queuedRequest: DataRequest?

        synchronizationQueue.sync {
            // 1) Append the filter and completion handler to a pre-existing request if it already exists
            let urlID = ImageDownloader.urlIdentifier(for: urlRequest)

            if let responseHandler = self.responseHandlers[urlID] {
                responseHandler.operations.append((receiptID: receiptID, filter: filter, completion: completion))
                queuedRequest = responseHandler.request
                return
            }

            // 2) Attempt to load the image from the image cache if the cache policy allows it
            if let nonNilURLRequest = urlRequest.urlRequest {
                switch nonNilURLRequest.cachePolicy {
                case .useProtocolCachePolicy, .returnCacheDataElseLoad, .returnCacheDataDontLoad:
                    let cachedImage: Image?

                    if let cacheKey = cacheKey {
                        cachedImage = self.imageCache?.image(withIdentifier: cacheKey)
                    } else {
                        cachedImage = self.imageCache?.image(for: nonNilURLRequest, withIdentifier: filter?.identifier)
                    }

                    if let image = cachedImage {
                        DispatchQueue.main.async {
                            let response = AFIDataResponse<Image>(request: urlRequest.urlRequest,
                                                                  response: nil,
                                                                  data: nil,
                                                                  metrics: nil,
                                                                  serializationDuration: 0.0,
                                                                  result: .success(image))

                            completion?(response)
                        }

                        return
                    }
                default:
                    break
                }
            }

            // 3) Create the request and set up authentication, validation and response serialization
            let request = self.session.request(urlRequest)
            queuedRequest = request

            if let credential = self.credential {
                request.authenticate(with: credential)
            }

            request.validate()

            if let progress = progress {
                request.downloadProgress(queue: progressQueue, closure: progress)
            }

            // Generate a unique handler id to check whether the active request has changed while downloading
            let handlerID = UUID().uuidString

            request.response(queue: self.responseQueue,
                             responseSerializer: serializer ?? imageResponseSerializer,
                             completionHandler: { response in
                                 defer {
                                     self.safelyDecrementActiveRequestCount()
                                     self.safelyStartNextRequestIfNecessary()
                                 }

                                 // Early out if the request has changed out from under us
                                 guard
                                     let handler = self.safelyFetchResponseHandler(withURLIdentifier: urlID),
                                     handler.handlerID == handlerID,
                                     let responseHandler = self.safelyRemoveResponseHandler(withURLIdentifier: urlID)
                                 else {
                                     return
                                 }

                                 switch response.result {
                                 case let .success(image):
                                     var filteredImages: [String: Image] = [:]

                                     for (_, filter, completion) in responseHandler.operations {
                                         var filteredImage: Image

                                         if let filter = filter {
                                             if let alreadyFilteredImage = filteredImages[filter.identifier] {
                                                 filteredImage = alreadyFilteredImage
                                             } else {
                                                 filteredImage = filter.filter(image)
                                                 filteredImages[filter.identifier] = filteredImage
                                             }
                                         } else {
                                             filteredImage = image
                                         }

                                         if let cacheKey = cacheKey {
                                             self.imageCache?.add(filteredImage, withIdentifier: cacheKey)
                                         } else if let request = response.request {
                                             self.imageCache?.add(filteredImage, for: request, withIdentifier: filter?.identifier)
                                         }

                                         DispatchQueue.main.async {
                                             let response = AFIDataResponse<Image>(request: response.request,
                                                                                   response: response.response,
                                                                                   data: response.data,
                                                                                   metrics: response.metrics,
                                                                                   serializationDuration: response.serializationDuration,
                                                                                   result: .success(filteredImage))

                                             completion?(response)
                                         }
                                     }
                                 case .failure:
                                     for (_, _, completion) in responseHandler.operations {
                                         DispatchQueue.main.async { completion?(response.mapError { AFIError.alamofireError($0) }) }
                                     }
                                 }
                             })

            // 4) Store the response handler for use when the request completes
            let responseHandler = ResponseHandler(request: request,
                                                  handlerID: handlerID,
                                                  receiptID: receiptID,
                                                  filter: filter,
                                                  completion: completion)

            self.responseHandlers[urlID] = responseHandler

            // 5) Either start the request or enqueue it depending on the current active request count
            if self.isActiveRequestCountBelowMaximumLimit() {
                self.start(request)
            } else {
                self.enqueue(request)
            }
        }

        if let request = queuedRequest {
            return RequestReceipt(request: request, receiptID: receiptID)
        }

        return nil
    }

    /// Creates a download request using the internal Alamofire `SessionManager` instance for each specified URL request.
    ///
    /// For each request, if the same download request is already in the queue or currently being downloaded, the
    /// filter and completion handler are appended to the already existing request. Once the request completes, all
    /// filters and completion handlers attached to the request are executed in the order they were added.
    /// Additionally, any filters attached to the request with the same identifiers are only executed once. The
    /// resulting image is then passed into each completion handler paired with the filter.
    ///
    /// You should not attempt to directly cancel any of the `request`s inside the request receipts array since other
    /// callers may be relying on the completion of that request. Instead, you should call
    /// `cancelRequestForRequestReceipt` with the returned request receipt to allow the `ImageDownloader` to optimize
    /// the cancellation on behalf of all active callers.
    ///
    /// - parameter urlRequests:   The URL requests.
    /// - parameter filter         The image filter to apply to the image after each download is complete.
    /// - parameter progress:      The closure to be executed periodically during the lifecycle of the request. Defaults
    ///                            to `nil`.
    /// - parameter progressQueue: The dispatch queue to call the progress closure on. Defaults to the main queue.
    /// - parameter completion:    The closure called when each download request is complete.
    ///
    /// - returns: The request receipts for the download requests if available. If an image is stored in the image
    ///            cache and the URL request cache policy allows the cache to be used, a receipt will not be returned
    ///            for that request.
    @discardableResult
    open func download(_ urlRequests: [URLRequestConvertible],
                       filter: ImageFilter? = nil,
                       progress: ProgressHandler? = nil,
                       progressQueue: DispatchQueue = DispatchQueue.main,
                       completion: CompletionHandler? = nil)
        -> [RequestReceipt] {
        urlRequests.compactMap {
            download($0, filter: filter, progress: progress, progressQueue: progressQueue, completion: completion)
        }
    }

    /// Cancels the request contained inside the receipt calls the completion handler with a request cancelled error.
    ///
    /// - Parameter requestReceipt: The request receipt to cancel.
    open func cancelRequest(with requestReceipt: RequestReceipt) {
        synchronizationQueue.sync {
            let urlID = ImageDownloader.urlIdentifier(for: requestReceipt.request.convertible)
            guard let responseHandler = self.responseHandlers[urlID] else { return }

            let index = responseHandler.operations.firstIndex { $0.receiptID == requestReceipt.receiptID }

            if let index = index {
                let operation = responseHandler.operations.remove(at: index)

                let response: AFIDataResponse<Image> = {
                    let urlRequest = requestReceipt.request.request
                    let error = AFIError.requestCancelled

                    return DataResponse(request: urlRequest,
                                        response: nil,
                                        data: nil,
                                        metrics: nil,
                                        serializationDuration: 0.0,
                                        result: .failure(error))
                }()

                DispatchQueue.main.async { operation.completion?(response) }
            }

            if responseHandler.operations.isEmpty {
                requestReceipt.request.cancel()
                self.responseHandlers.removeValue(forKey: urlID)
            }
        }
    }

    // MARK: Internal - Thread-Safe Request Methods

    func safelyFetchResponseHandler(withURLIdentifier urlIdentifier: String) -> ResponseHandler? {
        var responseHandler: ResponseHandler?

        synchronizationQueue.sync {
            responseHandler = self.responseHandlers[urlIdentifier]
        }

        return responseHandler
    }

    func safelyRemoveResponseHandler(withURLIdentifier identifier: String) -> ResponseHandler? {
        var responseHandler: ResponseHandler?

        synchronizationQueue.sync {
            responseHandler = self.responseHandlers.removeValue(forKey: identifier)
        }

        return responseHandler
    }

    func safelyStartNextRequestIfNecessary() {
        synchronizationQueue.sync {
            guard self.isActiveRequestCountBelowMaximumLimit() else { return }

            guard let request = self.dequeue() else { return }

            self.start(request)
        }
    }

    func safelyDecrementActiveRequestCount() {
        synchronizationQueue.sync {
            self.activeRequestCount -= 1
        }
    }

    // MARK: Internal - Non Thread-Safe Request Methods

    func start(_ request: Request) {
        request.resume()
        activeRequestCount += 1
    }

    func enqueue(_ request: Request) {
        switch downloadPrioritization {
        case .fifo:
            queuedRequests.append(request)
        case .lifo:
            queuedRequests.insert(request, at: 0)
        }
    }

    @discardableResult
    func dequeue() -> Request? {
        var request: Request?

        if !queuedRequests.isEmpty {
            request = queuedRequests.removeFirst()
        }

        return request
    }

    func isActiveRequestCountBelowMaximumLimit() -> Bool {
        activeRequestCount < maximumActiveDownloads
    }

    static func urlIdentifier(for urlRequest: URLRequestConvertible) -> String {
        var urlID: String?

        do {
            urlID = try urlRequest.asURLRequest().url?.absoluteString
        } catch {
            // No-op
        }

        return urlID ?? ""
    }
}