-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Create URLDataCache and DataDownloader (#164)
### Summary The ImageDownloader and Cache works great but only supports images that are supported by UIImage/NSImage for the OS version of the device. If the Image doesn't support the data type, the data is lost and not cached, and we are unable to fully utilize the cache if we support other image types from our application. `URLDataCache` and `DataDownloader` removes the Image requirement from downloading the data needed while maintaining the functionality of `ImageDownloader` and ImageCache. ### Implementation In order to reduce duplicated code, most of the existing code from `URLImageCache.swift` and `AutoPurgingURLImageCache.swift` and has been replaced with `NSData` in place of `UIImage` and `NSImage`. This allows for more flexibility in the data types being returned from dynamic URL types that we might not always have control over. `NSData` was chosen over `Data` in order to maintain the use of `NSCache` which requires the use of a class [NSData], instead of a struct [Data]. Since these are interoperable, I felt this wouldn't be an issue. ### Test Plan - Ensure Images are still downloaded, cached, and retrieved properly. This can be done using the tests or in your application. I have used Proxyman to determine that multiple requests were not being made by the application to request subsequent Images from the web. In prior versions these requests would continue to be made because once `UIImage`/`NSImage` is instantiated from `Data`, the cached data is lost. - [New functionality] Ensure unsupported image types from the web are being stored properly. This can be done using the tests or in your application. I have used Proxyman to determine that multiple requests were not being made by the application to request subsequent Images from the web.
- Loading branch information
1 parent
f070673
commit 44611d6
Showing
9 changed files
with
580 additions
and
62 deletions.
There are no files selected for viewing
100 changes: 100 additions & 0 deletions
100
Sources/Conduit/Networking/Data/AutoPurgingURLDataCache.swift
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
// | ||
// AutoPurgingURLDataCache.swift | ||
// | ||
// | ||
// Created by Anthony Lipscomb on 8/2/21. | ||
// | ||
|
||
import Foundation | ||
|
||
public struct AutoPurgingURLDataCache: URLDataCache { | ||
|
||
private let cache: NSCache<NSString, NSData> | ||
private let serialQueue = DispatchQueue( | ||
label: "com.mindbodyonline.Conduit.AutoPurgingDataCache-\(UUID().uuidString)" | ||
) | ||
|
||
/// Initializes an AutoPurgingURLDataCache with the desired memory capacity | ||
/// | ||
/// - Parameters: | ||
/// - memoryCapacity: The desired cache capacity before data eviction. Defaults to 60MB. | ||
/// | ||
/// - Important: The system will evict data based on different constraints within the system environment. | ||
/// It is possible for the memory capacity to be surpassed and for the system to purge data at a later time. | ||
public init(memoryCapacity: Int = 1_024 * 1_024 * 60) { | ||
cache = NSCache() | ||
cache.totalCostLimit = memoryCapacity | ||
} | ||
|
||
/// Attempts to retrieve a cached data for the given request | ||
/// | ||
/// - Parameters: | ||
/// - request: The request for the data | ||
/// - Returns: The cached data or nil of none exists | ||
public func data(for request: URLRequest) -> NSData? { | ||
guard let identifier = cacheIdentifier(for: request) else { | ||
return nil | ||
} | ||
|
||
var data: NSData? | ||
serialQueue.sync { | ||
data = cache.object(forKey: identifier as NSString) | ||
} | ||
return data | ||
} | ||
|
||
/// Attempts to build a cache identifier for the given request | ||
/// | ||
/// - Parameters: | ||
/// - request: The request for the data | ||
/// - Returns: An identifier for the cached data | ||
public func cacheIdentifier(for request: URLRequest) -> String? { | ||
return request.url?.absoluteString | ||
} | ||
|
||
/// Attempts to cache data for a given request | ||
/// | ||
/// - Parameters: | ||
/// - data: The data to be cached | ||
/// - request: The original request for the data | ||
/// - Returns: Boolean describing if the operation was successful | ||
@discardableResult | ||
public func cache(data: NSData, for request: URLRequest) -> Bool { | ||
guard let identifier = cacheIdentifier(for: request) else { | ||
return false | ||
} | ||
|
||
let totalBytes = numberOfBytes(in: data) | ||
serialQueue.sync { | ||
cache.setObject(data, forKey: identifier as NSString, cost: totalBytes) | ||
} | ||
return true | ||
} | ||
|
||
/// Attempts to remove an data from the cache for a given request | ||
/// - Parameters: | ||
/// - request: The original request for the | ||
/// - Returns: Boolean describing if the operation was successful | ||
@discardableResult | ||
public func removeData(for request: URLRequest) -> Bool { | ||
guard let identifier = cacheIdentifier(for: request) else { | ||
return false | ||
} | ||
|
||
serialQueue.sync { | ||
cache.removeObject(forKey: identifier as NSString) | ||
} | ||
return true | ||
} | ||
|
||
/// Purges all data from the cache | ||
public func purge() { | ||
serialQueue.sync { | ||
cache.removeAllObjects() | ||
} | ||
} | ||
|
||
private func numberOfBytes(in data: NSData) -> Int { | ||
return data.count | ||
} | ||
} |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,137 @@ | ||
// | ||
// DataDownloader.swift | ||
// Conduit | ||
// | ||
// Created by Anthony Lipscomb on 8/2/21. | ||
// Copyright © 2021 MINDBODY. All rights reserved. | ||
// | ||
|
||
import Foundation | ||
|
||
/// Represents an error that occurred within an DataDownloader | ||
/// - invalidRequest: An invalid request was supplied, most likely with an empty URL | ||
public enum DataDownloaderError: Error { | ||
case invalidRequest | ||
} | ||
|
||
public protocol DataDownloaderType { | ||
func downloadData(for request: URLRequest, completion: @escaping (DataDownloader.Response) -> Void) -> SessionTaskProxyType? | ||
} | ||
|
||
/// Utilizes Conduit to download and safely cache/retrieve | ||
/// data across multiple threads | ||
public final class DataDownloader: DataDownloaderType { | ||
|
||
/// Represents a network or cached data response | ||
public struct Response { | ||
/// The resulting data | ||
public let data: NSData? | ||
/// The error that occurred from transport or cache retrieval | ||
public let error: Error? | ||
/// The URL response, if a download occurred | ||
public let urlResponse: HTTPURLResponse? | ||
/// Signifies if the data was retrieved directly from the cache | ||
public let isFromCache: Bool | ||
|
||
public init(data: NSData?, error: Error?, urlResponse: HTTPURLResponse?, isFromCache: Bool) { | ||
self.data = data | ||
self.error = error | ||
self.urlResponse = urlResponse | ||
self.isFromCache = isFromCache | ||
} | ||
} | ||
|
||
/// A closure that fires upon data fetch success/failure | ||
public typealias CompletionHandler = (Response) -> Void | ||
|
||
private var cache: URLDataCache | ||
private let sessionClient: URLSessionClientType | ||
private var sessionProxyMap: [String: SessionTaskProxyType] = [:] | ||
private var completionHandlerMap: [String: [CompletionHandler]] = [:] | ||
private let completionQueue: OperationQueue? | ||
private let serialQueue = DispatchQueue( | ||
label: "com.mindbodyonline.Conduit.DataDownloader-\(UUID().uuidString)" | ||
) | ||
|
||
/// Initializes a new DataDownloader | ||
/// - Parameters: | ||
/// - cache: The data cache in which to store downloaded data | ||
/// - sessionClient: The URLSessionClient to be used to download data | ||
/// - completionQueue: An optional operation queue for completion callback | ||
public init(cache: URLDataCache, | ||
sessionClient: URLSessionClientType = URLSessionClient(), | ||
completionQueue: OperationQueue? = nil) { | ||
self.cache = cache | ||
self.sessionClient = sessionClient | ||
self.completionQueue = completionQueue | ||
} | ||
|
||
/// Downloads data or retrieves it from the cache if previously downloaded. | ||
/// - Parameters: | ||
/// - request: The request for the data | ||
/// - Returns: A concrete SessionTaskProxyType | ||
@discardableResult | ||
public func downloadData(for request: URLRequest, completion: @escaping CompletionHandler) -> SessionTaskProxyType? { | ||
var proxy: SessionTaskProxyType? | ||
let completionQueue = self.completionQueue ?? .current ?? .main | ||
|
||
serialQueue.sync { [weak self] in | ||
guard let `self` = self else { | ||
return | ||
} | ||
|
||
if let data = self.cache.data(for: request) { | ||
let response = Response(data: data, error: nil, urlResponse: nil, isFromCache: true) | ||
completion(response) | ||
return | ||
} | ||
|
||
guard let cacheIdentifier = self.cache.cacheIdentifier(for: request) else { | ||
let response = Response(data: nil, | ||
error: DataDownloaderError.invalidRequest, | ||
urlResponse: nil, | ||
isFromCache: false) | ||
completion(response) | ||
return | ||
} | ||
|
||
self.register(completionHandler: completion, for: cacheIdentifier) | ||
|
||
if let sessionTaskProxy = self.sessionProxyMap[cacheIdentifier] { | ||
proxy = sessionTaskProxy | ||
return | ||
} | ||
|
||
proxy = self.sessionClient.begin(request: request) { data, response, error in | ||
if let data = data { | ||
_ = self.cache.cache(data: data as NSData, for: request) | ||
} | ||
|
||
let response = Response(data: data as NSData?, error: error, urlResponse: response, isFromCache: false) | ||
|
||
func execute(handler: @escaping CompletionHandler) { | ||
completionQueue.addOperation { | ||
handler(response) | ||
} | ||
} | ||
|
||
self.serialQueue.async { | ||
self.sessionProxyMap[cacheIdentifier] = nil | ||
self.completionHandlerMap[cacheIdentifier]?.forEach(execute) | ||
self.completionHandlerMap[cacheIdentifier] = nil | ||
} | ||
} | ||
|
||
self.sessionProxyMap[cacheIdentifier] = proxy | ||
} | ||
|
||
return proxy | ||
} | ||
|
||
private func register(completionHandler: @escaping CompletionHandler, for cacheIdentifier: String) { | ||
var handlers = completionHandlerMap[cacheIdentifier] ?? [] | ||
handlers.append(completionHandler) | ||
completionHandlerMap[cacheIdentifier] = handlers | ||
} | ||
|
||
} |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
// | ||
// URLDataCache.swift | ||
// Conduit | ||
// | ||
// Created by Anthony Lipscomb on 8/2/21. | ||
// Copyright © 2021 MINDBODY. All rights reserved. | ||
// | ||
|
||
import Foundation | ||
|
||
/// Caches data keyed off of URLRequests | ||
public protocol URLDataCache { | ||
|
||
/// Attempts to retrieve cached data for the given request | ||
/// | ||
/// - Parameters: | ||
/// - request: The request for the data | ||
/// - Returns: The cached data or nil of none exists | ||
func data(for request: URLRequest) -> NSData? | ||
|
||
/// Attempts to build a cache identifier for the given request | ||
/// | ||
/// - Parameters: | ||
/// - request: The request for the data | ||
/// - Returns: An identifier for the cached data | ||
func cacheIdentifier(for request: URLRequest) -> String? | ||
|
||
/// Attempts to cache data for a given request | ||
/// | ||
/// - Parameters: | ||
/// - data: The data to be cached | ||
/// - request: The original request for the data | ||
mutating func cache(data: NSData, for request: URLRequest) -> Bool | ||
|
||
/// Attempts to remove data from the cache for a given request | ||
/// - Parameters: | ||
/// - request: The original request for the data | ||
mutating func removeData(for request: URLRequest) -> Bool | ||
|
||
/// Purges all data from the cache | ||
mutating func purge() | ||
|
||
} |
Oops, something went wrong.