You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
578 lines
27 KiB
578 lines
27 KiB
//
|
|
// 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
|
|
}
|
|
|
|
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).absoluteString)
|
|
#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 ?? ""
|
|
}
|
|
}
|
|
|